Event Sourcing stores state changes as a sequence of events. CQRS separates read and write operations. Together, they enable powerful patterns for complex systems.
What is Event Sourcing?#
Traditional State Storage#
Traditional approach:
┌─────────────────────────────────┐
│ Account │
│ ───────── │
│ id: acc_123 │
│ balance: $500 │
│ status: active │
└─────────────────────────────────┘
Question: How did we get to $500?
Answer: Unknown 🤷
Event Sourcing Approach#
Event sourcing approach:
┌─────────────────────────────────┐
│ Events for acc_123 │
│ ───────────────── │
│ 1. AccountOpened($0) │
│ 2. MoneyDeposited($1000) │
│ 3. MoneyWithdrawn($300) │
│ 4. MoneyWithdrawn($200) │
└─────────────────────────────────┘
Current state: $1000 - $300 - $200 = $500
Full history available ✓
Implementing Event Sourcing#
Event Store#
1interface Event {
2 id: string;
3 aggregateId: string;
4 type: string;
5 data: Record<string, unknown>;
6 version: number;
7 timestamp: Date;
8}
9
10class EventStore {
11 async append(aggregateId: string, events: Event[], expectedVersion: number): Promise<void> {
12 const currentVersion = await this.getVersion(aggregateId);
13
14 if (currentVersion !== expectedVersion) {
15 throw new ConcurrencyError(
16 `Expected version ${expectedVersion}, but found ${currentVersion}`
17 );
18 }
19
20 for (const event of events) {
21 await this.db.query(`
22 INSERT INTO events (id, aggregate_id, type, data, version, timestamp)
23 VALUES ($1, $2, $3, $4, $5, $6)
24 `, [event.id, aggregateId, event.type, event.data, event.version, event.timestamp]);
25 }
26 }
27
28 async getEvents(aggregateId: string): Promise<Event[]> {
29 return this.db.query(`
30 SELECT * FROM events
31 WHERE aggregate_id = $1
32 ORDER BY version ASC
33 `, [aggregateId]);
34 }
35}Aggregate with Events#
1// Events
2class AccountOpened {
3 constructor(
4 public readonly accountId: string,
5 public readonly ownerId: string,
6 public readonly openedAt: Date,
7 ) {}
8}
9
10class MoneyDeposited {
11 constructor(
12 public readonly accountId: string,
13 public readonly amount: number,
14 public readonly depositedAt: Date,
15 ) {}
16}
17
18class MoneyWithdrawn {
19 constructor(
20 public readonly accountId: string,
21 public readonly amount: number,
22 public readonly withdrawnAt: Date,
23 ) {}
24}
25
26// Aggregate
27class Account {
28 private id: string;
29 private balance: number = 0;
30 private status: 'active' | 'closed' = 'active';
31 private version: number = 0;
32 private uncommittedEvents: Event[] = [];
33
34 // Rebuild from events
35 static fromEvents(events: Event[]): Account {
36 const account = new Account();
37 for (const event of events) {
38 account.apply(event, false);
39 }
40 return account;
41 }
42
43 // Command handlers produce events
44 static open(accountId: string, ownerId: string): Account {
45 const account = new Account();
46 account.applyChange(new AccountOpened(accountId, ownerId, new Date()));
47 return account;
48 }
49
50 deposit(amount: number): void {
51 if (amount <= 0) throw new Error('Amount must be positive');
52 if (this.status !== 'active') throw new Error('Account is not active');
53
54 this.applyChange(new MoneyDeposited(this.id, amount, new Date()));
55 }
56
57 withdraw(amount: number): void {
58 if (amount <= 0) throw new Error('Amount must be positive');
59 if (amount > this.balance) throw new Error('Insufficient funds');
60 if (this.status !== 'active') throw new Error('Account is not active');
61
62 this.applyChange(new MoneyWithdrawn(this.id, amount, new Date()));
63 }
64
65 // Apply events to update state
66 private apply(event: Event, isNew: boolean = true): void {
67 switch (event.constructor.name) {
68 case 'AccountOpened':
69 this.id = (event as AccountOpened).accountId;
70 this.status = 'active';
71 break;
72 case 'MoneyDeposited':
73 this.balance += (event as MoneyDeposited).amount;
74 break;
75 case 'MoneyWithdrawn':
76 this.balance -= (event as MoneyWithdrawn).amount;
77 break;
78 }
79
80 if (isNew) {
81 this.uncommittedEvents.push(event);
82 }
83 this.version++;
84 }
85
86 private applyChange(event: Event): void {
87 this.apply(event, true);
88 }
89
90 getUncommittedEvents(): Event[] {
91 return [...this.uncommittedEvents];
92 }
93
94 clearUncommittedEvents(): void {
95 this.uncommittedEvents = [];
96 }
97}Repository#
1class AccountRepository {
2 constructor(private eventStore: EventStore) {}
3
4 async getById(accountId: string): Promise<Account | null> {
5 const events = await this.eventStore.getEvents(accountId);
6 if (events.length === 0) return null;
7 return Account.fromEvents(events);
8 }
9
10 async save(account: Account): Promise<void> {
11 const events = account.getUncommittedEvents();
12 if (events.length === 0) return;
13
14 await this.eventStore.append(
15 account.id,
16 events,
17 account.version - events.length
18 );
19
20 account.clearUncommittedEvents();
21 }
22}CQRS (Command Query Responsibility Segregation)#
The Problem#
Without CQRS:
┌─────────────────────────────────────┐
│ Same model for reads and writes │
│ ───────────────────────────────── │
│ - Write optimized for consistency │
│ - Read needs denormalized data │
│ - Conflicts in design decisions │
└─────────────────────────────────────┘
CQRS Solution#
With CQRS:
┌─────────────────┐ ┌─────────────────┐
│ Write Model │ │ Read Model │
│ ───────────── │ │ ───────────── │
│ - Commands │ │ - Queries │
│ - Aggregates │ │ - Projections │
│ - Consistency │ │ - Optimized │
└────────┬────────┘ └────────▲────────┘
│ │
│ ┌─────────┐ │
└────▶│ Events │───────┘
└─────────┘
Write Side#
1// Commands
2class DepositMoney {
3 constructor(
4 public readonly accountId: string,
5 public readonly amount: number,
6 ) {}
7}
8
9// Command Handler
10class DepositMoneyHandler {
11 constructor(
12 private accountRepository: AccountRepository,
13 private eventBus: EventBus,
14 ) {}
15
16 async handle(command: DepositMoney): Promise<void> {
17 const account = await this.accountRepository.getById(command.accountId);
18 if (!account) throw new Error('Account not found');
19
20 account.deposit(command.amount);
21
22 await this.accountRepository.save(account);
23
24 // Publish events for read side
25 for (const event of account.getUncommittedEvents()) {
26 await this.eventBus.publish(event);
27 }
28 }
29}Read Side (Projections)#
1// Optimized read model
2interface AccountSummary {
3 id: string;
4 ownerName: string;
5 balance: number;
6 lastActivity: Date;
7 transactionCount: number;
8}
9
10// Projection updates read model from events
11class AccountSummaryProjection {
12 constructor(private db: Database) {}
13
14 async handle(event: Event): Promise<void> {
15 switch (event.type) {
16 case 'AccountOpened':
17 await this.onAccountOpened(event as AccountOpened);
18 break;
19 case 'MoneyDeposited':
20 await this.onMoneyDeposited(event as MoneyDeposited);
21 break;
22 case 'MoneyWithdrawn':
23 await this.onMoneyWithdrawn(event as MoneyWithdrawn);
24 break;
25 }
26 }
27
28 private async onAccountOpened(event: AccountOpened): Promise<void> {
29 const owner = await this.db.query('SELECT name FROM owners WHERE id = $1', [event.ownerId]);
30
31 await this.db.query(`
32 INSERT INTO account_summaries (id, owner_name, balance, last_activity, transaction_count)
33 VALUES ($1, $2, 0, $3, 0)
34 `, [event.accountId, owner.name, event.openedAt]);
35 }
36
37 private async onMoneyDeposited(event: MoneyDeposited): Promise<void> {
38 await this.db.query(`
39 UPDATE account_summaries
40 SET balance = balance + $2,
41 last_activity = $3,
42 transaction_count = transaction_count + 1
43 WHERE id = $1
44 `, [event.accountId, event.amount, event.depositedAt]);
45 }
46
47 private async onMoneyWithdrawn(event: MoneyWithdrawn): Promise<void> {
48 await this.db.query(`
49 UPDATE account_summaries
50 SET balance = balance - $2,
51 last_activity = $3,
52 transaction_count = transaction_count + 1
53 WHERE id = $1
54 `, [event.accountId, event.amount, event.withdrawnAt]);
55 }
56}
57
58// Query handler reads from optimized store
59class GetAccountSummaryHandler {
60 async handle(accountId: string): Promise<AccountSummary> {
61 return this.db.query(`
62 SELECT * FROM account_summaries WHERE id = $1
63 `, [accountId]);
64 }
65}Snapshots#
1// For aggregates with many events, use snapshots
2class AccountRepository {
3 private snapshotInterval = 100;
4
5 async getById(accountId: string): Promise<Account | null> {
6 // Try to load snapshot
7 const snapshot = await this.loadSnapshot(accountId);
8
9 // Load events since snapshot (or all events)
10 const fromVersion = snapshot?.version ?? 0;
11 const events = await this.eventStore.getEvents(accountId, fromVersion);
12
13 if (!snapshot && events.length === 0) return null;
14
15 // Rebuild from snapshot + events
16 const account = snapshot
17 ? Account.fromSnapshot(snapshot)
18 : new Account();
19
20 for (const event of events) {
21 account.apply(event, false);
22 }
23
24 return account;
25 }
26
27 async save(account: Account): Promise<void> {
28 // Save events
29 await this.eventStore.append(
30 account.id,
31 account.getUncommittedEvents(),
32 account.version - account.getUncommittedEvents().length
33 );
34
35 // Create snapshot if needed
36 if (account.version % this.snapshotInterval === 0) {
37 await this.saveSnapshot(account);
38 }
39
40 account.clearUncommittedEvents();
41 }
42}Benefits and Trade-offs#
Benefits#
✓ Complete audit trail
✓ Time travel (rebuild state at any point)
✓ Event replay for debugging
✓ Separate read/write optimization
✓ Scalable read models
✓ Easy to add new projections
Trade-offs#
✗ Increased complexity
✗ Eventual consistency between read/write
✗ Event schema evolution challenges
✗ More infrastructure (event store, projections)
✗ Learning curve
When to Use#
Good fit:
- Audit requirements
- Complex domain with many state transitions
- Need for multiple read models
- High read/write ratio
Not ideal:
- Simple CRUD applications
- Strong consistency requirements
- Small teams without event sourcing experience
Conclusion#
Event sourcing and CQRS are powerful patterns for complex domains. They provide complete audit trails, enable flexible read models, and support scalable architectures.
Start simple: event sourcing alone adds significant value. Add CQRS when you need optimized read models. The patterns work best together but aren't required to be used together.