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.
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:
- Duplicated in every entity
getCurrentUser()logic scattered everywhere- Easy to forget on new entities
- 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:

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:

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:
![]()
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.

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:

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
| Approach | Use Case | Complexity |
|---|---|---|
@CreatedDate / @LastModifiedDate | Basic timestamps | Low |
@CreatedBy / @LastModifiedBy | Track users | Low |
| Hibernate Envers | Full revision history | Medium |
| Custom audit tables | Compliance requirements | High |
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
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
Spring Data JPA: Advanced Queries and Best Practices
Master Spring Data JPA with advanced queries, custom repositories, specifications, projections, and performance optimization techniques.
Spring BootSpring WebFlux: When Threads Are Too Expensive (And When They're Not)
Reactive programming isn't always the answer. Learn when WebFlux actually helps, how it works under the hood, and how to avoid the common pitfalls.
Spring BootSpring Boot 3 Virtual Threads: Complete Guide to Java 21 Concurrency
Master virtual threads in Spring Boot 3. Learn configuration, performance benchmarks, when to use them, common pitfalls, and production-ready patterns for high-throughput applications.
Comments
Comments are powered by GitHub Discussions.
Configure Giscus at giscus.app to enable comments.