Outbox Pattern
The outbox pattern decouples event publishing from handler execution. Instead of dispatching events immediately when EmitAsync is called, events are stored in an outbox and dispatched later in a controlled batch. This is useful when you need to guarantee that events are only dispatched after a database transaction commits successfully.
How it works
EmitAsync(event, EmitMode.Outbox)stores the event inIEventOutboxStorage. No dispatch occurs.- Calling
context.CommitEventsAsync()orIOutboxCommit.CommitAsync()moves pending events through the event pipeline and handlers. - If a handler fails, the outbox retries with exponential backoff up to
MaxRetryAttempts. - After all retry attempts are exhausted, the event is marked
Failed(dead-lettered).
Using the outbox
Store and defer an event
// Store event in outbox — nothing dispatched yet
await context.PublishEventAsync(new TaskCreatedEvent(id, title), EmitMode.Outbox, ct);
Flush after your side-effects commit
public class CreateTaskHandler : IRequestHandler<CreateTaskCommand, Guid>
{
private readonly ITaskRepository _repository;
private readonly IContext _context;
public CreateTaskHandler(ITaskRepository repository, IContext context)
{
_repository = repository;
_context = context;
}
public async ValueTask<Result<Guid>> HandleAsync(CreateTaskCommand cmd, CancellationToken ct = default)
{
var id = Guid.NewGuid();
await _repository.SaveAsync(id, cmd.Title, ct); // ← side-effect
await _context.PublishEventAsync(
new TaskCreatedEvent(id, cmd.Title),
EmitMode.Outbox, ct); // ← store, don't dispatch yet
await _context.CommitEventsAsync(ct); // ← dispatch after save succeeded
return Result.Success(id);
}
}
You can also inject IOutboxCommit directly and call it outside the handler:
await outboxCommit.CommitAsync(ct);
Configure the outbox
Set outbox options in AddSynapse:
services.AddSynapse(cfg =>
{
cfg.ConfigureOutbox(opts =>
{
opts.MaxRetryAttempts = 5;
opts.InitialRetryDelay = TimeSpan.FromSeconds(1);
opts.BackoffFactor = 2.0; // exponential: 1s, 2s, 4s, 8s, 16s
opts.BatchSize = 100; // max events per CommitAsync call
});
});
| Option | Default | Description |
|---|---|---|
MaxRetryAttempts | 3 | Number of delivery attempts before marking the event as failed. |
InitialRetryDelay | TimeSpan.Zero | Delay before the first retry. |
BackoffFactor | 2.0 | Multiplier applied to the delay for each subsequent retry. |
BatchSize | unlimited | Cap the number of events dispatched per CommitAsync call. |
Configure the default emit mode
If most of your events should use the outbox, set it as the default:
cfg.SetDefaultPublishingMode(EmitMode.Outbox);
With this setting, EmitAsync(event) and EmitAsync(event, EmitMode.Default) store to the outbox automatically.
:::warning Production storage
The built-in InMemoryEventOutboxStorage is not persistent — all pending events are lost on restart. Replace it with a database-backed implementation before going to production.
:::
Replace the storage for production
The built-in InMemoryEventOutboxStorage is suitable for testing and simple scenarios. For production, replace it with a persistent implementation:
cfg.SetEventOutboxStorage<EfCoreEventOutboxStorage>();
Register EfCoreEventOutboxStorage in DI with whatever lifetime your database context uses, then implement IEventOutboxStorage:
public class EfCoreEventOutboxStorage : IEventOutboxStorage
{
private readonly AppDbContext _db;
public EfCoreEventOutboxStorage(AppDbContext db) => _db = db;
public async ValueTask<Result> AddAsync<TEvent>(TEvent @event, CancellationToken ct = default)
where TEvent : class, IEvent
{
_db.OutboxEvents.Add(OutboxEvent.From(@event));
await _db.SaveChangesAsync(ct);
return Result.Success();
}
// ... implement remaining members
}
Health check
Register the outbox health check to surface queue depth and lag in your /health endpoint:
builder.Services.AddHealthChecks()
.AddCheck<OutboxHealthCheck>("outbox", tags: ["ready"]);
OutboxHealthCheck reads its OutboxHealthCheckOptions from its constructor (not IOptions<T>), so register a configured instance as a singleton; AddCheck<OutboxHealthCheck> resolves it through the optional constructor parameter:
builder.Services.AddSingleton(new OutboxHealthCheckOptions
{
DegradedPendingThreshold = 50, // Degraded when pending >= 50
CriticalPendingThreshold = 200, // Unhealthy when pending >= 200
CriticalLagThreshold = TimeSpan.FromMinutes(5),
});
See Observability for the full threshold list (dead-letter and lag thresholds included).
See also
- Events —
EmitModeexplained, how to publish events withIEmitter. - Observability — outbox metrics (queue depth, lag, failed count).