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çoiventNULLou 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.
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_versionpar 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,DELETEetTRUNCATErefusés sur le journal.
Sources
- Doctrine DBAL, Types et notes de mise à jour 4.3.
-
Doctrine ORM,
Events
et
Attributes Reference
:
postUpdate,readOnly. -
Symfony,
Workflow, événements
:
AsTransitionListener. - PostgreSQL, JSON Types, CREATE TRIGGER, TRUNCATE, Privileges et Foreign Keys.
- PostgreSQL 18, fonctionnalités SQL non prises en charge : T180.
- Wikipédia, SQL:2011 : tables temporelles.
- Martin Fowler, Audit Log et Event Sourcing.