Intro

A broker can deliver an integration event only after the producer places it on the transport. Saving business state and publishing in two independent operations leaves a failure gap: the database can commit while the publish fails. A transactional outbox stores the business change and outgoing message through the same local DbContext transaction, then a delivery service forwards the message to the broker.

Consumers face the inverse problem. A handler can commit its business change and crash before acknowledging the message, so the broker redelivers it. Inbox or consumer-outbox state records the message identity in the same local transaction as the consumer’s work. This produces at-least-once delivery with effectively-once local state transitions, not global exactly-once execution.

Contract

public sealed record OrderPlacedIntegrationEvent(
    Guid EventId,
    Guid OrderId,
    Guid CustomerId,
    decimal Total,
    DateTime OccurredAtUtc);

The contract uses a stable EventId for tracing and application-level deduplication. Schema evolution must remain backward compatible while old consumers and retained messages exist.

Producer with a bus outbox

builder.Services.AddDbContext<OrdersDbContext>(options =>
    options.UseNpgsql(builder.Configuration.GetConnectionString("Orders")));
 
builder.Services.AddMassTransit(bus =>
{
    bus.AddEntityFrameworkOutbox<OrdersDbContext>(outbox =>
    {
        outbox.UsePostgres();
        outbox.UseBusOutbox();
    });
 
    bus.UsingRabbitMq((context, rabbit) =>
        rabbit.ConfigureEndpoints(context));
});
app.MapPost("/orders", async (
    PlaceOrderRequest request,
    OrdersDbContext db,
    IPublishEndpoint publish,
    CancellationToken cancellationToken) =>
{
    var order = Order.Place(request.CustomerId, request.Total);
    db.Orders.Add(order);
 
    await publish.Publish(
        new OrderPlacedIntegrationEvent(
            Guid.NewGuid(),
            order.Id,
            order.CustomerId,
            order.Total,
            DateTime.UtcNow),
        cancellationToken);
 
    await db.SaveChangesAsync(cancellationToken);
    return Results.Accepted($"/orders/{order.Id}", new { order.Id });
});

With UseBusOutbox, Publish is captured by the scoped OrdersDbContext. SaveChangesAsync commits the order and outbox rows together. Delivery to RabbitMQ happens afterward and may retry without losing the event.

Consumer with durable deduplication

builder.Services.AddDbContext<BillingDbContext>(options =>
    options.UseNpgsql(builder.Configuration.GetConnectionString("Billing")));
 
builder.Services.AddMassTransit(bus =>
{
    bus.AddConsumer<OrderPlacedConsumer>();
    bus.AddEntityFrameworkOutbox<BillingDbContext>(outbox =>
        outbox.UsePostgres());
 
    bus.UsingRabbitMq((context, rabbit) =>
    {
        rabbit.ReceiveEndpoint("billing-order-placed", endpoint =>
        {
            endpoint.UseEntityFrameworkOutbox<BillingDbContext>(context);
            endpoint.ConfigureConsumer<OrderPlacedConsumer>(context);
        });
    });
});
public sealed class OrderPlacedConsumer(BillingDbContext db)
    : IConsumer<OrderPlacedIntegrationEvent>
{
    public async Task Consume(ConsumeContext<OrderPlacedIntegrationEvent> context)
    {
        var message = context.Message;
 
        db.PaymentIntents.Add(
            PaymentIntent.Create(message.EventId, message.OrderId, message.Total));
 
        await db.SaveChangesAsync(context.CancellationToken);
    }
}

Put a unique constraint on the business idempotency key used by PaymentIntent.Create, such as OrderId for one intent per order. Transport inbox state suppresses duplicate handler completion for the message, while the domain constraint protects the business invariant if the same fact arrives under another message identity.

Failure boundaries

FailureDurable stateRecovery
Process stops before producer saveNeither order nor message committedClient may retry with an idempotency key
Process stops after producer saveOrder and outbox row committedOutbox delivery service publishes later
Broker redelivers after consumer commitBilling change and inbox state committedDuplicate delivery is suppressed
Consumer permanently rejects schema or dataMessage remains unprocessedDead-letter with alert and replay procedure

The outbox closes the local database-to-broker gap. It does not make the broker and every downstream database one distributed transaction. Event-Driven Architecture still requires explicit ordering, idempotency, compatibility, and workflow recovery decisions.

References