Skip to content
All essays
ArchitectureMarch 27, 202511 min

Event Sourcing: Store Events, Not State

Master event sourcing architecture. Store events as the source of truth and rebuild state from event logs.

Ü
Ümit Uz
Mobile & Full Stack Developer

Understanding Event Sourcing

Event sourcing ensures that every change to the state of an application is captured as an event object. Instead of storing current state, we store the sequence of events that led to that state.

typescript
// Traditional: Store current state
user = {
  id: 1,
  name: "John",
  email: "john@example.com"
}

// Event Sourcing: Store events
events = [
  { type: "USER_CREATED", data: { name: "John" } },
  { type: "EMAIL_UPDATED", data: { email: "john@example.com" } }
]

Benefits

Complete Audit Trail

typescript
// Every change is recorded
const events = [
  { type: "ORDER_CREATED", timestamp: "2025-01-01T10:00:00Z" },
  { type: "PAYMENT_RECEIVED", timestamp: "2025-01-01T10:05:00Z" },
  { type: "ORDER_SHIPPED", timestamp: "2025-01-01T14:00:00Z" }
];

// Full history, time travel possible

Temporal Queries

typescript
// Rebuild state at any point in time
function getStateAt(date: Date) {
  return events
    .filter(e => new Date(e.timestamp) < date)
    .reduce(reducer, initialState);
}

// What was the state yesterday?
const stateYesterday = getStateAt(new Date("2025-01-01"));

Implementation

Event Store

typescript
interface Event {
  type: string;
  data: any;
  timestamp: Date;
  version: number;
}

class EventStore {
  async saveEvents(aggregateId: string, events: Event[]) {
    // Append events to log
    await db.insert('events', {
      aggregateId,
      events,
      timestamp: new Date()
    });
  }
  
  async getEvents(aggregateId: string): Promise<Event[]> {
    return db.query(
      'SELECT * FROM events WHERE aggregate_id = ? ORDER BY version',
      [aggregateId]
    );
  }
}

Aggregate

typescript
class User {
  private events: Event[] = [];
  private version = 0;
  
  static create(name: string): User {
    const user = new User();
    user.applyEvent({
      type: "USER_CREATED",
      data: { name },
      version: 0,
      timestamp: new Date()
    });
    return user;
  }
  
  updateEmail(email: string) {
    this.applyEvent({
      type: "EMAIL_UPDATED",
      data: { email },
      version: this.version + 1,
      timestamp: new Date()
    });
  }
  
  private applyEvent(event: Event) {
    this.events.push(event);
    this.version = event.version;
    
    switch (event.type) {
      case "USER_CREATED":
        this.name = event.data.name;
        break;
      case "EMAIL_UPDATED":
        this.email = event.data.email;
        break;
    }
  }
  
  getUncommittedEvents(): Event[] {
    return this.events;
  }
  
  static loadFromHistory(events: Event[]): User {
    const user = new User();
    events.forEach(e => user.applyEvent(e));
    return user;
  }
}

Command Handler

typescript
class CommandHandler {
  constructor(private eventStore: EventStore) {}
  
  async handle(command: Command) {
    // Load current state
    const events = await this.eventStore.getEvents(command.aggregateId);
    const aggregate = User.loadFromHistory(events);
    
    // Execute command
    switch (command.type) {
      case "CREATE_USER":
        aggregate.updateEmail(command.email);
        break;
    }
    
    // Save new events
    await this.eventStore.saveEvents(
      command.aggregateId,
      aggregate.getUncommittedEvents()
    );
  }
}

Projections

Read Models

typescript
// Build read models from events
class UserListProjection {
  async handle(event: Event) {
    switch (event.type) {
      case "USER_CREATED":
        await db.insert('user_list', {
          id: event.aggregateId,
          name: event.data.name
        });
        break;
        
      case "EMAIL_UPDATED":
        await db.update(
          'user_list',
          { email: event.data.email },
          { id: event.aggregateId }
        );
        break;
    }
  }
}

Best Practices

  1. 1Use immutable events
  2. 2Version events for conflict detection
  3. 3Create multiple projections
  4. 4Implement snapshotting for performance
  5. 5Handle event schema evolution

Challenges

  1. 1Event versioning
  2. 2Snapshot management
  3. 3Query complexity
  4. 4Debugging event streams
  5. 5Learning curve

Conclusion

Event sourcing provides complete audit trail, temporal queries, and flexible read models. Use it for complex domains with state changes.

Embrace event-driven architecture with event sourcing!

Enjoyed this essay? Share it.

Related essays

Next essay
CAP Theorem: Understanding Distributed System Trade-offs