Choreography Saga
How to implement a saga with no central coordinator — each service reacts to events from its neighbors and publishes its own event in turn, forming a self-organizing chain across loan approval, funds deposit, and credit profile updates.
Learning objectives
- Explain how a choreography saga achieves a multi-step business process without any central coordinator
- Implement an event listener that reacts to an incoming event by dispatching a new command
- Implement a compensating transaction that undoes a service's own earlier step in reaction to a failure event
- Identify the point at which a choreography saga's implicit coupling becomes a genuine liability
◆ Story
Picture a long row of dominoes set up across a table. Nobody stands at the end shouting instructions to each tile — each domino simply falls the instant the one before it touches it, and its own fall is what tips the next one over. There is no controller watching the whole row, no plan written down anywhere except in how the dominoes happen to be arranged. The pattern only exists as the sum of many tiny, local, independent reactions.
A choreography saga works the same way. When a business process spans several services — say, approving a loan, which needs money deposited into an account and a customer's credit profile updated — no single service holds the full sequence of steps. Each service simply listens for events published by the services before it, does its own small piece of work, and publishes its own event when it's done. The next service in line reacts to that event the same way, and so on, until the process is complete.
This is deliberately different from calling a method directly. Loans Service never calls Accounts Service and waits for a response — it publishes LoanApprovedEvent and moves on, trusting that whoever is interested will pick it up. Accounts Service doesn't know Loans Service exists as a caller; it only knows how to react to an event type. This is what makes choreography attractive: the services stay loosely coupled, each one only needs to know about the events it cares about, not about who published them or who else might be listening.
The trade-off is that the "whole plan" — approve loan, then deposit funds, then update credit profile — doesn't live in any single place in the code. It only exists as an emergent property of how several independent listener classes, scattered across several services, happen to react to each other. That trade-off is fine for a short chain. Whether it stays fine as the chain grows longer is the real question this pattern raises.
Consider a loan approval flowing through three services, entirely through events:
- Loans Service approves a loan and publishes
LoanApprovedEvent, containing the loan ID, account ID, and approved amount. - Accounts Service has a listener watching for
LoanApprovedEvent. When one arrives, it deposits the approved amount into the customer's account and publishesFundsDepositedEvent. - Customer Service has a listener watching for
FundsDepositedEvent. When one arrives, it updates the customer's credit profile to reflect the new loan and publishesCreditProfileUpdatedEvent. - Nothing listens for
CreditProfileUpdatedEventin this flow, so the chain naturally ends there. The saga is complete — not because anything declared it complete, but because there's nothing left to react.
Notice the shape: every service in the middle of the chain both listens for the previous step's event and publishes its own event for whoever comes next. That dual role — consumer and producer at once — is what lets the chain propagate without any service needing to know more than one hop in either direction. Accounts Service doesn't know Customer Service exists; it just knows that when funds get deposited, that fact is worth telling the world about.
This also means adding a new step to the process usually means adding a new listener somewhere, not modifying an existing one. If a new notifications service wants to email the customer when their credit profile updates, it just adds a listener for CreditProfileUpdatedEvent — none of the three existing services need to change at all. That's a genuine strength of choreography: extending a flow rarely touches code that's already working.
The cost of that strength shows up when you need to answer a different kind of question: "what is the full sequence of things that happen when a loan gets approved?" With choreography, answering that means finding every listener across every service and manually tracing which ones fire for which events — there's no single file you can open to read the answer.
A choreography step is, mechanically, nothing more than a regular event handler that happens to send a command instead of (or in addition to) updating a read model. There's no special "saga" annotation or framework support required — any component that can listen for an event and use a CommandGateway can participate in a choreography saga.
◆ Under the hood
The listener below is an ordinary @EventHandler that reacts to LoanApprovedEvent by sending a DepositFundsCommand. If that command succeeds, AccountAggregate applies FundsDepositedEvent — which is exactly the event Customer Service is listening for next. There's genuinely no new mechanism here: it's the same event-handling and command-dispatch building blocks used everywhere else in an Axon application, just chained together, service after service, to form a saga.
Two things are worth noticing about this code. First, the listener lives inside Accounts Service, alongside its other event handlers — there's nothing that marks it as "part of a saga" from the outside. Second, the listener has no idea what happens after FundsDepositedEvent gets published. It doesn't know Customer Service exists, doesn't know a credit profile is about to get updated, and doesn't need to. Its entire responsibility begins and ends with "when a loan gets approved, deposit the money."
This narrow responsibility is exactly what makes choreography easy to write and easy to test in isolation — you can unit-test this listener completely by publishing a LoanApprovedEvent and asserting that a DepositFundsCommand gets sent, without needing to know or care about the rest of the saga at all.
💻 Code example
@Component public class LoanApprovedListener { private final CommandGateway commandGateway; public LoanApprovedListener(CommandGateway commandGateway) { this.commandGateway = commandGateway; } @EventHandler public void on(LoanApprovedEvent event) { commandGateway.send(new DepositFundsCommand(event.getAccountId(), event.getLoanAmount())); // If this command succeeds, AccountAggregate applies FundsDepositedEvent — // which Customer Service listens for next, continuing the chain. } }
Real-world processes fail partway through. If the deposit into the customer's account fails — insufficient permissions, a downstream system down, a business rule violation — the loan that was already approved needs to be undone. This is a compensating transaction: not a rollback in the database sense, but a new, explicit action that reverses the effect of an earlier step.
In choreography, compensation uses exactly the same mechanism as the happy path: a service listens for a failure event and reacts by sending a command against its own aggregate.
The elegance here is real. Loans Service doesn't need a special "saga-aware" mode to participate in compensation — it just adds one more listener, for DepositFailedEvent, alongside whatever other listeners it already has. When that event arrives, it sends CancelLoanCommand against its own LoanAggregate, undoing the approval it granted earlier. From Loans Service's point of view, this is no different from reacting to any other event; it just happens to be undoing its own past action rather than reacting to someone else's.
This symmetry — happy path and compensation both implemented as plain event listeners — is one of choreography's more attractive properties. There's no separate compensation framework to learn, no special state machine to configure. Each service is simply responsible for knowing how to undo the one thing it did, and for listening for whatever event signals that it needs to.
▲ Edge case
Compensation only works if the compensating command is safe to send even if the original action never actually completed, or completed twice. If LoanApprovedEvent gets redelivered (which event-driven systems must always tolerate), CancelLoanCommand needs to behave sensibly whether the loan is currently approved, already cancelled, or was never approved in the first place — an idempotent command handler, not one that assumes a single, guaranteed-once execution.
💻 Code example
@Component public class DepositFailedListener { private final CommandGateway commandGateway; public DepositFailedListener(CommandGateway commandGateway) { this.commandGateway = commandGateway; } @EventHandler public void on(DepositFailedEvent event) { // Compensation: Loans Service reacts to someone else's failure // by undoing its own earlier step. commandGateway.send(new CancelLoanCommand(event.getLoanId())); } }
Choreography's biggest weakness only shows up once a process outgrows three tidy services. With loan approval spanning Loans, Accounts, and Customer Service, the whole flow is genuinely easy to hold in your head. Now imagine seven or eight services, each one listening for two or three different events from two or three different neighbors, some of them publishing events that trigger yet more listeners two hops away.
At that point, the "plan" — the full sequence of what happens when a loan gets approved — doesn't live anywhere in the code. It exists only as an emergent pattern that you'd have to reconstruct by opening every service, finding every @EventHandler, and manually tracing which listener reacts to which event. There is no single file, no single class, that shows the whole picture.
▲ Common mistake
Treating choreography as the default choice regardless of how many steps a process has. It's an excellent fit for two or three loosely coupled steps. Reaching for it on a process with six, seven, or more steps — especially one where auditability or a clear failure story matters — usually means signing up for a debugging session where the real question ("why didn't the customer get their credit profile updated?") requires tracing through several unrelated codebases to answer.
A second, subtler mistake is forgetting that every listener in the chain needs to independently handle event redelivery, out-of-order delivery, and partial failure. Because there's no central coordinator tracking saga state, there's also no central place enforcing that the chain behaves correctly under those conditions — each service has to get it right on its own, every time.
The honest fix, when a choreography saga starts to feel unwieldy, isn't to add more documentation describing the implicit flow — it's to reach for a saga with an explicit, centrally visible plan instead.
Choreography earns its keep in processes that are genuinely short and where the participating services would benefit from not knowing about each other at all. A few patterns that fit well:
- Fan-out notifications. An order being placed might need to trigger an email, an SMS, an inventory adjustment, and an analytics update. None of these downstream reactions depend on each other, and none of them need to report back a result — a classic choreography shape.
- Small, linear pipelines. A two- or three-step process like "loan approved → funds deposited → credit profile updated" is short enough that the implicit plan stays easy to reason about, and the loose coupling means Loans Service can ship changes without coordinating with Customer Service's release schedule.
- Cross-team boundaries with minimal coordination. When different teams own different services and want to avoid one team's saga logic reaching into another team's domain, choreography keeps each team's code self-contained — they only need to agree on event shapes, not on a shared orchestrator.
In practice, many teams deliberately start a new multi-service process with choreography, precisely because it's the simpler thing to write for two or three steps, and only migrate to an explicit orchestrator once the process grows past four or five steps or starts needing a clear audit trail of what happened and why. Recognizing that moment — rather than continuing to bolt more listeners onto an already-sprawling implicit chain — is the actual skill worth building here.
Q: Does any single service in a choreography saga know the full sequence of steps? A: No. The overall flow only emerges from each service independently reacting to events published by its neighbors — no service holds the whole plan.
Q: How does a service participate in the happy path of a choreography saga? A: It listens for an event from the previous step, does its own work, and publishes its own event so the next service can react in turn.
Q: How is a compensating transaction triggered in choreography? A: The same way as any other reaction — a service listens for a specific failure event and responds by sending a command that undoes its own earlier step.
Q: What's the main cost of choreography as a saga grows past a handful of steps? A: The plan stops living anywhere in the code. Understanding the full flow means manually tracing listeners across every participating service, since there's no central, readable definition of the sequence.
Q: What should a service's compensating command handle correctly, given that events can be redelivered? A: It should be idempotent — safe to receive more than once, and safe even if the original action it's compensating for never actually completed.
Want a visual for this concept?
Generate a diagram tailored to “Choreography Saga” — the AI picks whichever visual (flowchart, comparison, sequence, etc.) best fits.
Sign in to generate a visual →