CQRS (Command Query Responsibility Segregation)

Core Idea

Separate the write model (commands that mutate state) from the read model (queries that return data). Each side is optimized independently.

Client
  ├── Command ──→ Write Model (normalised DB, domain logic)
  │                   ↓ (event / projection)
  └── Query  ──→ Read Model  (denormalised, query-optimised view)

Without CQRS, one model serves both purposes — normalized for integrity, but often slow or awkward to query (many joins, aggregate computations, cross-table lookups). CQRS lets the read side be shaped exactly for its consumers without compromising the write side.


Commands vs Queries

CommandQuery
IntentMutate stateRead state
ReturnVoid or minimal acknowledgementData
ExamplesCreateBooking, CapturePaymentGetBookingSummary, ListUserReservations
Side effectsYes — DB writes, eventsNo
IdempotencyMust be handled explicitlyNaturally safe to repeat

The split is not just architectural — it enforces that query paths never accidentally trigger side effects, and command paths are never polluted with read-optimisation concerns.


Projections

A projection is how the read model is built and kept in sync with the write model. Two approaches:

Inline Projection (Synchronous)

The read model is updated in the same DB transaction as the write:

@Transactional
void createBooking(CreateBookingCommand cmd) {
    Reservation reservation = reservationRepository.save(...);  // write model
    bookingSummaryView.upsert(BookingSummaryRow.from(reservation)); // read model — same txn
}
BEGIN TRANSACTION
  INSERT INTO reservations …          ← write model
  UPSERT INTO booking_summary_view …  ← read model (inline)
COMMIT

Properties:

  • Read model is immediately consistent — the moment the command commits, the query side reflects it
  • Both writes are atomic — if the command rolls back, the read model also rolls back
  • Write path must know about the read model’s schema — coupling between write and read sides
  • Adding a new read model requires changing the write path

Async Projection (Event-Driven)

The write side emits events (via outbox); a separate consumer updates the read model asynchronously:

BEGIN TRANSACTION
  INSERT INTO reservations …        ← write model
  INSERT INTO outbox_events …       ← event relay
COMMIT

[OutboxPoller] → Kafka → [ProjectionConsumer]
                              → UPSERT INTO booking_summary_view …

Properties:

  • Read model is eventually consistent — lags behind the write model by the delivery latency
  • Write path is decoupled from read model schema — multiple projections can consume the same event
  • Adding a new read model requires a new consumer, no write path change
  • Consumers must be idempotent (at-least-once delivery)

Comparison

Inline ProjectionAsync Projection
ConsistencyImmediate (same transaction)Eventual (delivery lag)
AtomicitySame txn — rollback cleans bothSeparate txn — failure leaves them out of sync until retry
Write path couplingTight — write service knows read schemaLoose — write service only emits an event
Multiple read modelsEach requires a write path changeEach is a new independent consumer
InfrastructureNone beyond the DBMessage broker + consumer
Failure recoveryAutomatic (transaction rollback)Consumer retry / dead-letter queue
Query freshnessAlways up-to-dateMay lag under load or failures
Operational complexityLowHigher (broker, consumers, lag monitoring)

When to Use Each

Inline projection is appropriate when:

  • The read model is tightly owned by the same service as the write model
  • Query freshness is critical (user expects to see their change immediately)
  • The projection is simple (one or two extra writes)
  • You want to avoid event broker infrastructure for a purely local concern

Async projection is appropriate when:

  • Multiple independent consumers need to build their own views of the same event
  • The projection belongs to a different service or bounded context
  • Eventual consistency is acceptable (e.g. analytics dashboards, activity feeds)
  • You want to decouple the write service from all downstream read concerns

Relation to Event Sourcing

CQRS and Event Sourcing are different concepts that are often combined but don’t require each other:

CQRSEvent Sourcing
What it separatesRead model from write modelCurrent state from event history
StorageSeparate tables/DBs for read and writeEvents are the source of truth; current state is derived
ProjectionsBuild read model from write modelBuild any view by replaying events
Can be used aloneYes — plain CQRS without event sourcingYes — event sourcing without separate read model

Event sourcing with CQRS: events are the write model; projections (inline or async) build read models by replaying or streaming events. The outbox pattern fits naturally here — events are published from the event store to Kafka, consumers build projections.

CQRS without event sourcing (more common): normal write model (mutable DB rows); separate read model updated via inline projection or async projection. Atomic-book leans toward this.


Relation to Outbox

The outbox pattern is the infrastructure bridge between the write side and async projections:

Write Side                    Outbox                    Read Side
──────────                    ──────                    ─────────
INSERT reservation     →   INSERT outbox_event   →   [Poller/CDC]
(same transaction)                                    → Kafka
                                                      → ProjectionConsumer
                                                          → UPSERT read model
  • Without the outbox, publishing an event directly to Kafka inside a transaction is unsafe (broker call can fail after DB commits)
  • The outbox makes async projections reliable — the event is guaranteed to be delivered if the transaction committed
  • The idempotency requirement on consumers mirrors the at-least-once delivery guarantee of the outbox

Inline projection and the outbox write share the same mechanism — atomic co-write in one transaction — but serve different goals:

Outbox writeInline projection write
Targetoutbox_events relay tableRead model / view table
ConsumerExternal services via KafkaSame service’s query layer
ConsistencyEventual (after broker delivery)Immediate

In Atomic-Book

Atomic-book does not implement explicit CQRS — each service queries the same normalized tables it writes to. However, several patterns in the codebase are natural precursors:

Write and read on the same model (current state):

  • BookingController calls CreateBookingService (command) and GetBookingService (query) — the query reads directly from reservations
  • No separate read table; joins are done at query time via jOOQ

Patterns that point toward CQRS:

Current patternIf CQRS were applied
reservations table queried with joinsbooking_summary_view table updated inline or via event
payment-service queries payments by userAsync projection into user_payment_history consumer
Notification-service subscribes to BookingConfirmed Kafka eventsAlready an async projection — updates notification state from write-side events
Ledger double-entry writesNatural fit for event sourcing; account_balance_view as a projection

notification-service is the closest to async CQRS in the system: it consumes BookingConfirmed, PaymentCaptured, etc. events from Kafka (published via outbox) and builds its own send-queue state. The write side (booking/payment) emits events; notification-service projects them into dispatch records. This is async projection across a service boundary.


Read Model Design Patterns

Denormalised Summary Table

Pre-join data that is frequently queried together:

-- Write model (normalised)
reservations(id, user_id, slot_id, status, created_at)
slots(id, name, supplier_id)
payments(id, reservation_id, amount, status)
 
-- Read model (denormalised, inline-projected or async-projected)
booking_summary(
  reservation_id, user_id,
  slot_name, supplier_id,
  payment_amount, payment_status,
  reservation_status, created_at
)

Aggregate View

Pre-compute aggregates that would require expensive GROUP BY at query time:

account_balance(account_id, current_balance, hold_amount, last_updated_at)
-- updated inline on every hold/capture/release in ledger-service

Time-Series / Event Log View

Append-only table ordered by time for activity feeds or audit logs:

user_activity_log(id, user_id, event_type, summary, occurred_at)
-- appended via async projection from domain events

Gotchas

Inline projection increases transaction size — every write transaction now does more work. Under high write throughput, this can increase lock contention and transaction duration. Profile before adding inline projections to hot paths.

Async projection lag is observable — the user creates a booking and immediately queries their booking list; the inline-projected summary may not appear if it’s async. Design the UI to handle this (optimistic updates, loading states, eventual refresh).

Projection schema changes require coordination — for async projections, changing the read model schema requires updating the consumer and potentially replaying historical events. For inline projections, it requires a write path change.

Event replay for new projections — when adding a new async projection, you need historical events to build the initial state. The outbox table only holds recent unpublished events; for full history, event sourcing or a separate event log is needed.

Avoid fat read models — a projection that mirrors the entire write model defeats the purpose. Project only the fields the query actually needs.