Event Sourcing, CQRS, and Projection Boundaries

LESSON

Event-Driven Architecture and Streaming Foundations

010 30 min intermediate

Event Sourcing, CQRS, and Projection Boundaries

By the end of this lesson, you will be able to...

  • Explain when event sourcing and CQRS clarify an application boundary.

  • Compare a mutable-state design with an event-history plus projection design.

  • Review the cost, replay, and compatibility risks before choosing this pattern.

Idea in one sentence: Event sourcing and CQRS are useful when the history of decisions matters enough to separate how the system accepts changes from how it serves reads.

Core Insight

A support agent opens order O-812.

The user says:

I changed my shipping address before the package was sent.
Why did the confirmation email still show the old address?

The order service shows only the current row:

order_id status shipping_address updated_at
O-812 shipped 9 Hill St 10:44

That row is useful, but it hides the story.

The real question is not only:

What is the current order state?

It is also:

Which facts happened, in what order, and which read model used which facts?

In the previous lesson, we asked where a process is visible. This lesson asks a sharper design question: when should the history itself become the source of truth, and when should reads be built from that history instead of from the command model's current tables?

Event sourcing and CQRS are answers to that question. They are not default architecture. They are boundary choices.

The Promise We Need to Keep

Use a small commerce system.

The order service must keep these promises:

At first, this sounds like one database table should be enough.

The service receives a command, validates it, updates the row, and publishes an event:

ChangeShippingAddress(O-812, 9 Hill St)
  -> update orders.shipping_address
  -> publish ShippingAddressChanged

That design can be perfectly good.

If the business only needs the current state, a normal mutable model is simpler. A row answers the common question quickly:

Where should we ship this order now?

But some domains care about the sequence of changes.

Support may need to know whether the address changed before or after payment. Finance may need an audit trail. A read model may need to rebuild a customer timeline. A downstream consumer may need to recover after missing a deployment window.

The design pressure is history.

The Naive Design

The naive design stores current state and treats events as notifications.

orders table = source of truth
events = messages emitted after changes

For order O-812, the service might process these commands:

Time Command Database after command Published event
10:00 PlaceOrder status=placed, address=4 Lake Rd OrderPlaced
10:06 ChangeShippingAddress address=9 Hill St ShippingAddressChanged
10:40 ShipOrder status=shipped OrderShipped

This works while all important questions are current-state questions.

It breaks when the system needs to answer history questions:

The mutable row has already overwritten earlier states. The published events may exist in the broker for a while, but the broker is not necessarily the application history. Retention may expire. Event shapes may be optimized for subscribers rather than for domain reconstruction.

The result is an awkward middle state: the system talks like history matters, but it stores truth like only the latest row matters.

The Boundary Choice

Plain meaning:

Instead of storing only the current answer, store the important facts that led to the answer.

In this scenario:

The order service records facts such as OrderPlaced, ShippingAddressChanged, and OrderShipped as the durable history for order O-812.

Technical name:

This is event sourcing. The event history is the source of truth for the aggregate or boundary.

Event sourcing changes the write side.

A command is still an intent:

ChangeShippingAddress(order_id=O-812, new_address=9 Hill St)

The service validates the command against current business rules and the known order history. If the command is allowed, the service appends a new fact:

ShippingAddressChanged(order_id=O-812, address=9 Hill St)

The current state is then derived from the events.

That derived current state might be kept in memory while handling a command. It might also be stored as a snapshot or projection. But the design says:

The durable truth is the sequence of events.

CQRS is a related separation.

Plain meaning:

Use one model to accept changes and another model to answer read questions.

In this scenario:

The command model validates ShipOrder. A shipping-status projection serves the user page. A support-timeline projection serves support. An analytics projection serves reporting.

Technical name:

This is Command Query Responsibility Segregation, or CQRS.

CQRS does not require event sourcing. You can separate command and read models while still using normal tables. Event sourcing often makes CQRS attractive because an event history can feed several projections.

A Worked Projection Trace

Start with this event history for order O-812:

Position Event Important data
1 OrderPlaced address=4 Lake Rd, total=84.00
2 PaymentCaptured payment_id=P-91
3 ShippingAddressChanged address=9 Hill St
4 OrderShipped shipment_id=S-33

Now build two projections.

The user order page needs a compact current view:

Projection field After event 1 After event 2 After event 3 After event 4
status placed paid paid shipped
address 4 Lake Rd 4 Lake Rd 9 Hill St 9 Hill St
shipment_id empty empty empty S-33

The support timeline needs a different read model:

10:00 order placed with address 4 Lake Rd
10:05 payment captured
10:06 address changed to 9 Hill St
10:40 order shipped as S-33

Same input. Different read models.

That is the useful part of CQRS with event sourcing. The command boundary records facts once. Projections can shape those facts for different readers.

Now compare the naive failure.

If the system only stored the final row, the user page could still say:

status=shipped, address=9 Hill St

But support could not explain whether the old email came from a stale projection, an out-of-order update, or a real business decision at that time.

The event history gives the system evidence. The projection tells each reader what that evidence means for their job.

So far, we have not made the system magically reliable. We have moved the important boundary: facts are durable, reads are derived, and each projection has a visible responsibility.

Design Alternatives

Do not jump to event sourcing just because the system publishes events.

Review these alternatives first:

Design Source of truth Good fit Weak fit
Mutable state plus notifications Current tables CRUD-heavy domains where latest state is enough Audit-heavy domains or replay needs
Mutable state plus audit log Current tables plus separate history Compliance trails and debugging Rebuilding application state from facts
CQRS without event sourcing Write tables plus read projections Different read shapes, expensive joins, scaling read traffic Domains where exact decision history is central
Event sourcing plus CQRS Event history plus projections Decision history, replay, multiple derived views Simple domains, low change discipline, weak schema ownership

The important design question is not:

Do we use events?

The better question is:

Which boundary needs durable facts, and which readers need derived views?

For many systems, only one part needs this treatment. Billing adjustments, account ledgers, workflow histories, and inventory reservations often benefit more than simple profile settings.

That is the central trade-off: better evidence and rebuildable views in exchange for more permanent contracts and operational discipline.

Projection Boundaries

A projection is a read model built from facts.

A projection boundary should have a clear reader and a clear tolerance for staleness.

For example:

Projection Reader Can be stale? Repair path
order_page_view Customer UI Briefly, if shown honestly Replay from last processed event
support_timeline Support agents Briefly, but must be explainable Rebuild from event history
fulfillment_queue Warehouse Less tolerance; affects work Reconcile before shipping
finance_ledger_view Finance reports Depends on reporting rules Controlled rebuild with audit

Projection boundaries matter because a projection is not just a cache.

A cache usually means:

I can throw this away and fetch the same answer from the source.

A projection means:

I have interpreted a stream of facts into a read model for a purpose.

That interpretation has code, schema, lag, and failure modes.

Check: A product team asks for a new "customer order history" screen. Should you add fields to the command model or create a projection?

Think first, then reveal.

Answer: If the screen needs a read shape that combines past order facts for display, a projection is likely a better boundary. The command model should stay focused on validating changes. If the screen only needs one current field that the command model already owns, a projection may be unnecessary.

Trade-offs and Limits

Event sourcing improves historical evidence.

It costs storage, schema discipline, replay discipline, and operational care.

The hardest cost is that old events stay meaningful. If you append ShippingAddressChanged today, future code must still know how to interpret older versions of that event. You can add fields carefully, transform events during reads, or version projection code, but you cannot pretend the past was written with today's schema.

Replay is also not free.

Replaying events can rebuild projections, repair bugs, and create new read models. It can also send duplicate side effects if projection code is mixed with external actions. A projection rebuild should update derived state. It should not quietly send emails, charge cards, or call shipping providers again.

This helps when history is part of the domain.

It costs more when every small state change becomes a long-lived contract.

It does not protect you from bad event boundaries, unclear ownership, or unsafe side effects.

You can see the boundary when projection lag, replay time, schema migrations, or support investigations become part of normal operations.

Common Confusions

Confusion: "We publish events, so we are event sourced"

Why it is tempting:

Both designs use events, and both may put messages on a broker.

Better model:

Publishing events means other systems can react. Event sourcing means the local application state is derived from an event history that is the source of truth.

Confusion: "CQRS means two databases"

Why it is tempting:

Many examples show separate write and read stores.

Better model:

CQRS means separate command and query models. They may use different databases, different tables, or different code paths. The separation is conceptual first and physical only when useful.

Confusion: "Projection lag is always a bug"

Why it is tempting:

Users often expect read pages to update immediately.

Better model:

Projection lag is a design property. It must be bounded, visible, and acceptable for the reader. Some projections can lag for seconds. Others need stronger coordination or a different design.

Check Your Understanding

Check: An account ledger must explain every balance change, including reversals and corrections. Is event sourcing a reasonable candidate?

Think first, then reveal.

Answer: Yes, it is a reasonable candidate. The history of decisions is central to the domain. The team still needs to review schema evolution, replay, and operational costs before choosing it.

Check: A settings service stores a user's theme preference: light or dark. The product only needs the latest value. What is the likely better design?

Think first, then reveal.

Answer: A normal mutable-state design is probably better. Event sourcing would add long-lived event contracts and replay machinery without much benefit.

Practice

Review this design:

A subscription service stores only the current subscription row.
It publishes SubscriptionChanged whenever a user upgrades, downgrades, pauses, or resumes.
Support often needs to explain why a customer was billed a specific amount last month.
Finance wants a monthly view.
The product page needs the current plan quickly.

Decide whether you would use:

A good answer should mention:

Resources

Key Takeaways

PREVIOUS Choreography, Orchestration, and Process Visibility NEXT Outbox, Inbox, and Dual-Write Avoidance