Martin Fowler's warning about CQRS is one of the most-cited cautions in software architecture: 'For most systems CQRS adds risky complexity, and many information systems fit well with the notion of an information base that is updated in the same way that it's read.' He's right. I've built ERP modules where a simple service layer with PostgreSQL read and writes was the correct architecture. CQRS earned its complexity on a specific module — the approval workflow engine — where the read model (who can approve next, what's pending) was genuinely different from the write model (recording approval events). This guide covers what CQRS and event sourcing actually solve, and when they're worth the overhead.
CQRS (Command Query Responsibility Segregation) was first described by Greg Young, building on Bertrand Meyer's Command-Query Separation principle. The core idea: use a different model to update information than the model you use to read it. In practice, this means your application has two stacks — Commands (write operations that change state) and Queries (read operations that return data). Commands go through a command bus and are handled by command handlers that update the write model. Queries go through a query bus to query handlers that read from a potentially separate read model (could be the same database, or a denormalized projection table, or Redis cache).
The real power of CQRS emerges when your read model and write model need to be different. An approval workflow has a complex write model (business rules about who can approve what, state machine transitions, audit trail) and a simple read model (show me pending approvals for this user). Storing these as the same data model means either compromising the write model to make reads efficient or vice versa. Separating them lets you optimize each independently. You can also scale them independently — read traffic might be 100x write traffic, so a separate read model served from Redis makes sense.
CQRS Architecture with @nestjs/cqrs
┌──────────────────────────────────────────────────────────────┐
│ Client (React) │
└──────────────────┬───────────────────┬───────────────────────┘
│ Commands │ Queries
│ (write intent) │ (read request)
▼ ▼
┌─────────────────────────┐ ┌─────────────────────────────────┐
│ Command Bus │ │ Query Bus │
│ ApproveInvoiceCommand │ │ GetPendingApprovalsQuery │
└────────────┬────────────┘ └────────────────┬────────────────┘
│ │
▼ ▼
┌─────────────────────────┐ ┌─────────────────────────────────┐
│ ApproveInvoice │ │ GetPendingApprovals │
│ CommandHandler │ │ QueryHandler │
│ │ │ │
│ - Validate rules │ │ - Read from projection table │
│ - Update write model │ │ - No business rules │
│ - Emit InvoiceApproved │ │ - Fast, indexed query │
└────────────┬────────────┘ └─────────────────────────────────┘
│ event
▼
┌─────────────────────────┐
│ Event Bus │
│ InvoiceApproved │
└──────┬──────────────────┘
│
┌────┴────────────────────────────┐
▼ ▼
ApprovalProjection InvoiceSaga
(updates read model) (sends approval email)From building the approval workflow for Commsult's ERP: use CQRS for modules where the read model is materially different from the write model — where you'd otherwise have complex joins or denormalized fields just to serve read queries. Skip CQRS for CRUD-heavy modules like user profiles, address books, or simple settings pages. The @nestjs/cqrs package makes the pattern accessible, but applying it indiscriminately adds boilerplate without benefit.
Event sourcing stores the sequence of events that led to the current state, rather than the current state itself. Instead of UPDATE invoices SET status='paid', you append an InvoicePaid event to an event log. The current state is derived by replaying the event history. The benefits: complete audit trail (every state change is recorded with who did it and when), event replay (rebuild any read model from the event log), and temporal queries (what was the state of this invoice on any past date?). The costs: event schema evolution is harder than relational schema migration, and reads require event replay or maintaining up-to-date projections.
// @nestjs/cqrs implementation
// 1. Command
export class ApproveInvoiceCommand {
constructor(
public readonly invoiceId: string,
public readonly approverId: string,
public readonly level: number,
) {}
}
// 2. Command Handler
@CommandHandler(ApproveInvoiceCommand)
export class ApproveInvoiceHandler implements ICommandHandler<ApproveInvoiceCommand> {
constructor(
private repo: InvoiceRepository,
private eventBus: EventBus,
) {}
async execute(cmd: ApproveInvoiceCommand) {
const invoice = await this.repo.findById(cmd.invoiceId);
invoice.approve(cmd.approverId, cmd.level); // domain logic
await this.repo.save(invoice);
this.eventBus.publish(new InvoiceApprovedEvent(invoice.id, cmd.approverId));
}
}
// 3. Query + Handler
export class GetPendingApprovalsQuery { constructor(public userId: string) {} }
@QueryHandler(GetPendingApprovalsQuery)
export class GetPendingApprovalsHandler implements IQueryHandler<GetPendingApprovalsQuery> {
async execute({ userId }: GetPendingApprovalsQuery) {
// Read from denormalized projection — no joins, no business rules
return this.projectionRepo.findPendingFor(userId);
}
}
// 4. Saga — orchestrates side effects
@Injectable()
export class InvoiceSaga {
@Saga()
invoiceApproved = (events$: Observable<any>): Observable<ICommand> => {
return events$.pipe(
ofType(InvoiceApprovedEvent),
map(event => new SendApprovalEmailCommand(event.invoiceId, event.approverId)),
);
}
}NestJS provides a @nestjs/cqrs package that implements the Command Bus, Query Bus, and Event Bus patterns. Commands are plain TypeScript classes with properties. CommandHandlers implement the ICommandHandler interface and are registered in the module. Events are emitted after state changes and handled by EventHandlers for side effects (sending emails, updating projections, triggering other commands). The implementation is clean and testable — each handler is a small, focused class with a single responsibility.
Think carefully before committing to event sourcing for an entire application. Schema evolution of events is painful — if you add a required field to an event, you need migration logic to backfill it when replaying old events. Querying event-sourced data requires either maintaining projection tables (adding operational complexity) or replaying events on every read (adding latency). Debugging is harder — you need to replay event history to understand current state. Greg Young himself said in 2016 that CQRS 'was a product of its time' and recommends using it only for specific bounded contexts, not system-wide.
Projections are the read-side of event sourcing — they subscribe to events and maintain a denormalized, query-optimized view of the data. For an approval workflow: the ApprovalProjection listens to ApprovalRequested, ApprovalGranted, and ApprovalRejected events and maintains a table showing each request's current status and next approver. The projection can be rebuilt from scratch by replaying all events — this is the 'time travel' capability that makes event sourcing valuable for audit-heavy systems. Use PostgreSQL JSONB columns for your event store — cheap, queryable, and no additional infrastructure.
In @nestjs/cqrs, a Saga is a class that listens to domain events and dispatches commands in response. Sagas handle the choreography of multi-step business processes — when InvoiceCreated fires, the saga dispatches GeneratePdfCommand and SendEmailCommand. Sagas are the event-driven glue between bounded contexts. They must be idempotent — if the same event is processed twice (due to retry), the saga should not dispatch duplicate commands. Use correlation IDs on commands and events to detect and skip duplicates.
The most pragmatic version of this pattern is CQRS without event sourcing. Separate your command handlers (write operations that use the full domain model) from your query handlers (reads that query optimized projections or views) — but still store state as current state in PostgreSQL, not as an event log. This gives you most of the architectural benefits (separation of concerns, independent scaling of read and write paths, clean code organization) without the operational complexity of event sourcing. Apply event sourcing selectively — only for bounded contexts where complete audit history, event replay, or temporal queries are genuine product requirements, not just 'nice to have.'