CQRS and Event Sourcing

advanced cqrs event-sourcing architecture eventual-consistency projections

Most applications use the same database model for both reading and writing data. We design one table schema, write to it, and read from it. Simple. And for most apps, that’s perfectly fine.

But sometimes reads and writes have very different requirements. Writes need strong consistency, validation, and normalized data. Reads need fast queries, denormalized data, and specialized views. Trying to optimize for both in a single model leads to compromises everywhere.

That’s where CQRS comes in. And if we want to take it further, Event Sourcing changes how we think about data entirely.

CQRS — Command Query Responsibility Segregation

The name sounds intimidating, but the idea is straightforward. In simple language, CQRS means: use different models for reading and writing data.

  • Command side (writes): handles creates, updates, deletes. Optimized for consistency and business rules. Uses a normalized schema.
  • Query side (reads): handles queries and reports. Optimized for speed. Uses denormalized views, materialized views, or even a completely different database.
Command Side (Write)
● Validates business rules
● Normalized schema
● Strong consistency
● Relational DB (PostgreSQL)
events / sync
→→→
Query Side (Read)
● Optimized for queries
● Denormalized views
● Eventual consistency (usually)
● Could be Elasticsearch, Redis, etc.

A Practical Example

Say we’re building an e-commerce platform. The write side has normalized tables: orders, order_items, products, customers. Great for consistency.

But the product listing page needs to show product name, average rating, inventory count, seller info, and recent reviews — all in one query. With a normalized schema, that’s a multi-table JOIN.

With CQRS, the read side has a pre-built product_listing_view table with all of that data already denormalized. One simple SELECT, no JOINs.

-- Write model: normalized, proper constraints
INSERT INTO orders (customer_id, total, status) VALUES (42, 99.99, 'pending');
INSERT INTO order_items (order_id, product_id, qty) VALUES (1001, 55, 2);

-- Read model: denormalized, fast queries
-- This table gets updated asynchronously when the write side changes
SELECT product_name, avg_rating, stock_count, seller_name, thumbnail_url
FROM product_listing_view
WHERE category = 'electronics'
ORDER BY popularity DESC
LIMIT 20;

When Is CQRS Worth It?

CQRS adds complexity — we have two models to maintain, and keeping them in sync introduces eventual consistency. It’s not worth it for simple CRUD apps.

It IS worth it when:

  • Read and write patterns are drastically different
  • We need different scaling for reads vs writes
  • We need specialized read stores (search engine, analytics DB)
  • The domain is complex enough that separating concerns helps clarity

Event Sourcing — Store Events, Not State

This is where things get interesting. In a traditional database, we store the current state of an entity. If a user changes their email, we update the email column. The old email is gone.

With event sourcing, we don’t store current state. We store every event that ever happened:

UserCreated { id: 42, name: "Manish", email: "old@example.com" }
EmailChanged { id: 42, email: "new@example.com" }
NameChanged { id: 42, name: "Manish P" }
AccountDeactivated { id: 42 }
AccountReactivated { id: 42 }

To get the current state of user 42, we replay all events from the beginning. The event log is the source of truth — everything else is derived from it.

The Event Store

-- A simplified event store table
CREATE TABLE events (
    event_id BIGSERIAL PRIMARY KEY,
    aggregate_id UUID NOT NULL,         -- which entity this event belongs to
    aggregate_type VARCHAR(100),         -- "User", "Order", etc.
    event_type VARCHAR(100) NOT NULL,    -- "OrderPlaced", "OrderShipped"
    event_data JSONB NOT NULL,           -- the event payload
    created_at TIMESTAMPTZ DEFAULT NOW(),
    version INT NOT NULL                 -- for optimistic concurrency
);

-- Index for replaying events for a specific entity
CREATE INDEX idx_events_aggregate ON events (aggregate_type, aggregate_id, version);

Projections

Nobody wants to replay thousands of events every time they query something. That’s where projections come in.

A projection is a materialized view built from events. It’s a read-optimized representation of the current state. When a new event arrives, the projection updates itself.

Think of it like a bank statement. The event log is every transaction that ever happened on our account. The projection (current balance) is just the sum of all those transactions. We don’t recalculate from scratch every time — we update the running total.

Event: OrderPlaced { id: 1001, customer: 42, total: 99.99 }
  → Projection updates: customer_orders[42].count += 1
  → Projection updates: customer_orders[42].total_spent += 99.99
  → Projection updates: daily_revenue[today] += 99.99

Benefits of Event Sourcing

  • Complete audit trail — every change ever made is recorded. Perfect for finance, healthcare, compliance.
  • Temporal queries — “What did this order look like at 3pm yesterday?” Just replay events up to that timestamp.
  • Easy debugging — when something goes wrong, we can see exactly what happened and in what order.
  • Rebuild views — if a projection has a bug, fix it and rebuild from the event log. The events are the truth.
  • New features from old data — want to add a new analytics dashboard? Build a new projection from existing events.

Challenges

  • Eventual consistency — projections lag behind the event log. If we just placed an order and immediately check our order list, it might not show up yet.
  • Schema evolution — events are immutable. If we need to change the structure of an event, we have to handle both old and new formats.
  • Storage growth — events accumulate forever. Snapshotting helps (periodically save current state so we don’t replay from the beginning).
  • Complexity — it’s a fundamentally different way of thinking about data. The learning curve is steep.

CQRS + Event Sourcing Together

These two patterns work great together, but they don’t have to be used together:

  • CQRS without Event Sourcing: separate read/write models, but both store current state. Perfectly valid.
  • Event Sourcing without CQRS: store events, but use the same model for reads. Works for simple cases.
  • Both together: events are the write model, projections are the read model. The full power combo.
Command
PlaceOrder, CancelOrder
↓ validated & persisted as
Event Store
OrderPlaced, OrderCancelled (immutable log)
↓ events consumed by
Projection A
Order list view
Projection B
Revenue dashboard
Projection C
Search index

Interview Tip

Interviewers love asking “when would you use CQRS or event sourcing?” The safe answer: for complex domains with different read/write patterns, audit requirements, or event-driven architectures. Always acknowledge the trade-offs — eventual consistency, increased complexity, and the learning curve. And make it clear that for most CRUD applications, a simple shared model with good indexing is the right call.