Transactional Outbox

Publish a handler's outbound sends durably, and — with the right store — atomically with the handler's own state write, so a crash between "commit the order" and "send order:placed" can never silently lose (or duplicate-without-record) the send.

Benzene.Outbox ships this as first-class packages: Benzene.Outbox (the host-agnostic engine), Benzene.Outbox.DynamoDb and Benzene.Outbox.EntityFramework (the stores that make the atomic write mode real). This cookbook is the guide to using them.

Problem Statement

A handler that must "write state AND send a message" today does two independent operations: it commits to its own store, then calls IBenzeneMessageSender.SendAsync to publish. If the process dies (or the transport is unreachable) between the two, you've committed the state but never sent the message — or sent it and then rolled the state back. The common workaround, wrapping the send in a swallow-and-log try/catch, just trades a crash for silent data loss.

The outbox pattern removes the dual write: capturing a send writes an envelope durably — in the best case, in the same transaction as the state write — and a separate relay dispatches it afterwards. The relay gives you at-least-once delivery instead of best-effort.

Prerequisites

Step 1 — register the engine and a store

services
    .AddOutbox(o => o.MaxAttempts = 10)   // the dispatch engine's shared options
    .AddInMemoryOutboxStore()             // single-process only — see "Choosing a store" below
    .AddOutboxDispatcherWorker();         // optional — only if this process also relays (see "Relay per host")

AddInMemoryOutboxStore is fine for a single instance or a demo, but envelopes captured on one instance are only ever dispatched by that same instance's worker. For a multi-instance or Lambda deployment, swap in a real store:

// DynamoDB
services.AddSingleton<IAmazonDynamoDB>(_ => new AmazonDynamoDBClient());
services
    .AddOutbox(o => o.WriteMode = OutboxWriteMode.Transactional)
    .AddDynamoDbOutboxStore("orders-outbox")
    .AddDynamoDbOutboxTransaction("orders-outbox");
// EF Core — see Step 4 for the full picture, including the DbContext/DbContextFactory pair it needs.
services.AddEntityFrameworkOutbox<AppDbContext>(o => o.WriteMode = OutboxWriteMode.Transactional);

Neither store package creates its own table — provision the DynamoDB table (+ TTL + sparse GSI) or the EF migration yourself; see each package's CLAUDE.md for the exact shape.

Step 2 — add UseOutbox() to the route

UseOutbox() is outbound-route middleware, not a new send API. Add it after the cross-cutting stamping middleware and before the terminal transport converter, so the captured envelope carries the business-time traceparent/x-correlation-id:

services.AddOutboundRouting(routing => routing
    .Route("payments:capture", pipeline => pipeline
        .UseW3CTraceContext()
        .UseCorrelationId()
        .UseOutbox()                 // <-- capture point
        .UseSqs(queueUrl)));

That route now captures instead of sending inline. Handler code is unchanged:

await sender.SendAsync<CapturePaymentRequest, Void>("payments:capture", request);

Constraint: an outboxed route is fire-and-forget only. SendAsync<TRequest, TResponse> with any TResponse other than Void throws the existing OutboundResponseTypeMismatchException — the same behavior a send-acknowledgement-only transport (SQS/SNS/...) already produces. Request/response topics can't be outboxed; a deferred send has no response to give back.

The two write modes — be honest about what each guarantees

Neither mode is exactly-once. Pick the one your route actually needs.

Set the mode process-wide via AddOutbox(o => o.WriteMode = ...), or per route by passing the same option to UseOutbox(o => o.WriteMode = ...) — a route-level override clones the shared options, so one route can diverge without affecting any other.

Step 3 — the DynamoDB unit of work

Benzene.Outbox.DynamoDb makes Transactional mode real for DynamoDB: the handler commits through IDynamoDbOutboxTransaction, which drains everything staged on the request's scope and issues one TransactWriteItems containing the application's own items plus a Put per staged envelope. All-or-nothing — either the state and every envelope persist, or none of them do.

public async Task<IBenzeneResult<Void>> Handle(CreateOrderRequest request, IDynamoDbOutboxTransaction outbox)
{
    await _sender.SendAsync<CapturePaymentRequest, Void>("payments:capture", ToCaptureRequest(request));
    // ^ captured, not sent yet — staged on this scope's buffer.

    var orderPut = new TransactWriteItem { Put = new Put { TableName = "orders", Item = ToItem(request) } };
    await outbox.CommitAsync([orderPut]);   // ONE TransactWriteItems: order + captured envelope(s).

    return BenzeneResult.Created<Void>();
}

Bounded by DynamoDB's 100-item transaction limit, shared with the application's own items — CommitAsync throws a clear InvalidOperationException if the combined count would exceed it, or if you call it with nothing staged and no application items (a caller-side no-op is surfaced loudly, not silently swallowed).

Step 4 — the EF Core same-DbContext flow

Benzene.Outbox.EntityFramework makes Transactional mode real for EF Core by putting the outbox row in the application's own DbContext. EntityFrameworkOutboxStage<TDbContext> adds the row to that DbContext's change tracker and never calls SaveChanges — the handler's own SaveChangesAsync is the commit. There's no separate "unit of work" type to call through here; the shared DbContext instance is the unit of work.

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.AddOutboxEntities();   // "BenzeneOutbox" table, or pass a table name override
    modelBuilder.Entity<Order>();
}
// Both registrations are required — the stage needs the scoped DbContext (to share the handler's
// instance), the store needs a DbContextFactory (it's a singleton behind a per-call fresh context —
// see Benzene.Outbox.EntityFramework/CLAUDE.md's "Why the store uses a factory" for why).
services.AddDbContext<AppDbContext>(o => o.UseSqlServer(connectionString));
services.AddDbContextFactory<AppDbContext>(o => o.UseSqlServer(connectionString));
services.AddEntityFrameworkOutbox<AppDbContext>(o => o.WriteMode = OutboxWriteMode.Transactional);
public async Task HandleAsync(CreateOrderRequest request)
{
    _dbContext.Orders.Add(new Order(request.OrderId, request.CustomerId));

    // UseOutbox() in Transactional mode stages the envelope onto THIS SAME _dbContext's change
    // tracker and returns immediately — nothing is persisted yet.
    await _sender.SendAsync<CapturePaymentRequest, Void>("payments:capture", new(request.OrderId));

    // ONE SaveChangesAsync commits the order row AND the staged outbox row together, in one
    // database transaction. If this throws, neither is persisted — consistent by construction.
    await _dbContext.SaveChangesAsync();
}

This is exactly the shape this cookbook used to teach you to hand-roll — Benzene.Outbox.EntityFramework productizes it.

Relay per host

Dispatch is one host-agnostic engine (IOutboxDispatcher); only how it gets invoked differs by where the service runs.

Delivery semantics and parking

State these plainly, because they apply regardless of write mode or store:

Pairs with Benzene.Idempotency

At-least-once delivery means consumers need dedup. At capture, if the route's headers lack idempotency-key, UseOutbox() stamps the envelope's own id into that header by default (OutboxOptions.StampIdempotencyKey = true). A consumer running Benzene.Idempotency's UseIdempotency() with its default key strategy then dedups relay redeliveries with zero extra configuration — the two packages click together without either referencing the other. If you outbox a route, put UseIdempotency() on the consumer at the other end.

Response-as-event flows get this for free

If you use UseResponseEvents to republish a handler's response as an event, you don't need to do anything extra to outbox it: the default IResponseEventPublisher publishes through IBenzeneMessageSender, same as any other send, so the moment the event topic's outbound route adds UseOutbox(), response-as-event publishes inherit exactly the same durability/atomicity story as a handler-initiated send. No custom publisher, no integration code.

Testing

See test/Benzene.Core.Test/Outbox/ in this repo for the full worked test suite the packages ship with.

Variations & Gotchas