Design Your Own: Distributed Transaction Coordinator
This is not an explainer — you already have that. This is a build session. Every segment gives you a callout telling you exactly what to draw, decide, or write in pseudocode before you're allowed to reveal. The reveal is a reference design to check your own against, not "the" answer.
✎ build it yourself firstWrite your own 5–7 FR bullets and NFR targets (throughput, p99 latency, consistency/availability trade-off) for a coordinator sitting in front of MF redemption, IPO allotment, NSDL demat, PolicyBazaar, and LAMF. Then write one sentence: what are you explicitly scoping OUT, and why?
Q1Reveal: a reference FR list — compare shape, not just count.reveal
1. Start a named saga instance from a caller-supplied definition + business payload.
2. Execute steps in a defined order, invoking each participant service's API.
3. Track per-step and overall saga state durably, queryable at any time.
4. On step failure, run compensations in reverse order for all completed steps.
5. Detect and resolve ambiguous outcomes (timeouts) via reconciliation, not blind retry.
6. Guarantee idempotent step execution and idempotent compensation under retry.
7. Expose saga status to operators/support without requiring log spelunking.
Why this shape: notice requirement 5 and 6 aren't "nice to have" — they exist because segment-1 FRs for a coordinator have to encode failure handling as a first-class function, not an afterthought. A coordinator whose FR list reads like a CRUD service (create, read, update, delete saga) has already missed the point of what makes this system hard.
Q2Reveal: NFR targets — and why the numbers should differ per flow, not be one blanket SLA.reveal
Step latency (p99)
<800ms excl. external calls
Saga start throughput
2k/s peak, order flow
Coordinator availability
99.95% control plane
Consistency
Strong state; eventual effects
Why differ per flow: IPO allotment tolerates minutes of latency per step (it's batch-adjacent); LAMF disbursal cannot tolerate an ambiguous intermediate state for long because money is moving. A single blanket NFR either over-engineers the cheap flows or under-protects the expensive ones — NFRs belong to the saga type, not the coordinator as a monolith.
Q3Reveal: what to explicitly scope OUT, and why that's a design decision, not laziness.reveal
Scope OUT: the coordinator does not guarantee exactly-once delivery to participants, does not own participant-side business logic, and does not attempt cross-saga transactions (one saga can't atomically depend on another's outcome).
Why it matters: every one of those is a tempting scope-creep target — "what if we just also handled cross-saga dependencies" is exactly how coordinators turn into unmaintainable god-services. Scoping out exactly-once specifically forces every participant team to own their own idempotency, which is the correct place for that responsibility to live.
02
Scope & the core design decision
✎ build it yourself firstCommit, in writing: orchestration or choreography? And: how do steps fail — at-least-once retry with compensation, or true 2PC? Justify each against your own segment-1 numbers, not in the abstract.
Q4Reveal: orchestration vs. choreography for this specific coordinator.reveal
Orchestration. The coordinator you're building is, by definition, the central place that owns workflow sequencing — building it as a choreography engine is a contradiction, since choreography's whole premise is no central owner.
Why: your 99.95% availability and queryable-state FRs from segment 1 require a single authoritative place to answer "what state is this saga in" — that's only cheap with orchestration. Choreography could still exist elsewhere in the platform for looser flows; it's simply not what this component is.
Q5Reveal: saga-with-compensation, or 2PC — justified against your own NFRs.reveal
Saga with compensation, at-least-once step execution. 2PC is rejected here specifically because your NFR target (2k/s saga starts, 99.95% availability) is incompatible with blocking every participant on a coordinator-driven prepare phase — and MOFSL/NSDL/PolicyBazaar are external systems that categorically cannot join a 2PC protocol at all.
Why this is the load-bearing decision: this single choice determines everything downstream — your data model needs a compensation log (segment 5), your state machine needs a COMPENSATING state (segment 8), and your APIs need a compensate-callback endpoint (segment 4). Get this wrong and every later segment inherits the mistake.
03
Component breakdown
✎ build it yourself firstDraw 5–7 boxes. Before revealing, answer: why can't the component that decides "what's the next step" live in the same process/deployable as the component that durably records step outcomes?
Why the split matters: the state store must survive the orchestrator process crashing mid-step — if "decide next step" and "record what happened" are the same in-memory thing, a crash between deciding and recording silently loses the decision. Separating them forces the durable write to happen before the side effect is trusted, which is the same discipline the outbox pattern applies one layer down.
Q7Reveal: why the Timeout/Reconciliation Scheduler has to be its own component, not a method on the Orchestrator Core.reveal
Because it runs on wall-clock triggers, not saga events — it has to wake up and act even when nothing has happened, which is a fundamentally different execution model (poll/sweep) from the Orchestrator Core's event-reactive model (react to a step callback).
Why it matters: conflating them means a saga that never receives a callback (the exact failure mode a coordinator exists to catch) has nothing forcing it out of limbo — because the only component that reacts to events never gets an event to react to.
04
Low-level API design
✎ build it yourself firstSketch 5–6 endpoints yourself: method, path, caller, idempotency behavior. Then decide: should authorization scope by who owns the saga type, or by blast radius (what this specific endpoint can actually do if misused)? Write one sentence on which you'd pick and why, before revealing.
POST/sagas
Start a new saga instance from a registered definition + business payload.
Caller upstream business service (order-svc, allotment-svc)
Payload saga_type, business_key, initial context
Idempotency keyed on caller-supplied business_key — replays return the existing saga_id, never start a second instance
GET/sagas/{id}
Fetch current state, step history, and any pending reconciliation flag.
Caller support tooling, on-call dashboards, business services polling status
Payload none
Idempotency read-only, trivially idempotent
POST/sagas/{id}/step-callback
Participant reports a step's outcome (success/failure) back to the coordinator.
Caller the participant service that was just invoked (payment-svc, nsdl-adapter)
Payload step_id, outcome, idempotency_key used for that step's call
Idempotency keyed on step_id + outcome — a duplicate callback for an already-recorded step is a no-op
POST/sagas/{id}/compensate
Force compensation of a saga from its current step backward — used by reconciliation or manual ops action.
Caller the internal Reconciliation Scheduler, or an authorized operator via ops tooling
Payload reason code, triggered_by
Idempotency re-triggering on an already-compensating saga is a no-op, not a second compensation run
POST/saga-definitions
Register or version a new saga type's step sequence and compensation map.
Caller a platform/operator during deployment, never a runtime service
Idempotency versioned — same definition content produces the same version id, no silent overwrite
GET/sagas?state=PENDING_RECONCILIATION
List sagas currently stuck in an ambiguous or unresolved state.
Caller on-call engineer, or an autoscaler-adjacent alerting job watching backlog depth
Payload none (query param filter)
Idempotency read-only
Q8Reveal: authorization scoping — by ownership, or by blast radius?reveal
Blast radius.POST /sagas/{id}/compensate gets far stricter scoping than GET /sagas/{id} even though both touch "the same resource" — because one can trigger real refunds and reversals across an entire saga, and the other cannot.
Why ownership-based scoping isn't enough: "payment-svc owns the payment step" sounds right, but payment-svc calling /compensate on a saga it doesn't own the overall lifecycle of is exactly the kind of lateral-authority mistake blast-radius scoping is designed to prevent. Ownership answers "should this caller know about this saga"; blast radius answers "what's the worst thing this caller can trigger if compromised or buggy" — and the second question is the one that actually matters for a compensate endpoint.
05
Data model & sizing
✎ build it yourself firstSketch your own schema. Then estimate: rows/day, peak write throughput, and storage growth across your 80+ service footprint. Separate what's durable config from what's ephemeral derived state — write one line naming a piece of each. Finally: is this actually a big-data problem, or not?
Why append-only for step_log specifically: it's your audit trail and your reconstruction source when saga_instances' current-state pointer disagrees with reality — mutating it in place destroys exactly the evidence you'd need to debug that disagreement.
Q10Reveal: sizing, and the durable-vs-ephemeral trap.reveal
Saga starts / day
~4M across flows
Step log rows / day
~20M avg 5 steps
Peak write rate
~2k/s step_log inserts
Hot storage window
90d then cold archive
The trap: the reconciliation_queue is derived from saga_instances (anything past its timeout with no callback) — it must never be treated as the source of truth. Teams that persist it as durable state independently from saga_instances eventually get the two out of sync, and then can't tell which one to trust.
Why "is this big data" matters as a gut-check: 20M rows/day sounds large, but it's well within a well-indexed relational store's comfort zone at this write rate — reaching for a big-data platform here would trade transactional guarantees (which you need for saga_instances) for horizontal scale you don't yet need. The mistake isn't under-scaling; it's over-engineering the data layer before the numbers justify it.
06
Core algorithm / internals
✎ build it yourself firstWrite actual pseudocode — not prose — for the saga instance data structure and the "execute next step" operation. Cover: what happens under concurrent access (two orchestrator instances picking up the same saga), and what happens when there is no next step to execute (queue exhausted).
Q11Reveal: reference pseudocode for the executor loop.reveal
struct SagaInstance {
saga_id, saga_type, state, current_step_index,
lease_owner, lease_expires_at
}
function execute_next_step(saga_id):
saga = load_and_lock(saga_id) // SELECT ... FOR UPDATE
if saga.lease_owner != self and not expired(saga.lease):
return SKIP // another instance already owns this saga
acquire_lease(saga, self, ttl=30s)
if saga.current_step_index >= len(definition.steps):
saga.state = COMPLETED
persist(saga)
return DONE
step = definition.steps[saga.current_step_index]
key = idempotency_key(saga_id, step.id)
result = invoke(step.endpoint, payload, key) // at-least-once call
if result == SUCCESS:
saga.current_step_index += 1
persist(saga)
elif result == KNOWN_FAILURE:
saga.state = COMPENSATING
persist(saga)
else: // TIMEOUT / UNKNOWN
saga.state = PENDING_RECONCILIATION
persist(saga)
Why the lease matters: two orchestrator instances racing on the same saga_id without a lease can both invoke the same step — the lease turns "who executes this saga right now" into a single-owner decision without needing a separate distributed lock service, since the lease lives in the same row you're already updating transactionally.
Q12Reveal: the empty/exhausted edge case, made explicit.reveal
current_step_index >= len(steps) is not an error — it's the success terminal state, and it must be checked before attempting to invoke a step, not discovered by an out-of-bounds failure. The equivalent exhaustion check on the compensation path (compensation_index < 0) marks COMPENSATED, the failure terminal state.
Why this needs to be explicit, not implicit: an off-by-one here either double-executes the last step or silently drops it — and because this is the core loop, that bug replicates into every saga type built on top of it. This is exactly the kind of edge case a "how it works" explainer glosses over and a real build session forces you to confront.
07
Supporting subsystem — the reconciliation scheduler
✎ build it yourself firstDesign the reconciliation loop's logic precisely: what does it check, how often, what does it do on each outcome. Then decide: should this scheduler run as one centralized process, or as a distributed job per orchestrator instance? Justify before revealing.
Q13Reveal: reference reconciliation loop.reveal
every 60s:
sweep = SELECT * FROM saga_instances
WHERE state = PENDING_RECONCILIATION
AND next_check_at <= now()
for saga in sweep:
truth = query_participant_status(saga.saga_id, saga.current_step)
if truth == CONFIRMED_SUCCESS:
saga.current_step_index += 1
saga.state = IN_PROGRESS
elif truth == CONFIRMED_FAILURE:
saga.state = COMPENSATING
else: // still unknown
saga.attempt_count += 1
saga.next_check_at = now() + backoff(saga.attempt_count)
if saga.attempt_count > MAX_ATTEMPTS:
saga.state = MANUAL_REVIEW
alert(saga)
persist(saga)
Why MANUAL_REVIEW has to exist as a real terminal-ish state: not every ambiguity resolves itself automatically — a participant whose status API is also down can't be reconciled by more polling. Without an explicit escape hatch to a human, this loop just backs off forever and the saga is stuck exactly as badly as before, just with extra steps.
Q14Reveal: centralized vs. distributed reconciliation — which, and why.reveal
Centralized (a small dedicated pool, not one-per-orchestrator-instance). Reconciliation is low-frequency (60s sweep) and read-heavy against external systems — running it per orchestrator instance means every instance independently polls the same ambiguous sagas, multiplying load on already-struggling external APIs at exactly the moment they're least able to absorb it.
Why this differs from the executor itself: the step executor benefits from being distributed across instances because saga volume is high and steps are independent. Reconciliation volume is comparatively tiny (it only fires on the unhappy path) — the bottleneck risk here isn't coordinator throughput, it's hammering an already-flaky external dependency, which centralizing directly prevents.
08
Failure handling & state machine
✎ build it yourself firstDraw the full state machine yourself first — every state mentioned in segments 1–7 has to appear somewhere. Then answer: which transition guarantees a saga can never be stuck forever, and where does idempotency actually plug in?
Why every earlier-segment concept has to land somewhere here: the lease from segment 6 governs the IN_PROGRESS transition; the compensation map from segment 5's schema drives COMPENSATING → COMPENSATED; the reconciliation loop from segment 7 owns the PENDING_RECONCILIATION branch entirely. A state machine that doesn't visibly connect to every earlier decision means one of those decisions wasn't actually load-bearing.
Q16Reveal: the guaranteed-exit mechanism, and where idempotency plugs in.reveal
The MAX_ATTEMPTS → MANUAL_REVIEW transition in the reconciliation loop is the guaranteed exit — every other state either completes, fails cleanly, or eventually times its way into reconciliation, and reconciliation itself is bounded by attempt count, not by external system behavior. Idempotency plugs in at exactly three points: the step invocation (segment 4's callback keying), the compensation invocation (segment 5's compensation_map + its own idempotency_key), and the lease acquisition (segment 6 — re-acquiring an expired lease you already held must be a no-op, not a new lease).
Why "guaranteed exit" has to be a design decision, not a hope: without an explicit attempt ceiling, PENDING_RECONCILIATION can retry indefinitely against a participant that will never answer — that's not resilience, it's an invisible resource leak (open sagas, held leases, growing reconciliation_queue) that looks fine on a dashboard until it isn't.
09
Scaling this component itself
✎ build it yourself firstNot the backend systems it calls — the coordinator itself. How do multiple orchestrator instances stay roughly in sync without a shared lock becoming the bottleneck? And: name one concrete metric, tied to your segment-1 NFRs, that tells you it's time to scale out.
Q17Reveal: how instances avoid becoming a coordination bottleneck.reveal
Per-saga leasing (segment 6), not a global lock. Each orchestrator instance polls a shard of "sagas with work pending" (partitioned by saga_id hash) and only contends on the lease row for the specific saga it's about to touch — instances working on different sagas never block each other at all.
Why a global coordination layer (e.g. a single leader deciding all work) would be wrong here: saga execution is embarrassingly parallel across different saga_ids — there's no reason instance A working on saga X needs to know anything about instance B working on saga Y. Introducing a global lock or leader for this would recreate exactly the blocking, availability-limiting problem segment 2 rejected 2PC for, one layer up.
Q18Reveal: the concrete scale-out signal, tied back to segment-1 NFRs.reveal
Saga backlog age — the gap between a step becoming eligible to execute and an instance actually picking it up. When p99 of that gap exceeds the segment-1 step-latency target (800ms), that's your scale-out trigger, not raw CPU or instance count.
Why not CPU: the executor loop is mostly waiting on network I/O to participants, not computing — CPU utilization can look comfortably low while sagas are still backing up because every instance is saturated on in-flight calls, not compute. Backlog age is a direct measurement of the thing your NFR actually promised (step latency), so it's the metric that can't lie to you the way CPU can.
10
End-to-end assembly
✎ build it yourself firstDraw the whole thing from memory — both the hot/data path (a saga actually executing) and the control/config path (registering a new saga type, an operator forcing compensation) in one diagram. No peeking at segments 1–9 while you draw.
Q19Reveal: reference end-to-end diagram.reveal
CONTROL / CONFIG PATH
Operator --> POST /saga-definitions --> Saga Definition Store
HOT / DATA PATH
order-svc --> POST /sagas --> Orchestrator Core --lease--> Saga State Store
|
v
Step Invoker --> payment-svc / nsdl-adapter / ...
^ |
| POST /step-callback
|________________________|
|
(timeout) --> Reconciliation Scheduler
|
query participant status --> resolve or MANUAL_REVIEW
|
Outbox / Event Publisher --> downstream services
Why both paths have to be on one diagram: the control path (definitions) and the hot path (execution) share the Orchestrator Core as their only intersection point — if you drew them as fully separate diagrams, it's easy to forget that a bad saga-definition deploy can break every in-flight saga using it, which is exactly the kind of cross-path risk a single assembled view exposes and two separate ones hide.
Q20 — no answer keyTrace your own diagram back against your own segment-1 requirements. Where's the gap?reveal
This one has no reference answer. Walk your FR list from segment 1 line by line against the diagram you just drew — for each FR, point to the exact box and arrow that satisfies it. If any FR doesn't map to something on the page, that's not a documentation gap. That's a component you haven't actually designed yet, no matter how confident the rest of the diagram looks.