Here’s the English translation:
Have you ever been stuck when an audit requirement asks you to “keep a change history of records”? For created and updated timestamps alone, JPA Auditing is enough, but once you’re asked for “who changed what, when, and how,” the story changes.
An approach where you hand-roll a history table tends to fall apart every time a column is added, right? In this article, we’ll look at how to automatically retain the history of every revision using Hibernate Envers, from an implementation-based perspective.
The Difference Between JPA Auditing and Envers
First, let’s clarify where each one stands. @CreatedDate / @LastModifiedDate simply give a single record the timestamps of “when it was created and when it was last updated.” When you update it, the previous value is overwritten and lost.
Envers, on the other hand, saves a snapshot (revision) to a separate table every time an entity changes. In other words, you can later restore the value at any point in time. For an audit trail where you’re asked for the “value before the change” or even the “person who made the change,” Envers is the more natural fit.
We won’t repeat the basics of JPA Auditing here. Recording creation/update timestamps and Envers can be used together, so think of them as a division of roles.
Adding the Dependency and Enabling Envers
If you’re using spring-boot-starter-data-jpa, the only thing you need to add is Envers itself. We don’t specify the version, since it’s managed under Spring Boot’s BOM.
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.hibernate.orm:hibernate-envers'
}
No special activation setting is needed. If Envers is on the classpath, Hibernate detects it automatically and treats entities annotated with @Audited as audit targets.
Only when you want to change something like the table name suffix do you adjust it via properties.
# Suffix for the history table (default is _AUD)
spring.jpa.properties.org.hibernate.envers.audit_table_suffix=_AUD
# Whether to also keep the original values when REVTYPE=DEL
spring.jpa.properties.org.hibernate.envers.store_data_at_delete=true
Automatically Generating the History Table with @Audited
After that, all you need to do is add @Audited to the entity.
import org.hibernate.envers.Audited;
@Entity
@Audited
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private BigDecimal price;
// getter / setter
}
This generates a PRODUCT_AUD table corresponding to the PRODUCT table. The structure looks roughly like the following (since the types vary by DB/dialect, treat this only as a rough image).
CREATE TABLE product_aud (
id BIGINT NOT NULL,
rev INTEGER NOT NULL, -- revision number
revtype TINYINT, -- 0=ADD, 1=MOD, 2=DEL
name VARCHAR(255),
price DECIMAL(19,2),
PRIMARY KEY (id, rev)
);
CREATE TABLE revinfo (
rev INTEGER NOT NULL PRIMARY KEY,
revtstmp BIGINT -- revision timestamp
);
In addition to the columns of the original entity, it includes REV (the revision number) and REVTYPE (which distinguishes add/update/delete). Revision numbers and timestamps are managed centrally in the REVINFO table, and if multiple entity changes occur in the same transaction, they share the same REV. Note that Envers assigns REV numbers using its own generator (a sequence by default), so you shouldn’t design it to leave numbering to the DB’s IDENTITY.
Retrieving Past Versions with AuditReader
You read the saved history through AuditReader. You can obtain it as long as you have an EntityManager.
@Service
public class ProductHistoryService {
@PersistenceContext
private EntityManager entityManager;
public List<Number> revisions(Long productId) {
AuditReader reader = AuditReaderFactory.get(entityManager);
return reader.getRevisions(Product.class, productId);
}
public Product atRevision(Long productId, Number rev) {
AuditReader reader = AuditReaderFactory.get(entityManager);
return reader.find(Product.class, productId, rev);
}
}
With getRevisions() you get a list of the revision numbers at which that record was changed, and with find() you can restore the entire entity as it was at a specific revision.
For slightly more elaborate searches, use AuditQuery. Let’s retrieve all revisions of an entity, complete with change information.
AuditReader reader = AuditReaderFactory.get(entityManager);
List<?> results = reader.createQuery()
.forRevisionsOfEntity(Product.class, false, true)
.add(AuditEntity.id().eq(productId))
.getResultList();
forRevisionsOfEntity returns arrays where each element contains the “entity, revision info, and REVTYPE,” so by comparing adjacent revisions you can diff the before-and-after of a change.
If you want to narrow down by changes to a specific column, such as “only the revisions where the price changed,” you can use AuditEntity.property("price").hasChanged(). Note, however, that hasChanged() works only when the modified-flag feature is enabled. If you set spring.jpa.properties.org.hibernate.envers.global_with_modified_flag=true (or @Audited(withModifiedFlag = true) on the target), a change-flag column such as price_mod BOOLEAN is added to the _AUD table, and these queries become usable. If you haven’t enabled the flag, the _MOD column won’t exist and it will fail at runtime, so enable the feature and add the DDL as a set.
Recording “Who” Made the Change
Envers’ standard REVINFO only has a timestamp. To retain the operator, you define a custom revision entity.
@Entity
@RevisionEntity(UserRevisionListener.class)
public class UserRevisionEntity extends DefaultRevisionEntity {
private String username;
private String clientIp;
// getter / setter
}
In the RevisionListener, you set the values just before the revision is created. The authenticated user is retrieved from the SecurityContext.
public class UserRevisionListener implements RevisionListener {
@Override
public void newRevision(Object revisionEntity) {
UserRevisionEntity rev = (UserRevisionEntity) revisionEntity;
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
rev.setUsername(auth != null ? auth.getName() : "system");
ServletRequestAttributes attrs =
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attrs != null) {
rev.setClientIp(attrs.getRequest().getRemoteAddr());
}
}
}
This adds username and client_ip columns to REVINFO, so the operator is retained for each revision. Since a custom @RevisionEntity replaces the default revinfo, keep in mind that if you already have existing history you’ll need a table migration, and that you can define only one @RevisionEntity within an application.
What you want to watch out for is asynchronous processing and batches. On a separate thread, the SecurityContext and RequestContextHolder tend to be empty, so always provide a fallback such as "system". For batch jobs, it’s safest to explicitly populate the executing user.
Auditing Relations and Collections
When you want to include an associated entity in the history, @Audited is required on both sides. With only one side, you won’t be able to trace things as expected.
Conversely, for fields or associations you don’t want to keep in the history, add @NotAudited.
@Entity
@Audited
public class Order {
@OneToMany(mappedBy = "order")
private List<OrderLine> lines; // OrderLine also needs @Audited
@NotAudited
@ManyToOne
private Warehouse warehouse; // references a non-audited target
}
If an audited entity references a non-audited entity, you can encounter a RELATION_NOT_FOUND error when restoring past versions. In that case, you can relax the behavior with the following setting.
spring.jpa.properties.org.hibernate.envers.global_relation_not_found_legacy_flag=false
This switches from the traditional behavior of throwing an exception when the referenced target isn’t found, to a relaxed behavior that treats it as null. It’s convenient, but there’s a risk that an association that should exist silently goes missing, so use it in a targeted way. The more complex your entity relationship mapping is, the better off you are drawing the line early on how far to audit.
Managing _AUD Table DDL with Flyway
During development, hbm2ddl.auto=update conveniently auto-generates the tables, but relying on it in production isn’t recommended. The generation timing and diffs are unpredictable, and there’s nothing you want changing on its own less than an audit table.
If you’re using Flyway, explicitly include the DDL for _AUD and REVINFO in your migrations as well.
-- V2__create_product_aud.sql
CREATE TABLE revinfo (
rev INTEGER NOT NULL PRIMARY KEY,
revtstmp BIGINT,
username VARCHAR(100),
client_ip VARCHAR(45)
);
CREATE TABLE product_aud (
id BIGINT NOT NULL,
rev INTEGER NOT NULL REFERENCES revinfo(rev),
revtype TINYINT,
name VARCHAR(255),
price DECIMAL(19,2),
PRIMARY KEY (id, rev)
);
The important point here is that assigning rev numbers is handled by the Envers-side generator (a sequence by default). If you specify IDENTITY in the DDL and leave numbering to the DB, the numbering schemes will conflict, so don’t make revinfo’s rev an IDENTITY column; if necessary, prepare a matching sequence as well. The most reliable approach is to have Hibernate output the DDL locally once and use that as a starting point to work into your migrations. Deciding as a team on a rule that “when you add a column to an entity, the _AUD side follows suit” will reduce mishaps.
Countermeasures Against History Table Bloat
Since audit tables are append-only, they will inevitably swell over long-term operation. Get ahead of it.
First, reduce the volume you write. Don’t audit every column and every entity; narrow it down to what truly needs to be tracked. Simply excluding fields that are frequently updated but have low audit value with @NotAudited can go a long way.
On top of that, once you reach the stage where the volume is predictable, consider the following.
- Archiving is a method of moving old revisions past their retention period to a separate table or lower-cost storage.
- Partitioning is a method of setting up range partitions by
REVor timestamp so that old partitions can be detached wholesale. - Purging is a method of periodically deleting expired revisions based on the retention period determined by your audit requirements.
Which one to choose should be reasoned backward from “how many years you’re obligated to retain.” Before the technology, nail down the retention-period requirement.
Summary
With Hibernate Envers, you can automatically record the history of every revision just by adding @Audited. From there, the flow is: restore past values with AuditReader, retain the operator with a custom RevisionEntity, and address bloat based on the retention period.
JPA Auditing handles “creation/update timestamps,” while Envers handles “the full change history” — their roles are separate. The similar theme of soft delete (logical delete) is aimed at “representing deletion” and is a different matter from preserving history, so sorting it out alongside will help keep your design from getting muddled. Also check out the related articles on JPA Auditing and migrations with Flyway.