🎯 OBJECTIF
Comprendre comment :
_AUD, REVINFO, REVTYPEAuditReader et RevisionRepositoryDefaultAuditStrategy et ValidityAuditStrategy🧠 MODÈLE MENTAL
Le besoin : savoir qui a changé quoi, quand, et pouvoir retrouver l'état d'une ligne à une date passée. Une colonne updated_at ne suffit pas : elle ne garde que le dernier changement. Les triggers SQL et les tables d'historique maison fonctionnent, mais il faut les écrire et les maintenir pour chaque table.
Envers automatise tout ça. Hibernate voit déjà passer chaque insert, update et delete d'entité. Envers écoute ces opérations. À chaque écriture, il copie l'état complet de l'entité dans une table d'audit dédiée. Cette copie part dans la même transaction que l'écriture normale : si la transaction échoue, l'audit est annulé aussi.
Le point le plus important : Envers numérote les transactions, pas les lignes. Une transaction qui modifie 3 entités auditées crée 1 numéro de révision et 3 copies liées à ce numéro. On peut donc retrouver d'un seul coup tout ce qu'une transaction a modifié.
REVINFO — table qui liste les révisions : numéro, timestamp, et métadonnées custom éventuelles._AUD) — copie de la table métier : mêmes colonnes auditées + REV + REVTYPE, avec (id, rev) en clé primaire.REVTYPE — type de l'opération : 0 = ADD (insert), 1 = MOD (update), 2 = DEL (delete).REVINFO. On peut la remplacer pour ajouter des colonnes (modified_by, etc.).DefaultAuditStrategy ou ValidityAuditStrategy.Il faut deux dépendances. Depuis Spring Data 3.0, Spring Data Envers fait partie de Spring Data JPA : @EnableEnversRepositories et RevisionRepository sont donc déjà fournis. Il reste à ajouter le module Hibernate :
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-envers</artifactId> <!-- ✓ version gérée par le BOM Boot -->
</dependency>xmlOn annote ensuite l'entité. @Audited sur la classe audite tous les champs. @NotAudited exclut un champ.
@Entity
@Audited
public class Product {
@Id @GeneratedValue
private Long id;
private String name;
private BigDecimal price;
@NotAudited // ✓ pas copié dans l'audit
private String internalNote;
}javaEnvers génère deux tables :
create table revinfo (
rev integer generated by default as identity primary key,
revtstmp bigint -- date de la révision (epoch millis)
);
create table product_aud (
id bigint not null,
rev integer not null references revinfo(rev),
revtype smallint, -- 0 = ADD, 1 = MOD, 2 = DEL
name varchar(255),
price numeric(38,2),
primary key (id, rev) -- ⚠️ pas de colonne internal_note
);sqlrevinfo : la liste des révisions, une ligne par transaction.product_aud : une ligne par version du produit, rattachée à sa révision.Les réglages passent par les propriétés Hibernate (préfixe spring.jpa.properties. dans Boot) :
spring:
jpa:
properties:
org.hibernate.envers.audit_table_suffix: _aud # défaut _AUD
org.hibernate.envers.revision_field_name: rev
org.hibernate.envers.revision_type_field_name: revtype
org.hibernate.envers.store_data_at_delete: true # copie complète au DELETE
org.hibernate.envers.global_with_modified_flag: true # colonnes *_mod (bool par champ)yaml✅ Deux options à activer dès le début
store_data_at_delete : par défaut, un DELETE écrit une ligne _AUD avec toutes les colonnes à null. Avec cette option, la ligne garde le dernier état complet. Sans elle, impossible de répondre à « que contenait la ligne avant sa suppression ».
Modified flags : ajoute une colonne booléenne par champ (name_mod, price_mod) qui indique si le champ a changé dans cette révision. Ça coûte du stockage, mais ça permet de requêter « quelles révisions ont changé le prix » directement, sans comparer les versions en Java.
Envers écoute les événements Hibernate (post-insert, post-update, post-delete). Au premier événement de la transaction, il insère une ligne dans revinfo. Ensuite, chaque entité modifiée ajoute sa copie dans sa table _aud, avec ce numéro de révision. Tout est écrit dans la même transaction JDBC.
sequenceDiagram
participant S as ProductService
participant H as Hibernate (Session)
participant E as Envers (listeners)
participant DB as PostgreSQL
S->>H: update produit A + insert produit B
S->>H: commit (flush)
H->>DB: UPDATE product A / INSERT product B
H->>E: événements post-update / post-insert
E->>DB: INSERT revinfo (rev 42, une seule fois)
E->>DB: INSERT product_aud (A, rev 42, revtype 1)
E->>DB: INSERT product_aud (B, rev 42, revtype 0)
DB-->>S: COMMIT
Note over DB: métier + audit dans la même transaction :<br/>rollback métier = rollback auditmermaidExemple concret. Trois transactions sur le même produit : création, changement de prix, suppression. Voici ce que contiennent les tables :
-- revinfo
rev | revtstmp
40 | 1755600000000
41 | 1755600300000
42 | 1755600600000
-- product_aud
id | rev | revtype | name | price
7 | 40 | 0 | Perceuse | 89.90 -- ADD
7 | 41 | 1 | Perceuse | 79.90 -- MOD (baisse de prix)
7 | 42 | 2 | Perceuse | 79.90 -- DEL (avec store_data_at_delete)sql🔑 Conclusion clé
Une révision = une transaction. Plusieurs lignes _AUD avec le même rev ont été modifiées ensemble, dans la même transaction. C'est la différence avec un trigger par table : l'historique Envers relie les changements entre eux.
Par défaut, revinfo contient juste le numéro et le timestamp. Pour savoir qui a fait le changement, il faut deux choses : une revision entity custom (qui ajoute la colonne) et un RevisionListener (qui la remplit à chaque révision).
@Entity
@Table(name = "revinfo")
@RevisionEntity(UserRevisionListener.class)
public class UserRevisionEntity extends DefaultRevisionEntity {
@Column(name = "modified_by")
private String modifiedBy;
// getters / setters
}javapublic class UserRevisionListener implements RevisionListener {
@Override
public void newRevision(Object revisionEntity) {
var rev = (UserRevisionEntity) revisionEntity;
// ⚠️ instancié par Envers, pas par Spring : pas d'injection possible
rev.setModifiedBy(CurrentUserHolder.get()); // ThreadLocal, SecurityContext...
}
}javaAttention : ce listener est créé par Envers, pas par Spring. On ne peut pas y injecter de bean. La solution habituelle : lire un ThreadLocal ou le SecurityContextHolder, accessibles en statique.
Le schéma final ressemble à ça :
erDiagram
PRODUCT {
bigint id PK
varchar name
numeric price
varchar internal_note "NotAudited"
}
REVINFO {
int rev PK
bigint revtstmp
varchar modified_by "custom"
}
PRODUCT_AUD {
bigint id PK
int rev PK
smallint revtype "0 ADD / 1 MOD / 2 DEL"
varchar name
numeric price
}
PRODUCT_AUD }o--|| REVINFO : "rev FK"
PRODUCT ||..o{ PRODUCT_AUD : "versions"mermaidDeux APIs, selon le besoin.
AuditReader reader = AuditReaderFactory.get(entityManager);
// État du produit 7 à la révision 41
Product atRev41 = reader.find(Product.class, 7L, 41);
// État du produit 7 à une date donnée
Number rev = reader.getRevisionNumberForDate(
LocalDate.of(2026, 6, 1).atStartOfDay(ZoneOffset.UTC).toInstant());
Product atDate = reader.find(Product.class, 7L, rev);
// Historique complet, suppressions incluses
List<Object[]> history = reader.createQuery()
.forRevisionsOfEntity(Product.class, false, true) // ✓ true = inclure les DEL
.add(AuditEntity.id().eq(7L))
.addOrder(AuditEntity.revisionNumber().asc())
.getResultList();
for (Object[] row : history) {
Product snapshot = (Product) row[0];
UserRevisionEntity revInfo = (UserRevisionEntity) row[1];
RevisionType type = (RevisionType) row[2]; // ADD / MOD / DEL
}
// Avec les modified flags : révisions qui ont changé le prix
List<?> priceChanges = reader.createQuery()
.forRevisionsOfEntity(Product.class, false, true)
.add(AuditEntity.id().eq(7L))
.add(AuditEntity.property("price").hasChanged())
.getResultList();javaforRevisionsOfEntity retourne des triplets : la copie de l'entité, la révision (avec modifiedBy si custom), et le type d'opération.
@SpringBootApplication
@EnableEnversRepositories
public class ShopApplication { }javapublic interface ProductRepository
extends JpaRepository<Product, Long>,
RevisionRepository<Product, Long, Integer> { // entité, id, type de rev
}javaRevisions<Integer, Product> revisions = productRepository.findRevisions(7L);
revisions.forEach(r -> log.info("rev {} ({}) : {}",
r.getRequiredRevisionNumber(),
r.getMetadata().getRevisionType(), // INSERT / UPDATE / DELETE
r.getEntity()));
Optional<Revision<Integer, Product>> last =
productRepository.findLastChangeRevision(7L);javaRevisionRepository suffit pour les cas simples : historique d'un id, dernière révision. Pour filtrer par champ ou croiser des entités, il faut AuditReader.
Reprenons le produit 7 de la section 3. Trois versions : rev 40 (création à 89.90), rev 41 (prix baissé à 79.90), rev 42 (suppression). La question à laquelle les deux stratégies répondent différemment : « quel était l'état du produit 7 à la révision 41 ? »
Avec DefaultAuditStrategy (le défaut), la table ne stocke que la révision de début de chaque version :
-- product_aud (Default)
id | rev | revtype | price
7 | 40 | 0 | 89.90
7 | 41 | 1 | 79.90
7 | 42 | 2 | 79.90sqlAucune ligne ne dit jusqu'à quand elle est valable. Pour trouver la bonne, la requête doit calculer « la plus grande révision ≤ 41 » :
select *
from product_aud a
where a.id = 7
and a.rev = (select max(b.rev) -- ⚠️ sous-select
from product_aud b
where b.id = 7 and b.rev <= 41);sqlCe sous-select tourne pour chaque entité lue. Pour un produit, ça va. Pour reconstituer l'état de 500 produits sur une table d'audit de plusieurs millions de lignes, c'est lent.
Avec ValidityAuditStrategy, chaque ligne stocke aussi sa révision de fin (revend) :
-- product_aud (Validity)
id | rev | revend | revtype | price
7 | 40 | 41 | 0 | 89.90 -- valable de la rev 40 à la rev 41 (exclue)
7 | 41 | 42 | 1 | 79.90 -- valable de la rev 41 à la rev 42 (exclue)
7 | 42 | null | 2 | 79.90 -- dernière versionsqlLa ligne dit elle-même quand elle cesse d'être valable. La requête devient un filtre direct :
select *
from product_aud
where id = 7
and rev <= 41
and (revend > 41 or revend is null); -- ✓ pas de sous-selectsqlLe prix à payer se voit à l'écriture. Au moment d'écrire la rev 41, Envers fait deux choses : un INSERT pour la nouvelle ligne, et un UPDATE sur la ligne rev 40 pour poser revend = 41. Chaque écriture coûte donc deux opérations, et l'historique est modifié après coup.
En résumé : Default stocke seulement le début, donc la lecture doit recalculer la fin à chaque fois. Validity paie ce calcul au moment de l'écriture pour que la lecture soit directe.
Activation :
org.hibernate.envers.audit_strategy: >
org.hibernate.envers.strategy.internal.ValidityAuditStrategy
org.hibernate.envers.audit_strategy_validity_store_revend_timestamp: trueyaml| Critère | DefaultAuditStrategy | ValidityAuditStrategy |
|---|---|---|
| Écriture | 1 INSERT | 1 INSERT + 1 UPDATE (revend de la copie précédente) |
| Lecture d'un état passé | Sous-select (lent sur gros volume) | Filtre direct sur intervalle (rapide) |
Colonnes _AUD |
id, rev, revtype, données | + revend (+ revend_tstmp en option) |
| Historique jamais modifié | ✅ Oui (INSERT only) | ❌ Non (UPDATE sur les anciennes lignes) |
| ✅ Avantages Validity | ❌ Inconvénients Validity |
|---|---|
| Lectures temporelles beaucoup plus rapides | Chaque écriture coûte 2 opérations au lieu d'1 |
| Requêtes SQL directes possibles sur les intervalles | L'historique est modifié après coup (moins propre pour un audit réglementaire) |
| Adapté au reporting sur l'historique | Changer de stratégie plus tard oblige à recalculer tous les revend |
Quand le choisir : Validity si l'historique est lu souvent (écran « versions », reporting) ou très gros. Default si l'audit est surtout écrit et rarement lu. Le choix se fait avant la mise en prod : en changer ensuite demande un recalcul manuel des revend.
Règle de base : une relation auditée doit pointer vers une entité auditée. Sinon Envers refuse de démarrer (MappingException). Quand la cible n'a pas besoin d'historique, on audite seulement la FK :
@Entity
@Audited
public class Product {
@ManyToOne
@Audited(targetAuditMode = RelationTargetAuditMode.NOT_AUDITED) // ✓ FK auditée, cible non
private Category category;
@OneToMany(mappedBy = "product")
@NotAudited // ✓ à historiser côté Review si besoin
private List<Review> reviews;
}java☠️ Piège : lecture d'une relation NOT_AUDITED
Avec targetAuditMode = NOT_AUDITED, Envers historise la valeur de la FK, mais pas l'entité cible. Quand on relit une vieille version, Envers charge la Category depuis la table actuelle. Si elle a été supprimée entre-temps, la lecture échoue avec une EntityNotFoundException.
Pour les collections (@OneToMany et @ManyToMany avec table de jointure), Envers crée aussi une table d'audit pour la jointure (product_tags_aud). Chaque ajout ou retrait dans la collection y est enregistré.
Conséquence : auditer une classe entière peut créer beaucoup de tables et de volume. Mieux vaut décider champ par champ ce qui est audité, avec @NotAudited sur le reste.
☠️ Les bulk updates sont invisibles pour Envers
Envers ne voit que les opérations sur des entités Hibernate. Ne créent aucune révision : un UPDATE ... WHERE en JPQL, une méthode @Modifying Spring Data, du SQL natif, une modification faite par un batch externe ou un DBA. Résultat : l'audit décrit un état qui ne correspond plus à la table réelle, et rien ne le signale. Sur un modèle audité, trois options : interdire les bulk updates, écrire l'audit à la main pour ces cas, ou accepter le trou dans l'historique. Si beaucoup d'écritures passent en dehors de l'ORM, Envers n'est pas le bon outil : il faut capturer au niveau base, avec du CDC (kafka-connect-2-debezium-cdc) ou des triggers.
DDL par migration, jamais ddl-auto. En prod, le schéma est géré par Flyway ou Liquibase. Les tables _aud et revinfo font partie des migrations. À chaque évolution d'une entité auditée, il faut deux ALTER : un sur la table normale, un sur la table _aud. Si on oublie le deuxième, les écritures échouent au premier flush.
Volumétrie. Chaque update stocke une copie complète de la ligne, pas seulement les champs modifiés. Une table souvent modifiée produit donc une table _aud bien plus grosse que la table de départ. À prévoir dès le début : une politique de purge ou d'archivage (on peut supprimer par plage de rev sans rien casser), du partitionnement si le volume est très gros, et des index en plus de la PK (id, rev) si on requête par date ou par auteur.
Performance d'écriture. L'audit ajoute des écritures à chaque transaction : les copies _aud plus la ligne revinfo. Une transaction écrit environ deux fois plus. C'est le prix pour avoir l'audit dans la même transaction que le métier. Si ce coût est trop élevé sur un chemin critique, le CDC est l'alternative : la capture devient asynchrone et ne ralentit plus l'écriture.
| Critère | Envers | Triggers SQL | CDC (Debezium) | Event sourcing |
|---|---|---|---|---|
| Niveau de capture | ORM (entités) | Base (lignes) | Base (WAL) | Applicatif (événements) |
| Voit les bulk/SQL natif | ❌ Non | ✅ Oui | ✅ Oui | ❌ Non (par design) |
| Contexte applicatif (qui, pourquoi) | ✅ Facile (revision entity) | ⚠️ Difficile | ⚠️ Difficile | ✅ Natif |
| Dans la transaction métier | ✅ Oui | ✅ Oui | ❌ Asynchrone | ✅ Oui |
| Coût d'intégration Spring/JPA | Très faible | Moyen (hors code, par SGBD) | Infra Kafka Connect | Refonte du modèle |
| Requêtes historiques prêtes | ✅ AuditReader / repositories | ❌ À construire | ❌ À construire | ⚠️ Projections |
Envers est le bon choix par défaut quand toutes les écritures passent par l'ORM et qu'on veut un historique requêtable avec le contexte applicatif (qui, quand). Le CDC est le bon choix quand il faut capturer toutes les écritures, y compris hors ORM, ou alimenter d'autres systèmes (voir pattern-outbox-publication-fiable-de-messages-depuis-une-transaction-db pour le volet publication). Les deux peuvent coexister : Envers pour l'audit métier, CDC pour la réplication technique.
⚡ TL;DR — chaque concept en une ligne
@Audited / tables _AUD ✓ Copie complète de l'entité à chaque insert/update/delete, dans la même transaction que l'écriture métier. ⚠ Ne voit pas les bulk updates JPQL ni le SQL natif : ces écritures ne laissent aucune trace.
Révision (REVINFO) ✓ Une révision par transaction, partagée par toutes les entités modifiées dans cette transaction. ⚠ Table centrale référencée par toutes les tables _aud : sa purge doit suivre celle des tables d'audit.
Revision entity custom + RevisionListener ✓ Stocke qui a fait le changement (user, correlation id) sur chaque révision. ⚠ Le listener est créé hors de Spring : pas d'injection, il faut un ThreadLocal ou un accès statique.
AuditReader / RevisionRepository ✓ Lecture de l'historique sans SQL : état à une révision ou une date, liste des versions, champ modifié. ⚠ RevisionRepository ne couvre que les cas simples ; le filtre « ce champ a changé » demande les modified flags.
DefaultAuditStrategy vs ValidityAuditStrategy ✓ Default écrit moins (INSERT seul) ; Validity lit plus vite (colonne revend qui borne chaque version). ⚠ Choix quasi définitif : passer à Validity après coup oblige à recalculer tous les revend.
Relations auditées ✓ La FK est historisée ; les collections le sont via des tables de jointure _aud. ⚠ Avec NOT_AUDITED, la cible est relue dans la table actuelle : la lecture casse si elle a été supprimée.
🎓 À retenir
@Audited sur une classe embarque aussi ses collections auditées et leurs tables de jointure ; poser les @NotAudited fait partie du design.ALTER sur une table auditée doit être répété sur sa table _aud, sinon les écritures échouent au flush suivant.store_data_at_delete se décide au jour 1 : sans lui, les lignes DEL sont vides et l'état avant suppression est perdu pour tout l'historique déjà écrit.@EnableEnversRepositories et RevisionRepository depuis la fusion dans Spring Data JPA 3.0.