Pedro Bertoluchi

Outbox pattern in .NET: why MassTransit alone isn't enough

MassTransit on RabbitMQ ships in an afternoon and produces duplicates in production by week two. The piece the default setup quietly leaves out.

6 min read
Back to blog

MassTransit on RabbitMQ is the default reflex for messaging in .NET, and for good reason. It ships in an afternoon, the API is clean, and the documentation does not lie. What the default setup quietly leaves out is the gap between the database transaction that mutates the aggregate and the broker call that publishes the event. That gap is where duplicates and lost events live.

At-least-once delivery is not a bug, it is the contract. The broker guarantees the message will arrive at least once, possibly more, and possibly after the original process has died. If the event is published before the transaction commits, a crash leaves the system claiming something happened that did not. If it is published after, a crash leaves the system having mutated state without telling anyone. Neither is acceptable in a system that bills or ships.

The outbox pattern is the fix and it is mechanically simple. In the same database transaction that updates the aggregate, insert a row into an outbox table containing the serialized event and a unique message id. A dedicated worker polls the outbox, publishes through MassTransit, and marks the row as dispatched. The atomicity guarantee comes from the database, not from the broker, which is where it belongs.

Consumer-side idempotency is the other half. Every consumer keeps an inbox table or a dedup cache keyed by the message id, checks it before processing, and writes the result inside the same transaction as the business effect. Without this, the inevitable redelivery turns a single payment into two. MassTransit ships with first-class outbox and inbox support since v8, and Entity Framework Core integrates cleanly. The plumbing is one configuration call. The decision to actually turn it on is the architectural one.

The trade-off against Event Sourcing is worth naming. Event Sourcing makes the event log the source of truth and removes the dual-write problem by construction, at the cost of rebuilding read models and rethinking queries. The outbox keeps the relational model as the source of truth and accepts a small dispatch latency in exchange for a familiar mental model. For most line-of-business systems the outbox wins on operational cost and team ramp-up.

Skip the outbox when the message is genuinely fire-and-forget and a loss is acceptable, or when the system is a simple CRUD app with no critical delivery guarantee and no downstream side effects. Notifications that the user will see again on the next page load, analytics events that already have a separate pipeline, internal logging. Everything else, especially anything that touches money, inventory or external partners, deserves the outbox. The cost is one table and one worker. The cost of not having it is the call from the customer.

Tags

  • #dotnet
  • #architecture
  • #messaging

Let's talk about your next project.

Share the challenge in a few lines. Within one business day I respond with a technical assessment and the next steps.