0

Event Sourcing — storing events instead of state

Advanced5 min read·eng-05-005
interviewcomparesql

Concept

Event Sourcing is a persistence pattern where instead of storing the current state of an entity, you store the sequence of events that led to that state. The current state is derived by replaying events.

Traditional state storage: "User balance is $450." — you only know the current state. Event Sourcing: Store AccountOpened($500), MoneyWithdrawn($100), MoneyDeposited($50). Replay them to get $450. You also know the full history.

Core concepts:

  • Event: An immutable record of something that happened (UserRegistered, OrderPlaced, PaymentFailed). Named in the past tense.
  • Event Store: An append-only log (database table) of all events. Events are never updated or deleted.
  • Aggregate: The entity whose state is reconstituted by replaying events.
  • Projection: A query-side view built by processing events. A projection is just a regular table/view rebuilt from the event stream.

Benefits:

  • Complete audit log by design.
  • Time-travel: replay to a specific point in history.
  • Event-driven integrations: other services consume events.
  • Undo by storing a compensating event (not by deleting state).

Drawbacks:

  • Snapshot problem: replaying 10,000 events to get current state is slow. Mitigate with periodic snapshots.
  • Schema evolution: old events with old schemas must be handled carefully.
  • Higher complexity than simple CRUD.
  • Not suitable for every domain — use it when audit history is a first-class requirement.

Event Sourcing vs CQRS: They pair well but are independent. CQRS separates reads from writes. Event Sourcing defines HOW writes are stored. You can use one without the other.

Code Example

php
<?php
// Event definitions
readonly class MoneyDeposited
{
    public function __construct(
        public readonly string $accountId,
        public readonly float  $amount,
        public readonly string $occurredAt,
    ) {}
}

readonly class MoneyWithdrawn
{
    public function __construct(
        public readonly string $accountId,
        public readonly float  $amount,
        public readonly string $occurredAt,
    ) {}
}

// Event Store — append-only
class EventStore
{
    public function __construct(private readonly \PDO $pdo)
    {
        $this->pdo->exec('
            CREATE TABLE IF NOT EXISTS events (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                aggregate_id TEXT NOT NULL,
                type TEXT NOT NULL,
                payload TEXT NOT NULL,
                occurred_at TEXT NOT NULL
            )
        ');
    }

    public function append(string $aggregateId, object $event): void
    {
        $stmt = $this->pdo->prepare('INSERT INTO events (aggregate_id, type, payload, occurred_at) VALUES (?, ?, ?, ?)');
        $stmt->execute([
            $aggregateId,
            $event::class,
            json_encode((array) $event),
            date('Y-m-d H:i:s'),
        ]);
    }

    public function getEventsFor(string $aggregateId): array
    {
        $stmt = $this->pdo->prepare('SELECT * FROM events WHERE aggregate_id = ? ORDER BY id ASC');
        $stmt->execute([$aggregateId]);
        return $stmt->fetchAll(\PDO::FETCH_ASSOC);
    }
}

// Aggregate — rebuilt by replaying events
class BankAccount
{
    private float $balance = 0.0;
    private bool  $open    = false;

    public function getId(): string   { return $this->id; }
    public function getBalance(): float { return $this->balance; }

    private function __construct(private readonly string $id) {}

    // Reconstitute from event stream
    public static function reconstitute(string $id, array $events): self
    {
        $account = new self($id);
        foreach ($events as $record) {
            $payload = json_decode($record['payload'], true);
            match ($record['type']) {
                MoneyDeposited::class  => $account->balance += $payload['amount'],
                MoneyWithdrawn::class  => $account->balance -= $payload['amount'],
                default                => null,
            };
        }
        return $account;
    }
}

// Usage
$store = new EventStore(new \PDO('sqlite::memory:'));
$id    = 'acc-001';

// Record events
$store->append($id, new MoneyDeposited($id, 1000.0, date('Y-m-d')));
$store->append($id, new MoneyWithdrawn($id, 200.0, date('Y-m-d')));
$store->append($id, new MoneyDeposited($id, 150.0, date('Y-m-d')));

// Reconstitute current state
$account = BankAccount::reconstitute($id, $store->getEventsFor($id));
echo $account->getBalance(); // 950.0