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.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

Caching at the API Level: The Performance Win Most Backends Skip

Database query optimization and index tuning get the attention. HTTP caching — the layer that can eliminate database hits entirely for read-heavy endpoints — often gets ignored.

Read more

Why Office-Only Policies Don’t Solve Security or Productivity Problems

“We need everyone back in the office for security and productivity.” It sounds responsible—until you look at what actually improves those things.

Read more

Stop Skipping Integration Tests in Spring Boot

Unit tests give you confidence your classes work in isolation. Integration tests tell you whether your application actually works. Most Spring Boot projects have too few of the latter — and pay for it in production.

Read more

Dell, Apple, Tesla Are in Austin — and They Are Hiring the Same Developers You Need

When the biggest companies in the world set up in your city, the hiring market doesn't get easier. Here's how startups are staying in the game.

Read more