Spring Boot 7 min read

Spring Data JPA Auditing: Stop Writing createdAt/updatedAt Boilerplate Forever

Automatically track who changed what and when. Learn JPA auditing from basics to custom audit logs with revision history.

MR

Moshiour Rahman

Advertisement

The Problem: Manual Timestamp Hell

Every entity needs createdAt, updatedAt, createdBy, updatedBy. Every. Single. One.

// The boilerplate nightmare
@Entity
public class Product {
    @Id
    private Long id;
    private String name;
    private BigDecimal price;

    // Copy-paste for every entity...
    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;
    private String createdBy;
    private String updatedBy;

    @PrePersist
    void onCreate() {
        this.createdAt = LocalDateTime.now();
        this.createdBy = getCurrentUser(); // Where does this come from?
    }

    @PreUpdate
    void onUpdate() {
        this.updatedAt = LocalDateTime.now();
        this.updatedBy = getCurrentUser();
    }
}

Problems:

  1. Duplicated in every entity
  2. getCurrentUser() logic scattered everywhere
  3. Easy to forget on new entities
  4. Inconsistent implementations across the codebase

The Solution: JPA Auditing

Spring Data JPA’s auditing infrastructure handles all this automatically. Here’s how the components work together:

JPA Auditing Architecture

When an entity is saved, the auditing listener intercepts the operation, calls your AuditorAware implementation to get the current user from the security context, and populates the audit fields.

Setup: The Complete Configuration

Step 1: Enable Auditing

@Configuration
@EnableJpaAuditing
public class JpaConfig {
    // That's it for basic timestamp auditing
}

Step 2: Create Base Entity

@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseEntity {

    @CreatedDate
    @Column(nullable = false, updatable = false)
    private Instant createdAt;

    @LastModifiedDate
    @Column(nullable = false)
    private Instant updatedAt;

    @CreatedBy
    @Column(updatable = false)
    private String createdBy;

    @LastModifiedBy
    private String updatedBy;

    // Getters
    public Instant getCreatedAt() { return createdAt; }
    public Instant getUpdatedAt() { return updatedAt; }
    public String getCreatedBy() { return createdBy; }
    public String getUpdatedBy() { return updatedBy; }
}

Step 3: Implement AuditorAware (For createdBy/updatedBy)

@Component
public class SecurityAuditorAware implements AuditorAware<String> {

    @Override
    public Optional<String> getCurrentAuditor() {
        return Optional.ofNullable(SecurityContextHolder.getContext())
            .map(SecurityContext::getAuthentication)
            .filter(Authentication::isAuthenticated)
            .map(Authentication::getName)
            .or(() -> Optional.of("system")); // Fallback for scheduled jobs
    }
}

Step 4: Use It

@Entity
@Table(name = "products")
public class Product extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;
    private BigDecimal price;

    // No audit fields needed - inherited from BaseEntity
}

That’s it. Every entity extending BaseEntity automatically gets auditing.

Why Instant Instead of LocalDateTime?

In distributed systems, timezone handling matters. Here’s the critical difference:

Timestamp Type Comparison

When a server in NYC saves “10:30” and a server in London reads it, they interpret different actual moments. Instant stores UTC always, eliminating this ambiguity.

// Configure Hibernate to store Instant as TIMESTAMP WITH TIME ZONE
spring:
  jpa:
    properties:
      hibernate:
        jdbc:
          time_zone: UTC

Real-World Pattern: Audit Log Table with Envers

Basic auditing tells you the current state. But what if you need history?

“Who changed the price from $99 to $199 last Tuesday?”

Setup Hibernate Envers

<dependency>
    <groupId>org.hibernate.orm</groupId>
    <artifactId>hibernate-envers</artifactId>
</dependency>
@Entity
@Audited  // Enable full revision history
@Table(name = "products")
public class Product extends BaseEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;
    private BigDecimal price;

    @NotAudited  // Skip this field from history
    private String internalNotes;
}

Envers creates shadow tables that track every change with revision metadata:

Envers Revision Tracking

Each revision links to a revinfo table containing the timestamp and username of who made the change.

Custom Revision Entity (Add Username)

@Entity
@RevisionEntity(CustomRevisionListener.class)
@Table(name = "revisions")
public class CustomRevisionEntity extends DefaultRevisionEntity {

    @Column(name = "username")
    private String username;

    @Column(name = "ip_address")
    private String ipAddress;

    // Getters and setters
}
public class CustomRevisionListener implements RevisionListener {

    @Override
    public void newRevision(Object revisionEntity) {
        CustomRevisionEntity rev = (CustomRevisionEntity) revisionEntity;

        // Get current user from Spring Security
        String username = Optional.ofNullable(SecurityContextHolder.getContext())
            .map(SecurityContext::getAuthentication)
            .map(Authentication::getName)
            .orElse("system");

        rev.setUsername(username);

        // Get IP address from request
        RequestAttributes attrs = RequestContextHolder.getRequestAttributes();
        if (attrs instanceof ServletRequestAttributes servletAttrs) {
            HttpServletRequest request = servletAttrs.getRequest();
            rev.setIpAddress(getClientIp(request));
        }
    }

    private String getClientIp(HttpServletRequest request) {
        String xff = request.getHeader("X-Forwarded-For");
        return xff != null ? xff.split(",")[0].trim() : request.getRemoteAddr();
    }
}

Querying History

@Service
@RequiredArgsConstructor
public class ProductAuditService {

    private final EntityManager entityManager;

    /**
     * Get all revisions of a product
     */
    public List<ProductRevision> getProductHistory(Long productId) {
        AuditReader auditReader = AuditReaderFactory.get(entityManager);

        List<Number> revisions = auditReader.getRevisions(Product.class, productId);

        return revisions.stream()
            .map(rev -> {
                Product product = auditReader.find(Product.class, productId, rev);
                CustomRevisionEntity revEntity = auditReader.findRevision(
                    CustomRevisionEntity.class, rev
                );
                return new ProductRevision(
                    product,
                    revEntity.getRevisionDate(),
                    revEntity.getUsername()
                );
            })
            .toList();
    }

    /**
     * Get product state at a specific point in time
     */
    public Product getProductAtDate(Long productId, Instant pointInTime) {
        AuditReader auditReader = AuditReaderFactory.get(entityManager);

        Number revisionNumber = auditReader.getRevisionNumberForDate(
            Date.from(pointInTime)
        );

        return auditReader.find(Product.class, productId, revisionNumber);
    }

    /**
     * Find all products modified by a specific user
     */
    public List<Product> getProductsModifiedBy(String username) {
        AuditReader auditReader = AuditReaderFactory.get(entityManager);

        return auditReader.createQuery()
            .forRevisionsOfEntity(Product.class, true, true)
            .add(AuditEntity.revisionProperty("username").eq(username))
            .getResultList();
    }

    public record ProductRevision(Product product, Date timestamp, String modifiedBy) {}
}

Pattern: Soft Deletes with Audit Trail

Never actually delete data - mark it as deleted and track who did it.

@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class SoftDeleteEntity extends BaseEntity {

    @Column(name = "deleted")
    private boolean deleted = false;

    @Column(name = "deleted_at")
    private Instant deletedAt;

    @Column(name = "deleted_by")
    private String deletedBy;

    public void softDelete(String deletedBy) {
        this.deleted = true;
        this.deletedAt = Instant.now();
        this.deletedBy = deletedBy;
    }

    public void restore() {
        this.deleted = false;
        this.deletedAt = null;
        this.deletedBy = null;
    }

    // Getters
}
public interface ProductRepository extends JpaRepository<Product, Long> {

    // Automatically exclude soft-deleted records
    @Query("SELECT p FROM Product p WHERE p.deleted = false")
    List<Product> findAllActive();

    // Include deleted for admin views
    @Query("SELECT p FROM Product p")
    List<Product> findAllIncludingDeleted();

    // Find recently deleted (for recovery)
    @Query("SELECT p FROM Product p WHERE p.deleted = true AND p.deletedAt > :since")
    List<Product> findRecentlyDeleted(@Param("since") Instant since);
}

Global Filter: Hide Deleted by Default

@Entity
@FilterDef(name = "deletedFilter", parameters = @ParamDef(name = "isDeleted", type = Boolean.class))
@Filter(name = "deletedFilter", condition = "deleted = :isDeleted")
public class Product extends SoftDeleteEntity {
    // ...
}
@Service
@RequiredArgsConstructor
public class ProductService {

    private final EntityManager entityManager;
    private final ProductRepository productRepository;

    @Transactional(readOnly = true)
    public List<Product> findAll(boolean includeDeleted) {
        if (!includeDeleted) {
            Session session = entityManager.unwrap(Session.class);
            session.enableFilter("deletedFilter")
                   .setParameter("isDeleted", false);
        }
        return productRepository.findAll();
    }
}

Performance Consideration: Audit Table Growth

With frequent updates, audit tables can grow massive. Consider: 10 price updates/day × 365 days × 10,000 products = 36.5 million rows/year.

Audit Table Growth Management

The key strategies: partition by date to drop old data, tier storage (hot DB → warm S3 → cold archive), and use @NotAudited on volatile fields.

-- Archive old audit records (run monthly)
INSERT INTO products_aud_archive
SELECT * FROM products_aud
WHERE rev IN (
    SELECT rev FROM revisions WHERE timestamp < NOW() - INTERVAL '90 days'
);

DELETE FROM products_aud
WHERE rev IN (
    SELECT rev FROM revisions WHERE timestamp < NOW() - INTERVAL '90 days'
);

Testing Auditing

@DataJpaTest
@EnableJpaAuditing
class AuditingTest {

    @Autowired
    private ProductRepository productRepository;

    @Autowired
    private TestEntityManager entityManager;

    @MockBean
    private AuditorAware<String> auditorAware;

    @Test
    void shouldSetCreatedAtOnInsert() {
        when(auditorAware.getCurrentAuditor()).thenReturn(Optional.of("test-user"));

        Instant before = Instant.now();

        Product product = new Product("Widget", new BigDecimal("99.99"));
        productRepository.save(product);
        entityManager.flush();

        Instant after = Instant.now();

        assertThat(product.getCreatedAt())
            .isAfterOrEqualTo(before)
            .isBeforeOrEqualTo(after);
        assertThat(product.getCreatedBy()).isEqualTo("test-user");
    }

    @Test
    void shouldUpdateLastModifiedOnUpdate() {
        when(auditorAware.getCurrentAuditor())
            .thenReturn(Optional.of("creator"))
            .thenReturn(Optional.of("modifier"));

        Product product = productRepository.save(new Product("Widget", new BigDecimal("99.99")));
        entityManager.flush();
        entityManager.clear();

        Instant createdAt = product.getCreatedAt();

        // Wait a bit to ensure different timestamp
        Thread.sleep(10);

        Product loaded = productRepository.findById(product.getId()).orElseThrow();
        loaded.setPrice(new BigDecimal("149.99"));
        productRepository.save(loaded);
        entityManager.flush();

        assertThat(loaded.getCreatedAt()).isEqualTo(createdAt);  // Unchanged
        assertThat(loaded.getUpdatedAt()).isAfter(createdAt);
        assertThat(loaded.getCreatedBy()).isEqualTo("creator");  // Original creator
        assertThat(loaded.getUpdatedBy()).isEqualTo("modifier"); // New modifier
    }
}

Common Gotcha: Auditing Not Working

If your audit fields aren’t populating, here’s the troubleshooting guide:

Auditing Troubleshooting

Most issues trace back to missing @EntityListeners(AuditingEntityListener.class) on the entity or AuditorAware not registered as a Spring bean.

Code Sample

Full working example: github.com/Moshiour027/techyowls-io-blog-public/spring-data-jpa-auditing

Summary

ApproachUse CaseComplexity
@CreatedDate / @LastModifiedDateBasic timestampsLow
@CreatedBy / @LastModifiedByTrack usersLow
Hibernate EnversFull revision historyMedium
Custom audit tablesCompliance requirementsHigh

Start simple. Use basic auditing annotations. Add Envers only when you need “who changed what and when” for compliance or debugging.

Stop writing timestamp boilerplate. Let Spring handle it.

Advertisement

MR

Moshiour Rahman

Software Architect & AI Engineer

Share:
MR

Moshiour Rahman

Software Architect & AI Engineer

Enterprise software architect with deep expertise in financial systems, distributed architecture, and AI-powered applications. Building large-scale systems at Fortune 500 companies. Specializing in LLM orchestration, multi-agent systems, and cloud-native solutions. I share battle-tested patterns from real enterprise projects.

Related Articles

Comments

Comments are powered by GitHub Discussions.

Configure Giscus at giscus.app to enable comments.