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 metadata
  • revinfo — 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 AuditingHibernate Envers
Setup5 minutes10 minutes
What it tracksCurrent created_at, updated_at, created_by, updated_byEvery historical revision
Storage overhead4 extra columns per entityMirror audit table per entity
Queryable historyNoYes (AuditReader API)
Point-in-time restoreNoYes
Performance impactNegligibleLow (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 @EnableJpaAuditing and an AuditorAware bean.
  • Create an Auditable @MappedSuperclass with @CreatedDate, @LastModifiedDate, @CreatedBy, @LastModifiedBy, and add @EntityListeners(AuditingEntityListener.class).
  • Extend Auditable in entities that need audit fields — Spring fills them automatically.
  • Add @Audited to entities that need full revision history — Hibernate Envers creates mirror tables and tracks every insert, update, and delete.
  • Use AuditReader to query historical entity states at any revision.
  • Use a custom @RevisionEntity to 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.