← All articles
Production Messaging in .NET

Distributed Transactions: Why Two-Phase Commit Rarely Fits Microservices

Understand the guarantees and failure costs of two-phase commit, how .NET transactions escalate, and when local transactions plus messaging are the safer production design.

  • distributed-transactions
  • two-phase-commit
  • transaction-scope
  • dotnet
  • microservices
  • saga
  • transactional-outbox
  • consistency
0:000:00

Introduction

The previous articles use local transactions, outboxes, idempotency, and Sagas. A reasonable objection is:

Why accept eventual consistency when a distributed transaction could commit everything together?

Two-phase commit (2PC) is a real protocol with a stronger atomicity goal than a Saga. Under the right resource managers and operating environment, it can be correct and useful.

It rarely fits independently deployed microservices because the atomic unit crosses availability, ownership, deployment, and scaling boundaries. The question is not whether 2PC works. It is whether every participant can safely share its blocking and recovery model.

The Production Problem

An order request must update Orders and Inventory databases. A TransactionScope makes the code look local:

using var scope = new TransactionScope(
    TransactionScopeAsyncFlowOption.Enabled);

await orders.SaveAsync(cancellationToken);
await inventory.SaveAsync(cancellationToken);

scope.Complete();

The second durable resource can promote the transaction to a distributed coordinator. Production now depends on:

  • both database providers supporting enlistment and promotion;
  • coordinator connectivity, identity, firewall, and platform support;
  • locks remaining acceptable across network round trips;
  • operators recovering in-doubt transactions;
  • both service owners accepting one shared commit and failure domain.

If Inventory is an HTTP service or a broker without compatible enlistment, TransactionScope cannot make it a participant. An ambient transaction does not travel through arbitrary JSON requests.

Core Concepts

Phase one: prepare

The coordinator asks every participant whether it can commit. A participant makes its intended changes durable enough to commit later, keeps required resources, and votes yes or no.

After voting yes, it cannot unilaterally decide to roll back without violating atomicity.

Phase two: decision

If every participant votes yes, the coordinator records commit and instructs all participants to commit. Otherwise it instructs rollback.

The protocol coordinates one outcome, but failures can leave participants uncertain. A prepared participant may wait for the coordinator’s recorded decision while retaining locks or prepared state.

Atomicity does not imply availability

2PC prioritizes one commit decision. During coordinator or network failure, it may block progress rather than allow participants to diverge.

That can be correct for a tightly controlled financial database operation. It can be unacceptable for services expected to deploy, scale, and fail independently.

TransactionScope promotion is conditional

System.Transactions can begin with a lightweight local or promotable transaction and escalate when another durable resource enlists. Whether promotion succeeds depends on the resource providers and runtime environment.

Code review must identify actual enlisted resources. “It uses TransactionScope” does not prove the production operation is distributed or supported.

Isolation has a duration cost

A distributed transaction keeps its consistency boundary open across more participants and network latency. Longer lock duration increases contention, deadlock exposure, log retention, and blast radius.

Timeouts limit duration but introduce more aborts and recovery work. They do not make a slow distributed commit cheap.

How It Works

Application -> Coordinator: begin
Application -> Orders DB: write
Application -> Inventory DB: write
Coordinator -> both: prepare?
Orders DB -> Coordinator: yes
Inventory DB -> Coordinator: yes
Coordinator: durably record commit
Coordinator -> both: commit

If the coordinator stops after recording commit but before Inventory receives the decision, Inventory remains prepared until recovery learns the outcome.

If a participant votes no, every prepared participant must roll back. Business workflows cannot selectively keep useful partial work unless that choice exists outside the atomic transaction.

Architecture Diagram

Two-phase commit and an in-doubt participantAn application writes to two databases through a coordinator. Both prepare successfully. The coordinator records commit, but a network failure prevents one participant from receiving the decision, leaving it in doubt until recovery. Begin Write Write Prepare Prepare Yes Yes Record COMMIT Commit Commit decision unavailable Prepared and in doubtuntil recovery Application Coordinator Orders DB Inventory DB
Two-phase commit and an in-doubt participant

.NET Implementation

Use TransactionScope only after verifying every resource and deployment target.

var options = new TransactionOptions
{
    IsolationLevel = IsolationLevel.ReadCommitted,
    Timeout = TimeSpan.FromSeconds(5)
};

using var scope = new TransactionScope(
    TransactionScopeOption.Required,
    options,
    TransactionScopeAsyncFlowOption.Enabled);

await using var ordersConnection =
    new SqlConnection(ordersConnectionString);
await ordersConnection.OpenAsync(cancellationToken);

await using var inventoryConnection =
    new SqlConnection(inventoryConnectionString);
await inventoryConnection.OpenAsync(cancellationToken);

await WriteOrderAsync(ordersConnection, cancellationToken);
await ReserveInventoryAsync(
    inventoryConnection,
    cancellationToken);

scope.Complete();

Opening the second enlisted durable connection may promote the transaction. Validate promotion in an environment matching production, including coordinator configuration and failure recovery.

Do not wrap outbound HTTP, SMTP, or a normal broker publish in TransactionScope and assume atomicity. They participate only if their resource manager explicitly supports the transaction protocol used.

With EF Core:

  • a single SaveChanges normally uses one local transaction;
  • sharing a transaction across contexts requires compatible provider connections;
  • manually initiated transactions interact with retrying execution strategies and must be executed as one retriable unit;
  • an ambiguous commit outcome requires a verification strategy.

Inspect distributed transaction creation:

TransactionManager.DistributedTransactionStarted +=
    (_, args) =>
    {
        logger.LogWarning(
            "Distributed transaction started: {TransactionId}",
            args.Transaction.TransactionInformation
                .DistributedIdentifier);
    };

Treat this as diagnostic evidence, not the only monitoring mechanism. Provider and coordinator telemetry remain necessary.

Common Mistakes

Assuming ambient means distributed

Ambient context coordinates only enlisted resources. Arbitrary service calls do not enlist.

Discovering promotion in production

A code path may remain local in tests and promote only when a second connection opens under real load. Test the exact connection and provider topology.

Ignoring in-doubt recovery

Prepared transactions can retain resources. Operators need queries, ownership, alerts, and safe resolution procedures.

Crossing service ownership with database credentials

Letting one service directly enlist another service’s database bypasses its API, invariants, deployment boundary, and ownership.

Treating 2PC as a latency optimization

It provides atomic coordination at the cost of additional durable and network steps. It is not a faster alternative to messaging.

Replacing all local transactions

Most operations need only one service-owned transaction. Promotion adds failure modes without benefit.

Best Practices

  • Prefer one service-owned local transaction.
  • Verify every resource manager’s enlistment and platform support.
  • Keep distributed transactions short and bounded.
  • Use the weakest isolation level that preserves required invariants.
  • Monitor promotion, coordinator health, aborts, duration, and in-doubt participants.
  • Document recovery before enabling the first production transaction.
  • Load-test lock duration and coordinator failure.
  • Do not cross service database ownership to manufacture atomicity.

Observability

Capture:

  • local versus promoted transaction count;
  • transaction duration, abort, timeout, and heuristic or in-doubt outcomes;
  • coordinator availability and recovery backlog;
  • participant lock waits and deadlocks;
  • prepared-transaction age;
  • correlation between application request and coordinator transaction ID.

Avoid logging full connection strings or transaction payloads. Alert on any prepared transaction older than the expected maximum and on unexpected promotion.

Verification

In a production-like environment:

  1. prove whether the second resource promotes;
  2. stop a participant before prepare and verify rollback;
  3. interrupt coordinator connectivity after prepare and verify recovery;
  4. restart application and coordinator nodes;
  5. measure lock and latency impact under load;
  6. verify operating procedures can identify and resolve an in-doubt transaction.

A happy-path commit test is insufficient.

Decision Matrix

Context2PC fit
Two supported databases in one tightly controlled application boundaryPossible; measure and operate it
Independent microservices with private databasesPoor; use local transactions and messages
HTTP service plus databaseUnsupported unless an explicit compatible transaction protocol exists
Database plus broker with no shared coordinatorUse Transactional Outbox
Long-running workflow with human or external stepsUse Saga or durable workflow
Low-latency, high-contention pathUsually poor due to coordination and lock duration
Strong atomicity legally or financially required and all resources support itConsider 2PC with explicit availability trade-off
Single databaseUse a local transaction

Relationship to Other Patterns

Transactional Outbox keeps atomicity inside one producer database and accepts asynchronous publication.

Saga coordinates local transactions through compensation and explicit intermediate states. It trades isolation for independent availability and business recovery.

Idempotent Consumer handles repeated messages created by at-least-once delivery. Retry and Circuit Breaker manage transient failure but cannot resolve an in-doubt commit decision.

These are not interchangeable implementations of the same guarantee. Choose based on the required consistency boundary and acceptable failure mode.

Decision checklist

  • Which exact durable resources enlist?
  • Do every provider, runtime, and deployment target support promotion?
  • Is atomicity more important than independent availability during partition?
  • How long can locks and prepared state remain?
  • Who operates the coordinator and resolves in-doubt transactions?
  • Does the design cross service-owned database boundaries?
  • Would an outbox or Saga express the business failure more honestly?
  • Have coordinator and participant failures been tested under load?

Key takeaways

  • 2PC is a protocol, not an ambient-context magic trick.
  • A prepared participant may block while awaiting the decision.
  • HTTP calls do not automatically enlist in TransactionScope.
  • Distributed atomicity couples availability and operations.
  • Local transactions plus explicit messaging usually match microservice ownership better.

Sources