Understanding Event Sourcing and CQRS
Event-Driven Architecture (EDA) has become a cornerstone of modern distributed systems. Two patterns that sit at the heart of EDA are Event Sourcing and Command Query Responsibility Segregation (CQRS). When used together, they enable systems that are auditable, scalable, and capable of handling complex business logic with remarkable clarity. This tutorial takes you from first principles through to a complete, production-ready implementation.
What Is Event Sourcing?
Event Sourcing is a persistence pattern where you store state changes as a sequence of immutable events rather than storing the current state itself. Instead of saving a row in a database representing an entity's current values, you append an event to an event store describing what happened.
Think of it like a version control system for your data. Git doesn't store the latest snapshot of your codebase alone — it stores every commit (event) that ever happened. You can replay those commits to reconstruct any historical state. Event Sourcing works the same way:
- Each state change is captured as an event object
- Events are appended to an event store — never updated or deleted
- The current state is derived by replaying all events in order
- Events serve as the single source of truth and an audit log
What Is CQRS?
CQRS stands for Command Query Responsibility Segregation. It separates read operations (queries) from write operations (commands). In a traditional CRUD architecture, the same data model and database handle both reads and writes. With CQRS, you have distinct models:
- Command Model (Write Side): Processes commands, validates business rules, and produces events. This is where Event Sourcing lives.
- Query Model (Read Side): Optimized for fast reads. Often maintained by consuming events from the write side and building denormalized projections.
The separation allows you to optimize each side independently — the write side for consistency and business logic, the read side for query performance and specific use-case needs.
Why Event Sourcing and CQRS Matter
Together, these patterns unlock powerful capabilities that are difficult to achieve with traditional architectures:
- Complete Audit Trail: Every change is captured as an event. You can trace exactly what happened, when, and by whom — essential for finance, healthcare, and compliance-heavy domains.
- Temporal Queries: You can query the state of the system at any point in time by replaying events up to that moment. Want to know what the account balance was last Tuesday at 3pm? Just replay events up to that timestamp.
- Debugging and Replay: If a bug corrupts your read model, you can rebuild it from scratch by replaying the event stream. No data is lost.
- Scalability: Reads and writes scale independently. The read side can have multiple optimized projections, each in different databases or caches.
- Event-Driven Integration: Events naturally feed into other services, analytics pipelines, or notification systems — no additional change data capture needed.
- Business Alignment: Events represent domain concepts directly (e.g.,
OrderPlaced,PaymentReceived), making the system's behavior transparent to domain experts.
How to Implement Event Sourcing — Step by Step
1. Define Your Domain Events
Events are immutable facts about what happened. They should be named in the past tense and carry all relevant data. Here's an example for a bank account domain:
// domain-events.ts
export interface DomainEvent {
readonly eventType: string;
readonly aggregateId: string;
readonly timestamp: number;
readonly version: number;
}
export class AccountOpened implements DomainEvent {
readonly eventType = 'AccountOpened';
readonly version: number;
constructor(
readonly aggregateId: string,
readonly accountHolder: string,
readonly initialDeposit: number,
readonly timestamp: number = Date.now(),
version?: number
) {
this.version = version ?? 1;
}
}
export class MoneyDeposited implements DomainEvent {
readonly eventType = 'MoneyDeposited';
readonly version: number;
constructor(
readonly aggregateId: string,
readonly amount: number,
readonly description: string,
readonly timestamp: number = Date.now(),
version?: number
) {
this.version = version ?? 1;
}
}
export class MoneyWithdrawn implements DomainEvent {
readonly eventType = 'MoneyWithdrawn';
readonly version: number;
constructor(
readonly aggregateId: string,
readonly amount: number,
readonly description: string,
readonly timestamp: number = Date.now(),
version?: number
) {
this.version = version ?? 1;
}
}
export class AccountClosed implements DomainEvent {
readonly eventType = 'AccountClosed';
readonly version: number;
constructor(
readonly aggregateId: string,
readonly reason: string,
readonly timestamp: number = Date.now(),
version?: number
) {
this.version = version ?? 1;
}
}
// Union type for all account events
export type AccountEvent =
| AccountOpened
| MoneyDeposited
| MoneyWithdrawn
| AccountClosed;
2. Build the Aggregate
An aggregate is a cluster of domain objects that are treated as a single unit for data changes. It enforces business rules (invariants) and produces events. The aggregate's state is rebuilt by replaying events:
// account-aggregate.ts
import { AccountEvent, AccountOpened, MoneyDeposited, MoneyWithdrawn, AccountClosed } from './domain-events';
export class AccountAggregate {
private _id: string = '';
private _holder: string = '';
private _balance: number = 0;
private _isClosed: boolean = false;
private _version: number = 0;
// Rehydrate the aggregate from an event history
static fromHistory(events: AccountEvent[]): AccountAggregate {
const account = new AccountAggregate();
for (const event of events) {
account.applyEvent(event);
}
return account;
}
// Expose current state for queries (read model building)
get state() {
return {
id: this._id,
holder: this._holder,
balance: this._balance,
isClosed: this._isClosed,
version: this._version,
};
}
// Command: Open a new account
openAccount(id: string, holder: string, initialDeposit: number): AccountEvent[] {
if (this._id) {
throw new Error('Account already exists. Cannot open again.');
}
if (initialDeposit < 0) {
throw new Error('Initial deposit cannot be negative.');
}
return [new AccountOpened(id, holder, initialDeposit, Date.now(), this._version + 1)];
}
// Command: Deposit money
deposit(amount: number, description: string): AccountEvent[] {
this.assertAccountActive();
if (amount <= 0) {
throw new Error('Deposit amount must be positive.');
}
return [new MoneyDeposited(this._id, amount, description, Date.now(), this._version + 1)];
}
// Command: Withdraw money
withdraw(amount: number, description: string): AccountEvent[] {
this.assertAccountActive();
if (amount <= 0) {
throw new Error('Withdrawal amount must be positive.');
}
if (this._balance - amount < 0) {
throw new Error('Insufficient funds.');
}
return [new MoneyWithdrawn(this._id, amount, description, Date.now(), this._version + 1)];
}
// Command: Close account
close(reason: string): AccountEvent[] {
this.assertAccountActive();
return [new AccountClosed(this._id, reason, Date.now(), this._version + 1)];
}
// Apply an event to mutate internal state (used during replay and for new events)
private applyEvent(event: AccountEvent): void {
switch (event.eventType) {
case 'AccountOpened': {
const e = event as AccountOpened;
this._id = e.aggregateId;
this._holder = e.accountHolder;
this._balance = e.initialDeposit;
this._isClosed = false;
this._version = e.version;
break;
}
case 'MoneyDeposited': {
const e = event as MoneyDeposited;
this._balance += e.amount;
this._version = e.version;
break;
}
case 'MoneyWithdrawn': {
const e = event as MoneyWithdrawn;
this._balance -= e.amount;
this._version = e.version;
break;
}
case 'AccountClosed': {
const e = event as AccountClosed;
this._isClosed = true;
this._version = e.version;
break;
}
default:
throw new Error(`Unknown event type: ${(event as any).eventType}`);
}
}
private assertAccountActive(): void {
if (!this._id) {
throw new Error('Account does not exist.');
}
if (this._isClosed) {
throw new Error('Account is closed. No operations allowed.');
}
}
}
3. Implement the Event Store
The event store is responsible for persisting events and providing them back in order. A production event store often uses a dedicated database like EventStoreDB, or a pattern built on top of PostgreSQL or MongoDB. Here's a simplified in-memory version that mimics the essential contract:
// event-store.ts
import { AccountEvent } from './domain-events';
export interface IEventStore {
append(aggregateId: string, events: AccountEvent[], expectedVersion: number): Promise;
getEvents(aggregateId: string): Promise;
getAllEvents(): AsyncIterable;
}
export class InMemoryEventStore implements IEventStore {
private streams: Map = new Map();
async append(aggregateId: string, events: AccountEvent[], expectedVersion: number): Promise {
const existingEvents = this.streams.get(aggregateId) || [];
const currentVersion = existingEvents.length > 0
? existingEvents[existingEvents.length - 1].version
: 0;
// Optimistic concurrency check
if (expectedVersion !== currentVersion) {
throw new Error(
`Concurrency conflict for aggregate ${aggregateId}: ` +
`expected version ${expectedVersion}, actual version ${currentVersion}`
);
}
// Assign correct version numbers
let nextVersion = currentVersion + 1;
for (const event of events) {
// Ensure version continuity
const versionedEvent = { ...event, version: nextVersion } as AccountEvent;
existingEvents.push(versionedEvent);
nextVersion++;
}
this.streams.set(aggregateId, existingEvents);
}
async getEvents(aggregateId: string): Promise {
return this.streams.get(aggregateId) || [];
}
async *getAllEvents(): AsyncIterable {
// Return events in insertion order across all streams
for (const [, events] of this.streams) {
for (const event of events) {
yield event;
}
}
}
}
4. Wire Up the Command Handler
The command handler orchestrates the process: load the aggregate from its event history, execute the command, and persist the resulting new events:
// command-handler.ts
import { IEventStore } from './event-store';
import { AccountAggregate } from './account-aggregate';
import { AccountEvent } from './domain-events';
export class AccountCommandHandler {
constructor(private eventStore: IEventStore) {}
async handleOpenAccount(
id: string,
holder: string,
initialDeposit: number
): Promise {
// Create a fresh aggregate (no history for new accounts)
const account = new AccountAggregate();
const events = account.openAccount(id, holder, initialDeposit);
// Persist with expected version 0 (new stream)
await this.eventStore.append(id, events, 0);
// Apply events to aggregate so it reflects new state
const updatedAccount = AccountAggregate.fromHistory(events);
console.log('Account opened:', updatedAccount.state);
}
async handleDeposit(
accountId: string,
amount: number,
description: string
): Promise {
// Load existing events
const history = await this.eventStore.getEvents(accountId);
const account = AccountAggregate.fromHistory(history);
// Execute command
const newEvents = account.deposit(amount, description);
// Persist with optimistic concurrency check
await this.eventStore.append(accountId, newEvents, account.state.version);
console.log('Deposit processed. New balance:', account.state.balance + amount);
}
async handleWithdraw(
accountId: string,
amount: number,
description: string
): Promise {
const history = await this.eventStore.getEvents(accountId);
const account = AccountAggregate.fromHistory(history);
const newEvents = account.withdraw(amount, description);
await this.eventStore.append(accountId, newEvents, account.state.version);
console.log('Withdrawal processed. New balance:', account.state.balance - amount);
}
async handleCloseAccount(accountId: string, reason: string): Promise {
const history = await this.eventStore.getEvents(accountId);
const account = AccountAggregate.fromHistory(history);
const newEvents = account.close(reason);
await this.eventStore.append(accountId, newEvents, account.state.version);
console.log('Account closed:', accountId);
}
}
How to Implement CQRS — The Read Side
With Event Sourcing handling the write side, CQRS adds the read side through projections. A projection listens to events and builds a read-optimized view. This decouples reads from the write model entirely.
1. Build a Projection (Read Model)
// account-projection.ts
import { AccountEvent, AccountOpened, MoneyDeposited, MoneyWithdrawn, AccountClosed } from './domain-events';
export interface AccountReadModel {
id: string;
holder: string;
balance: number;
status: 'active' | 'closed';
transactionCount: number;
lastActivity: Date;
}
export class AccountProjection {
private accounts: Map = new Map();
// Process a single event to update the read model
processEvent(event: AccountEvent): void {
switch (event.eventType) {
case 'AccountOpened': {
const e = event as AccountOpened;
this.accounts.set(e.aggregateId, {
id: e.aggregateId,
holder: e.accountHolder,
balance: e.initialDeposit,
status: 'active',
transactionCount: 0,
lastActivity: new Date(e.timestamp),
});
break;
}
case 'MoneyDeposited': {
const e = event as MoneyDeposited;
const account = this.accounts.get(e.aggregateId);
if (account) {
account.balance += e.amount;
account.transactionCount++;
account.lastActivity = new Date(e.timestamp);
}
break;
}
case 'MoneyWithdrawn': {
const e = event as MoneyWithdrawn;
const account = this.accounts.get(e.aggregateId);
if (account) {
account.balance -= e.amount;
account.transactionCount++;
account.lastActivity = new Date(e.timestamp);
}
break;
}
case 'AccountClosed': {
const e = event as AccountClosed;
const account = this.accounts.get(e.aggregateId);
if (account) {
account.status = 'closed';
account.lastActivity = new Date(e.timestamp);
}
break;
}
}
}
// Rebuild entire projection from an event stream
async rebuildFromStream(eventStream: AsyncIterable): Promise {
this.accounts.clear();
for await (const event of eventStream) {
this.processEvent(event);
}
}
// Query methods for the read side
getAccount(id: string): AccountReadModel | undefined {
return this.accounts.get(id);
}
getAllAccounts(): AccountReadModel[] {
return Array.from(this.accounts.values());
}
getAccountsByStatus(status: 'active' | 'closed'): AccountReadModel[] {
return this.getAllAccounts().filter(a => a.status === status);
}
getHighBalanceAccounts(minBalance: number): AccountReadModel[] {
return this.getAllAccounts().filter(a => a.balance >= minBalance);
}
}
2. Create a Query Service (Thin Read-Side API)
// query-service.ts
import { AccountProjection, AccountReadModel } from './account-projection';
export class AccountQueryService {
constructor(private projection: AccountProjection) {}
getAccount(id: string): AccountReadModel | null {
const account = this.projection.getAccount(id);
return account || null;
}
listActiveAccounts(): AccountReadModel[] {
return this.projection.getAccountsByStatus('active');
}
listClosedAccounts(): AccountReadModel[] {
return this.projection.getAccountsByStatus('closed');
}
getVipCandidates(minBalance: number = 10000): AccountReadModel[] {
return this.projection.getHighBalanceAccounts(minBalance);
}
getAccountSummary(id: string): object | null {
const account = this.projection.getAccount(id);
if (!account) return null;
return {
holder: account.holder,
balance: account.balance,
status: account.status,
transactionCount: account.transactionCount,
lastActivity: account.lastActivity.toISOString(),
};
}
}
Putting It All Together: A Complete Working Example
Here's how the command and query sides work together in a single application entry point:
// main.ts
import { InMemoryEventStore } from './event-store';
import { AccountCommandHandler } from './command-handler';
import { AccountProjection } from './account-projection';
import { AccountQueryService } from './query-service';
async function main() {
// ---- Infrastructure Setup ----
const eventStore = new InMemoryEventStore();
const projection = new AccountProjection();
const commandHandler = new AccountCommandHandler(eventStore);
const queryService = new AccountQueryService(projection);
// ---- Subscribe projection to events (simulated polling here) ----
// In production, you'd use a message broker or streaming platform (Kafka, RabbitMQ)
// Here we manually feed events to the projection after each command for simplicity.
// ---- Execute Commands (Write Side) ----
console.log('=== COMMAND: Open Account ===');
await commandHandler.handleOpenAccount('acc-001', 'Alice Johnson', 500);
// Feed event to projection
await rebuildProjection(eventStore, projection);
console.log('\n=== COMMAND: Deposit Money ===');
await commandHandler.handleDeposit('acc-001', 200, 'Salary deposit');
await rebuildProjection(eventStore, projection);
console.log('\n=== COMMAND: Withdraw Money ===');
await commandHandler.handleWithdraw('acc-001', 100, 'ATM withdrawal');
await rebuildProjection(eventStore, projection);
console.log('\n=== COMMAND: Open Second Account ===');
await commandHandler.handleOpenAccount('acc-002', 'Bob Smith', 15000);
await rebuildProjection(eventStore, projection);
// ---- Execute Queries (Read Side) ----
console.log('\n=== QUERY: Get Account Summary ===');
const summary = queryService.getAccountSummary('acc-001');
console.log(JSON.stringify(summary, null, 2));
console.log('\n=== QUERY: List Active Accounts ===');
const activeAccounts = queryService.listActiveAccounts();
console.table(activeAccounts.map(a => ({
id: a.id,
holder: a.holder,
balance: a.balance,
transactions: a.transactionCount,
})));
console.log('\n=== QUERY: VIP Candidates (balance >= 10000) ===');
const vipCandidates = queryService.getVipCandidates(10000);
console.table(vipCandidates.map(a => ({
id: a.id,
holder: a.holder,
balance: a.balance,
})));
// ---- Demonstrate Temporal Query (Replay up to a point) ----
console.log('\n=== TEMPORAL QUERY: State after first 3 events ===');
const allEvents = await collectEvents(eventStore);
const firstThreeEvents = allEvents.slice(0, 3);
const tempProjection = new AccountProjection();
for (const event of firstThreeEvents) {
tempProjection.processEvent(event);
}
console.log('Account acc-001 after 3 events:',
JSON.stringify(tempProjection.getAccount('acc-001'), null, 2));
}
async function rebuildProjection(eventStore: InMemoryEventStore, projection: AccountProjection) {
// In a real system, this would be incremental via a subscription
// For simplicity, we rebuild from the full stream each time
await projection.rebuildFromStream(eventStore.getAllEvents());
}
async function collectEvents(eventStore: InMemoryEventStore): Promise {
const events: import('./domain-events').AccountEvent[] = [];
for await (const event of eventStore.getAllEvents()) {
events.push(event);
}
return events;
}
// Run the example
main().catch(console.error);
When you run this, you'll see the write side producing events, the projection updating the read model, and queries returning denormalized data instantly — all backed by the immutable event stream.
Best Practices
- Use Optimistic Concurrency: Always pass an expected version when appending events. If another process modified the same aggregate, reject the write and let the caller retry. This prevents silent data corruption in distributed systems.
- Keep Events Small and Purposeful: Each event should represent one meaningful business fact. Avoid "fat" events that try to carry too much data. Prefer many small events over few large ones.
- Version Your Event Schemas: Events will evolve over time. Use explicit schema versioning (e.g., a
schemaVersionfield) and implement upcasters to transform old event shapes into the current representation during replay. - Snapshot Aggregates Periodically: For aggregates with long event histories, replaying thousands of events on every command is expensive. Store periodic snapshots of aggregate state, then replay only events since the latest snapshot.
- Make Projections Idempotent: Projections should safely handle the same event being delivered multiple times (at-least-once delivery). Use event IDs or version numbers to deduplicate.
- Separate Read Models by Use Case: Don't build one monolithic read model. Create dedicated projections for different query patterns — one for the account dashboard, another for regulatory reporting, a third for the mobile app's summary view.
- Use a Message Broker for Event Distribution: In production, the event store should publish events to a broker (RabbitMQ, Kafka, AWS EventBridge) so that projections and external services can consume them asynchronously without coupling to the store's internals.
- Test with Event Replay: Write tests that verify business logic by constructing event histories, running commands, and asserting the resulting events. This gives you confidence that your domain rules are correct across all state transitions.
Common Pitfalls and How to Avoid Them
- Eventual Consistency Confusion: CQRS with asynchronous projections means the read model may lag behind the write model. Design your UI and APIs to handle this gracefully — show a "processing" indicator or use a polling pattern with explicit version tracking.
- Over-engineering: Not every system needs Event Sourcing and CQRS. If your domain has simple CRUD semantics with no audit requirements, traditional patterns are simpler and sufficient. Apply these patterns where complexity warrants them.
- Ignoring GDPR / Data Deletion: Immutable events can conflict with data deletion requirements. Implement crypto-shredding (encrypting sensitive fields and deleting the encryption key) or forgettable event payloads to comply with regulations while preserving the event log structure.
- Schema Evolution Without Upcasters: Without proper upcasters, old events become unreadable during replay. Always write upcasters before changing event schemas, and test replay with production event histories.
- Monolithic Projections: A single projection handling all read use cases becomes a bottleneck. Split projections early — each should serve one bounded context or query requirement.
Conclusion
Event Sourcing and CQRS together form a powerful architectural foundation for systems that demand auditability, scalability, and rich domain modeling. By storing state transitions as immutable events and separating reads from writes, you gain a complete history of every change, the ability to rebuild any view from scratch, and the flexibility to optimize each side of your system independently. The patterns require careful engineering — optimistic concurrency, schema evolution, eventual consistency handling — but the payoff in system resilience and business insight is substantial. Start small: identify one bounded context where audit trails or temporal queries would add real value, implement a clean event store and projection, and expand from there. The event-driven mindset, once adopted, tends to reveal benefits far beyond what you initially anticipated.