CQRS + event sourcing — plane tickets from multiple vendors

· updated

A single-file in-browser visualizer of how a CQRS + event-sourcing ticketing platform actually behaves under load and under failure. Rails on the command and query sides, PostgreSQL as the event store and read DB, RabbitMQ between them, Redis in front, three GDS-style vendors (Iberia / Lufthansa / Delta on NDC, Amadeus and Sabre respectively), a saga orchestrator wired through explicit compensations.

The default script walks the canonical edge cases — the kind of thing you discover the hard way in production:

boot                        → 5 projectors, 3 vendors, all queues empty
search (cache miss)         → Redis single-flight populator → vendor fan-out → cache hit
hold seat                   → HoldSeat → Booking aggregate v0 → v1 SeatHeld
pay → capture → issue       → v1 → v2 → v3, projection lag visible briefly
concurrent buyer race       → two HoldSeats for the last seat; one wins by version
vendor timeout              → 5 s injected on V2 → saga timeout (2.5 s) → release hold
payment OK, issue fails     → V3 5xx → refund saga (initiate → complete)
out-of-order delivery       → projector buffers the late event then applies
hold expires mid-payment    → timer fires SeatHoldExpired before pay → reject
at-least-once redelivery    → RabbitMQ dups → idempotency log skips on (agg, ver)
multi-vendor saga           → V1 leg holds, V2 leg fails → compensate V1
Redis cache stampede        → 200 concurrent gets → 1 populator (single-flight)
read-your-own-writes        → Redis write-through serves the just-booked customer
GDPR tombstone delete       → no destructive delete; PII redacted in projections
projection replay           → drop CustomerBookings, rebuild from seq 0
burst load                  → 50 cmd/s; event store grows, lag balloons + drains

Open fullscreen ↗

What this actually is (for someone who’s never built CQRS)

A regular CRUD web app stores the current state of every record. If a customer changes their address, you UPDATE the address column — the old value is gone. That’s fine for boring apps. It is not fine for a plane-ticket platform where you have to answer questions like “why did this booking end up refunded?” and “did the customer pay before or after the vendor confirmed the seat?” a year later. UPDATE loses the story.

Event sourcing says: don’t store the current state, store the history of events that led to it. SeatHeld, PaymentAuthorized, PaymentCaptured, TicketIssued — each one append-only, immutable, timestamped, signed. The current state is just a function over the history: fold(events) → state.

CQRS (Command Query Responsibility Segregation) then says: the path you write on and the path you read on don’t have to be the same shape. Writes go through aggregates — tiny consistency boundaries (one booking at a time) that enforce business rules and append events. Reads go through projections — denormalised views, rebuilt by replaying events off a bus. The hard part isn’t the pattern; the hard part is everything that goes wrong between “I appended an event” and “the read side knows about it”:

This simulator surfaces every one of those situations as a scripted scenario, with the relevant numbers visible. Toggle the failure-injection knobs in the toolbar to fire any of them on demand.

Views

ViewWhat it shows
SystemTop-down architecture diagram: Customer → Rails Command API → Booking Aggregate → PostgreSQL event store → Outbox → RabbitMQ → 5 projectors → Read DB. Query path: Customer → Rails Query API → Redis → Read DB. Saga orchestrator + 3 vendor adapters across the bottom. Particles flow between boxes on every command, event, projection. Hover anything for an explanation.
CommandAll aggregates with current version and state (status, vendor, flight, customer, PNR). Command log on the right with inflight / ok / conflict / rejected outcomes. Saga state-machine list with current step and log preview.
EventsThe append-only event store: seq, aggregate id, version, type, payload preview, timestamp. Click any event for the full payload, vendor / saga refs, and which projectors have processed it.
QueriesEach projector with its queue, lag (events behind), last-applied seq, processed count, idempotency log size. Per-projection view-state row count. Redis entries below with TTL, populating-flag, and waiter-count (single-flight).
VendorsPer-vendor (V1 Iberia / V2 Lufthansa / V3 Delta) inventory chips coloured by seat count. Saga timeline panel showing the latest sagas with their per-step log entries (ok, fail, compensate ok) — watch a multi-vendor booking unwind.

What’s “precise” about it

Controls

Key / mouseAction
spaceRun / pause the script
sStep one engine tick (50 ms simulated)
shift+sStep ×10
rReset (clear event store, projections, vendors, sagas)
vCycle System → Command → Events → Queries → Vendors
click an eventPin its full payload + processing status in the detail pane
hover anythingContext-aware explanation (HTML tooltips on toolbar / canvas tooltips on every region)
toolbar knobsSlow V2 · Fail issue · Dup deliver · Stampede · Burst · Replay

References

The patterns here are old and well-documented:

How it works under the hood

The simulator is a single HTML file with embedded CSS and JavaScript — no build, no server, no toolchain. The engine state (aggregates, event store, RabbitMQ queues, projectors, Redis, vendors, sagas, command log, timers) lives inside a small IIFE that exposes send({ cmd }) and getState(). The visualizer is a requestAnimationFrame loop wrapped in try/catch so a single regression can’t lock up the UI: every frame it reads the engine state and routes to one of five drawX() functions over a 1180 × 700 logical canvas.

Particles on the System view are spawned by the engine’s event subscription — every command, event publish and projection apply triggers one or two particles along the appropriate wire — giving you a literal picture of the read/write paths.

← distributed systems