Soffio

Event Sourcing transforms how we think about data by treating the history of events as the source of truth rather than current state. This architectural pattern provides powerful capabilities: perfect audit trails, time travel debugging, temporal queries, and the ability to reconstruct state at any point in time. Combined with CQRS, Event Sourcing enables multiple specialized projections from a single event stream, allowing different views optimized for different use cases. The pattern excels in domains with strict audit requirements, complex workflows, or where understanding causality is critical. However, it introduces complexity through storage growth, eventual consistency in projections, event versioning challenges, and a steeper learning curve. The key insight is treating time as immutable—events are facts that happened and cannot be changed, only appended to the timeline.

Event Sourcing: Treating Time as a First-Class Citizen

Event timeline visualization

Traditional CRUD systems treat the current state as the source of truth. When you update a database record, the old value is lost forever. Event Sourcing flips this paradigm on its head: the history of events is the source of truth, and current state is merely a projection derived from that history.

This isn't just a technical pattern—it's a philosophical shift in how we think about data, time, and causality in software systems.

The Philosophical Shift: From State to Events

In traditional systems, we ask: "What is the current state?"

In Event Sourcing, we ask: "What events led to this state?"

This shift has profound implications:

  • Immutability: Events are facts that happened; they cannot be changed
  • Auditability: Complete history of all changes is preserved
  • Temporal queries: We can ask "what was the state at time T?"
  • Causality: We understand why things are the way they are

Traditional vs Event Sourcing

Traditional CRUD: The Lossy Approach

Let's see what we lose with traditional state-based persistence:

// Traditional CRUD approach
interface BankAccount {
  id: string;
  balance: number;
  overdraftLimit: number;
  lastModified: Date;
}

class TraditionalBankAccountService {
  async withdraw(accountId: string, amount: number): Promise<void> {
    const account = await this.db.findOne({ id: accountId });
    
    if (account.balance < amount) {
      throw new Error('Insufficient funds');
    }
    
    // This update loses information:
    // - Who made the withdrawal?
    // - What was the exact time?
    // - Was this part of a larger transaction?
    await this.db.update(
      { id: accountId },
      { balance: account.balance - amount, lastModified: new Date() }
    );
  }
}

Problems with this approach:

  1. Lost history: Previous balance values are gone
  2. No audit trail: Can't prove what happened when
  3. Limited debugging: Can't replay events to find bugs
  4. Concurrency issues: Update conflicts are hard to resolve

Event Sourcing: Capturing the Timeline

Now, let's model the same domain with Event Sourcing:

// Event Sourcing approach

// Domain Events - immutable facts that happened
type AccountEvent =
  | { type: 'AccountOpened'; accountId: string; initialDeposit: number; timestamp: Date; userId: string }
  | { type: 'MoneyDeposited'; amount: number; timestamp: Date; userId: string; transactionId: string }
  | { type: 'MoneyWithdrawn'; amount: number; timestamp: Date; userId: string; transactionId: string }
  | { type: 'OverdraftLimitChanged'; newLimit: number; timestamp: Date; userId: string; reason: string };

// Aggregate - entity that processes commands and emits events
class BankAccount {
  private events: AccountEvent[] = [];
  private balance: number = 0;
  private overdraftLimit: number = 0;
  private isOpen: boolean = false;
  
  constructor(private readonly accountId: string) {}
  
  // Command handlers - validate and emit events
  openAccount(initialDeposit: number, userId: string): void {
    if (this.isOpen) {
      throw new Error('Account already open');
    }
    
    this.applyEvent({
      type: 'AccountOpened',
      accountId: this.accountId,
      initialDeposit,
      timestamp: new Date(),
      userId
    });
  }
  
  withdraw(amount: number, userId: string, transactionId: string): void {
    if (!this.isOpen) {
      throw new Error('Account not open');
    }
    
    if (this.balance - amount < -this.overdraftLimit) {
      throw new Error('Insufficient funds');
    }
    
    this.applyEvent({
      type: 'MoneyWithdrawn',
      amount,
      timestamp: new Date(),
      userId,
      transactionId
    });
  }
  
  deposit(amount: number, userId: string, transactionId: string): void {
    if (!this.isOpen) {
      throw new Error('Account not open');
    }
    
    this.applyEvent({
      type: 'MoneyDeposited',
      amount,
      timestamp: new Date(),
      userId,
      transactionId
    });
  }
  
  // Event application - update state based on events
  private applyEvent(event: AccountEvent): void {
    this.events.push(event);
    
    switch(event.type) {
      case 'AccountOpened':
        this.isOpen = true;
        this.balance = event.initialDeposit;
        break;
        
      case 'MoneyDeposited':
        this.balance += event.amount;
        break;
        
      case 'MoneyWithdrawn':
        this.balance -= event.amount;
        break;
        
      case 'OverdraftLimitChanged':
        this.overdraftLimit = event.newLimit;
        break;
    }
  }
  
  // Reconstitute state from event history
  static fromEvents(accountId: string, events: AccountEvent[]): BankAccount {
    const account = new BankAccount(accountId);
    events.forEach(event => account.applyEvent(event));
    return account;
  }
  
  // Accessors
  getBalance(): number { return this.balance; }
  getEvents(): readonly AccountEvent[] { return this.events; }
  getUncommittedEvents(): readonly AccountEvent[] {
    // In real implementation, track which events haven't been persisted
    return this.events;
  }
}

Event Sourcing flow

The Event Store: Append-Only Persistence

The Event Store is a specialized database optimized for appending events and reading event streams:

interface StoredEvent {
  streamId: string;  // e.g., "account-123"
  eventId: string;   // unique event identifier
  eventType: string;
  eventData: any;
  metadata: {
    timestamp: Date;
    userId: string;
    correlationId?: string;
  };
  version: number;   // for optimistic concurrency
}

class EventStore {
  private streams: Map<string, StoredEvent[]> = new Map();
  
  async append(
    streamId: string,
    events: AccountEvent[],
    expectedVersion: number
  ): Promise<void> {
    const stream = this.streams.get(streamId) || [];
    
    // Optimistic concurrency check
    if (stream.length !== expectedVersion) {
      throw new Error(`Concurrency conflict: expected version ${expectedVersion}, got ${stream.length}`);
    }
    
    // Append events
    const newEvents = events.map((event, index) => ({
      streamId,
      eventId: crypto.randomUUID(),
      eventType: event.type,
      eventData: event,
      metadata: {
        timestamp: event.timestamp,
        userId: event.userId,
      },
      version: expectedVersion + index + 1
    }));
    
    stream.push(...newEvents);
    this.streams.set(streamId, stream);
  }
  
  async getEvents(streamId: string, fromVersion: number = 0): Promise<AccountEvent[]> {
    const stream = this.streams.get(streamId) || [];
    return stream
      .filter(e => e.version > fromVersion)
      .map(e => e.eventData);
  }
  
  async getAllEvents(streamId: string): Promise<AccountEvent[]> {
    return this.getEvents(streamId, 0);
  }
}

Time Travel: Querying Historical State

One of Event Sourcing's superpowers is temporal queries—the ability to reconstruct state at any point in time:

class TemporalEventStore extends EventStore {
  // Get events up to a specific point in time
  async getEventsUpTo(streamId: string, upTo: Date): Promise<AccountEvent[]> {
    const allEvents = await this.getAllEvents(streamId);
    return allEvents.filter(e => e.timestamp <= upTo);
  }
  
  // Reconstruct state at a specific time
  async replayToTime(streamId: string, targetTime: Date): Promise<BankAccount> {
    const events = await this.getEventsUpTo(streamId, targetTime);
    return BankAccount.fromEvents(streamId, events);
  }
  
  // Get state at the end of each day
  async getDailySnapshots(streamId: string, year: number, month: number): Promise<Map<Date, number>> {
    const snapshots = new Map<Date, number>();
    const daysInMonth = new Date(year, month + 1, 0).getDate();
    
    for (let day = 1; day <= daysInMonth; day++) {
      const endOfDay = new Date(year, month, day, 23, 59, 59);
      const account = await this.replayToTime(streamId, endOfDay);
      snapshots.set(endOfDay, account.getBalance());
    }
    
    return snapshots;
  }
}

// Usage: Time travel debugging
const eventStore = new TemporalEventStore();

// What was the balance yesterday?
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000);
const accountYesterday = await eventStore.replayToTime('account-123', yesterday);
console.log('Balance yesterday:', accountYesterday.getBalance());

// Get daily balance history for the month
const dailyBalances = await eventStore.getDailySnapshots('account-123', 2025, 0);
dailyBalances.forEach((balance, date) => {
  console.log(`${date.toDateString()}: $${balance}`);
});

Time travel debugging

CQRS: The Natural Companion Pattern

Event Sourcing naturally leads to Command Query Responsibility Segregation (CQRS)—separating writes (commands) from reads (queries).

Write Side: Commands and Events

// Command - intent to change state
interface WithdrawMoneyCommand {
  accountId: string;
  amount: number;
  userId: string;
  transactionId: string;
}

class AccountCommandHandler {
  constructor(private eventStore: EventStore) {}
  
  async handle(command: WithdrawMoneyCommand): Promise<void> {
    // 1. Load current state from events
    const events = await this.eventStore.getAllEvents(command.accountId);
    const account = BankAccount.fromEvents(command.accountId, events);
    
    // 2. Execute command (may throw validation error)
    account.withdraw(command.amount, command.userId, command.transactionId);
    
    // 3. Persist new events
    const newEvents = account.getUncommittedEvents();
    await this.eventStore.append(command.accountId, newEvents, events.length);
  }
}

Read Side: Projections

// Projection - denormalized read model
interface AccountSummary {
  accountId: string;
  currentBalance: number;
  totalDeposits: number;
  totalWithdrawals: number;
  transactionCount: number;
  lastActivity: Date;
}

class AccountSummaryProjection {
  private summaries: Map<string, AccountSummary> = new Map();
  
  // Subscribe to events and update projections
  handleEvent(accountId: string, event: AccountEvent): void {
    let summary = this.summaries.get(accountId);
    
    if (!summary) {
      summary = {
        accountId,
        currentBalance: 0,
        totalDeposits: 0,
        totalWithdrawals: 0,
        transactionCount: 0,
        lastActivity: new Date(0)
      };
    }
    
    switch(event.type) {
      case 'AccountOpened':
        summary.currentBalance = event.initialDeposit;
        summary.totalDeposits = event.initialDeposit;
        summary.transactionCount = 1;
        summary.lastActivity = event.timestamp;
        break;
        
      case 'MoneyDeposited':
        summary.currentBalance += event.amount;
        summary.totalDeposits += event.amount;
        summary.transactionCount++;
        summary.lastActivity = event.timestamp;
        break;
        
      case 'MoneyWithdrawn':
        summary.currentBalance -= event.amount;
        summary.totalWithdrawals += event.amount;
        summary.transactionCount++;
        summary.lastActivity = event.timestamp;
        break;
    }
    
    this.summaries.set(accountId, summary);
  }
  
  // Fast read queries
  getSummary(accountId: string): AccountSummary | undefined {
    return this.summaries.get(accountId);
  }
  
  getAllSummaries(): AccountSummary[] {
    return Array.from(this.summaries.values());
  }
}

CQRS architecture

Multiple Projections: Different Views of the Same Data

With CQRS, you can create multiple specialized projections from the same event stream:

// Projection for fraud detection
class FraudDetectionProjection {
  async handleEvent(accountId: string, event: AccountEvent): Promise<void> {
    if (event.type === 'MoneyWithdrawn') {
      // Check for suspicious patterns
      const recentWithdrawals = await this.getRecentWithdrawals(accountId);
      
      if (this.detectSuspiciousPattern(recentWithdrawals, event)) {
        await this.raiseAlert(accountId, event);
      }
    }
  }
  
  private detectSuspiciousPattern(history: any[], current: any): boolean {
    // Implement fraud detection logic
    return false;
  }
}

// Projection for analytics
class AnalyticsProjection {
  private dailyMetrics: Map<string, number> = new Map();
  
  handleEvent(accountId: string, event: AccountEvent): void {
    if (event.type === 'MoneyWithdrawn' || event.type === 'MoneyDeposited') {
      const dateKey = event.timestamp.toISOString().split('T')[0];
      const current = this.dailyMetrics.get(dateKey) || 0;
      
      const amount = event.type === 'MoneyDeposited' 
        ? event.amount 
        : -event.amount;
        
      this.dailyMetrics.set(dateKey, current + amount);
    }
  }
}

Event Versioning: Handling Schema Evolution

As systems evolve, event schemas change. Event Sourcing requires strategies for handling old event formats:

// V1: Original event
interface MoneyWithdrawnV1 {
  type: 'MoneyWithdrawn';
  amount: number;
  timestamp: Date;
}

// V2: Added userId
interface MoneyWithdrawnV2 {
  type: 'MoneyWithdrawn';
  version: 2;
  amount: number;
  timestamp: Date;
  userId: string;
}

// Upcasting: Convert old events to new format
class EventUpcaster {
  upcast(event: any): AccountEvent {
    if (event.type === 'MoneyWithdrawn' && !event.version) {
      // Convert V1 to V2
      return {
        ...event,
        version: 2,
        userId: 'SYSTEM'  // Default for legacy events
      };
    }
    
    return event;
  }
}

Event versioning

Challenges and Tradeoffs

Event Sourcing is powerful but comes with complexity:

1. Storage Growth

Events accumulate forever (or until archived). Mitigation strategies:

class SnapshotStore {
  // Periodically save snapshots to speed up rehydration
  async saveSnapshot(streamId: string, version: number, state: any): Promise<void> {
    // Save snapshot
  }
  
  async getLatestSnapshot(streamId: string): Promise<{ version: number; state: any } | null> {
    // Retrieve snapshot
    return null;
  }
}

// Rehydrate from snapshot + remaining events
async function rehydrateWithSnapshot(streamId: string): Promise<BankAccount> {
  const snapshot = await snapshotStore.getLatestSnapshot(streamId);
  
  if (snapshot) {
    const account = BankAccount.fromSnapshot(streamId, snapshot.state);
    const remainingEvents = await eventStore.getEvents(streamId, snapshot.version);
    return BankAccount.fromEvents(account, remainingEvents);
  }
  
  // No snapshot, replay all events
  const allEvents = await eventStore.getAllEvents(streamId);
  return BankAccount.fromEvents(streamId, allEvents);
}

2. Eventual Consistency

Projections may lag behind events:

// Mark projections as eventually consistent
class EventuallyConsistentQuery {
  async getAccountBalance(accountId: string): Promise<{
    balance: number;
    asOf: Date;
    isConsistent: boolean;
  }> {
    const projection = accountProjection.getSummary(accountId);
    const lastEvent = await eventStore.getLastEvent(accountId);
    
    return {
      balance: projection.currentBalance,
      asOf: projection.lastActivity,
      isConsistent: projection.lastActivity >= lastEvent.timestamp
    };
  }
}

3. Learning Curve

Event Sourcing requires a different mental model. Teams need training and practice.

4. Querying Complexity

Ad-hoc queries require projections. You can't just SELECT * FROM users WHERE ...

Challenges visualization

When to Use Event Sourcing

Event Sourcing shines in domains where:

Audit requirements are strict (finance, healthcare, legal)
History matters (undo/redo, temporal queries)
Complex workflows (sagas, process managers)
Event-driven architecture (microservices, CQRS)
High write throughput (append-only is fast)

Avoid when:

  • Simple CRUD suffices
  • Team lacks experience
  • Reporting needs are unpredictable
  • Real-time consistency is critical

Conclusion: Time as a First-Class Citizen

Event Sourcing represents a fundamental shift in how we model state:

  1. Events over state: History is the source of truth
  2. Immutability: Facts cannot be changed, only added
  3. Temporal queries: State at any point in time
  4. Perfect audit: Complete causality chain
  5. Flexibility: Multiple projections from one stream

By treating time as a first-class citizen, Event Sourcing provides capabilities that traditional CRUD systems simply cannot match:

  • Time travel for debugging and analysis
  • Replay for testing and migration
  • Audit trails for compliance
  • Event-driven integration

The cost is increased complexity, but for domains where history matters, Event Sourcing provides unparalleled power.

The question isn't whether Event Sourcing is good or bad—it's whether your domain benefits from treating time as immutable history rather than ephemeral state.

Future of event-driven systems


Have you implemented Event Sourcing in production? What challenges did you face? Share your experiences in the comments.