Auditing: @CreatedDate, @LastModifiedBy, and Hibernate Envers
Why Auditing Matters
Production systems must answer: “Who changed this? When? What did it look like before?” Without auditing infrastructure, you add created_at, updated_at, created_by, updated_by columns manually to every entity — dozens of lines of boilerplate repeated everywhere.
Spring Data JPA auditing fills these fields automatically. Hibernate Envers goes further: it captures every revision of every entity in separate audit tables.
Spring Data JPA Auditing
Enable Auditing
@Configuration
@EnableJpaAuditing(auditorAwareRef = "auditorProvider")
public class JpaAuditingConfig {
@Bean
public AuditorAware<String> auditorProvider() {
return () -> Optional.ofNullable(SecurityContextHolder.getContext())
.map(SecurityContext::getAuthentication)
.filter(Authentication::isAuthenticated)
.map(Authentication::getName);
}
}
AuditorAware<T> tells Spring who the current user is. Return Optional.empty() for anonymous/system operations. The type parameter (String here) matches your @CreatedBy/@LastModifiedBy field type.
Base Auditing Entity
Create a @MappedSuperclass with all audit fields:
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class Auditable {
@CreatedDate
@Column(updatable = false, nullable = false)
private LocalDateTime createdAt;
@LastModifiedDate
@Column(nullable = false)
private LocalDateTime updatedAt;
@CreatedBy
@Column(updatable = false, length = 100)
private String createdBy;
@LastModifiedBy
@Column(length = 100)
private String updatedBy;
// getters (no setters — these are set by Spring)
}
@EntityListeners(AuditingEntityListener.class) is required on the class (or on the entity directly) — it’s what connects the lifecycle callbacks to Spring’s auditing infrastructure.
Apply to Entities
@Entity
@Table(name = "products")
public class Product extends Auditable {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private BigDecimal price;
private boolean active;
// ... rest of entity
}
That’s it. Spring populates createdAt, updatedAt, createdBy, updatedBy automatically on every save. No manual code needed anywhere.
Version Field with @Version
Add optimistic locking alongside auditing:
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class Auditable {
@CreatedDate
@Column(updatable = false, nullable = false)
private LocalDateTime createdAt;
@LastModifiedDate
@Column(nullable = false)
private LocalDateTime updatedAt;
@CreatedBy
@Column(updatable = false, length = 100)
private String createdBy;
@LastModifiedBy
@Column(length = 100)
private String updatedBy;
@Version
private Long version;
}
@Version provides optimistic locking — Hibernate includes the version in UPDATE statements and throws OptimisticLockException if it has changed (Article 29).
Auditing Without Spring Security
If you’re not using Spring Security, provide the current user from another source:
// Thread-local user context
public class UserContext {
private static final ThreadLocal<String> currentUser = new ThreadLocal<>();
public static void set(String user) { currentUser.set(user); }
public static String get() { return currentUser.get(); }
public static void clear() { currentUser.remove(); }
}
@Bean
public AuditorAware<String> auditorProvider() {
return () -> Optional.ofNullable(UserContext.get());
}
Set it in a filter or interceptor before each request:
@Component
public class UserContextFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws IOException, ServletException {
String user = extractUserFromRequest(request); // JWT, header, session
UserContext.set(user);
try {
chain.doFilter(request, response);
} finally {
UserContext.clear();
}
}
}
Hibernate Envers: Full Revision History
Spring Data JPA auditing records the current state. Hibernate Envers records every historical state — every insert, update, and delete is captured in audit tables.
Setup
<dependency>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-envers</artifactId>
</dependency>
No extra configuration needed — Hibernate Envers auto-configures with Spring Boot.
Annotate Entities
@Entity
@Table(name = "products")
@Audited
public class Product extends Auditable {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private BigDecimal price;
@ManyToOne(fetch = FetchType.LAZY)
@Audited(targetAuditMode = RelationTargetAuditMode.NOT_AUDITED)
private Category category; // audited, but Category itself is not
}
@Audited on the entity class enables full history tracking. Envers creates:
products_aud— audit table with all product columns plus revision metadatarevinfo— revision information table (revision number, timestamp)
Audit Table Structure
Envers generates a mirror table:
-- products_aud
CREATE TABLE products_aud (
id BIGINT NOT NULL,
rev INT NOT NULL, -- revision number (FK to revinfo)
revtype TINYINT NOT NULL, -- 0=INSERT, 1=UPDATE, 2=DELETE
name VARCHAR(255),
price DECIMAL(19,2),
active BIT,
category_id BIGINT,
PRIMARY KEY (id, rev)
);
-- revinfo
CREATE TABLE revinfo (
rev INT NOT NULL AUTO_INCREMENT,
revtstmp BIGINT NOT NULL, -- timestamp in milliseconds
PRIMARY KEY (rev)
);
Querying Audit History with AuditReader
@Service
@Transactional(readOnly = true)
public class ProductAuditService {
@Autowired
private EntityManager entityManager;
// Get all revisions for a product
public List<Number> getRevisions(Long productId) {
AuditReader reader = AuditReaderFactory.get(entityManager);
return reader.getRevisions(Product.class, productId);
}
// Get the product state at a specific revision
public Product getAtRevision(Long productId, Number revision) {
AuditReader reader = AuditReaderFactory.get(entityManager);
return reader.find(Product.class, productId, revision);
}
// Get complete history with revision metadata
public List<ProductRevision> getFullHistory(Long productId) {
AuditReader reader = AuditReaderFactory.get(entityManager);
List<Object[]> results = reader.createQuery()
.forRevisionsOfEntity(Product.class, false, true)
.add(AuditEntity.id().eq(productId))
.addOrder(AuditEntity.revisionNumber().asc())
.getResultList();
return results.stream()
.map(row -> {
Product product = (Product) row[0];
DefaultRevisionEntity rev = (DefaultRevisionEntity) row[1];
RevisionType type = (RevisionType) row[2];
return new ProductRevision(product, rev.getRevisionDate(), type.name());
})
.toList();
}
// Find all products changed after a date
public List<Product> getChangedAfter(LocalDateTime since) {
AuditReader reader = AuditReaderFactory.get(entityManager);
long sinceMs = since.toInstant(ZoneOffset.UTC).toEpochMilli();
return reader.createQuery()
.forRevisionsOfEntity(Product.class, true, false)
.add(AuditEntity.revisionProperty("timestamp").gt(sinceMs))
.getResultList();
}
}
Custom Revision Entity
Add custom metadata to each revision (e.g., the user who made the change):
@Entity
@RevisionEntity(CustomRevisionListener.class)
@Table(name = "revinfo")
public class CustomRevisionEntity extends DefaultRevisionEntity {
@Column(name = "changed_by", length = 100)
private String changedBy;
// getter/setter
}
@Component
public class CustomRevisionListener implements RevisionListener {
@Override
public void newRevision(Object revisionEntity) {
CustomRevisionEntity rev = (CustomRevisionEntity) revisionEntity;
rev.setChangedBy(UserContext.get()); // or SecurityContextHolder
}
}
Now every revinfo row records who made the change — enabling full accountability:
SELECT r.revtstmp, r.changed_by, p.name, p.price, p.revtype
FROM products_aud p
JOIN revinfo r ON p.rev = r.rev
WHERE p.id = 42
ORDER BY r.revtstmp;
Selective Auditing
Audit only specific fields with @Audited at field level:
@Entity
@Audited
public class Product {
private String name; // audited (class-level @Audited)
@Audited // explicitly audited
private BigDecimal price;
@NotAudited // excluded from audit history
private int viewCount; // high-churn field — don't pollute audit log
@NotAudited
private LocalDateTime cachedAt;
}
Exclude high-churn fields (counters, timestamps updated by background jobs) to keep audit tables lean.
Complete Example: Product Change History API
@RestController
@RequestMapping("/api/products/{id}/history")
public class ProductHistoryController {
private final ProductAuditService auditService;
@GetMapping
public List<ProductRevisionDto> getHistory(@PathVariable Long id) {
return auditService.getFullHistory(id)
.stream()
.map(rev -> new ProductRevisionDto(
rev.getRevisionDate(),
rev.getRevisionType(),
rev.getProduct().getName(),
rev.getProduct().getPrice()
))
.toList();
}
@GetMapping("/{revision}")
public ProductDto getAtRevision(
@PathVariable Long id,
@PathVariable int revision
) {
Product product = auditService.getAtRevision(id, revision);
return ProductDto.from(product);
}
}
Response for /api/products/42/history:
[
{ "date": "2026-01-15T09:00:00", "type": "INSERT", "name": "Laptop Pro", "price": 1299.99 },
{ "date": "2026-02-10T14:32:00", "type": "UPDATE", "name": "Laptop Pro", "price": 1199.99 },
{ "date": "2026-03-01T10:00:00", "type": "UPDATE", "name": "Laptop Pro 2026", "price": 1199.99 }
]
Spring Data JPA Auditing vs Envers
| Spring Data JPA Auditing | Hibernate Envers | |
|---|---|---|
| Setup | 5 minutes | 10 minutes |
| What it tracks | Current created_at, updated_at, created_by, updated_by | Every historical revision |
| Storage overhead | 4 extra columns per entity | Mirror audit table per entity |
| Queryable history | No | Yes (AuditReader API) |
| Point-in-time restore | No | Yes |
| Performance impact | Negligible | Low (async-friendly) |
Use both together: auditing for the created_at/updated_at columns that every entity needs, and Envers for entities that require full history (products, prices, orders, user profiles).
Summary
- Enable Spring Data JPA auditing with
@EnableJpaAuditingand anAuditorAwarebean. - Create an
Auditable@MappedSuperclasswith@CreatedDate,@LastModifiedDate,@CreatedBy,@LastModifiedBy, and add@EntityListeners(AuditingEntityListener.class). - Extend
Auditablein entities that need audit fields — Spring fills them automatically. - Add
@Auditedto entities that need full revision history — Hibernate Envers creates mirror tables and tracks every insert, update, and delete. - Use
AuditReaderto query historical entity states at any revision. - Use a custom
@RevisionEntityto record who made each change. - Exclude high-churn fields from Envers with
@NotAudited.
Next: Article 29 covers optimistic and pessimistic locking — how to handle concurrent updates and prevent lost updates in multi-user applications.