noddde
Core Concepts

CQRS & Event Sourcing

How noddde separates write and read models using CQRS and uses events as the source of truth through event sourcing

CQRS: Separating Reads from Writes

Command Query Responsibility Segregation (CQRS) splits your application into two distinct models: one optimized for writing data and one optimized for reading it. Instead of forcing a single schema to serve both consistent writes and efficient reads, each side gets a data structure that fits its purpose.

noddde organizes a domain into three sides:

  • Write Side -- Aggregates receive commands, enforce business rules, and emit events.
  • Process Side -- Sagas react to events and dispatch commands across aggregates, coordinating multi-step workflows that span more than one aggregate (see Sagas for the full pattern).
  • Read Side -- Projections consume events, build query-optimized views, and serve queries.

The Write Model

The write model is where domain logic lives. Its primary building blocks are aggregates -- Decider-based state machines that receive commands, validate them against the current state, and produce events. For commands that do not target an aggregate (sending a notification, calling an external API), noddde provides standalone command handlers.

In the domain definition, the write model is declared under the writeModel key:

domain.ts
import { defineDomain } from "@noddde/core";
import { wireDomain } from "@noddde/engine";

const bankingDomain = defineDomain({
  writeModel: {
    aggregates: {
      BankAccount,
    },
    standaloneCommandHandlers: {
      SendNotification: async (command, infrastructure) => {
        await infrastructure.notificationService.send(command.payload);
      },
    },
  },
  // ...
});

const domain = await wireDomain(bankingDomain);

For a complete treatment of how to define aggregates, commands, and evolve handlers, see Modeling Your Domain.

The Read Model

The read model consumes events and builds projections -- query-optimized data structures that answer specific questions about the domain. Each projection has two sides:

  • Reducers -- React to events and update a view.
  • Query handlers -- Serve queries by reading from that view.

For queries that do not belong to any projection, noddde provides standalone query handlers.

domain.ts
const bankingDomain = defineDomain({
  // ...
  readModel: {
    projections: {
      AccountBalance: AccountBalanceProjection,
      TransactionHistory: TransactionHistoryProjection,
    },
    standaloneQueryHandlers: {
      GetSystemStats: async (_query, infrastructure) => {
        return infrastructure.statsService.getStats();
      },
    },
  },
});

For details on defining projections, event handlers, and query handlers, see Projections.

The Process Model

The process model sits between write and read. It contains sagas -- stateful, event-driven process managers that coordinate workflows across multiple aggregates by reacting to events and dispatching commands.

A saga is the structural inverse of an aggregate:

  • Aggregate: command in, events out (decisions)
  • Saga: event in, commands out (coordination)

Sagas are declared under the processModel key in the domain configuration and live in their own top-level section because they are neither pure write-model (they subscribe to events) nor pure read-model (they dispatch commands).

For a detailed guide on defining sagas, on map entries, and handlers, see Sagas.

The Buses

Communication between the write model, read model, and the outside world flows through three buses. Together they form the CQRSInfrastructure:

packages/core/src/cqrs/cqrs-infrastructure.ts
interface CQRSInfrastructure {
  commandBus: CommandBus;
  eventBus: EventBus;
  queryBus: QueryBus;
}

CommandBus routes commands to the correct handler -- either an aggregate's decide handler or a standalone command handler, based on the command's name.

packages/core/src/cqrs/command-bus.ts
interface CommandBus {
  dispatch(command: Command): Promise<void>;
}

EventBus distributes events to all interested subscribers. After an aggregate produces events, the framework dispatches each one through the event bus. Every projection with a matching reducer and every saga with a matching association receives it.

packages/core/src/edd/event-bus.ts
interface EventBus {
  dispatch<TEvent extends Event>(event: TEvent): Promise<void>;
}

QueryBus routes queries to the appropriate handler and returns the result. It connects the outside world to the read model.

packages/core/src/cqrs/query-bus.ts
interface QueryBus {
  dispatch<TQuery extends Query<any>>(
    query: TQuery,
  ): Promise<QueryResult<TQuery>>;
}

noddde ships with in-memory implementations of all three buses for development and testing:

main.ts
import {
  InMemoryCommandBus,
  EventEmitterEventBus,
  InMemoryQueryBus,
} from "@noddde/engine";

In production, you would replace these with implementations backed by a message broker (RabbitMQ, Kafka, etc.) or a distributed event store.

Why Separate Models?

Separating write and read unlocks several architectural advantages:

  • Independent optimization -- The write model can use a structure optimized for consistency (an event stream, a normalized schema), while the read model uses structures optimized for query performance (denormalized views, materialized aggregations, search indexes).

  • Independent scaling -- Read traffic typically far exceeds write traffic. With CQRS you can scale the read side independently -- more replicas, aggressive caching, distributed projections -- without affecting the write side.

  • Multiple read models -- A single stream of events can feed many projections, each optimized for a different query pattern. The same TransactionAuthorized event might update an AccountBalance view, append to a TransactionHistory, feed a FraudDetection model, and populate an AnalyticsDashboard. Adding a new read model requires only writing a new projection.

  • Temporal flexibility -- Because events are immutable facts, you can add a new projection at any time and replay the entire event history to populate it. You can answer questions about your data that you did not anticipate when you first designed the system.

Event Sourcing

In most applications, the database stores the current state of each entity. When you update a bank account balance, the old value is overwritten. The history of how you arrived at that value is lost.

Event sourcing takes a different approach: instead of storing the current state, you store the sequence of events that produced it. The current state is derived by replaying all events from the beginning through pure functions.

plaintext
Traditional (state-stored):
  Account #123  ->  { balance: 750, status: "active" }

Event-sourced:
  Account #123  ->  [
    { name: "BankAccountCreated", payload: { id: "123" } },
    { name: "FundsDeposited",     payload: { amount: 1000 } },
    { name: "TransactionAuthorized", payload: { amount: 200, merchant: "Store A" } },
    { name: "TransactionAuthorized", payload: { amount: 50,  merchant: "Store B" } },
  ]

  Current state = replay all events -> { balance: 750, status: "active" }

The event stream is an append-only log. Events are never modified or deleted. Each event represents an immutable fact -- something that happened in the past.

Event Sourcing in noddde

noddde supports event sourcing through the EventSourcedAggregatePersistence interface:

packages/core/src/persistence/event-sourced-aggregate-persistence.ts
interface EventSourcedAggregatePersistence {
  save(
    aggregateName: string,
    aggregateId: string,
    events: Event[],
  ): Promise<void>;
  load(aggregateName: string, aggregateId: string): Promise<Event[]>;
}

When a command arrives for an aggregate instance, the framework follows this sequence:

  1. Load events -- Retrieve the full event history for the aggregate instance.
  2. Replay -- Start with initialState and fold each event through the matching evolve handler to reconstruct the current state.
  3. Handle command -- Pass the reconstructed state, the command, and infrastructure to the decide handler.
  4. Save new events -- Append the events produced by the decide handler.
  5. Apply new events -- Fold the new events into the state for any subsequent commands in the same session.

The conceptual replay logic looks like this:

replay-state.ts
function replayState<T extends AggregateTypes>(
  aggregate: Aggregate<T>,
  events: Event[],
): T["state"] {
  return events.reduce((state, event) => {
    const handler = aggregate.evolve[event.name];
    return handler(event.payload, state);
  }, aggregate.initialState);
}

This is why the evolve handlers in a noddde aggregate are so important. They are not just updating state after a command -- they are the canonical definition of how state is derived from events. Every time an aggregate instance is loaded, the evolve handlers run over the entire event history.

The State-Stored Alternative

Not every aggregate needs event sourcing. noddde also supports state-stored persistence, where the framework saves and loads the final state directly:

packages/core/src/persistence/state-stored-aggregate-persistence.ts
interface StateStoredAggregatePersistence {
  save(aggregateName: string, aggregateId: string, state: any): Promise<void>;
  load(aggregateName: string, aggregateId: string): Promise<any>;
}

With state-stored persistence, events are still produced by decide handlers and still flow through the event bus to projections. The difference is that events are not persisted in an event store -- only the resulting state is saved.

The key design choice: the aggregate definition does not change between event-sourced and state-stored persistence. The same defineAggregate with the same decide and evolve handlers works with either strategy. The persistence strategy is a deployment decision, not a domain modeling decision.

bank-account/bank-account.ts
// This aggregate works identically with either persistence strategy
const BankAccount = defineAggregate<BankAccountTypes>({
  initialState: { balance: 0, status: "active" },
  decide: {
    /* ... */
  },
  evolve: {
    /* ... */
  },
});

You could start with state-stored persistence for simplicity and switch to event sourcing later without changing a single line of domain code.

When to Use Which

Use event sourcing when you need a full audit trail, temporal queries, or the ability to replay events into new projections. Use state-stored persistence for simpler CRUD-ish aggregates that have no audit need and where O(1) load performance matters. Different aggregates in the same domain can use different strategies. For the full decision matrix, see Persistence — Decision Matrix.

The Replay Guarantee

Event sourcing only works if evolve is deterministic. Evolve handlers must therefore be pure functions of their inputs -- no clock reads, no random IDs, no external lookups. noddde enforces this at the type level by never passing infrastructure to evolve handlers.

If a value depends on the current time, a random ID, or an external service, capture it in the decide handler and include it in the event payload. The evolve handler then reads it from the event, ensuring deterministic replay.

For the full rationale (with BAD/GOOD examples) see Why Are Evolve Handlers Pure?.

Event Sourcing + CQRS Together

Event sourcing and CQRS are independent patterns, but they complement each other naturally:

  • Event sourcing provides a durable event stream on the write side.
  • CQRS provides the separation that lets the read side consume that stream and build query-optimized views.

Without CQRS, event sourcing would require replaying events every time you want to read data -- slow for complex queries. With CQRS, projections pre-compute the views you need, and queries are served from those pre-computed views.

Without event sourcing, CQRS would need a separate mechanism to propagate changes from write to read. With event sourcing, events are the natural propagation mechanism -- they are already produced by decide handlers and can be directly consumed by projections.

plaintext
  Command -> Aggregate -> Events -> Event Store (write side, event sourcing)
                                 |
                                 +-> EventBus -> Projection -> Read DB (read side, CQRS)
                                                                   |
                                                          Query -> QueryBus -> Response

The CQRS Data Flow

Here is the complete flow of a command through a noddde application:

plaintext
1. Client sends a command
   { name: "AuthorizeTransaction", targetAggregateId: "acc-1",
     payload: { amount: 50, merchant: "Store" } }

2. CommandBus routes to BankAccount aggregate

3. Framework loads current state for "acc-1"
   { balance: 1000, status: "active" }

4. Decide handler runs
   AuthorizeTransaction(command, state, infrastructure) -> event

5. Event returned
   { name: "TransactionAuthorized",
     payload: { id: "acc-1", amount: 50, merchant: "Store" } }

6. Evolve handler updates state
   TransactionAuthorized(event.payload, state) -> { balance: 950, status: "active" }

7. Event persisted to event store

8. EventBus dispatches event to projections and sagas
   - AccountBalanceView updates balance for "acc-1"
   - TransactionHistory appends new entry
   - FraudDetection checks for anomalies

9. Client can query the read model
   QueryBus.dispatch({ name: "GetAccountBalance",
     payload: { accountId: "acc-1" } })
   -> { balance: 950 }

Steps 1-7 are the write path (synchronous, consistent). Step 8 fans out to the read path (projections) and the process path (sagas). Step 9 is the query path.

Next Steps

On this page