Chapter 09 of 27 · Part 5: Data management

Managing transactions with sagas

How to keep data consistent across services without distributed transactions.

The saga pattern: local transactions and compensating steps, instead of one distributed rollback.

key idea / coordinatorservice / good pathin progress / watch outproblem / failureneutral

The problem: one operation, many databases

In a monolith one ACID transaction keeps everything consistent. With a database per service you need another way.

One operation touches several databasescreateOrder() needs data consistencyConsumer servicereads consumerOrder servicewrites orderKitchen servicewrites ticketAccounting servicewrites accountEach service has its own database, so one ACID transaction cannot cover them all.

Sagas instead of 2PC

Two-phase commit needs every participant online and many modern tools do not support it. A saga splits the work into local transactions.

Why not distributed transactionsDistributed transaction (2PC)Many NoSQL stores do not support itKafka and RabbitMQ do not support itSynchronous: everyone must be upAvailability multiplies downSagaSequence of local transactionsCoordinated by messagesWorks with any modern stackStays available
Create Order saga1. Order svccreate orderAPPROVAL_PENDING2. Consumer svcverify consumer3. Kitchen svccreate ticketCREATE_PENDING4. Accounting svcauthorize card5. Kitchen svcapprove ticket6. Order svcapprove orderAPPROVEDEach step is a local ACID transaction. Finishing one triggers the next.

Undoing a saga

A saga cannot simply roll back. It runs compensating transactions for the steps that already committed, in reverse order.

Saga rollback using compensating transactionsOrderConsumerKitchenAccountingcreate orderverify consumercreate ticketauthorize cardcard FAILSreject ticket (undo)reject order (undo)Committed steps cannot be rolled back. Undo them with compensating steps in reverse order.
Compensating transactions for each stepStepCompensating stepcreateOrder()rejectOrder()verifyConsumerDetails()none (read-only)createTicket()rejectTicket()authorizeCreditCard()none (pivot)approveTicket(), approveOrder()none (always succeed)

Who runs the saga?

Two styles: choreography (everyone listens to events) and orchestration (one orchestrator tells each service what to do).

Choreography versus orchestrationIn choreography services exchange events. In orchestration a central orchestrator sends commands.Choreography: services react to eventsOrder svcKitchen svcAccounting svceventsOrchestration: a boss sends commandsSaga orchestratorowns the sequenceConsumer svcKitchen svcAccounting svc
Dashed arrows are events. Solid arrows are commands. Replies go back to the orchestrator.
Saga orchestrator as a state machineVerifying consumerstartCreating ticketAuthorizing cardpivotApproving ticketApproving orderOrder approvedfinal stateAny failure moves the saga to Rejecting, then Order rejected.
Which to useChoreographySimple for small sagasNo central point of failureHard to see the whole flowCyclic dependencies riskOrchestration (preferred)Easy to read in one placeSimpler dependenciesDomain objects stay simpleKeep logic out of the orchestratorThe book recommends orchestration for all but the simplest sagas.

No isolation, so plan for it

Because each step commits at once, other sagas can see half-finished data.

Anomalies caused by lack of isolationSagas are ACD: no isolationLost updateoverwrites another sagaDirty readreads half-done dataFuzzy readsame data, different result
Structure of a sagaCompensatablecreateOrderverifyConsumercreateTicketPivotauthorize cardgo / no-go pointRetriableapproveTicketapproveOrderAfter the pivot commits the saga always runs to the end.

Countermeasures

The most common is the semantic lock: mark records as pending so other sagas know to wait or retry.

Semantic lock with Order stateAPPROVAL_PENDINGsemantic lock setAPPROVEDsaga finishedREJECTEDcompensatedOther sagas see the *_PENDING state and know a saga is still working on the order.
Countermeasures for lack of isolationCountermeasureIdeaSemantic lockflag the record (e.g. *_PENDING)Commutative updatesmake steps runnable in any orderPessimistic viewreorder steps to cut business riskReread valuecheck data is unchanged before writeVersion filerecord operations, then reorder themBy valuerisky requests use stronger control

What splitting a database costs

ACID means Atomicity, Consistency, Isolation, Durability. A service can still use an ACID transaction on its own database. What you lose is atomicity across services.

Same change in one database and in twoOne databaseTwo databasesCustomer: PENDING to VERIFIEDDelete PendingEnrollments rowone ACID transaction: both or neitherCustomer svc: set VERIFIEDEnrollments svc: delete rowtwo transactions, each may fail alone

So when data must change together, you have four options, from simplest to most work.

Options when changes must be atomicKeep togetherone DB, one serviceSplit schemaapp stays oneReorder stepsfail earlyUse a sagalocal steps + undoSplit the schema first to spot transaction and speed problems early.

Two-phase commit (2PC) is the classic way to get atomicity across databases. It has a vote phase and a commit phase:

Two-phase commitCoordinatorWorker AWorker Bvote: can you change?vote: can you change?yes (row locked)yes (row locked)commit, release lockcommit, release lockA "yes" is a promise, so each worker must lock its record until the commit arrives.
2PC problemWhy it hurts
LocksIt is mostly distributed locking: slow, and deadlocks are hard to avoid.
LatencyMore participants and slower workers mean longer locks.
Lost isolationCommits land at different moments, so one worker may show the change before another.
Silent workerA worker that voted yes then goes quiet can leave a mess an operator must fix by hand.
Rule of thumb. Use 2PC, if ever, only for very short operations. Systems like Google Spanner do it behind one logical database, with very costly infrastructure. In the words of Pat Helland, when every node must work, adding a node lowers availability, like adding an engine to a plane that needs all engines.

From Building Microservices (ch. 6, Workflow) and Decomposing the Database (ch. 4, Transactions).

Transactions: what splitting costs you

ACID means atomicity (all or nothing), consistency, isolation (no half-done reads) and durability. Each local transaction stays ACID after a split, but atomicity across databases is the first thing lost. Many teams then reach for distributed transactions.

One transaction becomes twoOne schema: atomicCustomerPENDING to VERIFIEDPendingEnrollmentsdelete rowone commitTwo schemas: two commitsCustomer DBcommit 1Enrollments DBcommit 2 may failYou can sequence them and handle the failure of the second step yourself,but the pair is no longer atomic.

Two-phase commit

A coordinator asks all workers to vote; if all agree, it tells them to commit. A worker that votes yes promises to make the change later, so it must lock the record until then.

Two-phase commit: vote, then commitCoordinatorWorker Alocks customer rowWorker Blocks enrollment rowphase 1: can you?phase 2: commit, release locksA voter must hold locks until the commit. Commits do not land at the same instant.
ProblemWhy
LocksIt is mostly distributed locking; long or large operations hold resources a long time.
Failure modesA worker votes yes then goes silent; recovery can need manual work.
Isolation lostDuring commit, one worker may show the change before the other.
Latency growsMore participants and slower networks widen the problem.
Just say no. The first alternative is not to split state that must change atomically; keep it in one database and one service, and split something else first. If you must split, use a saga.

From Monolith to Microservices, ch. 4: "Transactions", "ACID Transactions", "Two-Phase Commits" and "Distributed Transactions: Just Say No".

Failing backward and forward

A saga can recover in two ways. You can mix them in one saga.

Two recovery stylesBackward recoveryUndo committed stepsNeeds compensating actionsForward recoveryRetry from the failureNeeds saved progressMixedEach failure picks a styleLate steps go forwardSagas handle business failures, not technical ones such as a 500 error.
Reorder steps to reduce undo workPoints awarded early: a Pack failure needs two undosTake paymentAward pointsPack failsDispatchPoints awarded late: only the payment is left to refundTake paymentPack failsDispatchAward pointsMove steps that are likely to fail earlier, and steps hard to undo later.

A compensating action is a semantic rollback. You cannot unsend an email, so the undo sends a second email saying the order was cancelled. Keep a record of the aborted order, because that history is useful.

A fail-forward stepDispatch failsvan is fullRetrytry again tomorrowStill failingretries used upHuman steps innot a full rollbackMoney is taken and the item is packed, so rolling everything back would be odd.

From Building Microservices (ch. 6, Saga Failure Modes) and Decomposing the Database (ch. 4, Sagas).

Choosing and mixing saga styles

Choreographed sagas give up the central view. A common fix is a tracker that listens to every event. Each event carries the same correlation id, a unique id created for the saga.

A choreographed saga with a trackerOrderWarehousePaymentLoyaltyEvent topic on the brokerSaga trackergroups events by correlation idshows the state of each order
Mixing styles in one sagaChoreographed across teamsOrder svcWarehouse svcLoyalty svceventeventOrchestrated inside the WarehousePickPackDispatchone team owns this flow, so central control is easy
OrchestrationChoreography
Process visibleIn one placeSpread across services
CouplingOrchestrator knows every serviceServices only know events
Main riskLogic drains out of services (anemic services)Hard to see where a saga is
Usual styleRequest-responseEvents
Fits whenOne team owns the whole sagaSeveral teams are involved
Keep orchestrators small. Use a different orchestrating service per flow (placing orders, returns, goods receiving) so the shared Warehouse keeps its own logic. If logic can be centralized, it will be. Newman also prefers plain code over drag-and-drop BPM tools, which developers end up using anyway. If you do want a tool, he names Camunda and Zeebe. When mixing styles, still keep a clear way to see a saga's state.

From Building Microservices (ch. 6, Implementing Sagas).

Sagas

A saga breaks a long process into steps, each a local ACID transaction in one service, without holding locks across the whole thing. It does not give saga-level atomicity; it gives you enough state to reason about where you are. Full treatment in the saga sections above.

Order fulfilment saga with compensationReserve stockWarehouseTake paymentPaymentAward pointsLoyaltyPackagenot on shelf!Dispatchnever runsRelease stockRefundRemove pointsPackage fails, so run a compensating action for each committed step.
Failure modeMeaning
Backward recoveryUndo with compensating transactions (a semantic rollback; you cannot unsend an email, so send a second one).
Forward recoveryRetry from the failure point; needs persisted progress.
MixedEarly steps roll back; late steps (such as dispatch) retry, then call a person.
Reordering steps reduces rollbackPoints first: three compensations if packaging failsReservePaymentPointsPackagePoints last: nothing to undo for loyaltyReservePaymentPackagePointsPull likely failures forward. Once packed, a failed dispatch is retried, not rolled back.

Orchestration or choreography

Orchestrated versus choreographed sagaOrchestrationChoreographyOrchestratorPaymentWarehouseLoyaltyEvent topicWarehousePaymentLoyaltyRecommendOrchestrator sends commands. In choreography, services react to events.
OrchestratedChoreographed
ControlCentral coordinator, request/responseEvents on a broker
VisibilityProcess modeled in one placeRebuild it from services; add a correlation ID and a view
RiskDomain coupling; services become anemicHard to see state or trigger compensation
Choose whenOne team owns the whole sagaSeveral teams are involved
Tips. Use a different orchestrator per flow so logic is not all in one place. The author dislikes BPM drag-and-drop tools; developers end up using them anyway, and flows are hard to version and test.

Pat Helland's point: with distributed transactions the more nodes you add, the more likely the whole thing stalls. Modeling the process as a saga also makes the business process explicit.

From Monolith to Microservices, ch. 4: "Sagas", "Saga Failure Modes", "Implementing Sagas" and "Sagas Versus Distributed Transactions".

Living with eventual consistency

Asynchronous events mean one service may read data before another has applied the same event. This stale period is the inconsistency window. Agree with the business where it is acceptable. In CAP terms, event-driven design chooses availability, which pushes stale-data handling onto consumers.

A stale read inside the windowEvent brokerSubscription svcRead modelStockPurchased (qty 1)GET stock for productstock is still 2 (stale)same event, arrives laterBoth consumers read the same event at different speeds. The "last unit" alert is skipped.

Five ways to deal with it:

Five ways to handle eventual consistencyRicher eventCarry the data consumers needDomain boundaryTell outsiders after the inside is updatedVersion and retryEvent version newer than API? Retry laterKeep local stateStore or buffer upstream data yourselfEnd-to-end checkRe-check the rule at purchase time
StrategyGainCost
Richer eventNo lookup, fasterBigger events, tied to consumer needs
Domain boundaryStaleness contained insideSlower to reach outsiders; use sparingly
Version and retryQuick fixLoad spikes and cascades; use backoff and a circuit breaker
Keep local stateFull autonomy, resilientStorage cost and upkeep; copies spread
End-to-end checkParts stay loose and fastCheckout may still fail late
It is not eventual if nobody notices. One study cited by the book found about 98% of Cassandra reads inside a window of 1 ms or less, and MongoDB at 5 ms or less for 96%. Keep the window small and many users will never see it.

From Practical Event-Driven Microservices Architecture (ch. 5) and Decomposing the Database (ch. 4).

What eventual consistency costs

In a monolith a change is visible everywhere at once. With events, services read stale data until the change propagates. Two properties help you reason about it: safety (nothing invalid happens) and liveness (good things happen, mainly higher availability). Eventual consistency gives no safety guarantee by itself. From Practical Event-Driven Microservices Architecture, ch. 5.

A stale read between two consumers of one eventThe subscription service asks the inventory read model before it has been updated by the same event.Order svcInventory writeInventory readSubscriptionOrderCreatedOrderCreatedGET stockstill old valueupdate read modelResult: a wrong or duplicate "last unit left" notification, even with a small delay.

The CAP theorem is often used to justify eventual consistency. The book urges caution: CAP availability is about network partitions, not overall uptime. Google reported only 7.6 percent of Spanner incidents were network related. Eventual consistency also pushes the complexity of stale data onto every client, so confirm the business can live with it. One team lost trust in a product-visibility feature after delays and went back to manual work.

The practical goal is a small inconsistency window. Studies the book cites for Cassandra and MongoDB found windows of a millisecond or a few milliseconds for most requests. If eventual consistency is fast enough, nobody notices. For services that means horizontal scaling and a lag metric.

Autoscaling on consumer lagKafka exporter feeds lag to Prometheus; the Kubernetes horizontal pod autoscaler adds consumer instances above a threshold.Kafkaconsumer lagExporterreads lagPrometheuscustom metricHPAlag > limitInstancesscale out and inA bulk stock import raises lag; extra instances drain it; resources shrink after the peak.

Five ways to handle stale data

The running example: a subscription service must know the product total stock, but the inventory event only says one XS unit was bought. From Practical Event-Driven Microservices Architecture, ch. 5.

Strategies for eventual consistencyRicher events, publish after the read model updates, version and retry, keep local state, or validate end to end.1 Richer eventcarry total stock2 Domain boundarypublish after read model3 Version and retrydetect a stale answer4 Keep local statepersist or buffer in memory5 End-to-end checkvalidate at purchase time
StrategyAdvantagesLimits
1 Denormalized eventSimple, decoupled, no extra request, linear scalingLarger events; data must exist upstream; meaning can blur
2 Domain boundariesLittle code; flow stays visible; consumers see only settled dataSlower end to end; a read model publishing events is debatable
3 Version and retryQuick fix: compare event version with API version, retry laterDoes not remove the problem; load peaks; needs a circuit breaker
4 Local stateNo dependency; fast; resilient; reusableStorage and upkeep; copies spread across services
5 End to endParts stay fast and eventualNeeds a final check in the flow

Local state can also live in memory: either a time window of recent events (good for bulk jobs) or the whole stream loaded at startup (only for small, stable data sets such as a few hundred categories).

End-to-end argumentBrowsing may show stale stock, but order management validates stock at purchase.Browse path (eventual)Usersees stockProduct catalogread model, may lagPurchase path (checked)Userplaces orderOrder mgmtvalidates the ruleInventoryauthoritativeThe smaller parts can stay eventually consistent because the end-to-end flow guarantees the businessrule.

Recap

Quick check

Why can a saga not just ROLLBACK when card authorization fails?
Show answer

Each step already committed in its own local database. Rolling back means running new compensating transactions, such as rejectTicket() and rejectOrder().

Payment and loyalty points are done, then packing fails. What now?
Show answer

Run compensating transactions (refund, remove points). Better, reorder so points are awarded after dispatch. This is a semantic rollback, not a database rollback.