← Назад

Beyond CRUD: Crafting Resilient Software With CQRS and Event Sourcing

Why CRUD Starts Breaking Past the Prototype

Most tutorials start with a simple four-letter promise: Create, Read, Update, Delete. When the product is a weekend hack, CRUD feels invincible. Yet cracks appear the moment real users arrive. A user edits their profile, an admin changes their role, and the support team demands a history of who did what and when. CRUD tables have no memory; an UPDATE statement overwrites yesterday's truth without a trace. Rollbacks are painful, concurrent edits clash, and the same entity soon carries dozens of nullable fields to satisfy conflicting read scenarios. In short, the architecture optimized for the prototype starts to strangle the growth of the product.

The First Insight: Separate Reads From Writes

Command Query Responsibility Segregation—CQRS in short—makes the split explicit. Instead of a single model that tries to be everything to everyone, you create two:

  • A write model optimized for business rules, consistency, and commands such as RestorePurchasingPower or BlockAccount.
  • A read model shaped like the screens and reports your users actually see. This model can be a SQL view, an ElasticSearch index, or even a pre-rendered React tree stored in Redis; its only job is to be fast and friendly.

The sweet side effect is freedom. You can store the write model in a relational database with strict schemas while the read model lives in a schemaless document store. You can scale the read side horizontally without touching a single line of business logic. And mysterious SELECT N+1 issues disappear because the read model is built once and served many times.

The Second Insight: Store Facts, Not State

Event Sourcing swaps rows for a stream of domain events. Each meaningful action—UserRegistered, EmailChanged, InvoiceIssued—becomes an immutable fact appended to a log. The current state is no longer persisted; it is computed by folding every event from the beginning of time. Think of it like Git: you do not store a snapshot of your files, you store patches.

How the Two Patterns Fit Together

CQRS and Event Sourcing are siblings, not twins, but their synergy is remarkable. The write model becomes an event store guarded by the aggregate root pattern. Each aggregate loads its own history of events, applies business rules to the commands it receives, and emits fresh events. Once a commit succeeds, an outbox or message bus publishes those events to whichever listener needs them. Read-side processors subscribe and build materialized views on the fly. If you ever need a new dashboard tomorrow, you replay yesterday's stream into a brand-new read model instead of locking tables and praying.

Step-by-Step: Modeling an E-Commerce Order

1. Capture the Domain Language

First, talk to the business. A customer does not mutate an order_status column; they place an order, confirm payment, or request a refund. These verbs become commands:

  • PlaceOrderCommand
  • ConfirmPaymentCommand
  • RequestRefundCommand

2. Design Commands and Events

Commands carry intent but may still fail. Events are the outcome of successful commands.

CommandPossible Events
PlaceOrderCommandOrderPlaced, OrderRejected
ConfirmPaymentCommandPaymentConfirmed, PaymentDeclined
RequestRefundCommandRefundRequested, RefundDenied

Notice the granularity: you do not emit OrderUpdated. You emit what actually happened.

3. Build the Aggregate

"python
# A minimal aggregate root with Event Sourcing
class Order:
    def __init__(self, order_id):
        self.order_id = order_id
        self.lines = []
        self.paid = False
        self.refunded = False

    def place(self, customer_id, lines):
        if self.lines:
            raise OrderAlreadyPlaced
        yield OrderPlaced(
            order_id=self.order_id,
            customer_id=customer_id,
            lines=lines,
            at=datetime.utcnow()
        )

    def apply(self, event):
        if isinstance(event, OrderPlaced):
            self.lines = event.lines
"

The apply method mutates in-memory state while event handlers mutate durable storage. Production-grade libraries—such as Python's eventsourcing or .NET's EventStore—handle serialization, optimistic concurrency, and snapshots.

4. Persist Events

A simple relational table is enough to start.

CREATE TABLE events (
  aggregate_id UUID      NOT NULL,
  version      INTEGER   NOT NULL,
  event_type   TEXT      NOT NULL,
  payload      JSONB     NOT NULL,
  PRIMARY KEY (aggregate_id, version)
);

Indexes on event_type make replay cheaper.

5. Build Read Models

Suppose the sales team wants a live dashboard of orders by region. A subscriber listens for OrderPlaced and PaymentConfirmed events and updates a denormalized table.

CREATE MATERIALIZED VIEW orders_by_region AS
SELECT region_id,
       jsonb_agg(
           jsonb_build_object(
               'order_id', order_id,
               'total', (payload->>'amount')::numeric,
               'placed_at', (payload->>'at')::timestamptz
           )
       ) AS orders
FROM events
WHERE event_type = 'OrderPlaced'
GROUP BY region_id;

The view is updated incrementally in real time, not nightly by a fragile ETL.

Coding the Plumbing in Node.js

Quick start without external frameworks:

"js
// EventStore in memory
const events = [];

function append(aggregateId, newEvents, expectedVersion) {
  const stream = events.filter(e => e.aggregateId === aggregateId);
  if (stream.length !== expectedVersion) throw new ConcurrencyError;
  newEvents.forEach(e => events.push(e));
}

function load(aggregateId, aggregateClass) {
  const stream = events.filter(e => e.aggregateId === aggregateId);
  const agg = new aggregateClass(aggregateId);
  stream.forEach(ev => agg.apply(ev));
  return agg;
}
"

Wire this into an Express route:

"js
app.post('/orders/:id/place', async (req, res) => {
  const { id } = req.params;
  const { customerId, lines } = req.body;
  const order = load(id, Order);
  const newEvents = order.place(customerId, lines);
  append(id, newEvents, order.version);
  publishEvents(newEvents);
  res.status(201).end();
});
"

Snapshot: you just built an append-only log protected by optimistic locking and raised domain events every time the aggregate changed.

Managing Migrations and Downtime

Because the write model is append-only, you never UPDATE or DELETE historical rows. Schema evolution is additive—drop a new JSON column, bump the serializer version—then replay the stream offline to catch up. If you need to fix an old bug, replay from version zero with fixed logic. Replaying millions of events sounds expensive, but a single snapshot table stores the aggregate's state at version n. Rebuild the snapshot nightly; replay only deltas after that.

Snapshotting Strategies

Count-Based

After every 100 events, create a snapshot. This is simple, but heavy consumers may lag if events differ wildly in shape.

Time-Based

Every hour, snapshot every aggregate that changed. This levels I/O bursts and gives reasonable replay windows.

State-Based

Serialize the aggregate only when its size exceeds a threshold. Good for large graph structures.

The snapshot itself is just JSON or MessagePack; store it anywhere cheap—S3, Redis, or a column in SQL.

Handling Retroactive Business Rules

Imagine finance adds a retroactive tax policy. Run a one-off script:

# pseudocode
for event in stream:
    if event.type == 'OrderPlaced' and event.at < new_tax_date:
        new_tax = event.total * 0.05
        yield OrderTaxRecalculated(order_id=event.order_id, tax=new_tax)
append(aggregate_id, new_events, expected_version)

The event store is the single source of truth, so every read model will eventually see OrderTaxRecalculated and adjust the dashboard.

Scaling the Write Side

Because events are append-only, any log-centric storage shines:

  • Postgres with UNLOGGED tables
  • Kafka as an event bus and log roll-up
  • EventStoreDB built for exactly this use-case

Partitioning is natural: use an aggregate identifier as the partition key. Each aggregate writes strictly sequentially, while the total system is horizontally scalable. Sharding per bounded context is even cleaner: orders, payments, and invoicing live in separate storage clusters, joined only by asynchronous subscriptions.

Reading Fast Without Joins

Read models are crazy fast because they are pre-joined. PostgreSQL can maintain materialized views concurrently. MongoDB change streams listen to the event hub and upsert documents shaped like Angular components. Even Excel becomes a consumer—export yesterday's facts to CSV nightly without locking anyone's work.

Downsides and Mitigation

Learning Curve

Event Sourcing is different. Developers hear "eventual consistency" and panic. Mitigation: start with CRUD plus an outbox to publish events. Once the team is comfortable, flip the write model to event-sourced aggregates.

Event Versioning

Additive fields with sensible defaults keep the schema stable. Use schema_url in the envelope to signal breaking changes and run dual deserialization in parallel until every consumer has upgraded.

Debugging the Stream

Traditional debuggers inspect mutable state, but aggregate state is derived. Mitigation: build /debug/aggregate/:id endpoints that replay events into human-readable JSON on demand.

Testing Strategy

Golden Rule: Test Facts Not State

Give the aggregate a sequence of events as given, fire a command as when, assert that then the right events emerge. Libraries such as eventstore-ts supply fluent test builders:

"given
  .events(orderPlaced, paymentConfirmed)
.when
  .dispatch(refundCommand)
.then
  .expectEvents(refundRequested)
"

Contract Tests for Projections

Define JSON schemas for the events and run them through the read model. Any schema drift breaks CI, long before production.

Deployment to Production

In a typical Kubernetes setup:

  1. API pods expose REST/GraphQL. They append events and return HTTP 201.
  2. Projection pods subscribe to the same log and write SQL once per batch. They die on SIGTERM and restart cleanly.
  3. Copies of the read model live in each region. A Postgres follower in us-west and another in eu-central reduce lag for global users.

The cluster scales statelessly; horizontal pod autoscaler responds to CPU or queue depth instead of making schema migrations in flight.

When to Choose This Pattern

Select CQRS and Event Sourcing when:

  • You need a complete audit trail with no gaps.
  • Business rules are fluid and retroactive calculation happens yearly (finance, insurance).
  • Heavy read/write asymmetry exists (social feeds, analytics).
  • Infrastructure budget supports a polyglot stack: Kafka, PostgreSQL, Redis.

Skip it when:

  • The problem is a simple listing and searching app.
  • The team has no cloud budget and prefers SQLite.
  • Deadlines shrink to one sprint.

Putting It All Together: A One-Day Workshop Plan

  1. Morning: Problem Discovery. Identify use-cases that hurt with CRUD.
  2. Middle: Event Storming. Colored sticky notes on a whiteboard define commands, events, and read models.
  3. Afternoon: Code Dojo. Build a tiny order service using EventStore in Docker.
  4. Wrap-up: Run a Chaos Game Day. Kill network partitions, replay events for a brand-new read model, and watch the system heal itself.

Further Reading Path

For deeper exploration check:

  • Event Sourcing Made Simple by Steven Jehosephat, O'Reilly
  • Conference talk: "Money over HTTP with Event Sourcing" by Greg Young, YouTube
  • Project source code: github.com/username/cqrs-workshop-examples

Start small, measure twice, and let your audit story evolve one event at a time.

Disclaimer: This article is educational. Implementation choices depend on constraints and risk tolerance. Always run a proof of concept before committing to a new architectural style. Article generated by an AI journalist.

← Назад

Читайте также