Le problème

Reprenons la boutique en ligne découpée en services. Le service order tient les commandes et le catalogue : produits (Product), variantes (ProductVariant) et prix. Chaque ligne de commande (OrderLine) pointe vers la variante achetée.

Un lundi, le t-shirt bleu taille M passe de 19,90 € à 21,90 €. Un client qui a commandé le vendredi demande une copie de sa facture. La ligne relit la variante et affiche le nouveau prix : la facture ne correspond plus au paiement.

Une variante renommée change aussi le libellé des anciennes commandes. Une variante rattachée à un autre produit sort de l'historique du premier.

La réponse réflexe : des tables product_version et product_variant_version, une ligne par modification. Avant d'en arriver là, séparons trois besoins que le mot « version » mélange.

Trois besoins derrière le mot « version »

La piste d'audit technique. Qui a modifié quoi, quand, et pourquoi ? Le support ou un auditeur la consultent après coup : il leur faut une trace inaltérable, pas un retour arrière.

La traçabilité de l'usage. Quel prix, quel libellé, quel taux de TVA cette commande a-t-elle utilisés ? La facture, le retour et le litige en dépendent.

Le versionnement fonctionnel. Préparer des prix et les publier à une date, comparer deux versions, restaurer la précédente. La version devient un objet manipulé, avec un identifiant et un cycle de vie.

Une table de versions répond au troisième besoin. Elle est souvent construite pour les deux premiers, qui n'en demandent pas tant. Ce sont pourtant eux qu'un audit vérifie : qui a changé une donnée, et quelle valeur a réellement servi.

Les options

Une table de versions par entité

  • Ce qu'elle règle : les trois besoins. La ligne de commande pointe vers la version qu'elle a utilisée.
  • Ce qu'elle coûte : chaque lecture doit choisir la version courante. Une version de variante pointe-t-elle vers un produit ou vers une version de produit ? Chaque migration touche tout l'historique.

Un historique technique, interrogé à la date de la commande

  • Ce qu'elle règle : la piste d'audit, sans toucher au code. Des triggers recopient chaque ligne modifiée dans une table d'historique. SQL:2011 prévoit des tables versionnées par le système, que PostgreSQL 18 n'implémente pas.
  • Ce qu'elle coûte : le prix d'une commande se retrouve par une requête « à la date de ». L'historique enregistre des colonnes, pas des actions : une faute corrigée et une hausse de prix y ont la même forme.

Des colonnes copiées dans la ligne de commande

  • Ce qu'elle règle : la traçabilité de l'usage, avec des colonnes typées (unit_price, label) simples à requêter.
  • Ce qu'elle coûte : chaque champ ajouté est une migration de order_line, et les lignes existantes reçoivent NULL ou une valeur par défaut. On ne sait plus ce que chaque ligne savait à sa création.

Un instantané à l'usage et un journal append-only

  • Ce qu'elle règle : la ligne de commande garde en JSONB les valeurs du catalogue qu'elle a utilisées. Un journal append-only (on y ajoute, on n'y modifie rien) raconte les actions sur le catalogue.
  • Ce qu'elle coûte : aucun retour arrière automatique.

Notre recommandation

Tant que le retour arrière n'est pas un besoin, pas d'entité de version : un instantané JSONB à l'usage et un journal append-only.

L'instantané répond à la traçabilité de l'usage. Le journal, écrit aux actions métier, répond à la piste d'audit. Le versionnement fonctionnel attend un besoin réel, objet par objet.

Trois besoins, deux mécanismes : un instantané à l'usage, un journal append-only Piste d'audit technique Qui, quoi, quand, pourquoi ? → journal append-only Traçabilité de l'usage Quelles valeurs ont servi ? → instantané à l'usage Versionnement fonctionnel Publier à date, restaurer ? → si les deux tests passent catalog_journal ajout seul, jamais modifié variante price_changed produit A variant_detached produit B variant_attached variante product_changed OrderLine variant_id : identité snapshot (JSONB) : schema_version: 2 sku, label, vat_rate unit_price: 19.90 EUR Table de versions seulement pour un objet qui a un sens seul et que d'autres données citent par version (ex. : conditions générales) Catalogue Product archivé, pas supprimé variantes ProductVariant prix courant : 21,90 € référence d'identité écrit par les actions métier À éviter écrire le journal depuis un listener postUpdate (il ne voit que des colonnes)

Deux tests avant de créer une entité de version

L'objet a-t-il un sens seul ? Les conditions générales de vente du 1er mars se lisent seules : on les publie, on les compare, on les cite. L'état d'une variante un mardi matin n'intéresse que la commande qui l'a utilisé.

Des enregistrements externes pointent-ils durablement vers lui ? Chaque commande doit désigner la version des conditions générales acceptée par le client. D'une variante, elle n'a besoin que des valeurs qu'elle a utilisées.

Les conditions générales passent les deux tests : elles méritent des versions. La variante n'en passe aucun : un instantané suffit.

Référence d'identité, référence de version

Une référence d'identité désigne un objet à travers ses changements : order_line.variant_id reste le t-shirt bleu taille M, quel que soit son prix. Elle sert à naviguer et à agréger, par exemple pour lister les commandes d'une variante.

Une référence de version désigne un état figé, comme orders.terms_version_id vers un texte précis des conditions générales. Elle suppose que la version existe comme objet.

La ligne de commande garde une référence d'identité vers la variante. L'instantané remplace la référence de version qu'une table de versions aurait fournie.

L'instantané : ce qui a servi, rien de plus

L'instantané ne copie que les champs qui influencent le résultat : SKU, libellé imprimé, prix unitaire, devise, taux de TVA. La description et les photos restent dans le catalogue.

Chaque instantané porte un schema_version. Quand sa forme change, le code écrit la nouvelle version et sait toujours lire les anciennes. Règle : on ne migre jamais un ancien instantané.

Une migration réécrirait une preuve avec ce que l'on sait aujourd'hui. Un champ absent d'un ancien instantané reste absent : la lecture renvoie null, jamais une valeur devinée.

Le journal : écrit là où l'action a lieu

Chaque action sur le catalogue écrit une entrée : prix modifié, variante rattachée à un autre produit, produit archivé. L'entrée part dans la transaction de la modification. Elle nomme aussi son auteur, l'utilisateur authentifié : le journal dit qui a changé quoi, pas seulement ce qui a changé.

Nous ne l'écrivons pas depuis un listener Doctrine postUpdate. Il voit des colonnes modifiées, pas une intention : une faute corrigée et une promotion y produisent le même changeset. Il n'est pas appelé pour un UPDATE en DQL, et ce qu'il persiste n'est pas écrit par le flush en cours.

C'est le raisonnement de notre article sur le pattern Outbox : on écrit sur le fait métier, pas sur un hook de persistance.

Deux contraintes

Pas de suppression physique dans le catalogue. Un produit ou une variante retirés de la vente sont archivés (archived_at). Par défaut, une clé étrangère PostgreSQL refuse déjà de supprimer une variante commandée. Le journal n'a pas de clé étrangère : seul l'archivage garde ses sujets.

Journaliser les deux côtés d'une relation. Rattacher une variante à un autre produit écrit trois entrées : variant_detached sur l'ancien produit, variant_attached sur le nouveau, product_changed sur la variante. Chaque frise se lit alors seule.

La composition devient une question d'affichage

La frise d'un produit lit ses propres entrées. Pour y ajouter celles de ses variantes, elle suit ses entrées variant_attached et variant_detached : elles disent quelles variantes inclure, et sur quelle période. Créer une variante écrit donc aussi variant_attached sur son produit.

Agréger ou non devient un choix d'interface, pas de modèle de données. Ce journal n'est pas de l'event sourcing : l'état courant reste dans les tables, le journal raconte comment il y est arrivé.

Les compromis assumés

Pas de retour arrière automatique. Revenir à l'ancien prix est une nouvelle action, journalisée comme les autres. Le journal retrouve une valeur, il ne restaure pas un état complet.

Du code de lecture pour chaque forme d'instantané. La lecture de la version 1 ne disparaît jamais. Nous la gardons dans une seule classe, avec un test par version.

Un journal aussi complet que la discipline qui l'écrit. Une action qui oublie son entrée laisse un trou, et une correction en SQL direct n'y apparaît pas. Si ce risque compte, un historique technique par trigger le complète.

Des rapports moins directs. Un rapport sur les prix payés lit du JSONB, version par version. Un champ souvent agrégé mérite aussi sa colonne typée.

Le signal qui doit faire réexaminer ce choix : un besoin de publier des prix à date, de comparer ou de restaurer des versions. Ou un enregistrement externe qui doit citer un état précis du catalogue. Nous versionnons alors cet objet-là, pas tout le catalogue.

Mise en œuvre

Les exemples utilisent Symfony 7.4, Doctrine ORM 3 avec DBAL 4.3 ou plus, et PostgreSQL. Types::JSONB existe depuis DBAL 4.3, qui déprécie l'option jsonb du type json.

1. La ligne de commande et son instantané

ProductVariant porte sku, label, price, currency et vatRate, les montants en chaînes décimales.

// src/Entity/OrderLine.php (service order)
namespace App\Entity;

use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Types\UuidType;
use Symfony\Component\Uid\Uuid;

#[ORM\Entity]
class OrderLine
{
    #[ORM\Id]
    #[ORM\Column(type: UuidType::NAME)]
    private Uuid $id;

    #[ORM\ManyToOne(inversedBy: 'lines')]
    #[ORM\JoinColumn(nullable: false)]
    private Order $order;

    // Référence d'identité vers la variante.
    #[ORM\ManyToOne]
    #[ORM\JoinColumn(nullable: false)]
    private ProductVariant $variant;

    // Chaîne décimale, par exemple '2.000'.
    #[ORM\Column(type: Types::DECIMAL, precision: 12, scale: 3)]
    private string $quantity;

    // Figé au passage de la commande.
    #[ORM\Column(type: Types::JSONB, nullable: true)]
    private ?array $snapshot = null;

    // Constructeur (UUID v7 généré en PHP), getters, getProduct() via la variante, setSnapshot().
}

Une seule classe écrit la version courante et lit toutes les versions. Elle renvoie un DTO readonly, OrderLineSnapshot : SKU, libellé, prix, devise et taux de TVA nullable. Un match sans branche par défaut lève une erreur sur une version inconnue.

// src/Order/OrderLineSnapshotter.php (service order)
namespace App\Order;

use App\Entity\ProductVariant;

final class OrderLineSnapshotter
{
    public const SCHEMA_VERSION = 2;

    public function take(ProductVariant $variant): array
    {
        return [
            'schema_version' => self::SCHEMA_VERSION,
            'sku' => $variant->getSku(),
            'label' => $variant->getProduct()->getName().', '.$variant->getLabel(),
            'unit_price' => ['amount' => $variant->getPrice(), 'currency' => $variant->getCurrency()],
            'vat_rate' => $variant->getVatRate(),
        ];
    }

    public function read(array $snapshot): OrderLineSnapshot
    {
        return match ($snapshot['schema_version']) {
            // Version 1 : l'euro était la seule devise, le taux de TVA n'était pas enregistré.
            1 => new OrderLineSnapshot($snapshot['sku'], $snapshot['label'], $snapshot['unit_price'], 'EUR', null),
            2 => new OrderLineSnapshot(
                $snapshot['sku'],
                $snapshot['label'],
                $snapshot['unit_price']['amount'],
                $snapshot['unit_price']['currency'],
                $snapshot['vat_rate'],
            ),
        };
    }
}

2. Figer l'instantané au passage de la commande

La commande passe de draft à placed par la transition place d'un workflow Symfony, appliquée dans une transaction. Un listener de cette transition fige l'instantané de chaque ligne.

// src/EventListener/FreezeOrderLinesListener.php (service order)
namespace App\EventListener;

use App\Entity\Order;
use App\Order\OrderLineSnapshotter;
use Symfony\Component\Workflow\Attribute\AsTransitionListener;
use Symfony\Component\Workflow\Event\TransitionEvent;

final class FreezeOrderLinesListener
{
    public function __construct(
        private readonly OrderLineSnapshotter $snapshotter,
    ) {
    }

    #[AsTransitionListener(workflow: 'order', transition: 'place')]
    public function onPlace(TransitionEvent $event): void
    {
        /** @var Order $order */
        $order = $event->getSubject();

        foreach ($order->getLines() as $line) {
            $line->setSnapshot($this->snapshotter->take($line->getVariant()));
        }
    }
}

La colonne snapshot contient alors :

{
  "schema_version": 2,
  "sku": "TSHIRT-BLUE-M",
  "label": "T-shirt coton, bleu, M",
  "unit_price": { "amount": "19.90", "currency": "EUR" },
  "vat_rate": "20.00"
}

3. Le journal

Les actions sont les constantes d'une final class CatalogAction : PRICE_CHANGED, PRODUCT_CHANGED, VARIANT_ATTACHED, VARIANT_DETACHED, ARCHIVED.

// src/Entity/CatalogJournalEntry.php (service order)
namespace App\Entity;

use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Types\UuidType;
use Symfony\Component\Uid\Uuid;

// readOnly : Doctrine ignore toute modification de ces entrées.
#[ORM\Entity(readOnly: true)]
#[ORM\Table(name: 'catalog_journal')]
#[ORM\Index(name: 'catalog_journal_subject_idx', fields: ['subjectType', 'subjectId', 'occurredAt'])]
class CatalogJournalEntry
{
    public const SUBJECT_PRODUCT = 'product';
    public const SUBJECT_VARIANT = 'variant';

    public function __construct(
        #[ORM\Id]
        #[ORM\Column(type: UuidType::NAME)]
        private Uuid $id,
        #[ORM\Column(length: 32)]
        private string $subjectType,
        // Pas de clé étrangère : produit ou variante.
        #[ORM\Column(type: UuidType::NAME)]
        private Uuid $subjectId,
        #[ORM\Column(length: 64)]
        private string $action,
        #[ORM\Column(type: Types::JSONB)]
        private array $data,
        // null pour une tâche planifiée.
        #[ORM\Column(length: 180, nullable: true)]
        private ?string $actor,
        #[ORM\Column(type: Types::DATETIMETZ_IMMUTABLE)]
        private \DateTimeImmutable $occurredAt,
    ) {
    }

    // Des getters, aucune méthode de modification.
}

CatalogJournal::record() persiste une entrée avec un UUID v7, l'identifiant de l'utilisateur courant et la date. Il n'appelle pas flush() : l'action appelante s'en charge, dans sa transaction.

4. Écrire le journal dans l'action métier

Le motif d'un changement de prix entre dans le journal : aucun changeset ne le connaît.

// src/Catalog/CatalogActions.php (service order)
namespace App\Catalog;

use App\Entity\CatalogJournalEntry as Entry;
use App\Entity\Product;
use App\Entity\ProductVariant;
use Doctrine\DBAL\LockMode;
use Doctrine\ORM\EntityManagerInterface;

final class CatalogActions
{
    public function __construct(
        private readonly EntityManagerInterface $em,
        private readonly CatalogJournal $journal,
    ) {
    }

    public function changePrice(ProductVariant $variant, string $price, string $reason): void
    {
        $this->em->wrapInTransaction(function () use ($variant, $price, $reason): void {
            // SELECT ... FOR UPDATE et relecture : 'from' est le prix réellement remplacé.
            $this->em->refresh($variant, LockMode::PESSIMISTIC_WRITE);
            $this->journal->record(Entry::SUBJECT_VARIANT, $variant->getId(), CatalogAction::PRICE_CHANGED, [
                'from' => $variant->getPrice(),
                'to' => $price,
                'reason' => $reason,
            ]);
            $variant->setPrice($price);
        });
    }

    public function moveVariant(ProductVariant $variant, Product $target): void
    {
        $this->em->wrapInTransaction(function () use ($variant, $target): void {
            $source = $variant->getProduct();
            $data = ['variant_id' => $variant->getId()->toRfc4122()];

            $this->journal->record(Entry::SUBJECT_PRODUCT, $source->getId(), CatalogAction::VARIANT_DETACHED, $data);
            $this->journal->record(Entry::SUBJECT_PRODUCT, $target->getId(), CatalogAction::VARIANT_ATTACHED, $data);
            $this->journal->record(Entry::SUBJECT_VARIANT, $variant->getId(), CatalogAction::PRODUCT_CHANGED, [
                'from' => $source->getId()->toRfc4122(),
                'to' => $target->getId()->toRfc4122(),
            ]);

            $variant->setProduct($target);
        });
    }
}

wrapInTransaction() appelle flush() puis commite, ou annule tout sur une exception : la modification et ses entrées sont écrites ensemble, ou pas du tout.

5. Rendre le journal append-only dans PostgreSQL

readOnly n'empêche ni la suppression par Doctrine, ni une requête SQL. La base fait donc respecter la règle elle-même.

-- Migration du service order : catalog_journal append-only.
CREATE FUNCTION catalog_journal_append_only() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
    RAISE EXCEPTION 'catalog_journal est append-only : % refusé', TG_OP;
END;
$$;

-- TRUNCATE ne déclenche pas les triggers DELETE : il est cité à part.
CREATE TRIGGER catalog_journal_append_only
    BEFORE UPDATE OR DELETE OR TRUNCATE ON catalog_journal
    FOR EACH STATEMENT EXECUTE FUNCTION catalog_journal_append_only();

-- Le rôle de l'application garde SELECT et INSERT.
REVOKE UPDATE, DELETE, TRUNCATE ON catalog_journal FROM order_app;

L'erreur levée par le trigger annule la transaction. Mais le propriétaire d'une table peut désactiver ses triggers et se rendre ses privilèges, et un superutilisateur ignore les privilèges. L'application se connecte donc avec un rôle qui ne possède pas ses tables ; le rôle propriétaire sert aux migrations.

Checklist

  • Le besoin nommé : audit, usage ou versionnement fonctionnel.
  • Les deux tests passés avant toute entité de version.
  • Une référence d'identité vers la variante, un instantané pour les valeurs.
  • L'instantané se limite aux champs qui influencent le résultat.
  • Un schema_version par instantané, aucune migration des anciens.
  • Un test de lecture par version d'instantané.
  • Le journal écrit par les actions métier, dans leur transaction.
  • Les deux côtés d'une relation journalisés.
  • L'archivage au lieu de la suppression.
  • UPDATE, DELETE et TRUNCATE refusés sur le journal.

Sources