Skip to content

Event Sourcing (Basique)

Persister l'état comme une séquence immuable d'événements de domaine. Dériver l'état courant en rejouant le flux d'événements.

Vue d'ensemble

L'event sourcing stocke ce qui s'est passé (événements) plutôt que ce qui est (état courant). Le solde d'un compte n'est pas stocké ; il est calculé en rejouant tous les événements de dépôt et de retrait. Les événements sont immuables — ils ne sont jamais mis à jour ni supprimés.

Schéma de base de données

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 est la racine d'agrégat. events est le journal d'événements append-only. Il n'y a pas de colonne balance — elle est toujours calculée depuis les événements.

Types d'événements

Définir les types d'événements comme constantes pour prévenir les fautes de frappe et permettre l'analyse statique :

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

Ajout d'événements

Les événements sont toujours insérés, jamais mis à jour. L'API n'a pas d'endpoint qui modifie ou supprime des événements :

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],
    );
    ...
}

Rejeu d'état

Charger les événements dans l'ordre d'insertion et les réduire en état courant :

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 garantit l'ordre de rejeu. ORDER BY occurred_at ASC est fragile — deux événements avec le même horodatage auraient un ordre indéfini.

Validation des montants

Valider strictement les montants avant d'ajouter des événements :

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

if ($amount <= 0 || $amount > 1_000_000_000) {
    return 422;
}
  • is_int() rejette les valeurs flottantes (ex. 1.9) que PHP tronquerait sinon silencieusement à 1
  • La borne supérieure empêche le débordement d'entier lors de la somme de multiples grands dépôts
  • Rejeter au niveau API — ne pas laisser des montants invalides atteindre le journal d'événements

Fonds insuffisants

Vérifier le solde avant d'ajouter un événement de retrait :

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);

La vérification du solde se fait dans le gestionnaire (pas dans le repository) car c'est une règle métier, pas une contrainte d'intégrité des données.

Isolation des événements

Les événements sont limités à leur agrégat par aggregate_id. Rejouer les événements du compte A ne touche jamais le compte B :

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

Propriétés de sécurité

PropriétéImplémentation
Immuabilité des événementsPas d'endpoint DELETE/UPDATE sur les événements
Plage de montant1–1 000 000 000 (int) — rejette les flottants et les valeurs en débordement
Fonds insuffisantsSolde rejoué avant retrait ; 422 si insuffisant
Isolation entre comptesToutes les requêtes filtrent par aggregate_id
Injection de payloadPayload toujours ['amount' => int] ; pas de clés contrôlées par l'utilisateur
Injection de type d'événementType d'événement toujours depuis des constantes ; pas d'event_type contrôlé par l'utilisateur

Récapitulatif des routes

MéthodeCheminDescription
POST/accountsCréer un compte
POST/accounts/{id}/depositAjouter un événement de dépôt
POST/accounts/{id}/withdrawAjouter un événement de retrait (vérification du solde)
GET/accounts/{id}/balanceRejouer le solde depuis les événements
GET/accounts/{id}/eventsLister tous les événements du compte

Publié sous licence MIT.