Distributed Transactions vs Outbox Pattern

distributed-systemsdotnet
Distributed Transactions vs Outbox Pattern

Two-phase commit coordinates one atomic outcome across participating resource managers. The transactional outbox commits business data and an outgoing message in one database, then publishes the message asynchronously. Choose between them by identifying the invariant you need: atomic cross-resource commit and reliable message delivery provide different guarantees.

Which Transaction Boundary Do You Need?

Suppose placing an order changes the Orders database and publishes OrderPlaced to RabbitMQ. Those are independent operations. A crash after the database commit but before publishing leaves downstream services unaware of the order.

The outbox pattern solves that dual-write problem. It does not atomically reserve stock in another service or charge a card. Those steps belong to a separate business workflow.

If the requirement is instead "two participating databases must commit this transaction together," a distributed transaction may fit. Both databases need compatible transaction infrastructure, and the application must accept the coordination cost. If all writes fit in one database transaction, use that local transaction first.

What Two-Phase Commit Coordinates

In 2PC, a coordinator asks each participant to prepare its transaction. A participant that votes yes records enough durable information to finish later and usually retains locks protecting its changes. It cannot independently abandon the prepared transaction just because the coordinator becomes unreachable.

Once every participant votes yes, the coordinator durably records the commit decision and tells participants to commit. If a participant votes no, the coordinator chooses rollback. Recovery uses the recorded decision to finish participants that did not receive the original message.

Two-phase commit sequence where a coordinator asks the Orders and Inventory databases to prepare and lock resources, then tells both to commit once every participant votes yes

Atomic commit is not the same as every client observing all changes at the same instant. Read isolation and how clients access multiple databases still matter. 2PC also cannot enlist an arbitrary HTTP payment API simply because the call occurs inside your C# transaction scope.

A short loop over PrepareAsync and CommitAsync is not a usable 2PC implementation. It omits the durable decision log, recovery, message retries, and the treatment of transactions whose outcome is unknown. Use the protocol implementation supported by your resource managers.

The .NET Platform Constraint

TransactionScope represents an ambient transaction; it does not mean every scope is distributed. Enlisting another durable resource can cause promotion, depending on the provider and connections involved. Local transactions can remain local.

For modern .NET, distributed System.Transactions support arrived in .NET 7 and is Windows-only. Implicit promotion also requires explicitly enabling TransactionManager.ImplicitDistributedTransactions. The Microsoft API documentation documents that opt-in and its platform restriction.

Verify support in every database provider as well as the runtime. A database supporting prepared transactions does not prove that its .NET provider, deployment platform, and recovery configuration support your proposed distributed transaction. See EF Core's transaction limitations.

What Failure Looks Like With 2PC

Prepared participants can become in doubt when they cannot obtain the coordinator's decision. Locks on the affected data may remain held until recovery resolves the transaction. Unrelated data and unrelated transactions do not necessarily stop.

This is the availability cost: a transaction's progress depends on its participants and the decision being reachable. Coordinator replication and recovery reduce operational risk, but do not make network partitions disappear. The two-phase commit alternatives article follows this failure case in more detail.

Long network round trips also extend transaction duration. That matters most when many requests contend for the same rows. Measure contention under the actual deployment conditions instead of treating distributed transactions as either always too slow or automatically safe.

What the Outbox Coordinates

The outbox has a smaller transaction boundary:

  1. Insert or update the business data.
  2. Insert the outgoing message into the same database transaction.
  3. Commit the transaction.
  4. Let a dispatcher publish the committed message.
  5. Record successful delivery so normal polling no longer selects it.

The request can complete while the broker is unavailable, as long as the database remains available and the outbox has capacity. Downstream services catch up after delivery resumes. That interval is an eventual consistency window, with no automatic upper bound.

The EF Core transactional outbox example includes the packages, PostgreSQL configuration, message contracts, endpoint, and MassTransit model setup. Its scoped IPublishEndpoint captures the message before SaveChangesAsync, so the business write and outgoing message commit together. The configured delivery service publishes afterward.

That ordering is essential. Calling SaveChangesAsync first and publishing afterward through an ordinary endpoint retains the original dual-write bug. Adding an explicit database transaction around those two calls does not make a broker publish part of that transaction.

Delivery and Consumer Guarantees

If the dispatcher publishes a message and crashes before recording success, recovery publishes it again. This is at-least-once delivery, assuming retry and recovery continue. Consumers must tolerate duplicates.

Persist a deduplication record and the consumer's database changes in the same transaction. A unique constraint must prevent concurrent deliveries from both applying the operation. The inbox pattern describes this boundary.

MassTransit's bus outbox does not automatically deduplicate every downstream consumer. Its consumer outbox is a separate receive-endpoint configuration, and even that cannot make an external HTTP side effect atomic with a database commit. Use the external system's idempotency support where available and reconcile uncertain outcomes.

Compare the Actual Tradeoffs

ConcernTwo-phase commitTransactional outbox
Atomic boundaryParticipating resource transactionsBusiness write and outgoing message in one database
Broker outageCan prevent a participating transaction from completingPublishing can lag while local writes continue
Failure recoveryResolve prepared transactions from the durable decisionRetry pending delivery and handle duplicates
Consumer completionOnly covered if that resource participatesOutside the producer transaction
InfrastructureCompatible enlistment and recovery supportTransactional storage for business data and messages
Operational workCoordinator recovery, timeouts, lock contentionOutbox lag, retry handling, retention, idempotency

The outbox avoids a cross-resource commit protocol, but it still needs database transactions and durable infrastructure. It can run out of storage during a long broker outage, and a poison message can block an ordered stream. Those are capacity and recovery requirements you must plan for.

Where Sagas Fit

An order workflow might reserve inventory, request payment, and confirm the order. When these steps belong to separate services, the saga pattern tracks progress and defines compensation. It still uses eventual consistency.

If payment is definitively declined after stock was reserved, release the reservation and cancel the order. If the payment request times out, first determine whether the payment succeeded. A timeout does not establish whether you should retry, refund, or cancel.

Compensation is a new business operation, so it can fail and require retry or manual intervention. An outbox helps each service publish its result reliably; it does not choose the business response to that result.

Summary

Use a local transaction when it can protect the invariant. Use an outbox when a committed change must reliably produce a message. Consider 2PC when atomic commit across compatible resources is an explicit requirement and its recovery cost is acceptable.

For a multi-service workflow, model pending states, uncertain outcomes, and compensation before writing the handlers. That decision determines what users can observe while the system is recovering.

Frequently Asked Questions

What is two-phase commit (2PC)?

A protocol where a coordinator first asks every participant to prepare (locking resources), then tells all of them to commit or roll back. It coordinates atomic commit across participating resources, at the cost of coordination and possible blocking during recovery.

Why is the outbox pattern preferred over distributed transactions in microservices?

The outbox pattern needs only a local database transaction: the state change and the outgoing event commit atomically in one database, and a background process publishes the event afterward. It requires transactional storage for the business write and message, while broker delivery happens separately.

Does the outbox pattern guarantee exactly-once delivery?

No. Retrying delivery can publish a message more than once. The publisher can crash after sending but before marking a message processed, causing a duplicate. Consumers must be idempotent to handle redelivery.

Are distributed transactions ever the right choice?

Occasionally: within a single trust boundary where all participants support the protocol (for example, two SQL Server databases with MS DTC) and strong immediate consistency is a hard requirement. Across heterogeneous microservices, they are rarely practical.

What is the difference between the outbox pattern and a saga?

The outbox reliably publishes events alongside a local state change. A saga coordinates a multi-step business process across services, using compensating actions to undo earlier steps when a later one fails. Sagas typically use an outbox internally for reliable messaging.

  • Amazon SQS With .NET: A Practical Guide

    Amazon SQS removes the broker from your operations burden, but reliable consumers still depend on visibility timeouts, explicit deletes, long polling, and a dead letter policy. This guide builds that baseline in .NET with the AWS SDK, FIFO queues, batch operations, and MassTransit.

  • Azure Service Bus Topics, Subscription Filters, and Dead Letter Queues in .NET

    Go beyond basic queues in Azure Service Bus: publish to topics, route messages with SQL and correlation filters on the broker, and build a dead letter queue handling strategy in .NET.

  • Backpressure in .NET: Handling Producers That Outrun Consumers

    Backpressure slows producers when consumers cannot keep up. Use bounded .NET channels to wait for capacity, drop replaceable updates, or reject excess requests with 503. The examples distinguish admission to memory from durable processing and show how to size a queue for a burst.

Browse all Distributed Systems guides →
Loading comments...

Whenever you're ready, there are 5 ways I can help you:

  1. Pragmatic Self-Hosting: Learn to deploy and run your apps on a VPS with Dokploy and Tailscale. Keep administration private, automate releases, and practice restoring backups, with source code and a runbook you can reuse.
  2. Pragmatic Clean Architecture: Join 5,000+ students in this comprehensive course that will teach you the system I use to ship production-ready applications using Clean Architecture. Learn how to apply the best practices of modern software architecture.
  3. Modular Monolith Architecture: Join 2,800+ engineers in this in-depth course that will transform the way you build modern systems. You will learn the best practices for applying the Modular Monolith architecture in a real-world scenario.
  4. Pragmatic REST APIs: Join 1,900+ students in this course that will teach you how to build production-ready REST APIs using the latest ASP.NET Core features and best practices. It includes a fully functional UI application that we'll integrate with the REST API.
  5. Patreon Community: Join a community of 5,000+ engineers and software architects. You will also unlock access to the source code I use in my YouTube videos, early access to future videos, and exclusive discounts for my courses.

The .NET Weekly

One practical .NET tip, every Saturday.

Join 66,000+ engineers who read it. Free, and you can unsubscribe any time.