
How CQRS Works with Event Sourcing
If you need strict write rules, fast reads, and a full history of every change, CQRS with event sourcing is one of the clearest ways to do it.
I’d sum it up like this: commands change state, events record what happened, and projections turn those events into read views. The write side checks rules inside aggregates. The event store keeps an append-only log. The read side lags a bit by design, so you need to plan for eventual consistency, retries, and replay.
Here’s the article in plain English:
- CQRS splits reads and writes so each side can use a model shaped for its own job.
- Event sourcing stores events, not just current rows, which gives you a full audit trail and point-in-time rebuilds.
- Aggregates enforce rules and emit events like
OrderPlacedorPaymentAuthorized. - The event store is the source of truth, usually with optimistic concurrency checks to stop conflicting writes.
- Projections build query-ready views in stores like PostgreSQL, MongoDB, Elasticsearch, or Redis.
- Read models are eventually consistent, so users may see a short delay after a write.
- Idempotent handlers, snapshots, upcasters, outbox, tracing, and DLQs matter once the system is in production.
- This pattern is not for every app. It fits best when audit history, replay, multiple query views, or separate read scaling matter.
A few facts stand out:
- Teams often snapshot aggregates every 100 to 250 events to cut replay time.
- A common PostgreSQL guard is a
UNIQUE(aggregate_id, version)constraint. - With event sourcing, storage keeps growing because you keep 100% of change history, not just the latest state.
Bottom line: I’d use CQRS with event sourcing when CRUD starts hurting - usually in domains with many business rules, traceability needs, or several read patterns. If you don’t need those things, the extra moving parts can cost more than they give back.
That’s the core idea behind the full article: model the domain first, keep rule checks on the write side, build one projection per query pattern, and treat replay, versioning, and lag as normal parts of the system.
CQRS with Event Sourcing: How Commands, Events & Projections Flow
CQRS & Event Sourcing Code Walk-Through
Design the Domain Model Before You Write Any Code
Define the domain model first. Boundaries, commands, and events shape everything that comes next. If the command-to-event flow is going to work, it starts here.
The first job is setting boundaries, because boundaries decide what changes together and what gets stored together.
Choose Bounded Contexts and Aggregate Boundaries
Not every part of a system needs CQRS with event sourcing. Use it where it pays off: places with audit history, messy workflows, or several query views.
Once you’ve picked the right context, define your aggregates with care. Each aggregate should be small enough to guard one set of invariants and one transaction. This is where many teams get tripped up. Aggregate size matters more than it seems at first.
If an aggregate is too large, you can run into lock contention. If it’s too small, you end up with a lot of eventual consistency pain. A simple rule helps: if two fields don’t need to change together, they probably belong in separate aggregates.
Those boundaries also decide which commands an aggregate can accept.
Define Commands and Events With Clear Meaning
This naming pattern isn’t just about neat code. It tells people what each thing means.
Commands use imperative verbs because they ask for action: PlaceOrder, RegisterUser.
Events use past tense because they record facts that already happened: OrderPlaced, UserRegistered.
| Feature | Command | Event |
|---|---|---|
| Semantics | Request for future action | Record of a past fact |
| Naming | Imperative (e.g., CreateOrder) |
Past tense (e.g., OrderCreated) |
| Failure | Can be rejected if invariants fail | Cannot be undone; requires a compensating event |
Include the event ID, aggregate ID, version, and timestamp. That makes replay and tracing much easier later.
Events should describe business facts, not low-level system actions. UserAddressChanged says something useful about the domain. UpdateUserTableRow does not. That kind of name leaks implementation detail into a record that should stay focused on the business.
Once you lock in the event shape, keep write-side rules inside aggregates, not inside projections.
Avoid Common Modeling Mistakes
One of the biggest mistakes is letting business logic slip into projections. Projections build read models. They do not enforce rules. Write-side invariants belong inside the aggregate. Read models should stay denormalized and tuned for query speed. When boundaries are off or rules live in the wrong place, command handling usually breaks first. Projections tend to fall apart right after that.
Cross-aggregate transactions are another red flag. If you need one transaction to span two aggregates, there’s a good chance the boundaries are off. The other common case is that you need the Saga pattern to coordinate work across aggregates instead of forcing a shared transaction.
You also need a plan for schema evolution from day one. Events are immutable, so you can’t go back and edit them later. Without versioning, old events will eventually get in the way of reprocessing. An upcaster pattern helps here by transforming old event schemas into newer versions at runtime, without changing the original event store.
Implement the Command Side With an Event Store
With domain boundaries and event shapes locked in, the next step is getting the write path working. Commands come in, get checked, and then become new events.
Load Aggregate State by Replaying Events
When a command arrives, the handler loads the aggregate's event stream and replays it to rebuild the current state. This is the write side of CQRS in practice: the command handler guards invariants, and then the event store saves the outcome.
After replay, the aggregate checks the command against its business rules. If it passes validation, the aggregate emits events.
Append New Events With Concurrency Checks
When validation succeeds, the system produces events and appends them to the event store as one transactional unit. The main safeguard here is optimistic concurrency. The handler keeps the current version as the expected version, then appends using that loaded version. If another write has already moved the stream forward, the append fails.
In PostgreSQL-based event tables, teams often enforce this with a UNIQUE(aggregate_id, version) constraint on the events table. If that check fails, reload the aggregate, replay the newer events, and retry the command.
The event store doesn’t need much on the write side:
- Append-only writes
- Dependable version checks
Use Snapshots When Event Streams Get Large
Replaying events works well for most aggregates. But if an aggregate gets updated a lot and builds up a long history, replay takes more time. A snapshot stores the aggregate state at a given version. On reload, the system grabs the latest snapshot, then replays only the events that happened after that snapshot’s version number.
Create snapshots asynchronously so writes stay fast. Use them when replay starts to slow down or when the stream gets too large to load fast enough.
Once the command side can load and write with good speed, the next step is turning those events into query-ready read models.
sbb-itb-61a6e59
Build Read Models and Plan for Eventual Consistency
Because the event store is the source of truth, projections rebuild query views from the event stream. Projections are event handlers that listen to that stream and turn immutable events into denormalized, query-ready data structures.
The key idea is simple: each projection should serve one query pattern. That might be a dashboard, a report, or a search view. If you try to make one projection do everything, things get messy fast.
Create Projections for Specific Query Patterns
Build one projection per query pattern.
That storage choice should match how the data will be read:
- PostgreSQL works well for joins and reporting
- MongoDB fits flexible UI views and APIs
- Elasticsearch is built for full-text search and filtering
- Redis is a strong fit for real-time dashboards and caching
Every projection handler must be idempotent. In plain English, if the same event gets processed twice, the read model should still end up in the same state.
Why does that matter? Because it gives you room to recover. If a schema changes or you find a bug, you can rebuild the projection from scratch by replaying the event stream.
Design for Lag Between Writes and Reads
Once projections are in place, the main day-to-day issue is the delay between a write being committed and that change showing up in the read model. In CQRS, read models lag behind writes by design.
There are a few common ways to make that delay easier to live with.
Optimistic UI can show the change right away, even before the projection catches up. For users who need to see their own updates at once, read-your-writes can wait until the projection reaches the written version. And WebSocket pushes can notify the UI when projection processing is done.
The Outbox Pattern helps make event publication dependable. Events are written to a local outbox table in the same transaction as the domain change, then sent to a message bus like Kafka or RabbitMQ. That cuts down the chance of losing events between the write side and the read side.
Write Models vs. Read Models: A Direct Comparison
The split between write models and read models is easier to grasp when you put them side by side:
| Feature | Write Model (Command) | Read Model (Query) |
|---|---|---|
| Purpose | Business logic, validation, and state changes | Fast, specialized data for queries |
| Schema | Normalized, aggregate-based | Denormalized, optimized for specific views |
| Consistency | Strong consistency (ACID) | Eventual consistency (BASE) |
| Scaling | Scaled by partitioning aggregates | Scaled by replicating read stores |
| Failure Handling | Transactional integrity / Outbox pattern | Idempotent retries and event replaying |
Choosing storage for each projection comes down to the query pattern. That's the whole game here: pick the store that matches the job.
| Storage Type | Best For | Query Flexibility | Performance | Operational Complexity |
|---|---|---|---|---|
| Relational (PostgreSQL) | Complex reporting and joins | High | Moderate | Low |
| Document (MongoDB) | Flexible UI views and APIs | Moderate | High | Medium |
| Search (Elasticsearch) | Full-text search and filtering | Very High | High | High |
| Key-Value (Redis) | Real-time dashboards and caching | Low | Ultra-High | Low |
These storage choices affect reprocessing and versioning later.
Production Tradeoffs, Tooling, and Next Steps
Handle Versioning, Reprocessing, and Maintenance Issues
Once projections are live, the day-to-day production work usually moves to three things: replay cost, schema drift, and failure recovery.
Because events are immutable, teams often rely on upcasters. These are small transformation functions that convert older event formats into newer ones at read time. A schema registry also helps flag breaking changes early.
Snapshots help keep replay time under control. A common cadence is every 100 to 250 events, depending on aggregate size.
Observability gets harder with async flows. When something breaks, it can feel like pulling on a thread in the dark. That’s why teams add:
- distributed tracing
- structured logs with correlation IDs
- lag monitoring
- a DLQ for poison messages
CQRS With Event Sourcing vs. CRUD: A Direct Comparison
CQRS with event sourcing is not the default choice. The tradeoff is operational, not conceptual.
| Feature | CQRS with Event Sourcing | Traditional CRUD |
|---|---|---|
| Implementation Complexity | High - distributed, async, multi-store | Low - direct DB access, standard frameworks |
| Auditability | Built-in - every change is an immutable event | Manual - requires triggers or separate logs |
| Temporal Queries | Native - replay to any point in time | Difficult - requires historical tables or snapshots |
| Read Scalability | High - read models scale independently | Limited - bound to the write database |
| Storage Growth | Continuous - append-only, full history | Stable - current state only |
| Cloud Cost | Higher storage costs; lower compliance/audit TCO | Lower initial storage and compute costs |
Conclusion: When to Use This Pattern and What to Learn Next
Use this pattern when audit trail, rebuildability, and independent read scaling are actual needs - not as a default for every system. A good starting point is one bounded context where CRUD is plainly falling short. Ship that one bounded context first, then expand.
For hands-on practice, DataExpert.io Academy offers boot camps and subscriptions in data engineering and AI engineering.
FAQs
How do CQRS and event sourcing fit together?
CQRS and event sourcing work well together because they handle two different jobs.
CQRS splits writes from reads. Commands change data. Queries fetch it. Event sourcing takes a different angle: instead of storing only the latest state, it records every state change as an immutable event.
That makes the event store the write model’s source of truth. From there, projections process those events and update read models. The result is a setup that supports scalable, decoupled reads and writes, along with a complete audit trail and the ability to reconstruct state at any point.
When should I use this pattern instead of CRUD?
Use CQRS and Event Sourcing instead of CRUD when your application has complex domain logic, a high read-to-write ratio, strict auditing needs, or needs multiple read models tuned for different use cases.
CRUD is usually the better fit for simpler apps with straightforward business rules, tight deadlines, or cases where immediate consistency matters and extra architectural overhead just isn’t worth it.
How do teams handle replay and read lag in production?
Teams handle replay with snapshots, which cache aggregate state. That means they don’t have to reprocess long event logs from scratch every time. It’s a simple idea, but it saves a lot of time when streams get long.
For read lag, they use asynchronous projections so read models can update on their own schedule, separate from the write side. They also monitor consumer lag so they can scale before delays turn into a bigger problem. To protect data during recovery, they rely on idempotent sinks and checkpointing, which help keep writes consistent even when a consumer restarts or replays events.