Introduction
Alors ici on va toucher un truc simple sur le fond, mais franchement compliqué à mettre en place parce qu’en production, ça devient vite tentaculaire. L’event sourcing, j’ai moi-même eu à le comprendre, et surtout à me mettre des freins : ça peut aller très loin, voire trop loin, si on ne sait pas dire stop au bon moment.
Chez moi, le stop, il est venu de l’infra. Entre les snapshots à gérer et le chaînage des events par clés SHA-256, je ne construisais plus une appli : je maintenais une infrastructure. Et une infrastructure qui coûte, qui se sauvegarde, qui se surveille.
L’idée de départ, pourtant, tient en une phrase : garantir qu’une action soit traçable, et pouvoir reconstruire l’état du système à n’importe quel moment de son histoire. Pas juste savoir où on en est aujourd’hui, mais savoir comment on y est arrivé.
Et attention, ça n’a rien à voir avec les Events de Symfony, même si on utilise les mêmes mots. Un EventDispatcher, il notifie ses listeners et il passe à autre chose. Ici, l’événement est la donnée.
Du coup on va se construire un event store minimal avec Symfony 8, sans lib d’event sourcing, histoire de voir ce qu’il y a vraiment dedans. Et on verra aussi où poser les freins.
Event sourcing : pourquoi stocker l’histoire plutôt que l’état
Moi-même, quand j’ai voulu comprendre, je suis tombé sur de la doc qui date. Ou pire, j’ai demandé à Claude ou Codex : « mets en place l’event sourcing pour la gestion du stock ». Et là tu récupères quelque chose de plus ou moins fiable, qui tourne peut-être, mais tu n’as rien compris. Quel enfer d’apprendre un concept par un code qu’on te livre déjà fait.
Et c’est là le vrai risque : ça n’a rien à voir avec un CRUD ou avec injecter une donnée dans Twig. Tant que le modèle mental n’est pas assimilé, tu produis du code que tu ne sais pas maintenir.
Alors reprenons à zéro. Un produit en stock. En CRUD, tu as une ligne :
| id | produit | quantite |
|---|---|---|
| 42 | Café | 6 |
Une sortie de 4 unités arrive, tu fais un UPDATE, tu passes à 2. La valeur précédente n’existe plus nulle part. La ligne te dit où tu en es, jamais comment tu y es arrivé. Six unités après une réception de dix et un retrait de quatre ? Après un inventaire qui a corrigé une erreur de saisie ? Après une casse que personne n’a déclarée ? La ligne ne le dira jamais.
En event sourcing, tu n’écrases rien, tu ajoutes :
StockApprovisionne(quantite: 10)
StockRetire(quantite: 4)
StockApprovisionne(quantite: 3)
StockRetire(quantite: 3)quantite = 6 n’est plus une donnée stockée, c’est le résultat d’un calcul : tu repars du premier événement, tu les rejoues dans l’ordre, tu obtiens l’état courant. Et comme rien n’est jamais écrasé, tu obtiens aussi l’état d’hier soir, ou celui d’avant le dernier retrait il suffit de t’arrêter plus tôt dans le replay.
On reconstruit toute la chaîne, justement parce qu’on connaît les étapes métier qui l’ont produite.
Le vocabulaire minimum : quatre mots, pas quinze
L’event sourcing traîne un jargon décourageant : aggregate root, upcasting, saga, process manager, snapshot, read model. Tu n’as besoin de rien de tout ça pour comprendre. Quatre mots suffisent, et tu les as déjà sous les yeux.
L’événement. StockRetire(quantite: 4). Un fait, au passé, immuable. Il s’est produit, on ne le modifie plus jamais. Si on s’est trompé, on n’édite pas : on ajoute un nouvel événement qui corrige. Le nom au participe passé n’est pas une coquetterie c’est ce qui t’empêche de retomber dans le CRUD sans t’en rendre compte. RetirerStock serait une demande. StockRetire est un fait accompli.
Le stream. Les quatre événements du produit 42, dans l’ordre. C’est l’histoire complète d’un seul objet métier. Un autre produit a son propre stream, indépendant. Tu ne rejoues jamais toute la base : tu rejoues un stream.
L’agrégat. Celui qui décide. C’est le seul endroit du système autorisé à dire non. Et pour dire non, il lui faut son état — qu’il ne lit nulle part : il le reconstruit en rejouant son stream, à chaque fois. Il sait donc qu’il reste 6 unités, il voit passer une demande de retrait de 10, il refuse.
C’est le renversement le plus important du lot, alors prends le contre-exemple. Dans ton CRUD, qui décide qu’il reste du stock ? Personne. Un if ($stock->getQuantite() >= $demande) posé dans un contrôleur, recopié dans une commande console, et oublié dans l’import CSV du lundi matin. L’agrégat, c’est le fait de rassembler cette décision à un seul endroit qu’on ne peut pas contourner.
Et non, ce n’est pas ton entité Doctrine. L’agrégat n’est pas persisté, il est reconstruit. Il n’y a aucune table derrière lui.
La projection. La table lisible que tu régénères à partir des événements, pour ton front. Parce que tu ne vas évidemment pas rejouer un stream à chaque affichage de la liste produits. C’est une copie rapide à lire — et jetable : si tu la détruis, tu la reconstruis depuis les faits.
Le reste — snapshots, upcasting, CQRS — arrive quand le volume ou le temps l’imposent. On en reparle à la fin, quand on parlera des freins.
L’event store : une table, une contrainte
Ici, comme vu plus haut, on simule une gestion de stock de café. Oui c’est simple, mais c’est transposable tel quel à un entrepôt de magasin, avec le flux que ça implique entre les livraisons et les ventes.
Imagine ce même entrepôt en CRUD. Techniquement, ça tient : PostgreSQL encaisse les UPDATE sans broncher, ce n’est pas le volume qui pose problème. Ce qui ne tient pas, c’est le jour où on te demande pourquoi il reste 6 kilos. Ou celui où deux mouvements arrivent en même temps sur le même produit, et où tu n’as aucun moyen de savoir lequel est passé en premier.
On a quatre entités : Product, Order, Stock et Event. On ne va pas s’attarder sur Product. Les deux qui comptent, c’est Event le journal, la source de vérité et Stock, qui n’est qu’une projection : la table lisible qu’on régénère à partir du journal.
Et oui, elle s’appelle Event. Rien à voir avec les Events de Symfony dont on parlait plus haut : ici, c’est simplement la ligne en base qui stocke un fait métier.
Le scénario est volontairement bête : je reçois une livraison de café, mon stock grandit. Une commande arrive, je dois contrôler mon stock, valider ou refuser, puis retirer la quantité.
Une seule table, générique. Pas de colonne par type d’événement : un identifiant, un flux, une version, un type, et deux blobs JSON.
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Repository\EventRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: EventRepository::class)]
#[ORM\Table(name: 'event_store')]
#[ORM\UniqueConstraint(name: 'uniq_event_stream_version', columns: ['stream_id', 'stream_version'])]
#[ORM\Index(name: 'idx_event_stream', columns: ['stream_id'])]
class Event
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(name: 'event_id', length: 36, unique: true)]
private string $eventId;
#[ORM\Column(name: 'stream_id', length: 255)]
private string $streamId;
#[ORM\Column(name: 'stream_version')]
private int $streamVersion;
#[ORM\Column(name: 'event_type', length: 255)]
private string $eventType;
/** @var array<string, mixed> */
#[ORM\Column(type: Types::JSON)]
private array $payload;
/** @var array<string, mixed> */
#[ORM\Column(type: Types::JSON)]
private array $metadata;
#[ORM\Column(name: 'recorded_at', type: Types::DATETIME_IMMUTABLE)]
private \DateTimeImmutable $recordedAt;
/**
* @param array<string, mixed> $payload
* @param array<string, mixed> $metadata
*/
public function __construct(
string $eventId,
string $streamId,
int $streamVersion,
string $eventType,
array $payload,
array $metadata = [],
?\DateTimeImmutable $recordedAt = null,
) {
if ($streamVersion < 1) {
throw new \InvalidArgumentException('A stream version must be greater than or equal to 1.');
}
$this->eventId = $eventId;
$this->streamId = $streamId;
$this->streamVersion = $streamVersion;
$this->eventType = $eventType;
$this->payload = $payload;
$this->metadata = $metadata;
$this->recordedAt = $recordedAt ?? new \DateTimeImmutable();
}
public function getId(): ?int
{
return $this->id;
}
public function getEventId(): string
{
return $this->eventId;
}
public function getStreamId(): string
{
return $this->streamId;
}
public function getStreamVersion(): int
{
return $this->streamVersion;
}
public function getEventType(): string
{
return $this->eventType;
}
/** @return array<string, mixed> */
public function getPayload(): array
{
return $this->payload;
}
/** @return array<string, mixed> */
public function getMetadata(): array
{
return $this->metadata;
}
public function getRecordedAt(): \DateTimeImmutable
{
return $this->recordedAt;
}
}
Quatre-vingt-dix lignes, et rien d’exotique. Ce qui compte tient en quatre points.
Que des getters. Aucun setter dans toute la classe. Ce n’est pas de la coquetterie : c’est la traduction en PHP de ce qu’on disait plus haut un fait ne se modifie pas. Une fois l’objet construit, il est figé. La seule opération légitime sur un event store, c’est l’ajout.
Le stream_id et la stream_version. Le premier ici quelque chose comme stock/product-42 — regroupe les faits d’un même produit. Le second les ordonne : 1, 2, 3, sans trou. C’est la paire qui permet de dire « donne-moi toute l’histoire de ce café, dans l’ordre ».
Le payload et la metadata. Deux colonnes JSON, et la séparation entre les deux n’est pas cosmétique. Le payload, c’est le métier : combien de kilos, à quel prix. La metadata, c’est le contexte technique : quel utilisateur, quelle requête. Un retrait de 4 kilos reste un retrait de 4 kilos quelle que soit la mécanique qui l’a déclenché — mélanger les deux, c’est graver de la plomberie dans un fait métier.
L’event_type en chaîne, pas le FQCN. Ce qu’on écrit en base doit survivre à un renommage de classe ou à un déplacement de namespace. Le jour où tu refactorises, la base ne doit pas s’en apercevoir.
Et puis il y a cette ligne :
php
#[ORM\UniqueConstraint(name: 'uniq_event_stream_version', columns: ['stream_id', 'stream_version'])]Elle a l’air d’une précaution d’intégrité banale. C’est en réalité tout le mécanisme de gestion de concurrence du projet. On y revient plus loin avec Symfony Lock, qui utilise lui aussi PostgreSQL comme store, mais qui répond à un problème complètement différent.
L’agrégat, le cœur de la validation
L’idée est simple : c’est lui qui dit si oui ou non une action est autorisée. Ici, c’est la classe StockAggregate — à ne pas confondre avec l’entité Stock, qui n’est qu’une projection. On y revient juste après.
Avant de lire le code, une distinction qui structure tout le reste. Une commande est une intention : ApprovisionnerStock, RetirerStock, à l’impératif. Elle peut être refusée. Un événement est un fait accepté : StockApprovisionne, StockRetire, au participe passé. Cette dissymétrie grammaticale n’est pas une coquetterie — c’est la seule chose qui, dans une liste de classes, te dit au premier coup d’œil ce qu’on demande et ce qui s’est produit.
La commande valide la forme, jamais le métier. Une quantité négative n’a aucun sens quel que soit l’état du stock : c’est une erreur de saisie, elle se refuse à l’entrée. En revanche « retirer 4 kilos » est une demande parfaitement bien formée — savoir si elle est possible exige de connaître l’état du stock. Et ça, c’est le travail de l’agrégat.
<?php
declare(strict_types=1);
namespace App\Stock;
use App\Stock\Command\ApprovisionnerStock;
use App\Stock\Command\RetirerStock;
use App\Stock\Event\StockApprovisionne;
use App\Stock\Event\StockEvent;
use App\Stock\Event\StockRetire;
final class StockAggregate
{
private int $quantity = 0;
private int $valueInCents = 0;
private int $version = 0;
/** @var list<StockEvent> */
private array $uncommittedEvents = [];
private function __construct(private readonly string $stockId)
{
}
/** @param iterable<StockEvent> $history */
public static function reconstitute(string $stockId, iterable $history = []): self
{
$stock = new self($stockId);
foreach ($history as $event) {
$stock->apply($event);
++$stock->version;
}
return $stock;
}
public function approvisionner(ApprovisionnerStock $command): void
{
if ($command->stockId() !== $this->stockId) {
throw new \InvalidArgumentException('La commande cible un autre stock.');
}
$this->record(new StockApprovisionne(
stockId: $command->stockId(),
quantity: $command->quantity,
unitCostInCents: $command->unitCostInCents,
));
}
public function retirer(RetirerStock $command): void
{
if ($command->stockId() !== $this->stockId) {
throw new \InvalidArgumentException('La commande cible un autre stock.');
}
if ($command->quantity > $this->quantity) {
throw new \DomainException(sprintf(
'Stock insuffisant : %d unité(s) disponible(s), %d demandée(s).',
$this->quantity,
$command->quantity,
));
}
// Le coût moyen pondéré rend la valorisation déterministe au rejeu.
$removedValue = $command->quantity === $this->quantity
? $this->valueInCents
: intdiv($this->valueInCents * $command->quantity, $this->quantity);
$this->record(new StockRetire($command->stockId(), $command->quantity, $removedValue));
}
/** @return list<StockEvent> */
public function releaseEvents(): array
{
$events = $this->uncommittedEvents;
$this->uncommittedEvents = [];
return $events;
}
public function quantity(): int
{
return $this->quantity;
}
public function valueInCents(): int
{
return $this->valueInCents;
}
public function version(): int
{
return $this->version;
}
private function record(StockEvent $event): void
{
$this->apply($event);
$this->uncommittedEvents[] = $event;
}
private function apply(StockEvent $event): void
{
match (true) {
$event instanceof StockApprovisionne => $this->applyApprovisionnement($event),
$event instanceof StockRetire => $this->applyRetrait($event),
};
}
private function applyApprovisionnement(StockApprovisionne $event): void
{
$this->quantity += $event->quantity;
$this->valueInCents += $event->quantity * $event->unitCostInCents;
}
private function applyRetrait(StockRetire $event): void
{
$this->quantity -= $event->quantity;
$this->valueInCents -= $event->removedValueInCents;
}
}
Quatre choses à regarder dans cette classe.
Il ne lit son état nulle part. reconstitute() part d’un stock vide et rejoue l’historique, événement par événement. Aucun cache, aucune lecture de la table stock : l’agrégat ne fait confiance qu’aux faits. C’est le renversement du bloc 2, en PHP.
Il ne connaît pas la base. Pas de EntityManager, pas de Doctrine, pas une ligne de SQL. Il accumule ses événements dans $uncommittedEvents, et l’appelant vient les réclamer avec releaseEvents(). Conséquence directe : il se teste sans rien démarrer, sans base, sans conteneur.
Un refus ne produit aucun événement. retirer() lève une DomainException et s’arrête là. Le refus n’est pas un fait métier ici : rien n’est écrit, rien n’est à nettoyer. C’est un choix — on pourrait décider qu’une tentative refusée mérite d’être tracée, ce serait un autre événement.
La version compte les faits validés. $version n’est incrémentée que dans reconstitute(), pas dans record(). Elle porte donc le nombre d’événements déjà en base — exactement la version que l’agrégat s’attend à prolonger. Retiens ça, c’est toute la mécanique de concurrence du bloc suivant.
Concurrence : ce que PostgreSQL garantit, ce que Symfony Lock apporte
L’agrégat sort de sa reconstitution avec une version : 8 sur la capture. C’est exactement celle qu’il s’attend à prolonger.
Deux requêtes arrivent en même temps sur le même café. Les deux lisent le stream à la version 8, les deux décident chacune de leur côté, les deux tentent d’écrire la version 9.
sql
UNIQUE (stream_id, stream_version)
La première passe. La seconde heurte la contrainte et échoue. Aucun verrou, aucun compteur, aucune ligne de code de détection : c’est PostgreSQL qui arbitre, parce qu’il est le seul composant à voir les deux transactions.
J’ai longtemps appelé ça « le lock PostgreSQL ». C’est faux. Une contrainte ne verrouille rien : personne n’attend, rien n’est réservé. On essaie tous les deux, l’un se fait jeter au moment du commit. C’est de la détection de conflit, pas de la prévention.
Et un conflit n’est pas une panne. Laisser remonter l’exception te donne un 500 et un Sentry qui s’affole pour du fonctionnement parfaitement nominal. Attrape la violation d’unicité et réponds 409 Conflict : le client sait alors quoi faire, relire l’état et retenter.
Le second mécanisme
Le POC expose deux modes, commutables depuis l’interface :
php
interface StockOperationLock
{
public const string POSTGRESQL = 'postgresql';
public const string SYMFONY = 'symfony_lock';
/** @param \Closure(): void $operation */
public function run(string $streamId, \Closure $operation): void;
public function mode(): string;
}Piège de vocabulaire, et j’y suis tombé : le mode postgresql ne verrouille rien du tout. C’est le mode « on ne prend aucun verrou et on laisse la contrainte trancher ». Le seul vrai verrou des deux, c’est symfony_lock.
public function run(string $streamId, \Closure $operation): void
{
$lock = $this->lockFactory->createLock('event-stock.'.$streamId, 30.0);
$lock->acquire(blocking: true);
try {
$operation();
} finally {
$lock->release();
}
}Une clé par stream, un TTL de 30 secondes, acquisition bloquante. Sur la capture, l’événement v8 porte le badge SYMFONY LOCK : c’est la colonne metadata qui le dit, pas le payload. Un retrait de 4 reste un retrait de 4 quelle que soit la mécanique qui l’a synchronisé.
Ce que le verrou change : le conflit arrive tôt, à l’acquisition, plutôt que tard, au commit. On ne fait pas tourner tout le métier pour rien.
Ce qu’il ne change pas : la garantie. Un processus qui ne passe pas par run() écrit quand même — une commande console, un script de migration, un collègue pressé. Le verrou est une convention entre processus, la contrainte est une propriété de la base.
Symfony Lock sert de première ligne de coordination, PostgreSQL assure la garantie finale de cohérence. Si tu ne devais en garder qu’un, ce serait la contrainte.
La projection : le seul endroit qu’on peut détruire
La table current_stock ressemble trait pour trait à celle qu’on aurait écrite sans event sourcing : un produit, une quantité, une valeur. C’est la même donnée. Ce qui change, c’est son statut — elle n’est plus la vérité, elle en est une copie rapide à lire.
Une seule règle la protège :
if ($eventVersion <= $this->lastEventVersion) {
throw new \LogicException('A projection can only apply a newer event version.');
}Une projection n’applique qu’une version strictement plus récente que la dernière qu’elle a vue. C’est une garde anti-régression : un événement en retard ou déjà appliqué ne peut pas fausser les compteurs.
Attention à la nuance, elle compte pour la suite : cette garde refuse un doublon, elle ne l’ignore pas. Le jour où la projection partira dans un worker asynchrone, avec un broker qui redélivre en at-least-once, il faudra transformer ce throw en return silencieux sinon le worker plantera en boucle sur un cas parfaitement normal.
Et puisqu’elle est jetable, prouvons-le.
----------------- ------------ ---------- -------------- ---------
Stream Événements Quantité Valorisation Version
----------------- ------------ ---------- -------------- ---------
stock/product-1 9 30 2 062,98 € v9
----------------- ------------ ---------- -------------- ---------
[OK] 1 stream(s) reconstruit(s) à partir de 9 événement(s) rejoué(s).
La commande TRUNCATE la projection, relit chaque flux depuis le premier événement, et repose l’état obtenu. La page de démonstration expose le même service derrière un bouton : on peut détruire la projection et la voir renaître.
Le résultat attendu, c’est exactement le même état qu’avant. Un non-événement et c’est tout l’intérêt. Si l’état obtenu diffère, ce n’est pas le rejeu qui a tort : c’est la projection qui avait dérivé. La question « laquelle des deux tables croire ? » n’a plus lieu d’être.
C’est la propriété qui justifie tout le reste. Ajouter demain un écran « valeur du stock par mois », c’est écrire une nouvelle projection et la remplir depuis des faits qui existent déjà. Sans migration, sans backfill approximatif, sans avoir eu besoin d’anticiper le besoin.
Le rejeu en worker mode : ce qui tient en mémoire
Voilà la question qui fâche. L’application tourne sous FrankenPHP en worker mode, avec OPcache : le processus PHP ne meurt pas entre deux requêtes. Tout ce qui fuit reste.
Premier réflexe : faut-il implémenter ResetInterface sur les services ? Dans ce projet, non et pour une raison plus radicale que « mes services sont propres ».
php
if ($resetKernel) {
$this->application = clone $this->application;
}
// …et le __clone du kernel :
public function __clone()
{
$this->booted = false;
$this->container = null; // le container entier est jeté
}Avec FRANKENPHP_RESET_KERNEL=1, le container est reconstruit à chaque requête. Aucun service ne survit, donc aucun n’a d’état à réinitialiser.
Deux précisions, parce que c’est trompeur. Ce n’est pas le comportement par défaut : par défaut, le FrankenPhpWorkerRunner réutilise la même instance de kernel pour toutes les requêtes d’un worker, et tout état capturé par le kernel ou ses services peut fuiter d’une requête à l’autre à moins que les services concernés n’implémentent ResetInterface. Et ce réglage se paie : chaque requête reboote un kernel, avec un coût de performance notable par rapport au mode de réutilisation. C’est un compromis assumé on renonce à une partie du bénéfice du worker mode pour ne pas avoir à auditer chaque service. À noter aussi, la variable n’existe que depuis Symfony 8.1.
Si on désactive le réglage, un second filet prend le relais : le Registry de DoctrineBundle est déjà tagué kernel.reset, et son reset() appelle clear() sur chaque manager.
Le risque n’est donc pas entre les requêtes. Il est dans l’opération et la reconstruction de projection est exactement le genre d’opération concernée : elle rejoue l’event store entier en une passe.
Mesuré · reconstruction complète, 20 événements par stream
| Volume rejoué | Entités retenues | Mémoire conservée | Pic |
|---|---|---|---|
| 4 000 événements — sans vidage | 4 401 | 15,31 Mo | 26,48 Mo |
| 8 000 événements — sans vidage | 8 801 | 24,70 Mo | 36,24 Mo |
| 4 000 événements — lots de 50 | 0 | 6,10 Mo | 19,23 Mo |
| 8 000 événements — lots de 50 | 0 | 6,31 Mo | 19,44 Mo |
Sans précaution, l’UnitOfWork de Doctrine retient chaque événement hydraté jusqu’à la fin de la commande : doubler le volume double les objets retenus et fait grimper le pic de 26 à 36 Mo. Extrapolé à 400 000 événements, c’est un demi-million d’objets vivants — et un worker qui n’en revient pas.
Le correctif tient en deux lignes au bon endroit :
if (($offset + 1) % $this->batchSize === 0) {
$this->entityManager->flush();
$this->entityManager->clear();
}Le pic devient quasi constant : +0,21 Mo pour un doublement du volume, contre +9,76 Mo sans vidage. La consommation n’est plus fonction de la taille de l’event store, mais de la taille d’un lot un paramètre qu’on choisit.
Une précaution avant de copier ces deux lignes ailleurs : clear() détache tout. Si une référence à une entité survit au vidage, elle devient un objet orphelin et ses modifications ne partiront jamais en base, silencieusement. Ici ça tient parce qu’on recharge stream par stream.
Le point contre-intuitif
Vider l’
UnitOfWorkau milieu d’une transaction n’en casse pas l’atomicité. Ce sont deux mécanismes distincts : l’UnitOfWorkest un cache d’objets PHP, la transaction est une garantie PostgreSQL. Les lots déjàflushés appartiennent toujours à la transaction ouverte — une erreur au dernier stream les annule avec le reste.C’est vérifiable : un test provoque une erreur après un lot écrit, et constate que la projection effacée en début de transaction est bien revenue intacte.
Dernier point, qui rejoint le verrou : acquire(blocking: true) attend indéfiniment. En worker mode, ça immobilise un worker entier — et si le stream est sollicité, plusieurs. Côté client, un timeout HTTP sans rien dans les logs.
Conclusion
On a vu comment mettre en place l’event sourcing avec Symfony 8 et FrankenPHP, à la main, sans lib dédiée.
Ce n’est ni simple ni universel. Et surtout, on peut aller beaucoup plus loin : snapshots, chaînage des événements par SHA-256, projections asynchrones. C’est là que j’ai freiné. Le chaînage, par exemple, détecte qu’un historique a été altéré il n’empêche personne de le réécrire. En contexte réglementaire c’est indispensable, ailleurs c’est du poids mort. Les snapshots, eux, sont ce qu’on te propose dès que le replay ralentit, et c’est le premier engrenage : tu ne construis plus une appli, tu maintiens une infrastructure.
À mon sens, l’event sourcing ne doit pas être implémenté par défaut, et encore moins sur toute une application. Si une règle métier est critique un stock, un solde, un droit d’accès alors oui, c’est une approche à considérer. Pas une solution toute faite qui résoudrait le problème métier à ta place.
Le dépôt sera mis à jour au fil des épisodes, et encore une fois : ce n’est que mon approche du sujet.
Et toi, tu as déjà mis ça en place ?