Outbox Pattern
How SCRIPE stores domain events in the business transaction and dispatches them asynchronously with at-least-once delivery.
The Outbox Pattern solves the dual-write problem: how do you atomically save an entity AND dispatch a side-effect (email, webhook, permission sync) without risking sending the side-effect even if the database transaction rolls back? SCRIPE's implementation uses EF Core interceptors to capture domain events into an OutboxMessage table in the same database transaction as the entity mutation. The OutboxProcessor background job then dispatches them asynchronously.
The Dual-Write Problem
Without the Outbox Pattern, a typical handler might save the entity and then call an email service. If the email call succeeds but the database transaction fails (or vice versa), the system is in an inconsistent state. The Outbox Pattern solves this by making the event persistence part of the entity transaction — if the transaction fails, the event is also rolled back. If the transaction succeeds, the event is guaranteed to be dispatched eventually.
SCRIPE Outbox Implementation
SCRIPE's OutboxInterceptor is an EF Core ISaveChangesInterceptor. Before SaveChangesAsync completes, it reads all pending domain events from tracked entities, serializes them to JSON, inserts them as OutboxMessage records in the same SaveChanges call, and clears the events from the entities. The OutboxProcessor (an IAutoRegisteredJob running every 30 seconds) reads unprocessed messages and dispatches them via IMediator.Publish.
// Runs inside EF Core's SaveChangesAsync — part of the same DB transaction
public class OutboxInterceptor : SaveChangesInterceptor
{
public override async ValueTask<int> SavingChangesAsync(
DbContextEventData eventData,
InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
var context = eventData.Context;
if (context == null) return await base.SavingChangesAsync(eventData, result, cancellationToken);
// Find all domain events from tracked entities
var entities = context.ChangeTracker
.Entries<AuditableEntity>()
.Where(e => e.Entity.DomainEvents.Count != 0)
.Select(e => e.Entity)
.ToList();
// Serialize each event into an OutboxMessage record
var outboxMessages = entities
.SelectMany(e => e.DomainEvents)
.Select(domainEvent => new OutboxMessage
{
EventType = domainEvent.GetType().AssemblyQualifiedName!,
Payload = JsonSerializer.Serialize(domainEvent, domainEvent.GetType()),
CreatedAt = DateTime.UtcNow,
})
.ToList();
// Clear events BEFORE SaveChanges (prevents double-capture on retry)
entities.ForEach(e => e.ClearDomainEvents());
// Add outbox records — they commit in the same transaction
await context.Set<OutboxMessage>().AddRangeAsync(outboxMessages, cancellationToken);
return await base.SavingChangesAsync(eventData, result, cancellationToken);
}
}Outbox Pattern Flow
OutboxMessage Entity
public class OutboxMessage : BaseEntity
{
public string EventType { get; set; } = string.Empty; // Assembly-qualified type name for deserialization
public string Payload { get; set; } = string.Empty; // Serialized domain event JSON
public DateTime CreatedAt { get; set; } // When the entity mutation happened
public DateTime? ProcessedAt { get; set; } // Null = not yet dispatched
public int Retries { get; set; } // Incremented on dispatch failure
public string? Error { get; set; } // Last error message if Retries > 0
}OutboxProcessor — The Reliable Dispatcher
OutboxProcessor dispatches pending domain events and records retry state after failures. Each domain-event delivery resolves its publisher from a fresh dependency-injection scope, so tracked changes from a failed delivery cannot be saved by a later delivery. Delivery is at least once: consumers that mutate durable state still need an Inbox receipt committed atomically with their business changes; this scope boundary does not provide multi-host exactly-once delivery.
Idempotency Requirements
OutboxMessage.Id identifies one event delivery and can key an Inbox receipt; it is not a substitute for a business request idempotency key. A consumer that changes durable state should commit its Inbox receipt and business effect in the same local transaction. External effects such as email remain at least once unless the external provider offers its own idempotency contract.