CQRS and Event Sourcing for Modern SaaS

CQRS and Event Sourcing are the architecture patterns behind the most scalable SaaS products. A practical guide for startup CTOs.

Cover Image for CQRS and Event Sourcing for Modern SaaS

By Lucas Gertel, CTO at Meld

Every SaaS product starts simple. A few database tables, some CRUD endpoints, a React frontend. It works beautifully—until it doesn't. The moment you need audit trails, complex business rules, multi-tenant isolation, or real-time projections across different read contexts, the "just update the row" model starts cracking.

I've spent 20 years building enterprise systems—from large-scale platforms at Avenue Code to training the next generation of architects at the Software Architect Academy in Brazil. The pattern I keep coming back to for serious SaaS products is the combination of CQRS and Event Sourcing. Not because they're trendy. Because they solve real problems that every scaling SaaS eventually encounters.

This is a practical guide. No academic abstractions. Just the patterns, trade-offs, and implementation decisions that matter when you're building a product that needs to scale.

What CQRS Actually Is

Command Query Responsibility Segregation — as Martin Fowler describes it — is a simple idea with profound implications: separate your write model from your read model.

In a traditional architecture, the same data model serves both purposes. Your users table is where you write new user data and where you read it for display. Your ORM maps one class to one table, and that class handles commands ("create this user") and queries ("show me all active users") identically.

CQRS says: stop doing that. Instead, build two distinct paths:

  • Command side: Handles writes. Validates business rules. Ensures consistency. Emits events when state changes.
  • Query side: Handles reads. Optimized for the specific views your UI needs. Denormalized, fast, and eventually consistent with the command side.

Why This Matters

The read and write sides of most applications have fundamentally different requirements:

  • Writes need strong consistency, validation, and transactional guarantees
  • Reads need speed, flexibility, and the ability to serve many different view shapes from the same underlying data

When you force both through the same model, you compromise on both. Your write model gets polluted with computed fields and join logic for display. Your read model gets slowed down by normalization rules that exist for write integrity.

CQRS lets each side optimize independently. Your command model can be a rich domain model with complex business rules. Your read model can be flat, denormalized projections—one per view if needed—served from Redis, Elasticsearch, or pre-computed materialized views.

What Event Sourcing Actually Is

Event Sourcing flips the traditional persistence model on its head. Instead of storing the current state of an entity, you store the sequence of events that produced that state.

A traditional system stores: Order { status: "shipped", total: 149.99, items: [...] }

An event-sourced system stores:

  1. OrderCreated { orderId: "abc", customerId: "xyz", items: [...] }
  2. ItemAdded { orderId: "abc", productId: "p1", quantity: 2 }
  3. PaymentReceived { orderId: "abc", amount: 149.99 }
  4. OrderShipped { orderId: "abc", trackingNumber: "1Z999..." }

The current state is derived by replaying these events. The event log is the source of truth. The "current state" is just a convenient projection.

Why This Matters

  • Complete audit trail — You don't just know the current state. You know every state the entity has ever been in and exactly why it transitioned.
  • Temporal queries — What did this order look like last Tuesday? Replay events up to Tuesday. Done.
  • Bug investigation — When something goes wrong, you can replay the exact sequence of events that led to the bad state.
  • New projections without migration — Need a new report or dashboard? Build a new projection and replay historical events through it. No data migration needed.

Why CQRS and Event Sourcing Work Together

These patterns are independent—you can use CQRS without Event Sourcing and vice versa—but they're natural complements.

Events are the bridge between the command and query sides. When the command side processes a command and validates it against business rules, it emits domain events. Those events are persisted to the event store (Event Sourcing) and simultaneously projected into read models (CQRS).

The flow looks like this:

  1. Client sends a commandShipOrder { orderId: "abc" }
  2. Command handler loads the aggregate by replaying its events
  3. Business rules are validated — Is the order paid? Is inventory available?
  4. New events are emittedOrderShipped { orderId: "abc", ... }
  5. Events are persisted to the event store
  6. Projections consume the events and update read models

The event store is the single source of truth. Read models are disposable, rebuildable projections optimized for specific query patterns.

When to Use CQRS + Event Sourcing

These patterns add complexity. They're not appropriate for everything. Here's when they earn their weight:

Strong Fit

  • High-write systems where read and write loads need to scale independently
  • Audit and compliance requirements — healthcare, finance, aviation, legal
  • Complex domain logic where business rules evolve frequently and you need to understand the history of every decision
  • Multi-tenant SaaS where tenants need isolated, auditable data streams
  • Event-driven integrations where downstream systems react to domain events
  • Systems requiring temporal queries — "What was the state of X at time T?"

Poor Fit

  • Simple CRUD applications — If your domain is just "save this form and display it later," CQRS+ES is over-engineering
  • Early MVPs with unclear domains — You need to understand your domain boundaries before event sourcing them. Premature event sourcing creates premature abstractions
  • Read-heavy, write-light systems — A content site or catalog that rarely changes doesn't benefit from write-side optimization
  • Teams without event-driven experience — The learning curve is real. Forcing CQRS+ES on a team that's never worked with eventual consistency leads to pain

Practical Implementation

Let me walk through the key components and technology decisions.

The Event Store

At its simplest, an event store is a table in PostgreSQL:

events (
  id            UUID PRIMARY KEY,
  aggregate_id  UUID NOT NULL,
  aggregate_type VARCHAR NOT NULL,
  event_type    VARCHAR NOT NULL,
  event_data    JSONB NOT NULL,
  metadata      JSONB,
  version       INTEGER NOT NULL,
  created_at    TIMESTAMPTZ DEFAULT NOW(),
  UNIQUE(aggregate_id, version)
)

The UNIQUE(aggregate_id, version) constraint is critical—it provides optimistic concurrency control. If two commands try to append events to the same aggregate simultaneously, one will fail the uniqueness check and can be retried.

For most SaaS products, PostgreSQL is sufficient as an event store. You don't need EventStoreDB or Kafka from day one. Start with Postgres, add specialized infrastructure when your event volume demands it.

Command Handlers

A command handler receives a command, loads the relevant aggregate, executes business logic, and persists resulting events:

async function handleShipOrder(command: ShipOrder): Promise<void> {
  const events = await eventStore.getEvents(command.orderId);
  const order = Order.rehydrate(events);

  // Business rule validation
  order.ship(command.trackingNumber);

  // Persist new events
  await eventStore.append(
    command.orderId,
    order.uncommittedEvents,
    order.version
  );
}

The aggregate itself is a pure domain object. No database dependencies. No ORM. Just business logic that accepts commands and emits events.

Projections and Read Models

Projections are event handlers that build read-optimized views. They subscribe to the event stream and update denormalized read models:

function orderSummaryProjection(event: DomainEvent): void {
  switch (event.type) {
    case 'OrderCreated':
      readDb.insert('order_summaries', {
        orderId: event.orderId,
        customerName: event.customerName,
        status: 'created',
        total: 0
      });
      break;
    case 'OrderShipped':
      readDb.update('order_summaries',
        { orderId: event.orderId },
        { status: 'shipped', shippedAt: event.timestamp }
      );
      break;
  }
}

Read models can live in PostgreSQL, Redis, Elasticsearch—whatever serves the query pattern best. They're disposable. If a projection has a bug, fix the code, drop the read model, and replay all events through the corrected projection.

Sagas and Process Managers

Real systems have workflows that span multiple aggregates. A saga (or process manager) coordinates these by listening to events and dispatching commands:

  • OrderShipped → trigger SendShippingNotification command
  • PaymentFailed → trigger CancelOrder command
  • SubscriptionExpired → trigger SuspendTenantAccess command

Sagas themselves are event-sourced, giving you full visibility into the state of every long-running process in your system.

Eventual Consistency: The Trade-Off You Must Accept

CQRS with separate read models means reads are eventually consistent. When a command succeeds and events are persisted, the read models may take milliseconds to seconds to update.

For most SaaS applications, this is perfectly acceptable. Users don't notice a 200ms delay between clicking "submit" and seeing updated data. But you need to design for it:

  • Return command results directly — Don't make the UI poll the read model after a command. Return success/failure from the command handler.
  • Use optimistic UI updates — Update the UI immediately on command success, then reconcile when the read model catches up.
  • Design idempotent projections — Events may be delivered more than once. Your projections must handle duplicates gracefully.

The DDD Connection

CQRS and Event Sourcing are natural extensions of Domain-Driven Design. The connections are deep:

  • Aggregates are the consistency boundaries for your command model. Each aggregate is an event-sourced entity with its own event stream.
  • Domain events are first-class citizens, not afterthoughts. They're the integration contract between bounded contexts.
  • Bounded contexts map to independently deployable services, each with their own event store and read models.
  • Ubiquitous language is encoded directly in your event types. OrderShipped means the same thing to developers, domain experts, and the codebase.

If you're not already thinking in DDD terms, start with our guide to DDD for startups before adopting Event Sourcing. Understanding your domain boundaries is a prerequisite, not an optional enhancement. And Event Storming is the fastest way to discover those boundaries collaboratively.

When We Use It at Meld

We don't apply CQRS+ES to every project. For a straightforward SaaS MVP with clear CRUD semantics, a well-structured traditional architecture is faster to build and easier to maintain.

But when a client comes to us with compliance requirements, complex domain logic, audit trail needs, or multi-tenant data isolation, CQRS and Event Sourcing are our default recommendation. The upfront investment pays for itself within months as the system evolves and new requirements emerge that would be nightmarish with mutable state.

The key insight from two decades of enterprise architecture: choose your patterns based on your domain's actual complexity, not its perceived simplicity. A domain that looks simple today often reveals hidden complexity once real users and real money are involved. Event Sourcing gives you the flexibility to handle that complexity without rewriting your persistence layer.

Build your foundation for the problems you'll actually have. That's the architect's job. For a complete guide to getting your SaaS foundation right from day one, see building SaaS products that scale.