Event Sourcing Fundamentals

Philip Rehberger Dec 28, 2025 11 min read

Store state as a sequence of events. Learn event sourcing benefits, implementation patterns, and when to use it.

Event Sourcing Fundamentals

Event sourcing stores state changes as a sequence of events rather than overwriting current state. This pattern enables powerful capabilities like audit trails, time travel, and event replay. But it introduces complexity that isn't always warranted.

Traditional vs Event Sourced

Traditional CRUD

In traditional applications, you update records in place. When a user withdraws money, you directly update their balance. This is simple but loses information about how the current state was reached.

The following example shows a typical CRUD update where the previous balance is permanently lost. You can see the current state, but you cannot reconstruct how it got there.

State: { balance: 100 }

Operation: Withdraw 30
→ UPDATE accounts SET balance = 70 WHERE id = 1

State: { balance: 70 }

Previous state is lost.

Event Sourced

Event sourcing takes a different approach. Instead of storing the current state, you store every change as an immutable event. The current state is computed by replaying all events in sequence.

In contrast to CRUD, event sourcing preserves the complete history. You can always reconstruct any previous state by replaying events up to a specific point in time.

Events:
1. AccountOpened { balance: 0 }
2. MoneyDeposited { amount: 100 }
3. MoneyWithdrawn { amount: 30 }

Current state (computed): { balance: 70 }

All history preserved.

Core Concepts

Events

Events are immutable facts that happened in the past. They describe something that occurred in your domain, always using past tense naming. Each event should contain enough information to be meaningful on its own, but avoid including redundant data.

The following event classes demonstrate the essential properties every event should have. You will notice that events are named in past tense and include a timestamp and unique identifier for traceability.

class MoneyDeposited
{
    public function __construct(
        public readonly string $accountId,
        public readonly int $amount,
        public readonly DateTimeImmutable $occurredAt,
        public readonly string $eventId = null,
    ) {
        $this->eventId = $eventId ?? Str::uuid()->toString();
    }
}

class MoneyWithdrawn
{
    public function __construct(
        public readonly string $accountId,
        public readonly int $amount,
        public readonly DateTimeImmutable $occurredAt,
        public readonly string $eventId = null,
    ) {
        $this->eventId = $eventId ?? Str::uuid()->toString();
    }
}

Notice that all properties are readonly. Events are immutable once created because they represent historical facts that cannot change.

Event Store

The event store is your append-only database of events. Unlike traditional databases, you never update or delete events. The store must support loading all events for a given stream and appending new events with optimistic concurrency control.

This event store implementation demonstrates the core operations you need. The append method uses optimistic concurrency to prevent conflicting writes, while load retrieves the complete event stream for reconstruction.

interface EventStore
{
    public function append(string $streamId, array $events, int $expectedVersion): void;
    public function load(string $streamId): array;
    public function loadFrom(string $streamId, int $fromVersion): array;
}

class DatabaseEventStore implements EventStore
{
    public function append(string $streamId, array $events, int $expectedVersion): void
    {
        DB::transaction(function () use ($streamId, $events, $expectedVersion) {
            // Optimistic concurrency check
            $currentVersion = DB::table('events')
                ->where('stream_id', $streamId)
                ->max('version') ?? 0;

            if ($currentVersion !== $expectedVersion) {
                throw new ConcurrencyException(
                    "Expected version {$expectedVersion}, got {$currentVersion}"
                );
            }

            // Append events
            $version = $expectedVersion;
            foreach ($events as $event) {
                $version++;
                DB::table('events')->insert([
                    'stream_id' => $streamId,
                    'version' => $version,
                    'event_type' => get_class($event),
                    'payload' => json_encode($event),
                    'occurred_at' => $event->occurredAt,
                    'created_at' => now(),
                ]);
            }
        });
    }

    public function load(string $streamId): array
    {
        return DB::table('events')
            ->where('stream_id', $streamId)
            ->orderBy('version')
            ->get()
            ->map(fn ($row) => $this->deserialize($row))
            ->toArray();
    }
}

The version check prevents two concurrent operations from creating conflicting events. If another process modified the stream between when you loaded it and when you try to append, the operation fails and you must retry with fresh data.

Aggregates

Aggregates are domain objects that encapsulate business logic and emit events. They validate operations and ensure business rules are enforced before recording events. The aggregate's state is rebuilt by applying each event in sequence.

This BankAccount aggregate demonstrates the complete pattern. Public methods validate business rules and record events, while private apply methods update the internal state. You will use fromHistory to rebuild the aggregate from stored events.

class BankAccount
{
    private string $id;
    private int $balance = 0;
    private array $pendingEvents = [];
    private int $version = 0;

    public static function open(string $id, int $initialDeposit): self
    {
        $account = new self();
        $account->recordEvent(new AccountOpened($id, $initialDeposit, now()));
        return $account;
    }

    public function deposit(int $amount): void
    {
        if ($amount <= 0) {
            throw new InvalidArgumentException('Amount must be positive');
        }

        $this->recordEvent(new MoneyDeposited($this->id, $amount, now()));
    }

    public function withdraw(int $amount): void
    {
        if ($amount <= 0) {
            throw new InvalidArgumentException('Amount must be positive');
        }

        if ($amount > $this->balance) {
            throw new InsufficientFundsException();
        }

        $this->recordEvent(new MoneyWithdrawn($this->id, $amount, now()));
    }

    // Apply events to rebuild state
    private function apply(object $event): void
    {
        match (get_class($event)) {
            AccountOpened::class => $this->applyAccountOpened($event),
            MoneyDeposited::class => $this->applyMoneyDeposited($event),
            MoneyWithdrawn::class => $this->applyMoneyWithdrawn($event),
        };
        $this->version++;
    }

    private function applyAccountOpened(AccountOpened $event): void
    {
        $this->id = $event->accountId;
        $this->balance = $event->initialDeposit;
    }

    private function applyMoneyDeposited(MoneyDeposited $event): void
    {
        $this->balance += $event->amount;
    }

    private function applyMoneyWithdrawn(MoneyWithdrawn $event): void
    {
        $this->balance -= $event->amount;
    }

    private function recordEvent(object $event): void
    {
        $this->pendingEvents[] = $event;
        $this->apply($event);
    }

    public function getPendingEvents(): array
    {
        return $this->pendingEvents;
    }

    public function clearPendingEvents(): void
    {
        $this->pendingEvents = [];
    }

    public static function fromHistory(array $events): self
    {
        $account = new self();
        foreach ($events as $event) {
            $account->apply($event);
        }
        return $account;
    }
}

The separation between public methods (which validate and record events) and apply methods (which update state) is crucial. Public methods enforce business rules, while apply methods are pure state transitions that never fail.

Repository

The repository bridges aggregates and the event store. It handles loading aggregates from their event history and persisting new events. This keeps the aggregate focused on business logic rather than persistence concerns.

The repository pattern encapsulates all event store interactions. When you load an aggregate, it fetches the event stream and rebuilds the object. When you save, it persists only the new events that occurred since loading.

class BankAccountRepository
{
    public function __construct(private EventStore $eventStore) {}

    public function load(string $id): BankAccount
    {
        $events = $this->eventStore->load("account-{$id}");

        if (empty($events)) {
            throw new AccountNotFoundException($id);
        }

        return BankAccount::fromHistory($events);
    }

    public function save(BankAccount $account): void
    {
        $events = $account->getPendingEvents();

        if (empty($events)) {
            return;
        }

        $this->eventStore->append(
            "account-{$account->getId()}",
            $events,
            $account->getVersion() - count($events)
        );

        $account->clearPendingEvents();
    }
}

The expected version calculation ensures no other process has modified this aggregate since we loaded it. This prevents lost updates in concurrent scenarios.

Projections

Read Models

Events are optimized for writes; projections create read-optimized views. While you could always rebuild state from events, that becomes slow as event counts grow. Projections maintain denormalized read models that update as events occur.

This projection listens for account events and maintains a simple balance table. You can query this table directly for fast reads without replaying events.

class AccountBalanceProjection
{
    public function handle(object $event): void
    {
        match (get_class($event)) {
            AccountOpened::class => $this->onAccountOpened($event),
            MoneyDeposited::class => $this->onMoneyDeposited($event),
            MoneyWithdrawn::class => $this->onMoneyWithdrawn($event),
        };
    }

    private function onAccountOpened(AccountOpened $event): void
    {
        DB::table('account_balances')->insert([
            'account_id' => $event->accountId,
            'balance' => $event->initialDeposit,
            'updated_at' => $event->occurredAt,
        ]);
    }

    private function onMoneyDeposited(MoneyDeposited $event): void
    {
        DB::table('account_balances')
            ->where('account_id', $event->accountId)
            ->increment('balance', $event->amount);
    }

    private function onMoneyWithdrawn(MoneyWithdrawn $event): void
    {
        DB::table('account_balances')
            ->where('account_id', $event->accountId)
            ->decrement('balance', $event->amount);
    }
}

Projections can be rebuilt from scratch by replaying all events. This is powerful for fixing bugs in projection logic or creating new views of existing data.

Multiple Projections

One of event sourcing's strengths is creating multiple specialized views from the same events. Each projection optimizes for different query patterns without affecting the others.

These two projections demonstrate how the same events can power completely different read models. The transaction history provides an audit log, while the daily summary aggregates data for reporting dashboards.

// Same events, different read models
class AccountTransactionHistory
{
    public function handle(object $event): void
    {
        if ($event instanceof MoneyDeposited || $event instanceof MoneyWithdrawn) {
            DB::table('transactions')->insert([
                'account_id' => $event->accountId,
                'type' => $event instanceof MoneyDeposited ? 'deposit' : 'withdrawal',
                'amount' => $event->amount,
                'occurred_at' => $event->occurredAt,
            ]);
        }
    }
}

class DailyAccountSummary
{
    public function handle(object $event): void
    {
        if ($event instanceof MoneyDeposited || $event instanceof MoneyWithdrawn) {
            $date = $event->occurredAt->format('Y-m-d');
            $type = $event instanceof MoneyDeposited ? 'deposits' : 'withdrawals';

            DB::table('daily_summaries')->updateOrInsert(
                ['account_id' => $event->accountId, 'date' => $date],
                [$type => DB::raw("{$type} + {$event->amount}")]
            );
        }
    }
}

The transaction history provides a chronological log, while the daily summary aggregates for reporting. Both are built from the same source of truth but serve different use cases.

Snapshots

For aggregates with many events, snapshots improve load performance. Instead of replaying thousands of events, you can load a snapshot and only replay events since the snapshot was taken.

The snapshot store persists the aggregate state at a specific version. When loading, you first check for a snapshot and then only replay events that occurred after the snapshot version.

class SnapshotStore
{
    public function save(string $aggregateId, int $version, object $state): void
    {
        DB::table('snapshots')->updateOrInsert(
            ['aggregate_id' => $aggregateId],
            [
                'version' => $version,
                'state' => serialize($state),
                'created_at' => now(),
            ]
        );
    }

    public function load(string $aggregateId): ?array
    {
        $snapshot = DB::table('snapshots')
            ->where('aggregate_id', $aggregateId)
            ->first();

        if (!$snapshot) {
            return null;
        }

        return [
            'version' => $snapshot->version,
            'state' => unserialize($snapshot->state),
        ];
    }
}

class BankAccountRepository
{
    public function load(string $id): BankAccount
    {
        $streamId = "account-{$id}";

        // Try snapshot first
        $snapshot = $this->snapshotStore->load($streamId);

        if ($snapshot) {
            $account = $snapshot['state'];
            // Load only events after snapshot
            $events = $this->eventStore->loadFrom($streamId, $snapshot['version']);
        } else {
            $account = new BankAccount();
            $events = $this->eventStore->load($streamId);
        }

        foreach ($events as $event) {
            $account->apply($event);
        }

        // Create snapshot every 100 events
        if ($account->getVersion() % 100 === 0) {
            $this->snapshotStore->save($streamId, $account->getVersion(), $account);
        }

        return $account;
    }
}

The snapshot frequency is a tradeoff between storage space and load time. Snapshotting every 100 events means you never need to replay more than 100 events, while keeping snapshot overhead manageable.

When to Use Event Sourcing

Good Fit

  • Audit requirements: Financial systems, healthcare, compliance
  • Complex domain logic: State machines, business rules
  • Temporal queries: "What was the state on date X?"
  • Event-driven architecture: Events drive other systems
  • Debugging: Replay to reproduce issues

Poor Fit

  • Simple CRUD: Overkill for basic operations
  • High-frequency updates: Event storage grows quickly
  • Simple queries: Projections add complexity
  • Small team: Requires expertise to implement well

Common Pitfalls

Large Events

Events should contain only essential data. Including too much information bloats storage and couples events to data that might change. Keep events focused on what actually changed.

Compare these two event designs. The first includes data that does not belong, making events larger and harder to evolve. The second includes only the identifiers and values that actually changed.

// Bad: Too much data in event
class OrderPlaced
{
    public array $items;           // Could be huge
    public array $customerDetails; // Redundant
    public array $shippingOptions; // Reference data
}

// Good: Minimal, essential data
class OrderPlaced
{
    public string $orderId;
    public string $customerId;
    public array $itemIds;
    public int $totalCents;
}

Reference data like customer details or shipping options should be looked up separately rather than stored in every event.

Missing Events

Every state change must come from an event. If you modify state without recording an event, you break the fundamental guarantee that events are the source of truth. This is a common mistake when adding quick fixes.

The first example silently updates state without any record. The second correctly records the change as an event, preserving the audit trail and enabling replay.

// Bad: State change without event
public function applyDiscount(int $percent): void
{
    $this->total = $this->total * (100 - $percent) / 100;
    // No event recorded!
}

// Good: Every state change from an event
public function applyDiscount(int $percent): void
{
    $this->recordEvent(new DiscountApplied($this->id, $percent, now()));
}

Event Schema Evolution

Events are immutable and stored forever, but your code evolves. When you add new fields or change event structure, you need a strategy for handling old events. Upcasters transform old event formats to new ones during deserialization.

This example shows how to handle adding a new field to an existing event type. The upcaster fills in default values for old events that lack the new field, allowing your code to work with a consistent structure.

// Version 1
class CustomerRegistered
{
    public string $name;
}

// Version 2: Added email
class CustomerRegistered
{
    public string $name;
    public ?string $email; // Nullable for old events
}

// Upcaster for old events
class CustomerRegisteredUpcaster
{
    public function upcast(array $payload, int $version): array
    {
        if ($version < 2) {
            $payload['email'] = null;
        }
        return $payload;
    }
}

Making new fields nullable ensures old events deserialize correctly. The upcaster runs transparently during event loading, so your application code only sees the latest event structure.

Conclusion

Event sourcing provides powerful capabilities: complete audit trails, temporal queries, and event replay. But it adds significant complexity compared to traditional CRUD. Use it when you need its specific benefits;audit requirements, complex domains, or event-driven architectures. For simple applications, the overhead isn't justified. Start with traditional approaches and migrate to event sourcing only when the benefits clearly outweigh the costs.

Share this article

Related Articles

Need help with your project?

Let's discuss how we can help you build reliable software.