CQRS & Event Sourcing: Scalable Read/Write Architecture for Billing Systems
CQRS separates the read model from the write model — allowing each to be independently optimized. Event Sourcing goes further, storing domain state as an immutable append-only log of Domain Events rather than mutable relational records. Together, they provide complete audit trails, time-travel debugging, and horizontal read scalability for high-throughput billing and subscription systems.
Backend Clean Architecture & Domain-Driven Design
CQRS & Event Sourcing: Scalable Read/Write Architecture for Billing Systems
Domain objects are autonomous state machines with enforced invariants — they are not passive bags of data passed between controller functions. By Part 10, the billing engine's write side is solid: Orders are created through use cases, mutate through guarded Aggregate methods, and emit Domain Events. But reading is still done by loading the full Order Aggregate and projecting it in the use case — the same normalized domain model that enforces write-side invariants is also used to satisfy read queries. This creates a fundamental tension: the model optimized for enforcing invariants is rarely the model optimized for fast, flexible reads.
CQRS (Command Query Responsibility Segregation) resolves this by maintaining two entirely separate models: a write model (the Aggregate) that enforces invariants, and a read model (a denormalized projection) optimized for query performance. Event Sourcing extends this by storing the sequence of Domain Events as the source of truth rather than the current state — making the read model a continuously updated projection of that event stream.
This article implements the full CQRS split for the billing engine, builds the OrderSummaryProjector, and introduces the Outbox Pattern for durable at-least-once event delivery.
Prerequisites: Part 5 (Domain Events) for the event infrastructure; Part 6 (Application Layer) for the read/write separation already seeded with GetOrderSummaryUseCase; Part 9 (DI Container) for wiring the projector as an event handler.
1. The Unified Model Problem
The GetOrderSummaryUseCase from Part 6 works but has a scaling problem:
Three problems at scale:
- Two Aggregate loads per read: every
GET /orders/:idloadsOrder(with NLineItemjoins) andCustomer. For a dashboard showing 50 recent orders, that is 100 Aggregate loads + N×50 line item joins. - The write model's joins are wrong for reads: the
orders+order_itemsnormalized schema is optimized for write-side integrity; the read-side wants a single denormalized row per order withcustomerNamealready embedded. - Cross-Aggregate reads require a domain join: reading
customerNamefromCustomerfor anOrderlist requires either a cross-table JOIN (coupling read to write schema) or loading the fullCustomerAggregate per order.
CQRS solves this by building a separate order_summary_view table that is kept up-to-date by the Domain Event stream.
2. The CQRS Architecture
The write side enforces invariants through the Aggregate. The read side reads directly from the denormalized view — no Aggregate instantiation, no domain joins, a single SELECT statement. The projector keeps them synchronized via the Domain Event stream.
3. The Write Side: No Changes
The write side (Parts 3–10) requires zero modifications. Order.place() already raises OrderPlaced. PlaceOrderUseCase already dispatches it via IEventBus. CQRS is additive on the write side — the Aggregate and use cases are unchanged.
4. The Read Model: order_summary_view
The read model is a denormalized table with one row per order, containing everything a UI list or dashboard needs without any joins:
This table is write-only from the projector and read-only from query handlers. Application code never writes to it directly — only the projector does, in response to Domain Events.
In Prisma schema:
5. The Projector: Keeping the Read Model Current
The projector is an event handler (IEventHandler<DomainEvent>) that reacts to Domain Events and upserts the read model. It is registered in the DI container alongside other order.placed handlers:
5.1 The Projector Is Idempotent by Design
The upsert on OrderPlaced means replaying the event multiple times is safe — the read model converges to the same state regardless of how many times the event is processed. This is critical for the Outbox Pattern (§7).
6. The Read Side: Direct SQL Queries
With the read model in place, GetOrderSummaryUseCase becomes a direct SQL SELECT with zero Aggregate instantiation:
A 50-order dashboard page is now one SELECT ... LIMIT 50 against a single indexed table. Response time drops from O(N × Aggregate load) to O(1 indexed SELECT).
The read side uses PrismaClient directly — it does not go through IOrderRepository. This is intentional and correct: the read model is infrastructure, not a domain concern. The GetOrderSummaryUseCase is the one legitimate place in the Application layer where a direct infrastructure dependency is acceptable, because it is a pure read with no domain invariants to enforce.
7. The Outbox Pattern: Durable At-Least-Once Delivery
The post-commit dispatch from Part 5 has a gap: if the server crashes between repository.save() (successful DB commit) and eventBus.publish(), the events are lost. For a billing system, "lost OrderPlaced event" means the inventory is never reserved and the confirmation email is never sent.
The Outbox Pattern closes this gap by writing events to an outbox table in the same database transaction as the Aggregate save. A separate background worker reads unpublished events and dispatches them:
7.1 The Outbox Table
7.2 Modified PrismaOrderRepository.save(): Atomic Write + Outbox
7.3 The Outbox Poller
A background worker (run as a separate process or a setInterval in the same process) polls for unpublished events and dispatches them:
At-least-once vs. exactly-once: The Outbox Pattern guarantees at-least-once delivery — an event may be dispatched more than once if the poller crashes after publishing but before marking publishedAt. This is why projectors must be idempotent (upsert on order_summary_view). Design all event handlers to be safely re-runnable with the same event payload.
8. Event Versioning: Evolving the Event Schema
As the billing engine matures, OrderPlaced v1 may need a new field (promotionCode). Events already stored in the outbox or Kafka are v1. The projector must handle both versions:
The projector's onOrderPlaced handles both via eventVersion:
Summary
| Concept | Domain Rule |
|---|---|
| CQRS | Write model enforces invariants via Aggregates; read model is a denormalized projection optimized for queries |
| Projector | An event handler that upserts the read model; must be idempotent; lives in Infrastructure |
| Read model | A dedicated table/view with pre-joined data; read handlers use direct SQL, never Aggregate loading |
| Outbox Pattern | Events written to an outbox table in the same DB transaction as the Aggregate save; eliminates the post-commit crash gap |
| At-least-once delivery | Outbox polling can dispatch an event more than once; all handlers must be idempotent |
| Event versioning | New fields are nullable with defaults; eventVersion discriminates v1 vs. v2 handlers |
GetOrderSummaryUseCase |
Direct PrismaClient query on order_summary_view — zero Aggregate load, O(1) indexed SELECT |
What's Next
In Part 12, we build the full testing strategy — defining the test pyramid for this architecture, implementing contract tests for all Ports, and assembling the fast (~6s) full test suite that validates 237 behaviors across domain, application, and infrastructure layers. Part 12: Testing Strategy →
This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.