The Rollback Nobody Tested: Why Saga Compensation Logic Fails in Production
A payment-and-fulfillment saga at a mid-size e-commerce platform runs correctly several thousand times a day. Reserve inventory, charge the card, schedule the shipment — three services, three local transactions, one satisfied customer. The engineering team that built it can walk you through the happy path from memory: which service publishes which event, which topic the next service subscribes to, what the idempotency key looks like on the payment call.
Ask the same team to walk you through what happens when shipment scheduling fails after the card has already been charged, and the conversation slows down. Someone remembers there's a refund_payment function. Someone else isn't sure if it's been called in production since the initial rollout eight months ago. Nobody can say with confidence what happens if that refund call itself times out, or what happens if a second saga instance is compensating a different order at the same moment and both instances touch the same inventory row.
This is not a hypothetical failure of imagination. It is the normal condition of most saga implementations in production today. The forward path — reserve, charge, ship — gets the architecture diagram, the load test, the demo, and the bulk of the code review comments. The reverse path — the compensating transactions that undo reserve, charge, and ship when something downstream fails — gets written once, tested manually against one scenario, and then left alone, because it doesn't produce a working checkout flow and nobody is going to demo it to a stakeholder.
The problem is that the reverse path is not a rare code path. In any system operating at meaningful scale, with real third-party dependencies, real network partitions, and real payment processors that occasionally decline or time out, compensating transactions execute every day. They are not disaster-recovery code that might run once a year. They are steady-state production code that happens to receive steady-state production neglect. This article looks at why that gap exists, catalogs the specific ways compensation logic fails once it starts running under real conditions, and lays out a concrete way to close the gap — through testing techniques, ownership structures, and a small set of design decisions that determine whether a saga's rollback path is trustworthy or merely present.
The Asymmetry Nobody Designs For
Distributed transactions that span multiple services with independent databases cannot rely on a database engine's built-in atomicity. There is no single COMMIT that spans a payments database and a fulfillment database owned by different teams running different data stores. The saga pattern — first formalized for long-lived database transactions and later adapted to microservice architectures — solves this by breaking one business transaction into a sequence of local transactions, each of which commits independently, with the sequence held together by an orchestrator or a chain of published events. When a step fails, the saga does not roll back in the database sense. It compensates: it runs additional local transactions designed to semantically undo the effect of the steps that already committed.
That distinction — semantic undo instead of physical rollback — is the whole difficulty of the pattern, and it's worth sitting with before going further. A database rollback is symmetric and mechanical: the engine reverses exactly the bytes it wrote, and it does so as a guaranteed property of the storage engine. A compensating transaction is neither symmetric nor mechanical. It is a piece of business logic that someone has to write, reason about, and keep correct as the forward logic evolves. Reserving inventory and releasing a reservation are not mirror images of the same operation; they are two different code paths, written at different times, frequently by different engineers, tested against different scenarios, and reviewed with different levels of scrutiny.
That asymmetry shows up predictably in how teams actually build sagas. The forward path gets built first, because it's what makes the feature work. It gets a design review, because a product manager needs to sign off on the checkout flow. It gets a load test, because someone is worried about Black Friday traffic. It gets alerting, because a failed order is an obviously bad outcome that shows up in revenue dashboards. The compensation path gets added afterward, often in the same pull request as an afterthought, sometimes in a follow-up ticket that slips a sprint or two. It rarely gets a dedicated design review, because "undo the reservation" sounds simple enough that reviewers wave it through. It almost never gets a load test, because nobody schedules a load test for a code path they hope never fires. And its alerting, when it exists at all, usually just confirms that compensation started — not that it completed correctly, not that it was idempotent under retry, and not that it correctly restored the specific state the forward path had actually reached.
None of this is a matter of individual engineers being careless. It's a structural consequence of how software gets prioritized. The forward path is what the business asked for. The compensation path is what happens when the business's request didn't go as planned, and organizations consistently underinvest in the code paths that only exist to handle plans not going as expected — right up until the moment one of those paths runs in front of a customer, a regulator, or an auditor, and the underinvestment becomes visible all at once.
What a Compensating Transaction Actually Promises — And What It Doesn't
Before cataloging how compensation logic fails, it helps to be precise about what it is actually supposed to do, because a surprising number of production incidents trace back to a compensation that was doing exactly what it was written to do — the design itself was wrong.
The established vocabulary for saga steps, as documented in the saga pattern literature maintained by Chris Richardson at microservices.io and reflected in the Azure Architecture Center's saga pattern documentation, divides a saga's steps into three categories:
- Compensable transactions are steps that can be semantically undone by a corresponding compensating transaction if a later step fails. Reserving inventory is compensable — you can release the reservation.
- The pivot transaction is the step that marks the point of no return. Once it commits, the saga is guaranteed to run to completion — forward or through compensation — but it will never be "un-run" past that point. In a reserve-charge-ship saga, the payment charge is often the pivot: once the card is charged, the saga either completes fulfillment or it compensates by issuing a refund, but there is no scenario where the charge itself is silently forgotten.
- Retryable transactions are the steps after the pivot. By definition, they must eventually succeed, so they are designed to be retried — with backoff, with idempotency keys, with dead-letter handling — rather than compensated.
This taxonomy matters for testing because each category has a different failure contract. A compensable step needs a tested, idempotent, and correctly-scoped undo action. A pivot transaction needs to be identified explicitly in the design — many teams never name their pivot transaction and instead discover it in production, usually during an incident review when someone asks "wait, why didn't we just refund the customer instead of trying to un-ship the package?" A retryable transaction needs robust retry and idempotency handling but explicitly does not need a compensating action, and teams that write one anyway often introduce more risk than they remove, because now there are two different failure-recovery mechanisms for the same step and no clear rule for which one fires first.
It's also worth being explicit about what a compensating transaction is not, because the name invites a comparison to database rollback that doesn't hold. As Microsoft's own compensating-transaction pattern documentation states plainly, this is "a technique for undoing work in eventually consistent operations," not a return to the original state. The saga's participants have already committed their local transactions to their own databases. A compensating transaction does not erase that commit; it adds a new, separate transaction whose effect is to move the business state toward something acceptable given that the earlier commit happened and cannot be un-happened. In an inventory system, that might genuinely mean incrementing the available-stock counter back to where it was. In a payment system, it almost never means erasing the original charge — it means issuing a new, separate refund transaction, which shows up as its own line item, its own settlement event, and its own reconciliation record. Anyone who designs compensation logic as if it restores a clean prior state, rather than as a new transaction layered on top of an unerasable one, is setting up exactly the class of bug this article spends the next section describing.
Two structural properties compound the difficulty. First, sagas give up isolation — the "I" in ACID. While a saga is mid-flight, other transactions can read and act on the partially updated state, because there is no cross-service lock holding the whole business transaction invisible until it completes. Second, the pattern requires idempotency almost everywhere, because at-least-once delivery, timeouts, and retries mean that any given step — forward or compensating — may be invoked more than once for the same logical event. Both properties are well documented in the architecture literature; both are also the properties development teams most reliably forget to test, because neither one is visible when you run the happy path once on your laptop.
Four Ways Compensation Logic Fails in Production
The following is not an exhaustive list of every way a saga can go wrong. It is a deliberately narrow list of failure patterns that are specific to compensation logic — patterns that don't show up in forward-path testing no matter how thorough that testing is, because they only exist once something has already failed and the system is trying to undo partial work.
1. The Partial-Success Compensation
The most common design error is writing a compensating transaction that assumes the forward step it's undoing either fully succeeded or fully failed — with nothing in between. Real forward steps rarely respect that assumption. A "charge payment" step might authorize a payment and then time out waiting for capture confirmation. An "assign warehouse" step might reserve stock at one distribution center and then fail while attempting a secondary reservation at an overflow center for the remainder of a multi-unit order. In both cases, the step didn't cleanly succeed or cleanly fail — it did some of what it was supposed to do, and the compensating transaction has to know exactly how much in order to undo the right amount.
Teams write the compensation against the intended outcome of the forward step rather than its actual outcome, because the intended outcome is what's documented and what shows up in the API contract. The actual outcome — what specifically happened on the far side of a call that returned an ambiguous timeout — is state that the compensating transaction usually has no way to inspect unless the forward step was explicitly designed to record it.
2. The Non-Idempotent Compensation
Compensation logic runs under exactly the conditions that produce duplicate invocations: network timeouts, orchestrator crashes and restarts, message redelivery from at-least-once queues, and manual re-triggers from an on-call engineer trying to unstick a saga at 2 a.m. If a compensating action is not idempotent — if calling it twice produces twice the effect — every one of those ordinary operational events becomes a data-integrity incident.
This failure mode is particularly dangerous because it is invisible in almost every normal test. A unit test that calls the compensation function once and asserts the expected state change will pass. An integration test that runs the saga through a single failure scenario will pass. The bug only appears when the exact same compensating call fires twice, which is precisely the scenario most teams don't think to write a test for, because from the forward-path mindset, "the operation already ran once" reads as "we're done," not as "we should check what happens if it runs again."
3. The Compensation of a Compensation
Saga design assumes compensating transactions succeed. In practice, a compensating transaction is still a network call to another service, and it can fail for exactly the same reasons the forward call could fail — a timeout, a downstream outage, a validation error the forward path never triggers because it wasn't the code path being tested. When a compensation itself fails, most saga implementations have no defined next step. The orchestrator retries a few times per its generic retry policy, and if that's exhausted, the saga simply gets stuck: not completed, not compensated, sitting in an intermediate state that the system has no vocabulary for.
This is the failure mode most likely to require a human in the loop, and it's also the one teams are least likely to have designed for, because "what if the undo also fails" sounds like an edge case worth deferring until it's not the highest-priority ticket. The result, in practice, is a dead-letter queue or a stuck-workflow dashboard that accumulates entries no automated process resolves — entries that eventually require someone to manually reconstruct what state the business transaction should be in, using judgment rather than code.
4. The Semantic Lock That Outlives the Saga
Because sagas give up cross-service isolation, two sagas can legitimately interleave: saga A reserves inventory for order 1001, saga B reads that same inventory to decide whether it has stock for order 1002, and then saga A's payment fails and it compensates by releasing the reservation — but saga B already made its decision based on inventory that briefly, incorrectly, looked available. The architecture literature calls the standard mitigation a semantic lock: an application-level flag that marks a resource as "pending" so that concurrent readers know not to treat it as fully available or fully free.
The failure mode here is that the semantic lock is applied on the forward path — when the resource is reserved — but forgotten on the compensation path, or released at the wrong point relative to when downstream systems have already acted on the "pending" state. A semantic lock that isn't released atomically with the compensating transaction leaves a window where the resource looks locked to every other saga even though the transaction that locked it has already finished undoing itself. Depending on the domain, that window either silently blocks legitimate business (a seat that looks reserved for longer than it should) or, worse, gets manually cleared by an operator who doesn't understand why it's stuck, defeating the protection it existed to provide.
Three Scenarios: What This Looks Like When It Actually Happens
The following three scenarios are hypothetical composites, constructed for this article to illustrate the failure modes above in realistic operational detail. They do not describe any real QAtronic client, incident, or measured outcome. Each follows the same structure: the initial situation, the assumption nobody examined, the technical or organizational cause, the consequence, the decision the team faced, and the better approach.
Scenario 1 — E-Commerce: The Refund That Was Silently Short
Initial situation. A direct-to-consumer retailer runs a standard three-step saga for every order: reserve inventory, charge the card, schedule shipment with a third-party carrier API. The saga has run in production for over a year without a publicized incident.
Hidden assumption. The engineer who wrote the compensating transaction for the payment step assumed the refund amount could simply be recomputed from the current state of the order at the moment compensation runs, rather than captured from the original charge event. This seemed reasonable: the order total is stored on the order record, and recomputing it avoids having to pass extra data through the saga's event payloads.
Technical/organizational cause. Between the moment the card is charged and the moment shipment scheduling fails — typically a gap of a few seconds, but occasionally minutes during a carrier API outage — a promotional discount applied at checkout can expire from the pricing engine's cache, or a price-sync job can update the product's listed price. The order's stored total reflects what the customer was actually charged. The recomputed total, calculated from current pricing rules at compensation time, does not.
Consequence. When shipment scheduling fails and the payment step compensates, the refund issued is calculated against the current, uncached price — which in this scenario is higher than the discounted price the customer was actually charged. The refund undershoots the original charge by the value of the expired promotion. The customer is left having paid for an order that was cancelled, short by whatever the discount was worth, and nothing in the system flags the discrepancy because the compensation "succeeded" by every metric the team was tracking — a refund API call returned 200, and a refund record was created.
The decision. The team could patch the immediate bug by having the compensating transaction always pull the original charge amount from the payment event rather than recomputing it — a small, low-risk fix. Or they could treat this as a signal to audit every other compensating transaction in the saga for the same class of error: any compensation that recomputes a value from current state, rather than capturing the value the forward step actually produced.
The better approach. Compensating transactions should operate on data captured at the time the original step executed, not data reconstructed from current system state. In practice, this means the payment-charge event needs to carry the exact charged amount, currency, and any applied discounts as part of its payload, and the compensation logic needs to treat that payload as the source of truth — never as a value to be independently recalculated. This is a narrow rule, but it generalizes: any compensating transaction that says "recompute X" instead of "read the X we recorded" is a candidate for exactly this bug.
Scenario 2 — Fintech: The Double-Release That Inflated a Balance
Initial situation. A digital wallet provider runs a peer-to-peer transfer saga: place a hold on the sender's balance, run an automated fraud check through a third-party vendor, post the transfer to the receiver's ledger, and notify both parties. The fraud-check step occasionally times out because the vendor's API has inconsistent latency during peak hours.
Hidden assumption. The engineer who built the compensating transaction for the hold step wrote it as available_balance += held_amount — an increment, mirroring how the forward step decremented the balance. This mirrors the forward logic closely enough that it passed code review without discussion.
Technical/organizational cause. The orchestrator's retry policy resends the "release hold" command up to three times if it doesn't receive an acknowledgment within a short timeout window. During a period of elevated latency on the ledger service, the first release call actually succeeds and updates the balance, but the acknowledgment is delayed past the timeout. The orchestrator, having no way to distinguish "the call succeeded but the response was slow" from "the call never arrived," retries — and the retry executes the same increment again, because the release action was never made idempotent.
Consequence. The customer's available balance is inflated by one extra copy of the held amount. Because the increment-based design has no natural check against double-application — there's no state to compare against, just an arithmetic operation — nothing in the transfer saga itself detects the error. It surfaces later, during a routine ledger reconciliation, as a discrepancy between the sum of individual account balances and the platform's total liability figure — the kind of mismatch that triggers a compliance review rather than a simple bug ticket, because for a regulated financial product, an inflated customer-facing balance is not merely a bookkeeping error.
The decision. The immediate fix is narrow: make the release-hold operation idempotent, most simply by tracking hold records with unique identifiers and having the release action check whether that specific hold has already been released before applying any balance change, rather than blindly incrementing. The larger decision is whether to audit every compensating transaction across the ledger platform for the same increment/decrement anti-pattern, since the same reasoning that produced this bug was very likely applied consistently across other sagas built by the same team.
The better approach. Compensating transactions involving any kind of balance, counter, or quantity should be designed as idempotent set-or-check operations tied to a unique identifier for the specific event being compensated, not as arithmetic increments or decrements. "Release this specific hold, identified by hold ID X, if it hasn't already been released" is idempotent by construction. "Add this amount back to the balance" is not, no matter how carefully the amount is calculated, because the operation itself carries no memory of whether it has already run.
Scenario 3 — Marketplace and Logistics: The Compensation That Should Not Have Been a Rollback
Initial situation. A marketplace platform fulfills multi-vendor orders by splitting a single customer order into per-vendor sub-orders, each handled by an independent fulfillment saga: reserve stock at the vendor, request pickup from the shared logistics network, and confirm dispatch. A customer orders three items from three different vendors in one checkout. Two vendors dispatch their portions within hours. The third vendor's reservation fails after the item has actually gone out of stock at their warehouse, a fact their inventory system doesn't reflect until dispatch time.
Hidden assumption. The engineering team assumed "saga failed, so compensate" meant the same thing regardless of which step failed or what had already physically happened as a result of the steps that succeeded. Their orchestrator was built with a single generic compensation routine: on any downstream failure, walk back through every completed step in reverse order and call each one's compensating action.
Technical/organizational cause. By the time the third vendor's stock-out is discovered, the first two vendors' items have already been physically picked up by couriers — an action that, unlike a database write, cannot be reversed by any API call. The orchestrator's generic compensation logic doesn't know this. It calls the "cancel shipment" compensating action for all three sub-orders in reverse order, including the two that were already physically in transit. The two carriers' APIs handle a cancellation request on an already-picked-up shipment inconsistently: one returns a success response while the physical package continues moving, because cancellation past pickup only stops billing, not the physical package; the other returns an error, which the orchestrator interprets as a compensation failure and escalates to a dead-letter queue.
Consequence. The system now believes two of the three sub-orders are cancelled — in one case because a "successful" cancellation call created that belief incorrectly, and in the other because the failed cancellation call triggered a generic stuck-saga alert with no domain context attached. Meanwhile, both packages are actually en route to the customer. Support and finance discover the mismatch only when the customer contacts them asking why they received two of three items they were told (by an automated cancellation email) were cancelled.
The decision. The team's first instinct is to make the "cancel shipment" API calls more reliable — better retries, clearer error codes. That would not fix the underlying problem. The real decision is whether every step in a saga should even have a symmetric compensating action, or whether some steps — specifically, anything that has crossed a physical or otherwise irreversible boundary — need a different kind of failure handling entirely.
The better approach. This is precisely what the pivot-transaction concept exists to address, and it is also a case where full compensation is the wrong goal. Once a shipment has been physically dispatched, that step has passed its own local point of no return, regardless of what happens elsewhere in the saga. The correct design is not a "cancel shipment" compensating action invoked blindly by a generic rollback routine; it is domain-aware compensation logic that recognizes dispatched sub-orders as already-pivoted and routes the overall saga toward a partial fulfillment outcome — proceed with the two dispatched items, refund only the third vendor's portion, and generate an accurate, itemized notification to the customer reflecting what is actually happening to their order. A saga's compensation strategy has to be designed per step, informed by which steps are genuinely reversible and which are not, rather than implemented once as a single generic "walk backward and undo everything" routine and applied uniformly regardless of what each step actually did.
Why Standard Testing Practices Under-Test the Compensation Path
The failure modes above share a common thread: none of them would be caught by the kind of testing most teams already do well. It's worth being specific about why, because the gap isn't a matter of test quantity — plenty of these systems have respectable code coverage numbers. It's a matter of what the tests are actually checking.
| Dimension | Forward path | Compensation path |
|---|---|---|
| Typical unit test coverage | High — usually exercised by the same tests that validate the feature | Low — often only exercised if a developer deliberately wrote a failure-injection test |
| Typical integration test coverage | High — CI pipelines commonly run full happy-path saga scenarios | Sparse — requires deliberately failing a downstream call, which most integration suites don't simulate at every step |
| Exercised on every pull request | Usually yes, via existing feature tests | Usually no, unless the PR specifically touches compensation code |
| Idempotency explicitly tested | Occasionally, for the payment step specifically, because payment idempotency has an obvious financial consequence | Rarely, and rarely for any step besides payment |
| Concurrency/interleaving tested | Rarely for either path, but forward-path race conditions tend to surface faster because they affect visible features | Almost never — semantic-lock and interleaving bugs typically surface only under real production concurrency |
| How failures are typically discovered | Automated tests, staging environments, or immediate customer-facing errors | Manual reconciliation, audit findings, or customer complaints, often weeks after the underlying bug shipped |
The pattern in that table is not that compensation code is tested less because engineers are lazy about it. It's that the standard testing techniques teams already use — unit tests against a function, integration tests against a happy-path scenario, code coverage as a proxy for confidence — are built around the assumption that a code path gets exercised the same way each time it runs. Compensation code violates that assumption structurally: it only runs after a failure, its correctness depends on exactly what state the forward step left behind, and its risk is concentrated in scenarios (double invocation, partial forward success, concurrent interleaving) that don't show up unless someone deliberately constructs them.
Choreography-based and orchestration-based sagas fail this testing gap in different ways, which matters when deciding which pattern to test for and how.
| Aspect | Choreography (event-driven, no central coordinator) | Orchestration (central coordinator directs each step) |
|---|---|---|
| How a compensation trigger is discovered | Implicitly, by tracing which service published which event in response to which failure — often undocumented outside the code itself | Explicitly, in the orchestrator's own workflow definition, which can usually be read as a specification of the failure paths |
| Ease of testing one compensating action in isolation | Straightforward — each service's compensation handler can be unit-tested against a synthetic failure event | Straightforward, similarly — the participant's compensation logic is still a discrete function |
| Ease of testing the full end-to-end compensation chain | Difficult — requires standing up every participating service, or convincingly faking the event bus for all of them, to observe the full cascade | More tractable — a single orchestrator process can often be tested by mocking its downstream calls and asserting on the sequence of compensation commands it issues |
| Typical tooling gap | Integration tests frequently degrade into "does service A publish the right event" checks that never verify the full downstream chain actually completed | Orchestrator-level tests frequently verify the commands issued without verifying that participants actually executed them correctly, especially under retry |
| Primary test risk | A compensation handler exists and is individually correct, but the chain that should trigger it never fires end-to-end because an intermediate service doesn't subscribe to the right event, or subscribes to the wrong version of it | The orchestrator's compensation logic is correct on paper, but a participant's actual compensating action silently fails or is non-idempotent, and the orchestrator has no way to distinguish "compensated correctly" from "reported success without actually compensating" |
Neither pattern is inherently safer to test. Choreography's difficulty is that the failure path is distributed across services with no single place to read or test it as a whole. Orchestration's difficulty is that the orchestrator's confidence in a successful compensation is only as good as the honesty of the acknowledgment it receives from each participant — and that acknowledgment is exactly the kind of signal that Scenario 3 above showed can be misleading.
Illustrative Data: What the Coverage and Outcome Gap Looks Like
The two figures below are constructed, hypothetical scenarios built for this article to illustrate the shape of the problem described above. They are not measured production data, a published industry benchmark, or a claim about any specific organization. No reliable public dataset currently quantifies saga compensation frequency or test-coverage disparity across the industry, so these figures should be read as an illustration of a plausible pattern, not as a statistic to cite.
Figure 1 — Illustrative saga outcome distribution, hypothetical order-fulfillment service, 30-day window
| Outcome | Share of saga executions |
|---|---|
| Completed forward with no compensation | 96.4% |
| Single-step compensation (one prior step undone) | 2.9% |
| Multi-step compensation (two or more prior steps undone) | 0.6% |
| Compensation itself failed or required manual intervention | 0.1% |
Completed forward ############################################# 96.4%
Single-step compensation # 2.9%
Multi-step compensation 0.6%
Manual intervention needed 0.1%
What this illustrates: even in a scenario where 96 out of every 100 sagas complete without incident, the remaining fraction is not negligible at meaningful transaction volume. A platform processing 50,000 orders a day, under this illustrative distribution, would run roughly 1,450 single-step compensations and 300 multi-step compensations daily — and around 50 sagas a day that get stuck in a state requiring a human to resolve. Those are not rare-event numbers. They are the kind of volume that, if left to ad hoc handling, quietly becomes a full-time operational burden for someone, usually without ever being named as one in a headcount planning conversation.
Figure 2 — Illustrative test coverage gap, hypothetical saga codebase
| Saga step | Forward-path test coverage | Compensation-path test coverage |
|---|---|---|
| Reserve inventory | 91% | 34% |
| Charge payment | 88% | 22% |
| Schedule shipment | 84% | 12% |
Forward path Compensation path
Reserve #################### 91% ####### 34%
Charge ################## 88% ##### 22%
Ship ################# 84% ### 12%
What this illustrates: coverage tools report a healthy-looking number for the codebase overall, because forward-path logic — which is usually a larger share of total lines of code and is exercised by every feature test — pulls the aggregate figure up. A team looking only at an overall coverage percentage in the 70s or 80s can be carrying single-digit or low-double-digit coverage on the exact code paths most likely to produce a customer-facing financial or data-integrity incident, with no visibility into that gap unless coverage is broken out by path rather than reported as one aggregate number.
A Framework for Testing Compensation Logic
Closing the gap described above does not require a new testing philosophy. It requires applying the testing discipline teams already use for forward-path logic to a code path that has been getting a fraction of it. The following framework — built specifically for this article, not a repackaging of general test-planning advice — gives each saga step a fixed set of required tests, so that "we tested the saga" means something concrete and consistent regardless of which engineer wrote which step.
The Compensation Test Matrix. For every compensable step in a saga, define and pass six tests before considering that step production-ready:
- Forward test. The step executes correctly under normal conditions. (This is the test every team already writes.)
- Trigger test. A downstream failure correctly causes this step's compensation to be invoked — verified by actually failing a later step in an integration environment, not by unit-testing the compensation function in isolation and assuming it will be called correctly.
- Idempotency test. The compensating action is invoked twice for the same event. The resulting state after two invocations must be identical to the state after one. This is the single highest-value test in the matrix, because it is the one most teams skip and the one responsible for Scenario 2 above.
- Partial-forward-state test. The compensation is invoked after the forward step only partially completed — not after a clean full success or a clean full failure, but against whatever intermediate state a timeout or partial API failure would realistically leave behind. This requires the team to first identify what partial-success states are actually possible for that specific step, which is often a useful exercise on its own.
- Compensation-failure test. The compensating action itself fails or times out. Verify the system's behavior is a defined, observable state — a dead-letter entry with enough context for a human to act on, an alert that names the specific saga instance and step, a retry policy with a bounded number of attempts — rather than a silent stall.
- Concurrency test. Two saga instances that touch the same underlying resource run concurrently, with one compensating while the other is mid-flight. Verify the semantic lock, or equivalent guard, produces a consistent outcome rather than a race condition.
A simple way to operationalize this matrix is to require it explicitly in the pull request template for any change that touches saga step logic — not as a generic "did you write tests" checkbox, but as six specific named checks, each with a pass/fail state, reviewed the same way a security checklist item would be reviewed. Teams that have adopted something equivalent to this report — anecdotally, in engineering blog posts and conference talks about saga implementations — that the idempotency and compensation-failure tests are the ones that most often surface a real bug before release, precisely because they are the two categories almost never covered by default.
Fault Injection: Making Compensation Fire on Command
The Compensation Test Matrix only works if a team can reliably trigger each of those six conditions in a test or staging environment. Waiting for a real downstream failure to happen naturally is not a testing strategy — it's a hope. The practical answer is fault injection: deliberately causing a downstream call to fail, at a specific point in the saga, in a controlled environment.
There are a few workable approaches, in increasing order of realism and infrastructure investment:
- Injection at the client boundary. The service code calling a downstream dependency checks for a test-only flag or header (present only in non-production traffic) and, when set, simulates a timeout, a specific error code, or a partial-success response instead of making the real call. This is the cheapest option and works well for the trigger, idempotency, and partial-forward-state tests, because the team controls exactly what "partial success" looks like.
- Injection at a proxy layer. A sidecar or service-mesh proxy sitting in front of the real dependency can be configured, per test run, to fail a percentage of calls or fail calls matching a specific correlation ID. This is more realistic because it exercises the actual network client code and retry logic, not just business logic behind a mocked interface.
- Recorded-history replay. Borrowing a technique used by durable-execution workflow engines — Temporal's replay testing is a documented example, where updated workflow code is run against sampled histories of real production executions to catch non-deterministic or incompatible changes before deployment — teams running their own saga orchestrators can apply the same idea: capture the sequence of events from real saga executions (including real compensations that fired), and replay those recorded sequences against new versions of the compensation logic before shipping a change, to confirm the new code still produces the same corrective outcome for scenarios that have actually occurred.
The following is a text illustration of how a saga failing and compensating actually unfolds, useful as a shared reference when writing the trigger and compensation-failure tests described above:
SAGA: order-fulfillment-7f3a
STEP 1 reserve_inventory(order_id) -> COMMITTED (sku=ABC123, qty=2)
STEP 2 charge_payment(order_id, amount) -> COMMITTED (charge_id=ch_9182, amount=84.50)
STEP 3 schedule_shipment(order_id) -> FAILED (carrier_api_timeout, attempt=3/3)
ORCHESTRATOR: step 3 exhausted retries -> initiate compensation, reverse order
COMPENSATE STEP 2 refund_payment(charge_id=ch_9182, amount=84.50)
-> idempotency_key = "refund:ch_9182" (prevents duplicate on retry)
-> COMMITTED (refund_id=rf_4471)
COMPENSATE STEP 1 release_inventory(order_id, sku=ABC123, qty=2)
-> idempotency_key = "release:order_id:sku:ABC123"
-> COMMITTED
SAGA STATE: COMPENSATED
Notice what makes this trace trustworthy: every compensating call carries its own idempotency key derived from the specific event being undone, not from the saga run as a whole, and the compensation walks the steps in strict reverse order rather than in parallel — both deliberate design choices, not defaults a framework provides automatically. A team that can produce a trace like this on demand, for a synthetically injected failure, in a staging environment, has a saga they can reason about. A team that can only produce this trace by waiting for it to happen in production has a saga they are merely hoping about.
Who Owns the Compensation Path?
Testing gaps in compensation logic are frequently a symptom of an ownership gap, not just a skill gap. In a saga spanning three services owned by three teams, the forward-path ownership is clean: each team owns its own step. Compensation ownership is murkier, because the question "who is responsible for making sure the whole saga ends up in a consistent state" doesn't map neatly onto any single team's service boundary — it's a property of the sequence, not of any one participant.
| Responsibility | Typical owner in choreography | Typical owner in orchestration | Common gap |
|---|---|---|---|
| Forward-step logic | The service team for that step | The service team for that step | Rarely a gap — this is well understood |
| That step's own compensating action | The service team for that step | The service team for that step | Written, but rarely reviewed with the same rigor as the forward action |
| Correct sequencing of compensation across steps | Implicit in which events each service subscribes to — no single owner | The orchestrator team | In choreography, frequently nobody's explicit responsibility |
| Detecting a stuck or partially compensated saga | Whichever team happens to notice a downstream anomaly | The orchestrator or platform team, if a stuck-workflow dashboard exists | Often falls to whoever is paged for an unrelated symptom, weeks after the underlying event |
| The manual runbook for resolving a stuck saga | Rarely exists as a written artifact | Sometimes exists for the orchestrator generically, rarely for each specific saga | Frequently improvised live during an incident |
The practical fix is not to create a new "saga team" for every business process — that rarely scales and tends to become a bottleneck. It's to make compensation ownership an explicit deliverable of the same design process that already assigns forward-step ownership. When a saga is designed, the design document should name, for every step: who owns the compensating action, who is paged when that compensation fails, and where the runbook for a stuck instance of this specific saga lives. If a saga's design document only names owners for the forward steps, the compensation path has already been organizationally deprioritized before a single line of code is written.
Compliance and Audit Considerations Specific to Compensation Logic
Compensation logic carries a compliance dimension that forward-path logic often doesn't, and it's worth calling out separately because it changes what "correct" means for a compensating transaction beyond simply restoring the right state.
A refund, a fund-hold release, or an inventory reversal is not just an internal state change — in most regulated contexts, it is itself a reportable financial event with its own audit trail requirements, independent of the original transaction it's undoing. A compensating transaction that correctly restores the customer's balance but does so through a code path that bypasses the same ledger-entry and audit-logging discipline required of the forward transaction creates a system where the "undo" side of the business is less auditable than the "do" side — precisely backward from what a compliance reviewer or auditor would expect to find. Scenario 2 earlier in this article surfaced through a reconciliation process for exactly this reason: the compensating action changed a balance without leaving the same class of traceable record the forward action did.
Manual compensation — an on-call engineer directly intervening to unstick a saga, adjust a balance, or force a state transition — deserves its own scrutiny for a different reason. It is, functionally, a privileged write path into financial or business-critical state, executed under time pressure, often without the same review process a code change would go through. Organizations that have a clear access-control and audit-logging policy for every other privileged write path in their systems frequently have no equivalent policy for the runbook steps an engineer follows to manually resolve a stuck saga, which means the most operationally stressful, error-prone intervention in the entire system — a human directly editing state during an incident — is often also the least governed. At minimum, any manual compensation action should be logged with who performed it, what state it changed, and why, using the same rigor applied to any other privileged administrative action, and organizations in regulated industries should treat "can we produce an audit trail for every manual saga intervention in the last twelve months" as a fair question a reviewer might actually ask.
None of this argues against manual intervention as a last resort — Scenario 3 showed a case where the correct outcome required human judgment a generic automated compensation routine could not have supplied. It argues for treating that intervention as a governed action with its own record, not an invisible escape hatch outside the system's normal audit boundary.
A Maturity Model for Compensation Testing
Organizations tend to fall into one of four recognizable stages when it comes to how seriously they treat compensation logic. None of these labels are meant as a scorecard to feel bad about — most teams building their first saga-based system start at Level 0, and that's a reasonable place to start, given everything else that has to be built first. The value of naming the stages is in recognizing which one you're actually in, rather than assuming test coverage numbers or a working demo mean you've moved past it.
- Level 0 — Ad hoc. Compensation code exists because someone wrote it while building the forward path, but it has never been deliberately tested against a real failure. Confidence in it is based entirely on it "seeming reasonable" during code review. This is the default state for most first saga implementations and is not, on its own, a crisis — but it is a state where the four failure modes above are all live risks, untested.
- Level 1 — Manually verified. At least one engineer has manually triggered a failure in a staging environment and watched the compensation run once, successfully. This catches gross errors — a compensating action that doesn't exist, or one that clearly doesn't do what it's supposed to — but it does not catch idempotency bugs, partial-forward-state bugs, or concurrency bugs, because a single manual run by definition doesn't test any of those conditions.
- Level 2 — Automated compensation test suite. The Compensation Test Matrix, or something equivalent to it, runs automatically in CI for every change that touches saga step logic. Idempotency and partial-forward-state tests exist and are enforced. This is the level at which the four failure modes above become genuinely unlikely to reach production undetected.
- Level 3 — Continuously exercised in production-representative conditions. Beyond automated tests in CI, the organization periodically and deliberately injects real failures into a production-representative environment — a staged fault-injection exercise, not unlike a chaos-engineering game day but scoped specifically to saga compensation paths — and monitors whether the resulting compensations behave as designed under realistic concurrency and load. Stuck-saga dashboards exist, are actively monitored, and have named owners and documented runbooks.
Most organizations do not need to reach Level 3 for every saga in their system. A saga that touches a small dollar amount, runs infrequently, and would be cheap to resolve manually if it got stuck does not justify the same investment as a saga that moves real money at high volume, or one where a wrong compensation is legally or contractually significant. The maturity model is a tool for making that investment decision deliberately, saga by saga, rather than by default applying whatever level of rigor the original implementing engineer happened to bring to the first version.
Metrics That Actually Indicate Compensation Health
Most saga observability, where it exists at all, is built around forward-path success rate — the percentage of sagas that complete without ever entering compensation. That number is useful, but it answers a narrower question than most teams think it does. It tells you how often things go wrong. It tells you nothing about whether the system handles it correctly when they do. A saga that fails and compensates cleanly every time is a healthy system behaving exactly as designed. A saga that fails and compensates incorrectly every time can post the exact same forward-path success rate, because "compensation happened" and "compensation happened correctly" are different events, and most dashboards only track the first one.
A more useful set of measurements, specific to compensation health rather than general saga throughput, includes:
- Compensation completion rate, distinct from compensation initiation rate — the percentage of triggered compensations that reach a fully resolved terminal state, as opposed to the percentage that merely start. A gap between these two numbers is a direct measurement of Failure Mode 3 (the compensation of a compensation) actually occurring in production.
- Time-to-compensate, measured from the moment a downstream failure is detected to the moment every prior step's compensating action has committed. A rising trend here, even without an outright failure, often precedes an incident — it typically means a downstream service the compensation depends on is degrading before it fully fails.
- Duplicate-invocation rate — how often a given compensating action is called more than once for the same underlying event, regardless of whether the second call was harmless. This number won't tell you whether your idempotency handling is correct, but a team that has never measured it has no evidence either way, and a rate near zero is worth confirming rather than assuming.
- Stuck-saga age, tracked as a distribution rather than a single count — not just how many sagas are currently unresolved, but how long the oldest ones have been sitting there. A stuck-saga count of five sounds manageable until three of those five turn out to be forty days old, at which point they represent accumulated manual-reconciliation debt rather than an active operational queue.
- Compensation-triggered financial delta, where applicable — the difference between what a compensating transaction refunded, released, or reversed and what the original forward transaction actually moved. Scenario 1 earlier in this article is exactly the kind of discrepancy this metric is designed to surface; without it, that class of bug is discoverable only through manual reconciliation or a customer complaint.
None of these require exotic tooling. They require treating the compensation path as a first-class subject of the same observability investment already applied to the forward path — structured logging on every compensating call, a dashboard that separates "compensation started" from "compensation completed," and an alert threshold on stuck-saga age rather than only on stuck-saga count. Teams that add nothing beyond the two metrics they're missing most often — compensation completion rate as distinct from initiation, and stuck-saga age as a distribution — typically find they already had the data pipeline to support it; what was missing was the decision to look.
How This Differs Across Startups, Scale-Ups, and Enterprises
The right level of investment in compensation testing is not constant across company stage, and applying enterprise-grade rigor prematurely is its own kind of mistake.
An early-stage startup running its first saga-based feature, at low transaction volume, is usually better served by keeping the saga simple enough that a human can safely intervene manually when something gets stuck, rather than building automated fault injection and a six-point test matrix before the product has found its market. At this stage, the highest-leverage investment is usually just making sure every compensating action is idempotent and that stuck sagas are visible somewhere a human will actually look — Level 1 maturity, deliberately, not a gap to feel behind on.
A scale-up with real transaction volume and a growing number of engineers who didn't write the original saga is where the gap becomes genuinely expensive, because this is the stage where tribal knowledge about how a specific compensation is supposed to behave starts to outrun the number of people who still remember writing it. This is the stage where the Compensation Test Matrix earns its cost: automated tests turn implicit tribal knowledge about how compensation is supposed to behave into an explicit, enforced specification that survives team turnover, and the ownership map described earlier stops being optional once the original author of a saga is no longer the person who gets paged when it breaks.
An enterprise operating regulated financial, healthcare, or payments infrastructure faces a different pressure entirely: a stuck or incorrect compensation is not just an operational annoyance, it is frequently a reportable or auditable event, and the cost of discovering a Scenario-2-style discrepancy during a compliance review is categorically higher than discovering it through routine monitoring. At this scale, Level 3 maturity — deliberate, periodic fault injection against production-representative conditions, with named owners and documented runbooks — stops being aspirational and starts being close to a baseline expectation, particularly for any saga touching money movement, regulated data, or contractual obligations to customers.
The mistake to avoid in either direction is treating maturity level as a badge rather than a fit-for-purpose decision. A ten-person startup building Level 3 fault-injection infrastructure for an internal admin workflow is misallocating scarce engineering time. A payments platform processing regulated transactions at Level 0, relying on the same "seemed reasonable in code review" confidence a first-time saga implementation runs on, is carrying risk that has nothing to do with company size and everything to do with what the compensation is actually responsible for.
When Not to Build Compensating Transactions At All
The saga pattern, and the testing investment it demands, is not always the right answer. It's worth being direct about when compensation logic is the wrong tool, because a team that reflexively reaches for a saga out of familiarity with the pattern — rather than because the situation actually requires it — inherits all of the testing burden described above for no corresponding benefit.
- When the transaction doesn't actually cross a service boundary. If every piece of state the transaction touches lives in a single database under a single service's control, a normal ACID transaction with a real rollback is simpler, cheaper to reason about, and strictly safer than a hand-built saga. Sagas exist to solve a problem — no cross-service atomicity — that doesn't apply if there's no cross-service boundary in the first place.
- When a step is genuinely irreversible and no compensation can restore an acceptable state. Scenario 3 above showed a case where a "cancel shipment" compensating action existed but couldn't undo a physical dispatch. In cases like this, the right engineering response is not to write a compensation that pretends to work — it's to redesign the saga so the irreversible action happens as late as possible (as close to the pivot transaction as achievable), and to build an explicit, different recovery path — such as the partial-fulfillment outcome described in that scenario — for the case where an earlier step needs to be undone after the irreversible action has already occurred.
- When the volume and criticality don't justify the investment, at least not yet. A saga that runs a handful of times a day, for a non-critical internal workflow, where a stuck instance can be resolved by an engineer manually clearing a queue entry, does not need a Level 3 fault-injection program. Building comprehensive automated compensation testing for every low-stakes internal workflow is its own form of misallocated effort — the framework in this article is meant to be applied where the consequence of a wrong compensation is genuinely significant, not uniformly everywhere a saga appears in a codebase.
- When the team cannot commit to keeping compensation logic in sync with forward-path changes over time. A saga's compensating actions have to evolve whenever the forward action they undo changes — a new field on the charge payload, a new partial-success mode, a new downstream dependency. Teams that treat compensation code as a one-time deliverable, rather than a piece of logic with an ongoing maintenance obligation equal to the forward path, will watch the two drift apart regardless of how well the compensation was tested on day one.
A Practical Checklist for Reviewing an Existing Saga
For a team that already has one or more sagas in production and wants a starting point for assessing real exposure, the following questions are a reasonable first pass — deliberately built around the failure modes and ownership gaps covered above, not a generic architecture-review checklist:
- For every compensable step, can you point to a specific test that calls the compensating action twice and asserts identical resulting state?
- For every compensable step, has someone documented what partial-success states are actually possible for that step's real downstream dependency, and is there a test against at least one of them?
- Is there a pivot transaction explicitly identified for each saga, in writing, agreed on by the team — not inferred after the fact during an incident?
- If a compensating action itself fails, is there a specific, named alert — not a generic "workflow stuck" alert — that identifies the saga instance, the step, and enough context for a human to act without first reverse-engineering the event history?
- Does a design document or comparable artifact name, for every step, who owns the compensating action and who is paged if it fails?
- Has the team ever deliberately injected a failure into a staging or production-representative environment to observe a real compensation run end to end, or has every compensation in production so far been the first time it has ever actually executed?
A team that can answer all six of these with confidence is well ahead of the median saga implementation described throughout this article. A team that finds itself unable to answer several of them has just produced, at no engineering cost beyond reading this list, a prioritized starting point for where to invest first.
Frequently Asked Questions
Is a compensating transaction the same thing as a database rollback? No. A database rollback reverses uncommitted writes using the storage engine's own atomicity guarantees. A compensating transaction is a separate, explicitly written business operation that runs after an earlier step has already committed, and it moves the system toward an acceptable state rather than erasing the earlier commit. The distinction matters because a compensating transaction can itself fail, be delayed, or interact with concurrent activity in ways a database rollback cannot.
Does every step in a saga need a compensating action? No. Steps after the pivot transaction are typically retried until they succeed rather than compensated, and steps that are genuinely irreversible — like a physical shipment already picked up by a courier — need a different recovery strategy entirely, as shown in the marketplace scenario earlier in this article. Writing a compensating action for a step that shouldn't have one adds complexity and a false sense of safety without adding real reversibility.
Can compensating transactions realistically be tested in an automated CI pipeline? Yes, and this is the central practical recommendation of this article. Fault injection at the client boundary or proxy layer, combined with the six-test Compensation Test Matrix described above, can run automatically on every pull request that touches saga step logic, the same way forward-path feature tests already do.
How is a pivot transaction different from any other step, and why does it matter for testing? The pivot transaction is the step after which the saga is guaranteed to run to completion, one way or another — it will never be un-run. Identifying it explicitly matters for testing because every step before the pivot needs a genuinely tested compensating action, while every step after it needs robust retry and idempotency handling instead. Teams that never identify their pivot transaction end up testing the wrong things on the wrong sides of that boundary.
Should a compensating action be idempotent even if the forward action it undoes is already idempotent? Yes, and the two need to be verified independently. Idempotency in the forward action does not imply idempotency in the compensating action — they are different pieces of code, frequently written at different times, and the fintech scenario earlier in this article is a direct illustration of a compensating action that was not idempotent even though the corresponding forward action was.
Who should own compensation logic when a saga's steps span multiple teams? Each team should own the compensating action for its own step, the same way it owns the forward action — but the sequencing, alerting, and runbook for the saga as a whole need an explicit owner too, whether that's a platform team maintaining the orchestrator or a designated owner named in the saga's design document. Leaving that ownership implicit is one of the most common reasons compensation logic degrades over time even when each individual step was well-built initially.
Is a manual runbook a legitimate long-term answer for a stuck saga, or only a stopgap? It depends on frequency and stakes. For a low-volume, low-consequence saga, a well-documented manual runbook can be a reasonable permanent answer — building automated recovery for a scenario that happens twice a year and costs an engineer twenty minutes to resolve is not always the best use of engineering time. For a high-volume or financially significant saga, a manual runbook that gets exercised regularly is a signal the underlying compensation logic needs an automated fix, not a sign the runbook is doing its job well. The distinction is whether the manual step is a rare safety net or a routine part of how the saga actually gets resolved in practice.
Where QAtronic Fits
Compensation logic sits in an uncomfortable spot for most quality assurance practices: it's not covered by feature testing, because it isn't the feature; it's not covered by typical performance testing, because it isn't the load path; and it's not covered by security testing, because it isn't an attack surface in the conventional sense. It falls into the same category as a lot of the highest-risk code in a distributed system — technically someone's responsibility, practically nobody's default focus.
QAtronic works with engineering teams to bring the same rigor to failure and rollback paths that gets applied by default to forward-path features — building the fault-injection scaffolding needed to trigger compensation deliberately rather than waiting for it to happen in production, defining the idempotency and partial-state test cases specific to a given saga's actual dependencies, and helping teams identify where compensation ownership has quietly gone undefined. If your organization runs multi-step business transactions across services and isn't confident it could answer the six-question checklist above, that's a reasonable place to start a conversation.
The Question to Take Back to Your Team
The forward path of a saga gets tested because it's what makes the product work. The compensation path gets tested, if it gets tested at all, only after someone decides it's worth the effort — and that decision is usually made reactively, after an incident has already made the cost of skipping it concrete.
The principle worth carrying forward is simple to state and easy to forget in practice: a compensating transaction is production code that runs under production failure conditions, and it deserves the same design scrutiny, the same idempotency guarantees, and the same test coverage as the code it's undoing — not a fraction of it, and not only after the first time it embarrasses someone.
The next time your team reviews a saga's architecture diagram, ask to see the arrows pointing backward. Ask who wrote the compensating action for each one, who tested it against a duplicate invocation, and who gets paged if it fails. If those questions don't have confident answers, you already know where your next reliability investment belongs — not in the diagram everyone can draw from memory, but in the half of it nobody has looked at since the day it shipped.