Back to Spy NATS
Spy NATS Blog/ Orchestrating a Flight Booking Saga with JetStream

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.

4 min read
  • 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

terminal 1
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" --select
terminal 2 — every step of every booking, on one stream
nats stream add BOOKINGS \
  --subjects "booking.>" \
  --storage file --retention limits \
  --max-age=30d --max-msgs=-1 --max-bytes=-1 --defaults

Step 2 — the happy path, one event per step

terminal 3 — a booking request comes in
nats pub booking.requested '{"bookingId":"BK-1","flight":"UA123","seat":"14C"}'
terminal 4 — seat service subscribes, holds the seat, emits its own event
nats sub "booking.requested"
terminal 5 — simulating the seat service's reaction
nats pub booking.seat-held '{"bookingId":"BK-1","seat":"14C"}'
terminal 6 — payment service reacts to the seat hold, charges the card
nats pub booking.paid '{"bookingId":"BK-1","amount": 412.00}'
terminal 7 — ticketing reacts to payment, issues the ticket
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

terminal 6 — this time, payment fails
nats pub booking.payment-failed '{"bookingId":"BK-2","reason":"card_declined"}'
terminal 4 — the seat service subscribes to failures too, and releases its hold
nats sub "booking.payment-failed"
terminal 4 (reacting) — release the seat, so it doesn't stay blocked forever
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:

terminal 2 — the payment service's durable consumer
nats consumer add BOOKINGS payment-service \
  --filter "booking.seat-held" \
  --deliver all --ack explicit --max-deliver=5 --defaults
terminal 6 — pull-and-ack, simulating the payment worker's loop
nats consumer next BOOKINGS payment-service --count 1 --ack

Step 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:

terminal 2 — every event for one booking id, in order
nats stream view BOOKINGS --subject "booking.>" | grep "BK-2"

Troubleshooting

SymptomLikely causeFix
Seat stays "held" forever after a payment failureSeat service never subscribed to booking.payment-failed, so no compensating event firedEvery 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 bookingPayment service consumer used --ack none and reprocessed on redelivery/restart, charging againUse --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 serviceWrong 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 eventThe service between the two crashed before publishing anything, and isn't using a durable consumerSwitch that service to a durable, explicit-ack consumer so a crash before its own publish still redelivers the triggering message for a retry

Recap

ConceptTakeaway
One subject per saga stepbooking.seat-held, booking.paid, etc. — no direct service-to-service calls
Compensating eventsbooking.payment-failed → seat service releases its hold — the "undo" path is just another subject
Durable + explicit ack per servicePrevents a crash from silently dropping or double-processing a saga step
One stream, filtered replayFull audit trail per booking id, across every step and outcome

SpyNATS is an independent tool and is not affiliated with, sponsored by, or endorsed by Synadia Communications or the NATS.io project.