Skip to content

Event Sourcing (Basic) ​

Persist state as an immutable sequence of domain events. Derive current state by replaying the event stream.

Overview ​

Event sourcing stores what happened (events) rather than what is (current state). The balance of an account is not stored; it is computed by replaying all deposit and withdrawal events. Events are immutable — they are never updated or deleted.

Database Schema ​

sql
CREATE TABLE accounts (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    owner      TEXT    NOT NULL,
    created_at TEXT    NOT NULL
);

CREATE TABLE events (
    id           INTEGER PRIMARY KEY AUTOINCREMENT,
    aggregate_id INTEGER NOT NULL,
    event_type   TEXT    NOT NULL,
    payload      TEXT    NOT NULL,  -- JSON
    occurred_at  TEXT    NOT NULL,
    FOREIGN KEY (aggregate_id) REFERENCES accounts(id)
);

accounts is the aggregate root. events is the append-only event log. There is no balance column — it is always computed from events.

Event Types ​

Define event types as constants to prevent typos and enable static analysis:

php
public const string TYPE_ACCOUNT_CREATED = 'account_created';
public const string TYPE_DEPOSITED       = 'deposited';
public const string TYPE_WITHDRAWN       = 'withdrawn';

Appending Events ​

Events are always inserted, never updated. The API has no endpoint that modifies or deletes events:

php
public function appendEvent(int $aggregateId, string $eventType, array $payload, string $now): DomainEvent
{
    $payloadJson = json_encode($payload, JSON_THROW_ON_ERROR);

    $this->executor->execute(
        'INSERT INTO events (aggregate_id, event_type, payload, occurred_at) VALUES (?, ?, ?, ?)',
        [$aggregateId, $eventType, $payloadJson, $now],
    );
    ...
}

Replaying State ​

Load events in insertion order and fold them into current state:

php
public function replayBalance(int $aggregateId): int
{
    $events  = $this->findEventsByAggregateId($aggregateId);
    $balance = 0;

    foreach ($events as $event) {
        $amount = isset($event->payload['amount']) ? (int) $event->payload['amount'] : 0;

        if ($event->eventType === DomainEvent::TYPE_DEPOSITED) {
            $balance += $amount;
        } elseif ($event->eventType === DomainEvent::TYPE_WITHDRAWN) {
            $balance -= $amount;
        }
    }

    return $balance;
}

ORDER BY id ASC guarantees replay order. ORDER BY occurred_at ASC is fragile — two events with the same timestamp would have undefined order.

Amount Validation ​

Validate amounts strictly before appending events:

php
$amount = isset($body['amount']) && is_int($body['amount']) ? $body['amount'] : 0;

if ($amount <= 0 || $amount > 1_000_000_000) {
    return 422;
}
  • is_int() rejects float values (e.g. 1.9) that PHP would otherwise silently truncate to 1
  • The upper bound prevents integer overflow when summing multiple large deposits
  • Reject at the API layer — do not let invalid amounts reach the event log

Insufficient Funds ​

Check balance before appending a withdrawal event:

php
$balance = $this->repo->replayBalance($id);

if ($amount > $balance) {
    return $this->problems->create($request, 'insufficient-funds', 'Insufficient funds.', 422, '');
}

$event = $this->repo->appendEvent($id, DomainEvent::TYPE_WITHDRAWN, ['amount' => $amount], $now);

The balance check happens in the handler (not in the repository) because it is a business rule, not a data integrity constraint.

Event Isolation ​

Events are scoped to their aggregate by aggregate_id. Replaying account A's events never touches account B:

sql
SELECT * FROM events WHERE aggregate_id = ? ORDER BY id ASC

Security Properties ​

PropertyImplementation
Event immutabilityNo DELETE/UPDATE endpoint on events
Amount range1–1,000,000,000 (int) — rejects floats and overflow values
Insufficient fundsBalance replayed before withdrawal; 422 if insufficient
Cross-account isolationAll queries filter by aggregate_id
Payload injectionPayload always ['amount' => int]; no user-controlled keys
Event type injectionEvent type always from constants; no user-controlled event_type

Route Summary ​

MethodPathDescription
POST/accountsCreate account
POST/accounts/{id}/depositAppend deposit event
POST/accounts/{id}/withdrawAppend withdrawal event (balance check)
GET/accounts/{id}/balanceReplay balance from events
GET/accounts/{id}/eventsList all events for account

Released under the MIT License.