Spring Data JPA Auditing — @CreatedDate, @LastModifiedBy, and Entity Lifecycle Tracking
by Eric Hanson, Backend Developer at Clean Systems Consulting
The auditing annotations
Spring Data JPA provides four auditing annotations that populate fields automatically when entities are persisted or updated:
@CreatedDate— set to the current timestamp when the entity is first saved@LastModifiedDate— updated to the current timestamp on every save@CreatedBy— set to the current user identifier when the entity is first saved@LastModifiedBy— updated to the current user identifier on every save
@Entity
@EntityListeners(AuditingEntityListener.class)
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE)
private Long id;
@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 lastModifiedBy;
// ... other fields
}
@EntityListeners(AuditingEntityListener.class) registers the JPA listener that intercepts @PrePersist and @PreUpdate lifecycle events to populate the audit fields. Without this annotation on the entity (or on a mapped superclass), the auditing annotations do nothing.
updatable = false on @CreatedDate and @CreatedBy prevents Hibernate from including these columns in UPDATE statements — they're set once and never changed.
Enabling auditing
@Configuration
@EnableJpaAuditing
public class JpaAuditingConfig {
// @AuditorAware bean defined separately
}
@EnableJpaAuditing activates Spring Data's auditing infrastructure. It must be present in a @Configuration class. Adding it to @SpringBootApplication works but pollutes the main class; a dedicated config class is cleaner.
AuditorAware — providing the current user
@CreatedBy and @LastModifiedBy need to know who the current user is. Spring Data calls AuditorAware.getCurrentAuditor() each time an entity is persisted or updated:
@Component
public class SecurityAuditorAware implements AuditorAware<String> {
@Override
public Optional<String> getCurrentAuditor() {
return Optional.ofNullable(SecurityContextHolder.getContext().getAuthentication())
.filter(auth -> auth.isAuthenticated() &&
!(auth instanceof AnonymousAuthenticationToken))
.map(Authentication::getName);
}
}
If no AuditorAware bean is defined, @CreatedBy and @LastModifiedBy annotations are silently ignored — no exception, no warning. Verify the bean is registered before relying on it.
The return type of AuditorAware determines the field type. AuditorAware<String> populates String fields with the username. For applications that prefer to store the user ID:
@Component
public class UserIdAuditorAware implements AuditorAware<Long> {
@Override
public Optional<Long> getCurrentAuditor() {
return Optional.ofNullable(SecurityContextHolder.getContext().getAuthentication())
.filter(auth -> auth.isAuthenticated())
.map(auth -> (AppUserDetails) auth.getPrincipal())
.map(AppUserDetails::getId);
}
}
The entity field type must match: @CreatedBy private Long createdByUserId.
Auditable base class — reducing boilerplate
When multiple entities need audit fields, a mapped superclass eliminates repetition:
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class Auditable {
@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 lastModifiedBy;
// Getters
public Instant getCreatedAt() { return createdAt; }
public Instant getUpdatedAt() { return updatedAt; }
public String getCreatedBy() { return createdBy; }
public String getLastModifiedBy() { return lastModifiedBy; }
}
Entities extend Auditable:
@Entity
public class Order extends Auditable {
@Id
@GeneratedValue
private Long id;
// ... order-specific fields
}
@Entity
public class Product extends Auditable {
@Id
@GeneratedValue
private Long id;
// ... product-specific fields
}
@EntityListeners on the superclass applies to all subclasses — each entity inherits the listener and the audit fields. The Flyway migration adds the four audit columns to each table.
Spring Data also provides AbstractAuditable<U, ID, T extends TemporalAccessor> as a ready-made auditable base class. It's more opinionated (requires a @ManyToOne to the user entity rather than a string identifier) and less commonly used than a custom @MappedSuperclass.
Bulk operations bypass auditing
@Modifying @Query bulk updates execute SQL directly without going through the JPA entity lifecycle — @PrePersist and @PreUpdate callbacks don't fire, and AuditingEntityListener is never invoked:
@Modifying
@Query("UPDATE Order o SET o.status = :status WHERE o.status = 'PENDING'")
int updatePendingOrdersStatus(@Param("status") OrderStatus status);
// updatedAt and lastModifiedBy are NOT updated by this query
The fix: include audit fields explicitly in bulk update queries:
@Modifying
@Query("UPDATE Order o SET o.status = :status, " +
"o.updatedAt = :now, " +
"o.lastModifiedBy = :user " +
"WHERE o.status = 'PENDING'")
int updatePendingOrdersStatus(
@Param("status") OrderStatus status,
@Param("now") Instant now,
@Param("user") String user);
Or rely on the database to maintain updated_at via a trigger:
-- PostgreSQL trigger maintains updated_at automatically
CREATE OR REPLACE FUNCTION update_modified_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER update_orders_modtime
BEFORE UPDATE ON orders
FOR EACH ROW EXECUTE FUNCTION update_modified_column();
The trigger approach handles all update paths — JPA, bulk JPQL, native SQL, and direct database operations from migration scripts or external tools. The tradeoff: updatedAt is maintained, but lastModifiedBy still requires explicit handling for bulk operations.
Testing with auditing
@CreatedDate and @LastModifiedDate populate with Instant.now() during the test. This is fine for persistence tests but makes date assertions fragile:
// Fragile — exact timestamp comparison
assertThat(order.getCreatedAt()).isEqualTo(Instant.parse("2026-04-17T14:30:00Z"));
// Better — check that the field is set and within a reasonable range
assertThat(order.getCreatedAt())
.isNotNull()
.isAfter(Instant.now().minusSeconds(5))
.isBefore(Instant.now().plusSeconds(5));
For @CreatedBy and @LastModifiedBy, tests must either configure AuditorAware to return a test user or expect that auditing is not triggered:
@DataJpaTest
@Import(JpaAuditingConfig.class) // import the @EnableJpaAuditing config
class OrderAuditingTest {
@Autowired OrderRepository orderRepository;
@MockBean AuditorAware<String> auditorAware;
@Test
void createdBy_isSetOnFirstSave() {
when(auditorAware.getCurrentAuditor()).thenReturn(Optional.of("test-user"));
Order order = new Order(/* ... */);
Order saved = orderRepository.save(order);
assertThat(saved.getCreatedBy()).isEqualTo("test-user");
assertThat(saved.getLastModifiedBy()).isEqualTo("test-user");
}
@Test
void lastModifiedBy_updatesOnSubsequentSave() {
when(auditorAware.getCurrentAuditor())
.thenReturn(Optional.of("creator"))
.thenReturn(Optional.of("modifier"));
Order saved = orderRepository.save(new Order(/* ... */));
saved.setStatus(OrderStatus.PROCESSING);
Order updated = orderRepository.save(saved);
assertThat(updated.getCreatedBy()).isEqualTo("creator");
assertThat(updated.getLastModifiedBy()).isEqualTo("modifier");
}
}
@MockBean AuditorAware<String> creates a mock that AuditingEntityListener calls during saves. Without mocking AuditorAware, the bean returns Optional.empty() and the createdBy/lastModifiedBy fields remain null — which may cause constraint violations if the column is nullable = false.
@DataJpaTest doesn't load @EnableJpaAuditing by default — it loads only the JPA slice. Import the auditing configuration explicitly with @Import(JpaAuditingConfig.class).
Auditing with optimistic locking
@Version and auditing annotations coexist on the same entity without conflict. The version counter serves a different purpose — detecting concurrent modification — while audit fields track the modification history:
@Entity
@EntityListeners(AuditingEntityListener.class)
public class Order {
@Version
private Long version;
@LastModifiedDate
private Instant updatedAt;
@LastModifiedBy
private String lastModifiedBy;
}
Hibernate increments @Version on every update regardless of auditing. @LastModifiedDate and @LastModifiedBy are populated by AuditingEntityListener in the @PreUpdate callback — both fire on the same save operation.
When auditing isn't enough — audit log tables
@LastModifiedDate and @LastModifiedBy track only the current state — who last modified the record and when. They don't preserve history. For records where the full modification history matters (financial records, regulated data, debugging), an audit log table is required.
Hibernate Envers is the Spring Data JPA integration for full entity history:
<dependency>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-envers</artifactId>
</dependency>
@Entity
@Audited // all changes tracked in orders_aud table
public class Order extends Auditable {
// ...
}
Envers creates an orders_aud table with a revision number and type (ADD, MOD, DEL) for every change. The full history of an entity is queryable:
AuditReader reader = AuditReaderFactory.get(entityManager);
List<Number> revisions = reader.getRevisions(Order.class, orderId);
Order orderAtRevision = reader.find(Order.class, orderId, revisions.get(0));
Envers adds write overhead — every entity change requires an INSERT into the audit table. For high-volume entities (event logs, metrics), the overhead is prohibitive. For lower-volume, high-value entities (orders, contracts, user accounts), Envers is the complete audit trail solution.