Architecture Decision Record
ADR-054: Choreographed Saga Compensation with a Reconciliation Backstop
Status
Accepted (2026-07-25).
Context
Checkout spans a boundary no transaction covers. CheckOutHandler commits the order insert, the cart
transition and the atomic conditional stock decrements in one local transaction
(MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.Application/ShoppingCarts/UseCases/CheckOut/CheckOutHandler.cs:91-102),
but the money moves at Stripe and the confirmation arrives later, as a webhook, from outside the
database. Two failure shapes follow directly:
- A later step invalidates an earlier one. Cancelling an order leaves stock that checkout already committed. The decrement cannot be rolled back, because its transaction closed minutes ago.
- The external confirmation never arrives. A dropped webhook (endpoint outage past Stripe's retry
window, a misconfigured secret) leaves the order in
PaymentInitiatedforever with its stock held.
Neighbouring records answer adjacent questions and deliberately not this one. ADR-003 gets an event out of the process at least once. ADR-021 stops a broker redelivery from being applied twice at the consume edge. ADR-006 decides database-per-service and records "no cross-database transactions, no two-phase commit" only as a cost it accepts. None of them says what a multi-step workflow does when step two fails or step three never reports back. That is the decision recorded here: consistency without a distributed transaction.
Decision
Multi-step workflows are choreographed sagas: each step raises a domain event, and the follow-up or compensating action lives in its own handler. A periodic reconciliation sweep is the saga-timeout backstop for steps that depend on an external system.
- Compensation is a domain-event handler, never code in the command handler.
CancelOrderHandlerperforms the guarded transition and saves, and nothing else (.../Orders/UseCases/Cancel/CancelOrderHandler.cs:42-49); restoring stock isOrderCancelledSagaHandler : IDomainEventHandler<OrderCancelled>(.../Orders/Saga/OrderCancelledSagaHandler.cs:31-34) and notifying the customer of a failed payment isOrderPaymentFailedSagaHandler(.../Orders/Saga/OrderPaymentFailedSagaHandler.cs:20-22). A new compensating action is a new handler, not an edit to the command. - Each handler runs in its own DI scope. Domain-event handlers are registered as singletons
(
MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:117-122), so every one opens a scope throughIServiceScopeFactoryand resolves its ownIUnitOfWork(OrderCancelledSagaHandler.cs:41-42,OrderPaymentFailedSagaHandler.cs:29-31,.../Infrastructure/Services/PaymentReconciliationService.cs:93,136). Compensation therefore commits on its own, after the originating save, rather than joining the transaction it is compensating for. - Idempotency is a persisted marker committed by the SAME
SaveChangesas the compensating writes.Order.InventoryRestored(.../Domain/Orders/Order.cs:42-48) is the marker;MarkInventoryRestoredrefuses a second call and refuses a non-cancelled order (Order.cs:262-285). The handler checks the marker first (OrderCancelledSagaHandler.cs:56-62), applies the increases through a pure domain service (.../Domain/Services/InventoryRestorationDomainService.cs:14-27), then oneSaveChangesAsynccommits the increases and the marker together (OrderCancelledSagaHandler.cs:77-85). Same database, one transaction: the marker cannot exist without the writes it guards, and the writes cannot land unmarked. - Redelivery is the retry mechanism. A failing in-process handler leaves its outbox row
unprocessed (
MMCA.Common/.../Interceptors/DomainEventSaveChangesInterceptor.cs:255-277) and theOutboxProcessorre-dispatches the pure domain event on a later cycle (MMCA.Common/.../Outbox/OutboxProcessor.cs:366-369), with the bounded retries, backoff and dead-lettering ADR-003 already defines. Handlers whose work is a pure side effect and must not force a redelivery swallow their own failures instead (OrderPaymentFailedSagaHandler.cs:52-55; the packaged form isMMCA.Common/.../DomainEvents/SafeDomainEventHandler.cs:14-32). - Concurrent redeliveries are serialized by the
RowVersionconcurrency token. Every auditable entity carries one (MMCA.Common/.../Entities/AuditableBaseEntity.cs:39), configured as a concurrency token on every non-owned auditable type (MMCA.Common/.../DbContexts/ApplicationDbContext.cs:270-290, ADR-035). Two deliveries that both pass the marker check carry the same original token into their update: one commits, the other getsDbUpdateConcurrencyExceptionand its outbox retry then finds the committed marker and skips. - A periodic sweep drives the transitions a lost webhook would have.
PaymentReconciliationService(.../Infrastructure/Services/PaymentReconciliationService.cs:33-38) is registered as a hosted service by the Sales module's infrastructure (.../Infrastructure/DependencyInjection.cs:34-36). Each cycle selects the oldest orders that have sat inPaymentInitiatedpast a cutoff, ordered, bounded and projected to ids entirely in SQL (PaymentReconciliationService.cs:105-113) over a dedicated filtered index (.../Persistence/EntityConfiguration/OrderConfiguration.cs:61-67), asks Stripe for the session's authoritative status, and applies the matching transition: paid toMarkAsPaid, expired toMarkAsPaymentFailed, still open to nothing (PaymentReconciliationService.cs:184-206). It is configuration-gated (.../Infrastructure/Settings/PaymentReconciliationSettings.cs:13-36, defaults of a 10-minute interval, a 30-minute stuck age and a 50-order batch, carried inMMCA.Store/Source/Services/MMCA.Store.Sales.Service/appsettings.json:49-54). - The sweep gets no private path into the aggregate, and loses races on purpose. It calls the same
guarded transitions as the webhook handler
(
.../Orders/UseCases/ProcessPaymentWebhook/ProcessPaymentWebhookHandler.cs:67,98) and the client-initiated check (.../Orders/UseCases/VerifyPayment/VerifyPaymentHandler.cs:64). It reloads each order tracked in its own scope and re-checks the status under the fresh load (PaymentReconciliationService.cs:147-151), and aDbUpdateConcurrencyExceptionfrom a webhook that won the race is logged and skipped, not retried (PaymentReconciliationService.cs:171-176).
The loop shape is deliberate and shared: an enablement gate, a startup delay, a per-cycle try/catch
that never kills the loop, and every wait through TimeProvider
(PaymentReconciliationService.cs:41-80). MMCA.Common ships exactly that shape as a base class,
PeriodicBackgroundService
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/PeriodicBackgroundService.cs:20-87),
but nothing derives from it yet: its only subclass in any of the four repos is the test double in
its own unit tests
(MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Services/PeriodicBackgroundServiceTests.cs:104),
and Store's sweep hand-rolls the equivalent loop.
Adoption is one module. This pattern lives in MMCA.Store's Sales module only: the two saga handlers and the one reconciliation sweep above. MMCA.ADC and MMCA.Helpdesk have no compensating saga handler and no reconciliation sweep today. The record exists because the mechanism (compensate, mark, reconcile) is the framework's stated answer to cross-boundary consistency, not because it is broadly adopted.
Rationale
- No two-phase commit is available, and none is wanted. Transactions are per data source and best-effort sequential (ADR-006), and an external payment provider cannot enlist in a database transaction at all. Compensation plus reconciliation is the only mechanism left, so the decision is how to make it disciplined rather than whether to use it.
- Choreography fits a workflow whose state is already on the aggregate.
Order.StatusplusOrder.InventoryRestoredare the saga state. An orchestrator would add a state machine and a persistence store to track what the aggregate already records. - A marker committed with its writes beats a marker written after them. Because both land in one
SaveChangesagainst one database, idempotency is a database invariant rather than handler discipline. This is strictly stronger than ADR-021's inbox, which records the message after the handlers succeed and therefore keeps a narrow crash window open. - Throwing is the correct failure mode. The outbox already owns retry policy, backoff and dead-lettering, so a handler that cannot complete should fail loudly and let redelivery re-run it rather than invent local retries.
- The sweep needs no new infrastructure. The authoritative answer already lives at the provider, so a bounded, indexed poll plus the existing guarded transitions is enough: no timer message, no scheduler, no saga-timeout store.
- Reusing the domain transitions keeps one state machine. The sweep cannot reach a state the
webhook could not, because it goes through the same
Result-returning methods (ADR-013).
Trade-offs
- Inconsistency is bounded, not eliminated. Between the cancellation commit and the compensation
commit, stock is held against a cancelled order. Between a dropped webhook and the sweep, an order
sits in
PaymentInitiatedwith stock held for up to the stuck age plus one poll interval (30 plus 10 minutes at the shipped defaults). That window is the price of not having a distributed transaction. - Compensation is best-effort per line.
RestoreInventoryskips an order line whoseInventoryItemrow is missing (InventoryRestorationDomainService.cs:20-21) and the marker still commits, so that quantity is never restored and nothing retries it. Tolerable only because an inventory row is auto-created for every variant, which makes a missing row mean the variant itself is gone. - Redelivery re-runs every handler of the event, not the failed one. The dispatcher iterates
handlers sequentially with no per-handler isolation
(
MMCA.Common/.../Services/DomainEventDispatcher.cs:55-64), so one throwing handler also skips the handlers after it, and a redelivery re-runs the ones that already succeeded. Every handler on a shared event must be idempotent or must swallow. - The sweep is not replica-leased. The outbox processor claims rows with a lease before working
them (ADR-003); the sweep takes no such claim, so at the configured
maxReplicas: 2(MMCA.Store/infra/main.bicep:922) two replicas can pick the same stuck order and each spend a Stripe status call. Correctness holds through the concurrency token; the duplicated external call does not deduplicate. - Every compensating action needs its own marker. There is no generic mechanism: the ADR-021 inbox dedups broker messages between services, not in-process handler re-runs. A second compensating action means a second persisted marker or a naturally idempotent operation, decided by the author.
- The loop scaffolding is duplicated. Store's sweep reimplements what
PeriodicBackgroundServicepackages, so the two can drift until the sweep is rebased onto the base class.
Related
ADR-003 (the outbox delivery and retry this leans on for compensation redelivery; this record says
what the redelivered handler must do), ADR-006 (which accepts "no cross-database transaction" as a
cost without saying how it is paid; this is the payment), ADR-021 (dedup of broker redeliveries
between services, complementary to the aggregate marker that dedups in-process handler re-runs),
ADR-035 (the RowVersion token that serializes concurrent compensations and lets the sweep lose a
race to a webhook), ADR-014 (the command pipeline whose commit compensation runs after), ADR-052
(in-process background work, the hosted-service family this sweep belongs to, with a fixed-interval
poll instead of a queue drain), ADR-013 (the Result-returning guarded transitions the sweep reuses
rather than bypassing).