Back to All Concepts
ArchitecturePatternsDataAdvanced

Event Sourcing & CQRS

Comprehensive guide to Event Sourcing and Command Query Responsibility Segregation (CQRS) patterns, covering immutable event logs, state reconstruction, read/write separation, and real-world implementations in banking, e-commerce, and audit systems.

What is Event Sourcing?

Event Sourcing stores the state of a system as a sequence of immutable events rather than just the current state. Instead of UPDATE, you APPEND.

Key insight: The database stores "what happened" not "what is."

The Problem with CRUD

sql
-- Traditional CRUD
UPDATE accounts SET balance = 500 WHERE id = 1

Problem: You lost all history!
- What was the old balance?
- Who made the change?
- When did it happen?
- Why did it change?
Click to expand code...

Event Sourcing solution:

Don't store: balance = 500
Store instead:
1. AccountCreated(id=1, balance=0, timestamp=T1)
2. MoneyDeposited(id=1, amount=300, timestamp=T2)
3. MoneyDeposited(id=1, amount=200, timestamp=T3)

Current balance = 0 + 300 + 200 = 500
Click to expand code...

How Event Sourcing Works

Event Store

Event Store (append-only log):
┌─────────────────────────────────────┐
│ Event 1: AccountCreated             │
│ Event 2: MoneyDeposited(+100)       │
│ Event 3: MoneyWithdrawn(-20)        │
│ Event 4: MoneyDeposited(+50)        │
│ ...                                 │
└─────────────────────────────────────┘

Current State = Replay all events
Click to expand code...

State Reconstruction

mermaid
graph LR
    E1[Event 1<br/>Created] --> E2[Event 2<br/>+100]
    E2 --> E3[Event 3<br/>-20]
    E3 --> E4[Event 4<br/>+50]
    E4 --> State[Current State<br/>Balance: 130]
Click to expand code...

Implementation

Event Store Schema

typescript
// Event definition
interface Event {
  id: string;
  aggregateId: string;  // Entity this event belongs to
  eventType: string;
  data: any;
  timestamp: Date;
  version: number;
}

// Example events
class AccountCreated implements Event {
  id: string;
  aggregateId: string;
  eventType = 'AccountCreated';
  data: {
    owner: string;
    initialBalance: number;
  };
  timestamp: Date;
  version: number;
}

class MoneyDeposited implements Event {
  id: string;
  aggregateId: string;
  eventType = 'MoneyDeposited';
  data: {
    amount: number;
    source: string;
  };
  timestamp: Date;
  version: number;
}
Click to expand code...

Event Store Implementation

typescript
class EventStore {
  private events: Event[] = [];
  
  // Append event (never update!)
  async append(event: Event): Promise<void> {
    // Optimistic concurrency check
    const latestVersion = await this.getLatestVersion(event.aggregateId);
    if (event.version !== latestVersion + 1) {
      throw new Error('Concurrency conflict');
    }
    
    this.events.push(event);
    await this.persist(event);
    await this.publish(event);  // Notify subscribers
  }
  
  // Get all events for an aggregate
  async getEvents(aggregateId: string): Promise<Event[]> {
    return this.events.filter(e => e.aggregateId === aggregateId);
  }
  
  // Get events after specific version
  async getEventsSince(aggregateId: string, version: number): Promise<Event[]> {
    return this.events.filter(
      e => e.aggregateId === aggregateId && e.version > version
    );
  }
  
  private async persist(event: Event): Promise<void> {
    // Store in database (PostgreSQL, EventStoreDB, etc.)
    await db.query(
      'INSERT INTO events (id, aggregate_id, event_type, data, timestamp, version) VALUES ($1, $2, $3, $4, $5, $6)',
      [event.id, event.aggregateId, event.eventType, event.data, event.timestamp, event.version]
    );
  }
  
  private async publish(event: Event): Promise<void> {
    // Publish to message bus for CQRS projections
    await messageBus.publish('events', event);
  }
}
Click to expand code...

###Aggregate Root

typescript
class Account {
  private id: string;
  private balance: number = 0;
  private version: number = 0;
  private uncommittedEvents: Event[] = [];
  
  // Reconstruct from events
  static async load(id: string, eventStore: EventStore): Promise<Account> {
    const account = new Account(id);
    const events = await eventStore.getEvents(id);
    
    for (const event of events) {
      account.apply(event, false);
    }
    
    return account;
  }
  
  // Commands (business logic)
  deposit(amount: number, source: string): void {
    if (amount <= 0) {
      throw new Error('Amount must be positive');
    }
    
    const event = new MoneyDeposited({
      id: uuid(),
      aggregateId: this.id,
      eventType: 'MoneyDeposited',
      data: { amount, source },
      timestamp: new Date(),
      version: this.version + 1
    });
    
    this.apply(event, true);
  }
  
  withdraw(amount: number): void {
    if (amount > this.balance) {
      throw new Error('Insufficient funds');
    }
    
    const event = new MoneyWithdrawn({
      id: uuid(),
      aggregateId: this.id,
      eventType: 'MoneyWithdrawn',
      data: { amount },
      timestamp: new Date(),
      version: this.version + 1
    });
    
    this.apply(event, true);
  }
  
  // Apply event to state
  private apply(event: Event, isNew: boolean): void {
    switch (event.eventType) {
      case 'AccountCreated':
        this.balance = event.data.initialBalance;
        break;
      case 'MoneyDeposited':
        this.balance += event.data.amount;
        break;
      case 'MoneyWithdrawn':
        this.balance -= event.data.amount;
        break;
    }
    
    this.version = event.version;
    
    if (isNew) {
      this.uncommittedEvents.push(event);
    }
  }
  
  // Save to event store
  async save(eventStore: EventStore): Promise<void> {
    for (const event of this.uncommittedEvents) {
      await eventStore.append(event);
    }
    this.uncommittedEvents = [];
  }
}
Click to expand code...

Usage Example

typescript
// Create account
const account = new Account('acc-123');
account.create('John Doe', 0);
await account.save(eventStore);

// Deposit money
const loadedAccount = await Account.load('acc-123', eventStore);
loadedAccount.deposit(100, 'salary');
loadedAccount.deposit(50, 'bonus');
await loadedAccount.save(eventStore);

// Withdraw money
const acc2 = await Account.load('acc-123', eventStore);
acc2.withdraw(30);
await acc2.save(eventStore);

// Event store now contains:
// 1. AccountCreated(balance=0)
// 2. MoneyDeposited(amount=100, source='salary')
// 3. MoneyDeposited(amount=50, source='bonus')
// 4. MoneyWithdrawn(amount=30)
// Current balance = 0 + 100 + 50 - 30 = 120
Click to expand code...

CQRS (Command Query Responsibility Segregation)

Separate read and write models. Writes go to event store, reads go to optimized projections.

┌──────────┐     Command      ┌─────────────┐
│  Client  │────────2────────▶│Event Store  │
└──────────┘                  │(Write Model)│
     │                        └──────┬──────┘
     │                               │ Events
     │ Query                         ▼
     │                        ┌─────────────┐
     └────1────────▶          │ Projections │
                              │(Read Model) │
                              └─────────────┘
Click to expand code...

Why CQRS?

Problem with traditional CRUD:

sql
-- Complex report query
SELECT 
  u.name,
  COUNT(o.id) as order_count,
  SUM(o.amount) as total_spent,
  AVG(o.amount) as avg_order
FROM users u
JOIN orders o ON u.id = o.user_id
JOIN order_items oi ON o.id = oi.order_id
WHERE u.created_at > '2024-01-01'
GROUP BY u.id

-- Slow on write-optimized tables!
Click to expand code...

CQRS solution:

typescript
// Projection (read model) - pre-computed
interface UserSummary {
  userId: string;
  name: string;
  orderCount: number;
  totalSpent: number;
  avgOrder: number;
  lastUpdated: Date;
}

// Simple, fast query
const summary = await db.userSummaries.findById(userId);
Click to expand code...

CQRS Implementation

typescript
// Write side
class OrderService {
  async createOrder(userId: string, items: OrderItem[]): Promise<void> {
    const order = new Order(userId);
    order.create(items);
    await order.save(eventStore);
    // Events: OrderCreated, LineItemAdded (x N)
  }
}

// Read side - Projection
class UserSummaryProjection {
  async handle(event: Event): Promise<void> {
    switch (event.eventType) {
      case 'OrderCreated':
        await this.updateUserSummary(event.data.userId, {
          orderCount: +1
        });
        break;
        
      case 'LineItemAdded':
        await this.updateUserSummary(event.data.userId, {
          totalSpent: +event.data.price
        });
        break;
    }
  }
  
  private async updateUserSummary(userId: string, updates: Partial<UserSummary>): Promise<void> {
    // Update read model
    await db.query(
      'UPDATE user_summaries SET order_count = order_count + $1, total_spent = total_spent + $2 WHERE user_id = $3',
      [updates.orderCount || 0, updates.totalSpent || 0, userId]
    );
  }
}

// Event bus wires them together
eventBus.subscribe('OrderCreated', (event) => {
  userSummaryProjection.handle(event);
});
Click to expand code...

Benefits of Event Sourcing

1. Perfect Audit Log

typescript
// Get full history
const events = await eventStore.getEvents('acc-123');
// [
//   { eventType: 'AccountCreated', timestamp: '2024-01-01', ... },
//   { eventType: 'MoneyDeposited', amount: 100, timestamp: '2024-01-05', ... },
//   { eventType: 'MoneyWithdrawn', amount: 20, timestamp: '2024-01-10', ... }
// ]

// Who, what, when, why - all preserved!
Click to expand code...

2. Time Travel (Temporal Queries)

typescript
// "What was the account balance on January 5th?"
async function getBalanceAt(accountId: string, timestamp: Date): Promise<number> {
  const events = await eventStore.getEvents(accountId);
  const pastEvents = events.filter(e => e.timestamp <= timestamp);
  
  let balance = 0;
  for (const event of pastEvents) {
    if (event.eventType === 'MoneyDeposited') {
      balance += event.data.amount;
    } else if (event.eventType === 'MoneyWithdrawn') {
      balance -= event.data.amount;
    }
  }
  
  return balance;
}

const balanceOnJan5 = await getBalanceAt('acc-123', new Date('2024-01-05'));
Click to expand code...

3. Event Replay & New Projections

typescript
// Business wants new report - "users by signup source"
// No problem! Replay all UserCreated events

class SignupSourceProjection {
  async rebuild(): Promise<void> {
    const events = await eventStore.getAllEvents('UserCreated');
    
    for (const event of events) {
      await db.query(
        'INSERT INTO signup_sources (source, count) VALUES ($1, 1) ON CONFLICT (source) DO UPDATE SET count = count + 1',
        [event.data.source]
      );
    }
  }
}
Click to expand code...

4. Debugging

typescript
// Production bug: "User account balance is wrong"
const events = await eventStore.getEvents(userId);
console.log(events);
// See EXACTLY what happened, in order
// Event 47: MoneyWithdrawn(-500)  ← Bug! Should have been rejected
Click to expand code...

Challenges & Solutions

1. Performance (Replaying Many Events)

Problem: Account with 1 million transactions → slow to replay

Solution: Snapshots

typescript
interface Snapshot {
  aggregateId: string;
  version: number;
  state: any;
  timestamp: Date;
}

class Account {
  static async load(id: string, eventStore: EventStore): Promise<Account> {
    // Load latest snapshot
    const snapshot = await snapshotStore.getLatest(id);
    
    const account = new Account(id);
    if (snapshot) {
      account.balance = snapshot.state.balance;
      account.version = snapshot.version;
    }
    
    // Replay only events after snapshot
    const events = await eventStore.getEventsSince(id, snapshot?.version || 0);
    for (const event of events) {
      account.apply(event, false);
    }
    
    return account;
  }
  
  // Create snapshot every 100 events
  async saveWithSnapshot(eventStore: EventStore, snapshotStore: SnapshotStore): Promise<void> {
    await this.save(eventStore);
    
    if (this.version % 100 === 0) {
      await snapshotStore.save({
        aggregateId: this.id,
        version: this.version,
        state: { balance: this.balance },
        timestamp: new Date()
      });
    }
  }
}
Click to expand code...

2. Eventual Consistency

Problem: Write succeeds, but projection not updated yet → stale reads

Solution:

typescript
// Option 1: Return version to client
const result = await orderService.createOrder(userId, items);
// { orderId: '123', version: 5 }

// Client polls until version matches
while (true) {
  const order = await orderQuery.getOrder(orderId);
  if (order.version >= result.version) break;
  await sleep(100);
}

// Option 2: Inline projection for critical paths
async function createOrder(userId: string, items: OrderItem[]): Promise<Order> {
  const order = new Order(userId);
  order.create(items);
  await order.save(eventStore);
  
  // Immediately update read model for this specific order
  await orderProjection.handleSync(order.getUncommittedEvents());
  
  return order;
}
Click to expand code...

3. Schema Evolution

Problem: Old events have different structure

Solution: Upcasting

typescript
class EventUpcast {
  upcast(event: Event): Event {
    if (event.eventType === 'MoneyDeposited' && event.version < 2) {
      // Old version: { amount }
      // New version: { amount, currency, source }
      return {
        ...event,
        data: {
          amount: event.data.amount,
          currency: 'USD',  // Default
          source: 'unknown'  // Default
        },
        version: 2
      };
    }
    return event;
  }
}
Click to expand code...

Real-World Examples

Banking (Event Sourcing)

Events:
- AccountOpened(accountId, owner, initialBalance)
- MoneyDeposited(accountId, amount, source, timestamp)
- MoneyWithdrawn(accountId, amount, atm_location, timestamp)
- InterestCalculated(accountId, amount, rate, timestamp)
- FeeCharged(accountId, amount, reason, timestamp)

Benefits:
- Perfect audit for regulators
- Fraud detection (replay suspicious transactions)
- Customer service (see exact transaction history)
Click to expand code...

E-commerce (CQRS)

Write Model:
- Product catalog (normalized)
- Order processing

Read Models:
- Product search (Elasticsearch)
- User order history (denormalized)
- Analytics dashboard (pre-aggregated)
- Recommendation engine (ML-optimized format)
Click to expand code...

Stock Trading

Events (millions per second):
- OrderPlaced(symbol, quantity, price, timestamp)
- OrderMatched(buyOrderId, sellOrderId, price, quantity)
- OrderCancelled(orderId, reason)

Projections:
- Order book (real-time bid/ask)
- User portfolio (current holdings)
- Trade history
- Market data feed
Click to expand code...

Interview Tips 💡

When discussing Event Sourcing & CQRS in interviews:

  1. Explain the value: "For banking, we need perfect audit trail. Event Sourcing gives us immutable history..."
  2. Address performance: "For 1M events, use snapshots every 100 events to avoid full replay..."
  3. CQRS justification: "Read and write patterns differ. Orders need ACID writes, but product search needs fast full-text queries..."
  4. Eventual consistency: "CQRS projections are eventually consistent. For critical reads, update projection synchronously..."
  5. Real examples: "Banking uses Event Sourcing for compliance, e-commerce uses CQRS for search..."

Related Concepts

About ScaleWiki

ScaleWiki is an interactive educational platform dedicated to demystifying distributed systems, software architecture, and system design. Our mission is to provide high-quality, technically accurate resources for software engineers preparing for interviews or solving complex scaling challenges in production.

Read more about our Editorial Guidelines & Authorship.

Educational Disclaimer: The architectural patterns and system designs discussed in this article are based on common industry practices, technical whitepapers, and public engineering blogs. Actual implementations in enterprise environments may vary significantly based on specific product requirements, legacy constraints, and evolving technologies.

Related Articles