A distributed transaction that knows how to reverse itself.
An orchestration-based saga in Spring Boot and RabbitMQ — coordinating a multi-step money transfer across independent services, and automatically unwinding the completed steps when any one of them fails.
Problem · How it works · Architecture · Run it · Decisions
Moving money from one account to another sounds like a single operation. Across a microservice system, it isn't. It is at least three: debit the sender, credit the recipient, write the ledger entry — each owned by a different service, each with its own database. There is no transaction that spans all three. So the moment the credit succeeds but the ledger write fails, you are left with money debited from one account, credited to another, and no record of why — an inconsistency a naive design will simply leave behind.
unwind is a working answer to that failure. It implements the saga pattern with a central orchestrator that walks the transfer forward one step at a time and, the instant a step fails, walks it backward — issuing the inverse of every step that already committed, in reverse order, until the system is whole again. The failure paths are not an edge case bolted on at the end; they are the entire point of the project.
A live React monitor makes the abstraction concrete: each transfer is a thread that draws forward as it settles, and visibly retracts when it unwinds.
A transfer of $100 from A to B runs as an ordered saga. Each forward step has a compensating inverse:
| # | Forward step | Service | Compensating step |
|---|---|---|---|
| 1 | Debit A | account-service | Refund A |
| 2 | Credit B | account-service | Reverse credit B |
| 3 | Record ledger | ledger-service | — (final step, nothing to undo) |
The orchestrator sends step 1, waits for the reply, sends step 2, and so on. If step 2 fails, it compensates step 1 (refund A). If step 3 fails, it compensates steps 2 and 1 in reverse (reverse the credit to B, then refund A). Because compensation always runs in the reverse of the order things happened, the system lands back in a consistent state regardless of where the failure struck.
Every saga is a persisted state machine:
| State | Meaning |
|---|---|
STARTED |
Saga created, debit dispatched |
DEBITED |
Sender debited, crediting recipient |
CREDITED |
Recipient credited, writing ledger |
COMPLETED |
All steps succeeded |
COMPENSATING |
A step failed; inverse steps in progress |
FAILED |
Fully unwound; system consistent |
Because the state is written to PostgreSQL at every transition, the saga's progress is durable and auditable — you can always ask exactly where a transfer is and what it has done.
The orchestrator never calls a service directly. It publishes a command to RabbitMQ; the target service acts and publishes a reply event; the orchestrator advances its state machine on that reply. Services share no database tables — consistency across them is maintained entirely by the saga.
| Module | Responsibility |
|---|---|
| orchestrator | The coordinator. Owns the persisted state machine, dispatches commands, runs compensation logic, exposes the REST API, and pushes live updates over WebSocket. |
| account-service | Owns account balances. Executes debit and credit, plus their compensations (refund, reverse). |
| ledger-service | Records completed transfers. |
| common | The shared vocabulary — command and event contracts every module speaks. |
| frontend | React monitor. Starts transfers, injects failures per step, and renders each saga as a thread that advances and unwinds in real time. |
| Layer | Choice |
|---|---|
| Language | Java 21 |
| Framework | Spring Boot 3.3 |
| Messaging | RabbitMQ via spring-amqp (command / reply) |
| Persistence | PostgreSQL via Spring Data JPA, one schema per service |
| Realtime | Raw WebSocket (spring-boot-starter-websocket) |
| Frontend | React + Vite + TypeScript + Tailwind CSS v4 |
| Build | Multi-module Maven |
| Infrastructure | Docker Compose |
Prerequisites: Java 21, Node 20+, and Docker.
Start the infrastructure, build the backend, run each service, and launch the monitor:
# 1. RabbitMQ + PostgreSQL
docker compose up -d
# 2. Build all modules
./mvnw clean install
# 3. Run the services (each in its own terminal)
./mvnw -pl account-service spring-boot:run
./mvnw -pl ledger-service spring-boot:run
./mvnw -pl orchestrator spring-boot:run
# 4. Run the monitor
cd frontend && npm install && npm run devOpen the monitor at http://localhost:5173. Send a transfer and watch the thread settle. Then set Inject failure to at credit or at ledger and watch the same transfer unwind — the completed steps retracting in reverse as the orchestrator compensates.
| Surface | URL |
|---|---|
| Live monitor | http://localhost:5173 |
| Orchestrator API + WebSocket | http://localhost:8080 |
| RabbitMQ management | http://localhost:15672 |
Accounts acct-A (1000.00) and acct-B (500.00) are seeded automatically on startup.
Orchestration over choreography. In choreography, services react to one another's events with no central coordinator — elegant for simple flows, but the transaction logic scatters across services and no single place knows the overall state. Orchestration keeps the saga's lifecycle in one legible place, which is what makes the pattern teachable and debuggable. Choreography is a natural second implementation.
A hand-rolled state machine, not a library. A money-transfer saga is a short linear sequence with a reverse compensation path. A persisted status enum plus a decision function expresses it exactly, and keeps the logic visible in the codebase rather than hidden behind a framework's abstractions.
Asynchronous messaging, not synchronous REST between services. Commands travel over RabbitMQ, so a briefly-offline participant does not fail the transfer — the command waits in its queue. This mirrors how real financial systems coordinate, and makes the saga naturally resilient to restarts.
Deliberate scope choices, each with a well-understood production path.
- Balances are illustrative — enough to demonstrate the saga, not a real banking ledger (no double-entry accounting, currencies, or interest).
- A single orchestrator instance owns each saga. Running several would require distributed locking on saga rows.
- Compensations are assumed to succeed — the demo does not model a failing compensation, which a production system handles with retries and a manual-intervention queue.
- At-least-once delivery — a failed message is rejected and dropped rather than deduplicated against a persisted message ID, which a production consumer would add for full idempotency.
Complete and working end to end: the happy path settles, single-step failures compensate, and multi-step failures unwind in reverse order — all visible live in the monitor.
Released under the MIT License. See LICENSE.
