Orchestrating a Flight Booking Saga with JetStream
A travel/airline use case: coordinating seat-hold, payment, and ticket-issuance as a multi-step saga over JetStream, with compensating events when a step fails partway through.
- nats
- jetstream
- travel
- saga
- tutorial
The scenario
Booking a flight touches multiple independent systems — seat inventory, payment, ticketing — each of which can fail independently. A saga coordinates them as a sequence of steps with an explicit compensating action (undo) if a later step fails, instead of one giant all-or-nothing database transaction spanning systems that don't share a database.
Step 1 — server + a stream for the whole saga
docker run -d --name nats-server -p 4222:4222 -p 8222:8222 -v nats-js-store:/data nats:latest -js -sd /data
nats context add local --server="nats://localhost:4222" --selectnats stream add BOOKINGS \
--subjects "booking.>" \
--storage file --retention limits \
--max-age=30d --max-msgs=-1 --max-bytes=-1 --defaultsStep 2 — the happy path, one event per step
nats pub booking.requested '{"bookingId":"BK-1","flight":"UA123","seat":"14C"}'nats sub "booking.requested"nats pub booking.seat-held '{"bookingId":"BK-1","seat":"14C"}'nats pub booking.paid '{"bookingId":"BK-1","amount": 412.00}'nats pub booking.ticket-issued '{"bookingId":"BK-1","ticketNumber":"TCK-9981"}'Every service only needs to know one thing: which subject to react to. None of them call each other directly.
Step 3 — the compensating path when payment fails
nats pub booking.payment-failed '{"bookingId":"BK-2","reason":"card_declined"}'nats sub "booking.payment-failed"nats pub booking.seat-released '{"bookingId":"BK-2","seat":"14D"}'Step 4 — durable consumers so no step is silently dropped
Each service should use a durable, explicitly-acknowledged consumer so a crash mid-processing redelivers the event instead of silently losing a booking step:
nats consumer add BOOKINGS payment-service \
--filter "booking.seat-held" \
--deliver all --ack explicit --max-deliver=5 --defaultsnats consumer next BOOKINGS payment-service --count 1 --ackStep 5 — auditing a booking's full history, regardless of outcome
Because every step (success or compensating) lands on the same stream, replaying one booking's complete story — for support/dispute resolution — is a single filtered replay:
nats stream view BOOKINGS --subject "booking.>" | grep "BK-2"Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Seat stays "held" forever after a payment failure | Seat service never subscribed to booking.payment-failed, so no compensating event fired | Every service that acquires a resource in the saga must also subscribe to the relevant failure subject and emit its own compensating event |
| Payment charged twice for one booking | Payment service consumer used --ack none and reprocessed on redelivery/restart, charging again | Use --ack explicit and make the charge operation idempotent (keyed by bookingId), acking only after the charge is confirmed persisted |
| A step's event never triggers the next service | Wrong subject name in one service's subscription (typo, wrong step) | Confirm every service's subscribed subject matches exactly what the previous step actually publishes — nats stream info BOOKINGS shows subjects observed |
| Saga "stuck" with no failure event and no next-step event | The service between the two crashed before publishing anything, and isn't using a durable consumer | Switch that service to a durable, explicit-ack consumer so a crash before its own publish still redelivers the triggering message for a retry |
Recap
| Concept | Takeaway |
|---|---|
| One subject per saga step | booking.seat-held, booking.paid, etc. — no direct service-to-service calls |
| Compensating events | booking.payment-failed → seat service releases its hold — the "undo" path is just another subject |
| Durable + explicit ack per service | Prevents a crash from silently dropping or double-processing a saga step |
| One stream, filtered replay | Full audit trail per booking id, across every step and outcome |