CQRS and Event Sourcing — TypeScript Implementation Guide 2026
Advertisement
Introduction
Why This Matters
CQRS (Command Query Responsibility Segregation) and Event Sourcing are two independent patterns that are frequently combined. CQRS separates the write model (commands that change state) from the read model (queries that return data). Event Sourcing replaces the current-state database with an append-only log of all state-changing events.
Together they enable: complete audit trails, temporal queries ("what was the state at time T?"), event replay for bug reproduction, and independently scalable read/write stores. The cost is significant complexity — apply them only where the domain genuinely benefits.
CQRS: Separating Commands from Queries
The simplest form of CQRS uses separate request handlers for commands and queries, with a shared database:
// Commands — intent to change state
interface CreateOrderCommand {
type: 'CreateOrder';
userId: string;
items: Array<{ productId: string; quantity: number; price: number }>;
}
interface CancelOrderCommand {
type: 'CancelOrder';
orderId: string;
reason: string;
}
type OrderCommand = CreateOrderCommand | CancelOrderCommand;
// Queries — read-only data retrieval
interface GetOrderQuery {
type: 'GetOrder';
orderId: string;
}
interface GetUserOrdersQuery {
type: 'GetUserOrders';
userId: string;
status?: 'pending' | 'confirmed' | 'cancelled';
page: number;
pageSize: number;
}
type OrderQuery = GetOrderQuery | GetUserOrdersQuery;// Command handler — writes to the write model
class OrderCommandHandler {
constructor(
private orderRepository: OrderWriteRepository,
private eventBus: EventBus
) {}
async handle(command: OrderCommand): Promise<void> {
switch (command.type) {
case 'CreateOrder': {
const order = Order.create(command.userId, command.items);
await this.orderRepository.save(order);
await this.eventBus.publish('order.created', order.getUncommittedEvents());
break;
}
case 'CancelOrder': {
const order = await this.orderRepository.findById(command.orderId);
order.cancel(command.reason);
await this.orderRepository.save(order);
break;
}
}
}
}
// Query handler — reads from the read model (denormalized, optimized for display)
class OrderQueryHandler {
constructor(private readDb: OrderReadRepository) {}
async handle(query: OrderQuery): Promise<unknown> {
switch (query.type) {
case 'GetOrder':
return this.readDb.findById(query.orderId);
case 'GetUserOrders':
return this.readDb.findByUser(query.userId, query.status, query.page, query.pageSize);
}
}
}Event Sourcing: Storing Events as the Source of Truth
Instead of storing current state, Event Sourcing stores every state-changing event. Current state is derived by replaying events:
// Domain events
interface OrderCreatedEvent {
type: 'OrderCreated';
orderId: string;
userId: string;
items: Array<{ productId: string; quantity: number; price: number }>;
total: number;
timestamp: string;
}
interface OrderCancelledEvent {
type: 'OrderCancelled';
orderId: string;
reason: string;
timestamp: string;
}
type OrderEvent = OrderCreatedEvent | OrderCancelledEvent;// Event Store — append-only log
interface StoredEvent {
id: string;
aggregateId: string;
aggregateType: string;
sequence: number;
eventType: string;
payload: object;
timestamp: Date;
}
class PostgresEventStore {
constructor(private db: DatabaseClient) {}
async append(
aggregateId: string,
aggregateType: string,
events: DomainEvent[],
expectedSequence: number
): Promise<void> {
// Optimistic concurrency: check expected sequence
const current = await this.db.query<{ max: number }>(
'SELECT MAX(sequence) as max FROM events WHERE aggregate_id = $1',
[aggregateId]
);
const currentSequence = current.rows[0]?.max ?? 0;
if (currentSequence !== expectedSequence) {
throw new Error(
`Concurrency conflict: expected sequence ${expectedSequence}, got ${currentSequence}`
);
}
let seq = expectedSequence;
for (const event of events) {
seq++;
await this.db.query(
`INSERT INTO events (id, aggregate_id, aggregate_type, sequence, event_type, payload, timestamp)
VALUES ($1, $2, $3, $4, $5, $6, NOW())`,
[crypto.randomUUID(), aggregateId, aggregateType, seq, event.type, JSON.stringify(event)]
);
}
}
async getEvents(aggregateId: string, fromSequence = 0): Promise<StoredEvent[]> {
const result = await this.db.query<StoredEvent>(
'SELECT * FROM events WHERE aggregate_id = $1 AND sequence > $2 ORDER BY sequence ASC',
[aggregateId, fromSequence]
);
return result.rows;
}
}Aggregate Root with Event Sourcing
The aggregate root applies domain logic, records events, and can be reconstituted from its event history:
abstract class AggregateRoot {
private uncommittedEvents: DomainEvent[] = [];
protected sequence = 0;
protected record(event: DomainEvent): void {
this.apply(event);
this.uncommittedEvents.push(event);
}
abstract apply(event: DomainEvent): void;
getUncommittedEvents(): DomainEvent[] {
return [...this.uncommittedEvents];
}
clearUncommittedEvents(): void {
this.uncommittedEvents = [];
}
loadFromHistory(events: DomainEvent[]): void {
for (const event of events) {
this.apply(event);
this.sequence++;
}
}
}
class Order extends AggregateRoot {
public id!: string;
public userId!: string;
public status!: 'pending' | 'confirmed' | 'cancelled';
public items: Array<{ productId: string; quantity: number; price: number }> = [];
public total = 0;
static create(userId: string, items: Order['items']): Order {
const order = new Order();
const total = items.reduce((sum, i) => sum + i.price * i.quantity, 0);
order.record({
type: 'OrderCreated',
orderId: crypto.randomUUID(),
userId,
items,
total,
timestamp: new Date().toISOString(),
} as OrderCreatedEvent);
return order;
}
cancel(reason: string): void {
if (this.status === 'cancelled') {
throw new Error('Order is already cancelled');
}
this.record({
type: 'OrderCancelled',
orderId: this.id,
reason,
timestamp: new Date().toISOString(),
} as OrderCancelledEvent);
}
apply(event: DomainEvent): void {
const e = event as OrderEvent;
switch (e.type) {
case 'OrderCreated':
this.id = e.orderId;
this.userId = e.userId;
this.items = e.items;
this.total = e.total;
this.status = 'pending';
break;
case 'OrderCancelled':
this.status = 'cancelled';
break;
}
}
}Projections: Building Read Models from Events
Projections listen to events and build denormalized read models optimized for query performance:
interface OrderSummary {
orderId: string;
userId: string;
status: string;
total: number;
itemCount: number;
createdAt: string;
}
class OrderProjection {
constructor(private readDb: ReadDatabase) {}
async on(event: OrderEvent): Promise<void> {
switch (event.type) {
case 'OrderCreated':
await this.readDb.upsert('order_summaries', {
orderId: event.orderId,
userId: event.userId,
status: 'pending',
total: event.total,
itemCount: event.items.length,
createdAt: event.timestamp,
} satisfies OrderSummary);
break;
case 'OrderCancelled':
await this.readDb.update(
'order_summaries',
{ orderId: event.orderId },
{ status: 'cancelled' }
);
break;
}
}
// Rebuild from scratch by replaying all events
async rebuild(eventStore: PostgresEventStore): Promise<void> {
await this.readDb.truncate('order_summaries');
const events = await eventStore.getAllEvents('Order');
for (const stored of events) {
await this.on(stored.payload as OrderEvent);
}
}
}Projections are rebuilable at any time from the event log — making schema migrations and bug fixes safe because you can reconstruct any read model.
Common Mistakes
- Applying CQRS/ES to simple CRUD — these patterns add 5-10x implementation complexity. Use them only when audit trails, replay, or independent read/write scaling are genuine requirements.
- Mutating events in the store — events are immutable facts. Never update or delete stored events; create compensating events instead.
- Storing current state alongside events — if the event log is the source of truth, do not maintain redundant current state in a separate table. Use projections for queries.
- No snapshotting — aggregates with thousands of events are slow to reconstitute. Implement snapshots (cached state at sequence N) for long-lived aggregates.
- Forgetting optimistic concurrency — without sequence-number checks on append, concurrent writes to the same aggregate produce conflicting events.
Best Practices
- Start with CQRS alone (separate command/query handlers, shared database) before adding Event Sourcing.
- Use snapshots for aggregates with more than 500 events to keep reconstitution time under 10ms.
- Version event schemas from day one — add a
versionfield to every event type. - Implement projection rebuilding from the start — it is the escape hatch for all schema and logic mistakes.
- Monitor event store append latency, projection lag, and aggregate reconstitution time as key metrics.
Key Takeaways
- CQRS separates command handlers (writes) from query handlers (reads), enabling each side to be independently optimized and scaled.
- Event Sourcing replaces current-state storage with an append-only event log; current state is derived by replaying events.
- Aggregate roots record domain events, apply them to update internal state, and expose uncommitted events for persistence.
- Optimistic concurrency control via sequence numbers prevents concurrent writes to the same aggregate from producing conflicting events.
- Projections subscribe to events and build denormalized read models — they are fully rebuilable from the event log at any time.
- Snapshots cache aggregate state at a given sequence number, preventing slow reconstitution for aggregates with thousands of events.
- CQRS and Event Sourcing add significant complexity — apply them only when audit trails, event replay, or separate read/write scaling are genuine requirements.
- Projections can be rebuilt from scratch, making read-model schema migrations and bug fixes safe operations in event-sourced systems.
Advertisement