← Open in the full interactive course (progress tracking, search & more)

A single database will happily promise you ACID. Ask three independent services to promise it together, and the honest answer is: not without real, well-documented cost.

Advanced Part VIII's transactions lesson mentioned System.Transactions.TransactionScope in passing — a mechanism that can, in principle, coordinate a commit across a database and a genuinely separate resource, "conceptually via a two-phase commit protocol." It also gave you an honest warning: this is "distributed transaction territory," meaningfully more complex, and named the Outbox pattern as the modern alternative most systems reach for instead. This lesson makes good on the part that was left conceptual: what a two-phase commit actually does, step by step, and precisely why it's fallen out of favor for coordinating modern, independently-deployed services.

Here's the question worth sitting with before diving in: an order-fulfillment flow touches an Orders service, a Payments service, and an Inventory service — three independent databases, three independent processes, potentially three independent teams. What would it even mean for a single transaction to span all three, and what would it cost to actually guarantee it?

In this lesson, you'll learn precisely why distributed transactions are hard, walk through the Two-Phase Commit (2PC) protocol conceptually, understand 2PC's real, documented weaknesses — its blocking nature, its poor scalability, and its limited support among modern data stores and brokers — and see why the next two lessons in this Part, the Outbox pattern and Eventual Consistency, are the modern answer most production systems reach for instead.

What Is It?

The Simple Explanation

A distributed transaction is an attempt to get the exact same all-or-nothing guarantee a single-database transaction gives you — either every change happens, or none of them do — except spread across multiple independent systems: separate databases, separate services, sometimes separate organizations entirely. Nothing about the goal changes from an ordinary transaction. What changes is that no single system is in charge of all the pieces, which makes "all or nothing" dramatically harder to actually deliver.

The Technical Definition

Within a single database, ACID transactions work because one component — the database engine — controls the locks, the write-ahead log, and the commit decision for everything involved. A distributed transaction spans multiple independent resource managers (a database, a message broker, another service's own datastore) with no single component naturally in charge of all of them. Making them agree requires an explicit coordination protocol — a transaction coordinator — layered on top, since none of the participants can, on their own, know or control what the others are doing.

Why Does It Exist?

The Problem — Why "Just Coordinate Them" Is Genuinely Hard

Imagine trying to guarantee "reserve the inventory AND charge the card AND create the order — all three, or none of them" across three independent services. Each service can only directly control its own database. None of them can see inside another's transaction, none of them can force another to commit or roll back, and — critically — any of them, or the network between them, can fail at any moment, including in the narrow window after one has decided to commit but before the others have heard about it. A single-database transaction never faces this problem, because there's only ever one component making the commit decision. Spread that decision across multiple independent systems, and you've introduced a coordination problem that a single database's ACID guarantees were never designed to solve on their own.

The Historical Solution — Two-Phase Commit

The classic answer, developed for exactly this problem, is Two-Phase Commit (2PC): introduce a coordinator that first asks every participant to privately confirm it could commit (without actually doing so yet), and only once every single one has agreed does the coordinator tell them all to actually commit, together. It's a genuinely clever protocol — and, as this lesson will show, it comes with real, well-documented costs that have pushed most modern distributed systems toward different approaches entirely.

Big Picture — The Two Phases

COORDINATOR / | \ PREPARE PREPARE PREPARE / | \ Orders Payments Inventory │ │ │ VOTE VOTE VOTE (yes/no) (yes/no) (yes/no) \ | / ALL VOTED YES? / \ YES NO (even one) │ │ PHASE 2: COMMIT PHASE 2: ABORT (tell everyone to (tell everyone to actually commit) roll back instead)

The defining idea: nobody actually, permanently commits during Phase 1. They only promise they can. The real, irreversible commit only happens in Phase 2, and only once the coordinator has heard "yes" from absolutely everyone.

How It Works — Phase by Phase

PHASE 1 — PREPARE (THE VOTING PHASE)
1. THE COORDINATOR ASKS EVERY PARTICIPANT TO PREPARE
2. EACH PARTICIPANT VOTES YES OR NO
3. THE PARTICIPANT NOW HOLDS ITS LOCKS AND WAITS
PHASE 2 — COMMIT (THE DECISION PHASE)
1. THE COORDINATOR TALLIES THE VOTES
2. THE COORDINATOR BROADCASTS THE FINAL DECISION
3. EACH PARTICIPANT MAKES ITS CHANGE PERMANENT (OR ROLLS BACK) AND RELEASES ITS LOCKS

Simple Example — Where This Shows Up in .NET

You've already met the .NET-level entry point for this: System.Transactions.TransactionScope, from Part VIII. When operations inside a TransactionScope touch more than one genuinely separate resource manager, .NET can escalate the transaction to a full distributed transaction, coordinated (on Windows) by the Microsoft Distributed Transaction Coordinator (MSDTC) — a real, concrete implementation of the coordinator role described above:

using (var scope = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) { await using (var ordersContext = new OrdersDbContext(ordersOptions)) { ordersContext.Orders.Add(order); await ordersContext.SaveChangesAsync(); // resource manager #1: Orders database } await using (var paymentsContext = new PaymentsDbContext(paymentsOptions)) { paymentsContext.Charges.Add(charge); await paymentsContext.SaveChangesAsync(); // resource manager #2: Payments database } // If both resource managers support it, .NET escalates this to a real // two-phase commit coordinated by MSDTC once scope.Complete() is called. scope.Complete(); }

Code → Meaning → Result: This code looks like an ordinary transaction — that's exactly the appeal, and exactly the trap. Underneath, if both databases support participating in a distributed transaction, .NET silently escalates to the full 2PC machinery described above: a prepare phase across both databases, then a coordinated commit. It genuinely can work — but it's paying the real cost this lesson is about to detail, and many modern resources (most message brokers among them) don't support this kind of coordination at all, meaning this pattern simply isn't available for a large share of the systems you'll actually build against.

Real-World Example — 2PC's Real, Documented Weaknesses

Weakness 1 — It's Fundamentally a Blocking Protocol

Picture the exact moment the coordinator has collected all "yes" votes and is about to broadcast "commit" — and then the coordinator process crashes, right there. Every participant is stuck: each one has voted "yes," which means each one is contractually bound to commit if told to, but none of them can safely decide on its own whether the final answer was actually "commit" or "abort," because they never got to hear it. They're left holding their locks — real database locks, blocking other transactions — indefinitely, until the coordinator recovers (if it ever does) and tells them what was actually decided. This is not a rare implementation bug; it's an inherent, structural property of the protocol: participants who've voted "yes" cannot unilaterally proceed without risking disagreeing with what actually happened elsewhere.

Weakness 2 — It Scales Poorly as Participants Grow

Every participant has to hold its locks for the entire duration of both phases — the full round trip of "prepare, vote, wait for everyone else, commit." The more participants involved, and the slower or less reliable any one of them is, the longer every other participant sits there holding locks, blocking other work on their own systems. Two participants is manageable. A dozen independent services, each with its own latency and its own chance of a hiccup, and the odds that at least one of them is slow (or briefly unreachable) on any given transaction climb fast — and every other participant pays for that one straggler's delay by sitting locked and waiting.

Weakness 3 — Many Modern Resources Simply Don't Support It

2PC requires a resource manager that can genuinely "prepare" (do the work, durably, without committing) and later either commit or roll back on command. Most modern message brokers and many contemporary data stores were never built with this specific two-phase contract in mind — they're built around simpler, faster, more scalable operational models that don't offer a "prepare but don't commit yet" mode at all. That means, in practice, a large share of the infrastructure powering real distributed systems today — including the very message brokers this Part has been building around — cannot participate in a classic 2PC transaction, no matter how much you might want them to.

Analogy

"Speak Now, or Forever Hold Your Peace"

A wedding ceremony is a surprisingly exact analogy for 2PC. The officiant (the coordinator) asks each party (the participants) in turn to privately confirm their intent — "do you take this person...?" Each "I do" is a vote, given before anything is made final. Only once every required party has said "I do" does the officiant make the pronouncement that actually, permanently commits the marriage.

Now imagine the officiant collapses at the altar right after collecting every "I do," but before making the final pronouncement. The couple is stuck in limbo — each of them already said yes, bound by that answer, but neither can unilaterally declare themselves married, nor can they safely walk away, because the actual final word was never spoken. Everyone in the room — the guests, the caterers waiting to start the reception — is stuck waiting too. That paralysis, caused by one single point (the coordinator) failing at exactly the wrong moment, is precisely 2PC's blocking weakness in miniature.

Under the Hood

WHY A PREPARED PARTICIPANT CAN'T JUST DECIDE ON ITS OWN
1. A "YES" VOTE IS A DURABLE, BINDING PROMISE
2. GUESSING WRONG WOULD BREAK THE "ALL OR NOTHING" GUARANTEE ENTIRELY
3. THIS IS WHY THE COORDINATOR ITSELF BECOMES A SINGLE POINT OF FAILURE

Common Confusion

1. "TransactionScope always gives me a real distributed transaction" — only if every resource supports it

As Part VIII already warned, wrapping operations in a TransactionScope doesn't magically grant every resource distributed-transaction capability — it can only coordinate resources that actually implement the prepare/commit contract 2PC requires. A resource that doesn't support this either throws when you try, or — worse, and more dangerous — silently doesn't actually get the atomicity guarantee you assumed you were getting. Never assume distributed atomicity is happening just because the code compiles and runs inside a TransactionScope block.

2. "2PC is obsolete and nobody uses it anywhere" — it's specifically a poor fit for this domain, not universally useless

2PC and its relatives are still used in some contexts — certain database internals, some tightly-controlled environments with a small, known, reliable set of participants. What this lesson is really arguing is narrower and more precise: 2PC is a poor fit specifically for coordinating independently deployed microservices and modern messaging infrastructure, exactly the kind of system this Part has been building. The blocking behavior, the poor scaling with participant count, and the lack of broad support among modern brokers and data stores are what make it the wrong tool for that specific job — not evidence that the protocol itself is worthless everywhere.

Common Mistakes

Mistake 1 — Reaching for a distributed transaction as the default fix for a multi-service consistency problem

The instinct, especially coming from a single-database background, is "I need these three services to stay in sync, so I need a transaction spanning all three." That instinct, applied literally via 2PC, imports real blocking risk and scaling limits into a system that will only grow more services and more independent failure points over time. Ask first whether the specific problem is actually the narrower "dual write" problem (the next lesson's subject) or a broader consistency question better served by eventual consistency (the lesson after that) — reach for a full distributed transaction only when neither of those genuinely fits.

Mistake 2 — Assuming every resource can participate in 2PC

Designing a workflow that assumes a message broker or a modern NoSQL store will happily join a distributed transaction, only to discover in production that it simply doesn't support the prepare/commit protocol at all. Verify a resource's actual support for distributed transaction coordination before designing around it — and default to assuming most modern messaging infrastructure does not support it, since that's the common case.

Mistake 3 — Underestimating how long locks stay held across a slow participant

Not accounting for the fact that every participant in a 2PC transaction holds its locks for the full duration of both phases — if one participant is slow, every other participant's locks stay held that whole time too, throttling unrelated work on systems that had nothing to do with the slowness. Recognize this as a real, structural scaling cost, not a tuning problem to configure away.

When Should I Use It?

SituationReach for
A small, tightly-controlled number of resources, all known to genuinely support 2PC, where the blocking risk is acceptable and well-understoodA real distributed transaction (TransactionScope/MSDTC) — rare, but not impossible
Updating your own database AND publishing an event about that change (the extremely common "dual write" case)The Outbox pattern — the very next lesson
Coordinating state across genuinely independent services over time, where a brief window of temporary disagreement is acceptableEventual consistency — the lesson after that
Modern, independently-deployed microservices and message-broker-based architecture, generallyAvoid 2PC as a default — its weaknesses compound exactly as this kind of system scales
Rule of thumb: If you find yourself reaching for a distributed transaction spanning independent services, stop and ask whether the actual underlying need is narrower (a single dual-write) or broader (general cross-service consistency) — the next two lessons cover the modern answer to each.

Mental Model

Phase 1 (Prepare) = every participant does the work and votes yes/no, but doesn't commit yet
Phase 2 (Commit) = only once EVERYONE voted yes does the coordinator tell everyone to actually commit — together, or not at all
Blocking risk = a coordinator crash between the phases can leave every "yes" voter stuck holding locks indefinitely
Poor support = most modern message brokers and many modern data stores don't implement the prepare/commit contract 2PC needs

Remember: 2PC isn't wrong — it's a real, well-understood answer to a hard problem, whose costs are exactly why modern distributed systems generally reach for something else instead.

Key Takeaway


Check Your Understanding

You've walked through the two phases and the real reasons 2PC struggles at modern scale. Let's confirm it clicked.

1. During Phase 1 (Prepare) of Two-Phase Commit, what does a participant actually do?

Show answer

Correct: B

Why B is correct: Phase 1 is the voting phase — each participant does the real work and durably records its readiness, but the change is not yet permanent. It's a promise to commit later if instructed, not the commit itself.

Why A is incorrect: Committing immediately in Phase 1 would defeat the entire point of the protocol — the whole design exists specifically to avoid any participant committing before everyone has agreed.

Why C is incorrect: The participant does real, meaningful work in Phase 1 — preparing and voting — it isn't idle.

Why D is incorrect: Participants communicate with the coordinator, not directly with each other — the coordinator is the central point collecting votes and broadcasting the final decision.

Reinforcement: Prepare means "ready and promised, not yet permanent" — that distinction is the entire mechanism that makes 2PC's atomicity guarantee possible.

2. The coordinator crashes after collecting all "yes" votes but before broadcasting the final "commit" decision. What happens to the participants?

Show answer

Correct: B

Why B is correct: This is exactly the scenario the lesson (and the wedding analogy) walked through. A participant that voted yes cannot safely guess the final outcome — it has to wait, holding its locks, until the coordinator's actual decision arrives. This structural blocking is 2PC's most cited real weakness.

Why A is incorrect: A participant deciding unilaterally risks disagreeing with what actually happened elsewhere (maybe another participant voted no) — this is precisely the unsafe guess the protocol is designed to prevent.

Why C is incorrect: There's no automatic safe rollback here — that's exactly the problem; the participants are stuck without the coordinator's explicit instruction.

Why D is incorrect: Classic 2PC doesn't define a peer-to-peer fallback communication path between participants — they depend on the coordinator specifically.

Reinforcement: The coordinator is a single point of failure whose crash at the wrong moment can leave every participant blocked indefinitely — this is not a rare edge case, it's an inherent property of the protocol.

3. Why does 2PC scale poorly as the number of participants grows?

Show answer

Correct: B

Why B is correct: As the lesson explained, all participants hold locks across the full round trip of both phases. More participants means more chances that at least one is slow or briefly unreachable — and every other participant pays for that delay by staying locked and blocked.

Why A is incorrect: There's no hard two-participant limit in the protocol itself — the scaling problem is about lock duration and coordination overhead, not a hardcoded cap.

Why C is incorrect: The scaling issue described in the lesson is about locks and blocking time, not primarily about raw bandwidth consumption.

Why D is incorrect: Coordinators are designed to support a configurable set of participants — no source rewrite is implied by adding one.

Reinforcement: Lock duration scales with the slowest participant and the total participant count — that's the concrete mechanism behind 2PC's poor scalability.

4. A team wants to update their own database and publish an event to a message broker, and considers wrapping both in a distributed transaction via TransactionScope. What does this lesson suggest?

Show answer

Correct: B

Why B is correct: This is exactly the dual-write scenario the lesson flags as a poor fit for 2PC — most brokers don't support the prepare/commit contract, and even when a resource does, 2PC's blocking and scaling costs remain. The lesson explicitly points to the Outbox pattern as the modern, standard answer.

Why A is incorrect: The lesson is explicit that this is generally the wrong default reach for this scenario, precisely because of the weaknesses just covered.

Why C is incorrect: TransactionScope works fine within a single database or with resources that genuinely support distributed coordination — the limitation is specifically about resources (like most brokers) that don't support the 2PC contract.

Why D is incorrect: This is precisely the dual-write problem that needs some form of coordination — the point is that 2PC is the wrong tool, not that no solution is needed.

Reinforcement: Recognizing "this is a dual-write problem" is the cue to reach for the Outbox pattern, not a distributed transaction — exactly the handoff to the next lesson.

You now understand precisely why distributed transactions are hard, and exactly what 2PC costs to solve it the "textbook" way. Next up: the Outbox pattern — the real, modern fix for the specific dual-write problem this lesson set up.


dotnetmadeeasy.com — Learn C# and .NET, the right way.