Skip to content
ExponaBLOG
JAVA, SPRING FRAMEWORK & SPRING BOOT · DAY 051 / 100

Auditing with Spring Data JPA: best practices explained

createdAt, updatedAt and createdBy without manual code.

Expona Admin8 min read

Why auditing matters in production systems

Every production application tracks who did what and when—it's not optional, it's essential. Whether you're debugging a mysterious data change, proving compliance to auditors, or understanding user behaviour, timestamp and creator fields appear in nearly every table. The problem is that manually managing createdAt, updatedAt, and createdBy across your codebase leads to forgotten fields, inconsistent implementations, and bugs that slip through testing. Auditing with Spring Data JPA: best practices explained shows us how to eliminate this boilerplate entirely and build a reliable auditing layer that handles these requirements automatically.

Enable JPA auditing with @EnableJpaAuditing

Spring Data JPA provides built-in auditing support, but it's not active by default. You need to explicitly enable it in your configuration. This one annotation unlocks the framework's ability to inject audit metadata into your entities automatically.

java
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;

@Configuration
@EnableJpaAuditing(auditorAwareRef = "auditorProvider")
public class JpaAuditingConfig {
}

The auditorAwareRef parameter points to a bean that provides the current user—we'll define that in a moment. Without this configuration, the audit annotations on your entities won't trigger, and you'll wonder why your fields remain null.

In a typical e-commerce scenario, you might have a Product entity that needs to track who created and last modified it. Once auditing is enabled, the framework monitors every save and update operation, intercepting at the right moment to populate audit fields automatically. No service layer logic needed.

If you're using Spring Boot, you can alternatively use an auto-configuration approach by adding the annotation to your main application class, but the explicit @Configuration approach is cleaner and more testable, especially in larger teams where clarity matters.

Mark fields with @CreatedDate and @LastModifiedDate

Once auditing is enabled, you decorate your entity fields with Spring Data JPA's audit annotations. These tell the framework exactly which fields should receive automatic timestamp updates.

java
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import jakarta.persistence.*;
import java.time.Instant;

@Entity
@EntityListeners(AuditingEntityListener.class)
public class Order {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String orderNumber;
    private BigDecimal totalAmount;
    
    @CreatedDate
    @Column(nullable = false, updatable = false)
    private Instant createdAt;
    
    @LastModifiedDate
    @Column(nullable = false)
    private Instant updatedAt;
}

Notice the @EntityListeners(AuditingEntityListener.class) annotation on the class. This is the hook that allows Spring to intercept persistence lifecycle events. Without it, your @CreatedDate and @LastModifiedDate fields won't be populated.

The updatable = false constraint on createdAt is important—it prevents accidental or malicious updates to the creation timestamp. The database enforces this at the schema level, adding a safety barrier.

In a ride-hailing application, every trip has a creation time and completion time. Using @LastModifiedDate on a completedAt column ensures the trip's final state is always recorded accurately without requiring developers to remember to set it in the service layer. This consistency across hundreds of endpoints reduces bugs significantly.

You can use any temporal type: java.time.Instant, java.time.LocalDateTime, java.util.Date, or even Long for Unix timestamps. Instant is preferred in modern Spring applications because it's timezone-agnostic and works well with UTC-based audit logs.

Supply the current user with AuditorAware

Timestamps are only half the story. You also need to track who created or modified a record. Spring Data JPA provides the AuditorAware interface for this purpose. Implement it to return the current user from your security context.

java
import org.springframework.data.domain.AuditorAware;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import java.util.Optional;

@Component("auditorProvider")
public class SpringSecurityAuditorAware implements AuditorAware<String> {
    
    @Override
    public Optional<String> getCurrentAuditor() {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        
        if (authentication == null || !authentication.isAuthenticated()) {
            return Optional.of("SYSTEM");
        }
        
        return Optional.of(authentication.getName());
    }
}

The generic type parameter—<String> in this example—determines what type your auditor fields will be. You could use a UUID, a Long ID from your user table, or a username string. Choose whatever makes sense for your audit trail.

Now add audit fields to your entity:

java
import org.springframework.data.annotation.CreatedBy;
import org.springframework.data.annotation.LastModifiedBy;

@Entity
@EntityListeners(AuditingEntityListener.class)
public class Order {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String orderNumber;
    
    @CreatedDate
    @Column(nullable = false, updatable = false)
    private Instant createdAt;
    
    @LastModifiedDate
    @Column(nullable = false)
    private Instant updatedAt;
    
    @CreatedBy
    @Column(nullable = false, updatable = false)
    private String createdBy;
    
    @LastModifiedBy
    @Column(nullable = false)
    private String updatedBy;
}

In a SaaS application handling sensitive customer data, tracking who modified a customer record becomes legally important. When a support agent processes a refund, the audit trail automatically records their username and timestamp. If a dispute arises later, you have proof of exactly who did what and when, without any manual logging code.

For unauthenticated operations—like background jobs or system-initiated changes—your AuditorAware implementation returns a fallback value like "SYSTEM". This prevents null pointers and maintains a clean audit trail even when no human actor is involved.

Centralizing audit fields with a base class

Repeating the same audit fields across multiple entities is tedious and error-prone. Create an abstract base class and extend it:

java
import org.springframework.data.annotation.CreatedBy;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedBy;
import org.springframework.data.annotation.LastModifiedDate;
import jakarta.persistence.Column;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.MappedSuperclass;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.Instant;

@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class AuditableEntity {
    
    @CreatedDate
    @Column(nullable = false, updatable = false)
    protected Instant createdAt;
    
    @LastModifiedDate
    @Column(nullable = false)
    protected Instant updatedAt;
    
    @CreatedBy
    @Column(nullable = false, updatable = false)
    protected String createdBy;
    
    @LastModifiedBy
    @Column(nullable = false)
    protected String updatedBy;
    
    // Getters
    public Instant getCreatedAt() { return createdAt; }
    public Instant getUpdatedAt() { return updatedAt; }
    public String getCreatedBy() { return createdBy; }
    public String getUpdatedBy() { return updatedBy; }
}

Then every entity simply extends this base class:

java
@Entity
@Table(name = "orders")
public class Order extends AuditableEntity {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String orderNumber;
    private BigDecimal totalAmount;
}

This approach scales beautifully across a large codebase. Every entity automatically inherits audit tracking without repetition, and changes to audit strategy are made in one place.

Use Envers for complete audit history

Spring Data JPA's audit annotations give you the who and when, but they only track the current state. If you need a full history of every change—audit rows showing what values changed over time—you need Hibernate Envers.

Add the dependency:

java
// build.gradle
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.hibernate.orm:hibernate-envers'
}

Then annotate your entity:

java
import org.hibernate.envers.Audited;
import org.hibernate.envers.RevisionEntity;
import org.hibernate.envers.RevisionNumber;
import org.hibernate.envers.RevisionTimestamp;

@Entity
@Table(name = "orders")
@Audited
public class Order extends AuditableEntity {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String orderNumber;
    private BigDecimal totalAmount;
}

Envers creates additional tables automatically: orders_aud and revinfo. Every insert, update, or delete creates a new row in the audit table with the old values and a revision number. You can query the history:

java
import org.hibernate.envers.AuditReader;
import org.hibernate.envers.AuditReaderFactory;

@Service
public class OrderAuditService {
    
    private final EntityManager entityManager;
    
    public OrderAuditService(EntityManager entityManager) {
        this.entityManager = entityManager;
    }
    
    public List<Order> getOrderHistory(Long orderId) {
        AuditReader auditReader = AuditReaderFactory.get(entityManager);
        return auditReader.createQuery()
            .forRevisionsOfEntity(Order.class, false)
            .add(AuditEntity.id().eq(orderId))
            .getResultList();
    }
}

In a trading platform, every fill or order modification must be auditable. A trader might dispute an execution price; Envers lets you pull the exact historical record of what was recorded at that moment. This is far more valuable than timestamps alone.

Envers does add overhead—extra tables, extra writes—so reserve it for entities where historical tracking is genuinely needed. Not every table requires it.

Common mistakes

  • Forgetting @EntityListeners(AuditingEntityListener.class) — The audit fields are defined but never populated. This is the most common gotcha. The listener is required on each entity or base class.
  • Not enabling auditing in configuration — You add the annotations but forget to add @EnableJpaAuditing. The listeners exist but have nothing to trigger them. Always verify your config is loaded.
  • Using mutable field types for timestamps — java.util.Date is mutable and can lead to subtle bugs. Use java.time.Instant or LocalDateTime instead.
  • Allowing updates to createdBy or createdAt — Set updatable = false on these columns to prevent accidental overwrites. The original creator and creation time should never change.
  • Ignoring timezone considerations — Store timestamps as Instant (UTC-based) and convert to user's timezone only when displaying. Never store local times in the database.
  • Over-using Envers — It's powerful but expensive. Enable it only on entities where you genuinely need revision history, not everything.

Key takeaways

  • Enable auditing with @EnableJpaAuditing in a configuration class and implement AuditorAware<T> to supply the current user.
  • Use @CreatedDate, @LastModifiedDate, @CreatedBy, and @LastModifiedBy on entity fields, with @EntityListeners(AuditingEntityListener.class) on the entity or base class.
  • Create an abstract AuditableEntity base class to centralize audit fields across your domain model and avoid repetition.
  • Set updatable = false on creation fields to prevent accidental overwrites and ensure audit trail integrity.
  • Use Hibernate Envers when you need complete revision history, but reserve it for entities where historical tracking is genuinely required.
  • Use Instant for timestamps to avoid timezone-related bugs and keep audit logs consistent across regions.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles