Back to Blog
Event SourcingCQRSArchitecturePatterns

Event Sourcing and CQRS: A Practical Guide

Store events instead of state. Separate reads from writes. Build audit-ready, scalable systems with event sourcing and CQRS.

B
Bootspring Team
Engineering
December 20, 2024
7 min read

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.

Share this article

Help spread the word about Bootspring