Module 17 · Lesson 2 of 4
CQRS
Separate models for writing and reading.
Every Monday at 9:00, the finance team runs the weekly sales report. It joins bookings, payments and shows across three million rows, and for the next four minutes checkout gets slower for everyone. The same tables serve two very different jobs, and they're bad at doing both at once. CQRS splits them.
One model, two jobs
Look at what the booking database is asked to do.
Writes are few and careful. "Book A12 and A13 for show 17" must check that the seats are free, that the fan hasn't exceeded the limit, and that payment succeeded, all inside a transaction. The data is normalised so every fact lives in one place and can't contradict itself.
Reads are many, and they want the data in many shapes. The show page wants seat availability by section. The fan's "My tickets" screen wants their bookings with show names, venues and QR codes. Organisers want sales per hour per tier. Search wants shows by city, date and genre. At Gatepass there are about 100 reads for every write.
One normalised schema serves all of these by joining at read time, and the indexes and joins that make one query fast make another one slow. Worse, a heavy read like the Monday report competes for the same CPU, memory and locks as checkout.
Separate the writing from the reading
CQRS, Command Query Responsibility Segregation, says: use one model to change data and different models to read it.
- The command side handles commands like
BookSeatsandCancelBooking. It owns the business rules and the normalised data, and it is optimised for correct, transactional writes. It answers "did it work?", not "show me a page". - The query side is one or more read models: copies of the data shaped exactly for a screen or a report, often in a different database. They're never written by users directly.
- Events connect them. Every change on the command side publishes an event, through the outbox from the last module, and a projector for each read model consumes those events and updates its copy.
Now the Monday report reads from the sales read model, a database built for scanning millions of rows by time, and checkout never notices. Each read model scales on its own: the show page model is copied to every region, while the sales model lives in one place.
Shape the write side for correctness and the read side for each screen, and connect them with events.
Building a read model
A projector is an ordinary consumer, so everything from module 15 applies. For the organiser dashboard:
on BookingCreated(showId, tier, seats, amount, at):
row = sales[showId, tier, hour(at)]
row.tickets += seats.length
row.revenue += amount
remember event_id # same transaction, so replays don't double-count
on BookingCancelled(...):
subtract the same amounts
Three properties make read models pleasant to own:
- They're disposable. A read model contains nothing that isn't in the events. If a bug corrupts it, or you want a new column, drop it and rebuild it by replaying the topic from the start (if the topic keeps all history, or from the event store if you use event sourcing).
- They're cheap to add. Product wants "fans who booked twice in a month"? Write a new projector and backfill it. You don't touch the booking service.
- They're allowed to duplicate. A show's name lives in the search index, the ticket table and the sales table. That's fine, because the projectors keep them in step from the same events.
CQRS pairs naturally with event sourcing, where the events are the write model and you have no choice but to project them. But it doesn't need it. Gatepass's booking service keeps an ordinary Postgres schema and publishes events through an outbox, and that is CQRS too.
The price: reads lag behind writes
A projector runs after the write commits, usually tens of milliseconds behind, sometimes seconds during a spike, and longer if it's down. So a fan who books and immediately opens "My tickets" might not see the new ticket. This is the read-your-writes problem from module 5, and the fixes are similar.
- Return what the screen needs from the command. The booking response includes the ticket, and the app shows it without reading the read model.
- Wait for a version. The command returns the event's position, say offset 81,442. The app asks the read model for "my tickets, at least as new as 81,442", and the read side waits briefly, or falls back to the write side, until its projector has caught up.
- Be honest in the UI. "Your ticket is being prepared" for two seconds is better than an empty list.
Watch projector lag the way you watch consumer lag. If the sales projector is ten minutes behind, the organiser dashboard is showing ten-minute-old numbers while everyone thinks they're live.
Check yourself
A bug in the organiser sales projector double-counted refunds for a week. The bookings topic keeps 30 days of events. What's the fix?
Your turn
Design the read model behind the live seat map for a show. What store would you use, what does one record look like, and which events update it?
Key takeaways
- CQRS separates the model that changes data (commands) from the models that serve reads (queries).
- Read models are shaped for one screen or report each, live in whatever store suits them, and are kept up to date by projectors consuming events.
- Read models are derived and disposable: rebuild them by replaying events.
- Reads lag writes. Return results from commands, wait on versions, and monitor projector lag.
- It's a big step. Use it when read and write shapes truly differ, or reads hurt writes.
Finished reading?
Mark it complete to tick it off in the course.