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.

Core.Infrastructure/Interceptors/OutboxInterceptor.csC#
// 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

Outbox Pattern — Atomic Save + Reliable Dispatch
Handler: Entity.Create() + entity.AddDomainEvent()
IUnitOfWork.SaveChangesAsync()
OutboxInterceptor: Capture domain events into OutboxMessage records
DB Transaction Commits
async, later
OutboxProcessor (every 30s): Fetch unprocessed messages
IMediator.Publish(domainEvent)
INotificationHandlers execute
Mark OutboxMessage.ProcessedAt = UtcNow

OutboxMessage Entity

Core.Domain/OutboxMessage.csC#
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.