00:00

JPA Auditing

Last updated:

JPA auditing in Spring Data automatically records who created each database row and when, and who changed it last and when. You add four annotated fields to an entity, and Spring fills them in on every insert and update, with no code in your services. When a price is wrong or a record disappears, these columns are the first thing support and auditors look at, and doing it by hand in every service is easy to forget.

This tutorial explains how Spring Data JPA auditing works and how to enable it with @EnableJpaAuditing. It covers the @CreatedBy, @CreatedDate, @LastModifiedBy and @LastModifiedDate annotations, a reusable Auditable base class, and filling in the user from Spring Security with AuditorAware and the time from a Clock. It then builds a product catalog on Spring Boot 4.1, shows the real failures behind the most common pitfalls, and tests the audit columns with a controllable clock.

1. What Is JPA Auditing?

Auditing here means keeping four facts on every row: who created it, when it was created, who last changed it and when that was. Spring Data JPA can fill these columns for you. You mark entity fields with Spring Data's audit annotations, register an entity listener (a class JPA calls just before it inserts or updates a row), and tell Spring how to find the current user and the current time.

It records only the latest change, not a full history. If you need every past version of a row, you need a history tool such as Hibernate Envers on top (section 2). The Spring Data JPA overview covers the repository basics this page builds on.

2. Spring Data JPA Auditing vs Other Approaches

ApproachWhoWhenHistory of changesNotes
Setting fields by hand in servicesYes, if you rememberYes, if you rememberNoEasy to forget on one code path
Hibernate @CreationTimestamp / @UpdateTimestampNoYesNoTimestamps only, set by Hibernate
Spring Data JPA auditingYes, via AuditorAwareYes, via DateTimeProviderNoWorks with any JPA entity and your security context
Hibernate EnversWith a custom revision entityYesYes, a full copy per changeExtra audit tables; use for compliance trails

3. How JPA Auditing Works

How Spring Data JPA auditing fills the audit columnsProductService saves a product or changes and flushes one. JPA fires @PrePersist before an INSERT and @PreUpdate before an UPDATE. AuditingEntityListener receives the callback, asks AuditorAware who is making the change (priya, or system when nobody is logged in) and asks DateTimeProvider for the time from clock.instant(), then fills the audit fields. The INSERT or UPDATE writes created columns on insert only and last-modified columns on both. A bulk @Query update or plain SQL goes straight to the database with no lifecycle callback and no listener, so the audit columns are not touched. ProductService save(product) or change + flush JPA lifecycle @PrePersist before INSERT @PreUpdate before UPDATE AuditingEntityListener fills the audit fields INSERT / UPDATE product created_* on insert only, last_modified_* on both AuditorAware "priya" or "system" DateTimeProvider clock.instant() who? when? Bulk @Query update or plain SQL goes straight to the database: no lifecycle callback, no listener, audit columns untouched
Figure 1: auditing hooks into JPA's insert and update callbacks; anything that bypasses entities bypasses auditing too.
  1. Your service saves an entity or changes a loaded one. The entity has @EntityListeners(AuditingEntityListener.class).
  2. Just before the INSERT, JPA fires its @PrePersist callback. Just before an UPDATE, it fires @PreUpdate. AuditingEntityListener receives both.
  3. The listener asks your AuditorAware bean for the current user and your DateTimeProvider bean for the current time.
  4. On insert it fills all four fields. On update it fills only the two "last modified" fields.
  5. Hibernate writes the row with those values.

Everything hangs on the JPA callbacks. Anything that writes to the database without loading entities, such as a bulk @Query update or plain SQL, is invisible to auditing (section 7.3).

4. Setup: Enabling JPA Auditing

Auditing is part of Spring Data JPA, so the JPA starter is enough. The example also uses Spring Security to know who is logged in, and H2 as its database:

pom.xml
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-webmvc</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    <dependency>
        <groupId>com.h2database</groupId>
        <artifactId>h2</artifactId>
        <scope>runtime</scope>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa-test</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-webmvc-test</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

Spring Boot does not enable auditing by itself. Add @EnableJpaAuditing to a configuration class of its own, not to the main application class (section 7.2 shows why):

AuditingConfig.java (excerpt)
@Configuration
@EnableJpaAuditing(auditorAwareRef = "auditorProvider", dateTimeProviderRef = "auditingDateTimeProvider")
public class AuditingConfig {
    // AuditorAware and DateTimeProvider beans, see sections 5.3 and 5.4
}
Version note: Spring Boot 4.1 uses Spring Data JPA 4.1 and Hibernate 7.4, and the auditing annotations and setup are the same as in Spring Boot 3.x. Spring Boot 4 split test support into modules, so @DataJpaTest now lives in org.springframework.boot.data.jpa.test.autoconfigure (from spring-boot-starter-data-jpa-test), and TestEntityManager in org.springframework.boot.jpa.test.autoconfigure.

5. Core Concepts

5.1 The Four Audit Annotations

AnnotationSet on insertSet on updateTypical type
@CreatedByYesNoString (username) or a user id
@CreatedDateYesNoInstant
@LastModifiedByYes (because modifyOnCreate defaults to true)YesSame type as @CreatedBy
@LastModifiedDateYes (same reason)YesInstant

All four come from org.springframework.data.annotation. Use Instant for the dates: it is a single moment in UTC, so there is no confusion about time zones between servers.

5.2 A Reusable Auditable Base Class

Put the four fields in a @MappedSuperclass, a class whose fields are mapped into every entity that extends it, without a table of its own. The @EntityListeners annotation goes on it too, so no entity can forget the listener:

Auditable.java
package com.javabytechie.shop.product;

import java.time.Instant;

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 org.springframework.data.jpa.domain.support.AuditingEntityListener;

import jakarta.persistence.Column;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.MappedSuperclass;

// Shared audit columns: every entity that extends this class gets them
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class Auditable {

    @CreatedBy
    @Column(nullable = false, updatable = false, length = 100)
    private String createdBy;

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

    @LastModifiedBy
    @Column(nullable = false, length = 100)
    private String lastModifiedBy;

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

    public String getCreatedBy() { return createdBy; }
    public Instant getCreatedAt() { return createdAt; }
    public String getLastModifiedBy() { return lastModifiedBy; }
    public Instant getLastModifiedAt() { return lastModifiedAt; }
}

updatable = false on the "created" columns keeps them out of every UPDATE statement. That protects them from being overwritten by mistake (section 7.4).

5.3 Who Made the Change: AuditorAware

AuditorAware<T> has one method, getCurrentAuditor(), which returns the current user as an Optional. Reading it from Spring Security's context ties auditing to the real login. A fallback such as "system" covers scheduled jobs and startup code, which run without a logged-in user:

AuditingConfig.java (excerpt)
@Bean
public AuditorAware<String> auditorProvider() {
    return () -> {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if (authentication == null || !authentication.isAuthenticated()
                || authentication instanceof AnonymousAuthenticationToken) {
            return Optional.of("system");
        }
        return Optional.of(authentication.getName());
    };
}

The Spring Security introduction explains the security context that this reads from.

5.4 When It Happened: DateTimeProvider and Clock

By default, Spring takes the time from the system clock. A DateTimeProvider bean that reads an injected java.time.Clock lets tests replace the clock and check exact timestamps:

AuditingConfig.java (excerpt)
@Bean
public Clock clock() {
    return Clock.systemUTC();
}

@Bean
public DateTimeProvider auditingDateTimeProvider(Clock clock) {
    return () -> Optional.of(clock.instant());
}

5.5 @EnableJpaAuditing Options

AttributeDefaultPurpose
auditorAwareRefEmpty: a single AuditorAware bean is picked upBean name of the AuditorAware to use
dateTimeProviderRefEmpty: a single DateTimeProvider bean is picked up, otherwise the system time is usedBean name of a custom DateTimeProvider
modifyOnCreatetrueAlso fill the "last modified" fields when a row is created
setDatestrueFill the date fields at all; set to false to record only users

With exactly one bean of each type, both references are optional. The example names them anyway, so it is obvious which beans auditing uses.

6. A Complete Example: Auditing a Product Catalog

Two catalog managers, Priya and Arjun, create products and change prices. Every product extends Auditable, so its four audit columns are filled automatically. The bulk "raise all prices in a category" operation sets its audit columns explicitly, because bulk updates skip the listener. An unknown product returns 404 as a ProblemDetail.

6.1 Configuration

package com.javabytechie.shop.config;

import java.time.Clock;
import java.util.Optional;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.auditing.DateTimeProvider;
import org.springframework.data.domain.AuditorAware;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;

// Kept out of ShopApplication so test slices such as @WebMvcTest don't need JPA
@Configuration
@EnableJpaAuditing(auditorAwareRef = "auditorProvider", dateTimeProviderRef = "auditingDateTimeProvider")
public class AuditingConfig {

    static final String SYSTEM_USER = "system";

    @Bean
    public Clock clock() {
        return Clock.systemUTC();
    }

    // Who is making the change: the logged-in user, or "system" for jobs without one
    @Bean
    public AuditorAware<String> auditorProvider() {
        return () -> {
            Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
            if (authentication == null || !authentication.isAuthenticated()
                    || authentication instanceof AnonymousAuthenticationToken) {
                return Optional.of(SYSTEM_USER);
            }
            return Optional.of(authentication.getName());
        };
    }

    // When the change happens: read from the Clock bean, so tests can fix the time
    @Bean
    public DateTimeProvider auditingDateTimeProvider(Clock clock) {
        return () -> Optional.of(clock.instant());
    }
}

6.2 Entity and Repository

package com.javabytechie.shop.product;

import java.math.BigDecimal;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Version;

@Entity
public class Product extends Auditable {

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

    @Column(nullable = false, unique = true, length = 20)
    private String sku;

    @Column(nullable = false)
    private String name;

    @Column(nullable = false, length = 30)
    private String category;

    @Column(nullable = false, precision = 10, scale = 2)
    private BigDecimal price;

    @Version
    private long version;

    protected Product() {
    }

    public Product(String sku, String name, String category, BigDecimal price) {
        this.sku = sku;
        this.name = name;
        this.category = category;
        this.price = price;
    }

    public void changePrice(BigDecimal newPrice) {
        this.price = newPrice;
    }

    public Long getId() { return id; }
    public String getSku() { return sku; }
    public String getName() { return name; }
    public String getCategory() { return category; }
    public BigDecimal getPrice() { return price; }
    public long getVersion() { return version; }
}

The schema is generated by Hibernate here to keep the example short. In production, create the audit columns with a migration, as shown in the database migration tutorial.

6.3 Service, Controller and DTOs

package com.javabytechie.shop.product;

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Clock;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.domain.AuditorAware;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class ProductService {

    private static final Logger log = LoggerFactory.getLogger(ProductService.class);

    private final ProductRepository productRepository;
    private final AuditorAware<String> auditorAware;
    private final Clock clock;

    public ProductService(ProductRepository productRepository, AuditorAware<String> auditorAware, Clock clock) {
        this.productRepository = productRepository;
        this.auditorAware = auditorAware;
        this.clock = clock;
    }

    @Transactional
    public ProductResponse create(CreateProductRequest request) {
        Product product = productRepository.save(
                new Product(request.sku(), request.name(), request.category(), request.price()));
        return ProductResponse.from(product);
    }

    @Transactional(readOnly = true)
    public ProductResponse findById(Long id) {
        return ProductResponse.from(load(id));
    }

    // Load, change, let dirty checking write the UPDATE: the listener fills the "last modified" columns
    @Transactional
    public ProductResponse changePrice(Long id, BigDecimal newPrice) {
        Product product = load(id);
        product.changePrice(newPrice);
        productRepository.flush();
        return ProductResponse.from(product);
    }

    @Transactional
    public int raisePrices(String category, int percent) {
        BigDecimal factor = BigDecimal.ONE.add(BigDecimal.valueOf(percent).divide(BigDecimal.valueOf(100), 4, RoundingMode.UNNECESSARY));
        String user = auditorAware.getCurrentAuditor().orElseThrow();
        int updated = productRepository.raisePrices(category, factor, user, clock.instant());
        log.info("{} raised {} prices in {} by {}%", user, updated, category, percent);
        return updated;
    }

    private Product load(Long id) {
        return productRepository.findById(id).orElseThrow(() -> new ProductNotFoundException(id));
    }
}

6.4 Run It

Priya creates a keyboard. All four audit fields name her and share one timestamp:

Terminal
$ curl -u priya:priya-pass -X POST localhost:8080/api/products -H 'Content-Type: application/json' \
    -d '{"sku":"KB-100","name":"Mechanical Keyboard","category":"keyboards","price":89.00}'
HTTP/1.1 201
{"id":1,"sku":"KB-100","name":"Mechanical Keyboard","price":89.00,"createdBy":"priya","createdAt":"2026-10-25T17:32:24.577353Z","lastModifiedBy":"priya","lastModifiedAt":"2026-10-25T17:32:24.577353Z"}

Two seconds later, Arjun lowers the price. Only the "last modified" fields change:

Terminal
$ curl -u arjun:arjun-pass -X PUT localhost:8080/api/products/1/price -H 'Content-Type: application/json' \
    -d '{"price":84.50}'
HTTP/1.1 200
{"id":1,"sku":"KB-100","name":"Mechanical Keyboard","price":84.50,"createdBy":"priya","createdAt":"2026-10-25T17:32:24.577353Z","lastModifiedBy":"arjun","lastModifiedAt":"2026-10-25T17:32:26.798271Z"}

The SQL shows the listener at work. The INSERT includes all four audit columns, while the UPDATE leaves out created_at and created_by because of updatable = false:

Console (logging.level.org.hibernate.SQL=DEBUG)
org.hibernate.SQL : insert into product (category,created_at,created_by,last_modified_at,last_modified_by,name,price,sku,version,id) values (?,?,?,?,?,?,?,?,?,default)
org.hibernate.SQL : update product set category=?,last_modified_at=?,last_modified_by=?,name=?,price=?,sku=?,version=? where id=? and version=?

Sending the same price again changes nothing, so Hibernate sends no UPDATE and lastModifiedAt stays the same. The bulk increase records Arjun because the query sets the columns itself:

Terminal
# The same price again: no UPDATE, lastModifiedAt unchanged
$ curl -u arjun:arjun-pass -X PUT localhost:8080/api/products/1/price -H 'Content-Type: application/json' -d '{"price":84.50}'
{"id":1,...,"lastModifiedBy":"arjun","lastModifiedAt":"2026-10-25T17:32:26.798271Z"}

# Raise every keyboard price by 10%
$ curl -u arjun:arjun-pass -X POST 'localhost:8080/api/products/price-increase?category=keyboards&percent=10'
{"updated":2}

$ curl -u priya:priya-pass localhost:8080/api/products/2
{"id":2,"sku":"KB-200","name":"Compact Keyboard","price":64.90,"createdBy":"priya","createdAt":"2026-10-25T17:32:24.679703Z","lastModifiedBy":"arjun","lastModifiedAt":"2026-10-25T17:32:27.974364Z"}

$ curl -i -u priya:priya-pass localhost:8080/api/products/99
HTTP/1.1 404
{"detail":"Product not found: 99","instance":"/api/products/99","status":404,"title":"Not Found"}

7. Common Pitfalls and How to Fix Them

7.1 Audit Columns Stay Null

Without @EnableJpaAuditing, or without @EntityListeners(AuditingEntityListener.class) on the entity, nothing fills the fields. With nullable = false on the columns, the first insert fails, which is better than silently saving nulls:

Console (@EnableJpaAuditing removed)
POST /api/products -> HTTP/1.1 500

org.springframework.dao.DataIntegrityViolationException: not-null property references a null or transient value for entity com.javabytechie.shop.product.Product.createdAt

How to fix it: add @EnableJpaAuditing to a configuration class, and put @EntityListeners on the shared base class so no entity can miss it.

7.2 "JPA Metamodel Must Not Be Empty" in @WebMvcTest

With @EnableJpaAuditing on the main application class, every test slice loads it, including @WebMvcTest, which has no JPA at all:

Console (@EnableJpaAuditing on ShopApplication, running a @WebMvcTest)
Caused by: org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'jpaAuditingHandler': Cannot resolve reference to bean 'jpaMappingContext' while setting constructor argument
Caused by: org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'jpaMappingContext': JPA metamodel must not be empty
Caused by: java.lang.IllegalArgumentException: JPA metamodel must not be empty

How to fix it: move @EnableJpaAuditing to its own @Configuration class, like AuditingConfig. With that change, the same controller slice test passed.

7.3 Bulk Updates Skip Auditing

A JPQL update query changes rows directly in the database. No entity is loaded, so no listener runs. A bulk price increase without audit columns changed the price but left lastModifiedAt at its old value. The record looked as if nobody had touched it:

ProductRepository.java (the mistake) and test output
@Modifying(clearAutomatically = true)
@Query("update Product p set p.price = p.price * :factor where p.category = :category")
int raisePricesWithoutAudit(@Param("category") String category, @Param("factor") BigDecimal factor);

// Test output: price 97.90, lastModifiedAt unchanged = true

How to fix it: set the audit columns in the query yourself, using the same AuditorAware and Clock, as ProductRepository.raisePrices does. Increment @Version too, so optimistic locking still notices the change.

7.4 Created Fields Wiped by Saving a Detached Copy

Mapping a request body straight onto a new entity with an existing id, then calling save, merges that copy into the database. The copy's createdBy and createdAt are null. Without updatable = false, the merge overwrote them:

Test output
# Product created by arjun, then a copy with the same id and version saved from "request data"

created columns WITHOUT updatable = false:  createdBy=null  createdAt=null                         lastModifiedBy=arjun
created columns WITH    updatable = false:  createdBy=arjun createdAt=2026-10-25T17:46:12.310473Z  lastModifiedBy=arjun

How to fix it: mark the created columns updatable = false, which kept them intact in the same test, and prefer load, change, save in your services, as changePrice does.

7.5 lastModifiedAt Doesn't Change

If a request sets a field to the value it already has, Hibernate's dirty checking finds nothing to update and sends no UPDATE. No update means no @PreUpdate, so the "last modified" fields stay as they were. That is usually what you want. Only real changes count as modifications.

7.6 createdBy Is Null for Background Jobs

A scheduled job or message listener has no logged-in user. An AuditorAware that returns Optional.empty() then leaves createdBy null. How to fix it: return a clear fallback such as "system" (or the job's name), as the example does. If a job acts on behalf of a user, set that user in the security context before it runs.

8. Testing JPA Auditing

Audit columns depend on two things a test normally can't control: the current user and the current time. The test below controls both. A MutableClock starts at a fixed instant and can be moved forward, and a small as(user, action) helper puts a user in the security context. @DataJpaTest doesn't load your configuration classes, so AuditingConfig is imported explicitly, and the test clock replaces the real one with @Primary:

package com.javabytechie.shop.product;

import static org.assertj.core.api.Assertions.assertThat;

import java.math.BigDecimal;
import java.time.Duration;
import java.time.Instant;
import java.util.function.Supplier;

import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.boot.jpa.test.autoconfigure.TestEntityManager;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.context.annotation.Primary;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.context.SecurityContextHolder;

import com.javabytechie.shop.config.AuditingConfig;

@DataJpaTest
@Import({AuditingConfig.class, ProductService.class, ProductAuditingTest.FixedTime.class})
class ProductAuditingTest {

    static final Instant T0 = Instant.parse("2026-10-25T09:00:00Z");

    @TestConfiguration
    static class FixedTime {
        @Bean
        @Primary
        MutableClock testClock() {
            return new MutableClock(T0);
        }
    }

    @Autowired
    ProductService productService;

    @Autowired
    ProductRepository productRepository;

    @Autowired
    TestEntityManager entityManager;

    @Autowired
    MutableClock clock;

    @BeforeEach
    void resetClock() {
        // The clock bean is shared by all tests in the cached context
        clock.set(T0);
    }

    @AfterEach
    void logout() {
        SecurityContextHolder.clearContext();
    }

    // Runs the action as the given user, like a logged-in request would
    private <T> T as(String username, Supplier<T> action) {
        SecurityContextHolder.getContext().setAuthentication(UsernamePasswordAuthenticationToken.authenticated(
                username, "n/a", AuthorityUtils.createAuthorityList("ROLE_CATALOG")));
        try {
            return action.get();
        } finally {
            SecurityContextHolder.clearContext();
        }
    }

    private Product reload(Long id) {
        entityManager.flush();
        entityManager.clear();
        return productRepository.findById(id).orElseThrow();
    }

    private Long createKeyboardAsPriya() {
        return as("priya", () -> productService.create(
                new CreateProductRequest("KB-100", "Mechanical Keyboard", "keyboards", new BigDecimal("89.00")))).id();
    }

    @Test
    void creatingFillsAllFourAuditFields() {
        Long id = createKeyboardAsPriya();

        Product product = reload(id);
        assertThat(product.getCreatedBy()).isEqualTo("priya");
        assertThat(product.getCreatedAt()).isEqualTo(T0);
        // modifyOnCreate = true (the default): a new row also counts as modified
        assertThat(product.getLastModifiedBy()).isEqualTo("priya");
        assertThat(product.getLastModifiedAt()).isEqualTo(T0);
    }

    @Test
    void updatingChangesOnlyTheLastModifiedFields() {
        Long id = createKeyboardAsPriya();
        assertThat(reload(id).getLastModifiedBy()).isEqualTo("priya");

        clock.advance(Duration.ofHours(2));
        as("arjun", () -> productService.changePrice(id, new BigDecimal("84.50")));

        Product product = reload(id);
        assertThat(product.getLastModifiedBy()).isEqualTo("arjun");
        assertThat(product.getLastModifiedAt()).isEqualTo(T0.plus(Duration.ofHours(2)));
        assertThat(product.getCreatedBy()).isEqualTo("priya");
        assertThat(product.getCreatedAt()).isEqualTo(T0);
    }

    @Test
    void savingWithoutChangesDoesNotTouchLastModified() {
        Long id = createKeyboardAsPriya();

        clock.advance(Duration.ofHours(2));
        as("arjun", () -> productService.changePrice(id, new BigDecimal("89.00"))); // same price as before

        Product product = reload(id);
        assertThat(product.getLastModifiedBy()).isEqualTo("priya");
        assertThat(product.getLastModifiedAt()).isEqualTo(T0);
    }

    @Test
    void changesWithoutALoggedInUserAreRecordedAsSystem() {
        Long id = productService.create(
                new CreateProductRequest("MS-200", "Wireless Mouse", "mice", new BigDecimal("25.50"))).id();

        assertThat(reload(id).getCreatedBy()).isEqualTo("system");
    }

    @Test
    void bulkPriceIncreaseRecordsWhoAndWhen() {
        Long id = createKeyboardAsPriya();

        clock.advance(Duration.ofDays(1));
        int updated = as("arjun", () -> productService.raisePrices("keyboards", 10));

        Product product = reload(id);
        assertThat(updated).isEqualTo(1);
        assertThat(product.getPrice()).isEqualByComparingTo("97.90");
        assertThat(product.getLastModifiedBy()).isEqualTo("arjun");
        assertThat(product.getLastModifiedAt()).isEqualTo(T0.plus(Duration.ofDays(1)));
    }
}

reload flushes and clears the persistence context before reading, so the assertions see what is really in the database, not cached objects. A separate test checks the same behaviour through the REST API with real HTTP Basic logins. The Spring Boot testing tutorial explains the test annotations in more detail:

ProductApiAuditTest.java
package com.javabytechie.shop.product;

import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.assertj.MockMvcTester;
import org.springframework.test.web.servlet.assertj.MvcTestResult;

import com.jayway.jsonpath.JsonPath;

@SpringBootTest
@AutoConfigureMockMvc
class ProductApiAuditTest {

    @Autowired
    MockMvcTester mvc;

    @Test
    void auditFieldsFollowTheLoggedInUser() throws Exception {
        MvcTestResult created = mvc.post().uri("/api/products").with(httpBasic("priya", "priya-pass"))
                .contentType(MediaType.APPLICATION_JSON)
                .content("{\"sku\":\"HD-300\",\"name\":\"Wireless Headset\",\"category\":\"audio\",\"price\":149.00}")
                .exchange();
        assertThat(created).hasStatus(HttpStatus.CREATED)
                .bodyJson().extractingPath("$.createdBy").isEqualTo("priya");
        Integer id = JsonPath.read(created.getResponse().getContentAsString(), "$.id");

        MvcTestResult updated = mvc.put().uri("/api/products/{id}/price", id).with(httpBasic("arjun", "arjun-pass"))
                .contentType(MediaType.APPLICATION_JSON).content("{\"price\":139.00}")
                .exchange();
        assertThat(updated).hasStatusOk();
        assertThat(updated).bodyJson().extractingPath("$.createdBy").isEqualTo("priya");
        assertThat(updated).bodyJson().extractingPath("$.lastModifiedBy").isEqualTo("arjun");
    }

    @Test
    void unknownProductReturns404() {
        assertThat(mvc.get().uri("/api/products/999").with(httpBasic("priya", "priya-pass")))
                .hasStatus(HttpStatus.NOT_FOUND);
    }
}
Console
[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0 -- in com.javabytechie.shop.product.ProductAuditingTest
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0 -- in com.javabytechie.shop.product.ProductApiAuditTest
[INFO] Tests run: 7, Failures: 0, Errors: 0, Skipped: 0

9. When to Use JPA Auditing

Good fitPoor fit
"Who changed this last, and when?" on business records such as products, orders and customersA full history of every change; use Hibernate Envers or an event log
Showing "last updated by" in admin screensData written mostly by bulk SQL or other systems, which bypass entity listeners
Sorting or filtering by creation or update timeLegal audit trails that must be tamper-proof; use database triggers or a separate audit store
Debugging "this price changed and nobody knows why"Tracking reads as well as writes

10. Best Practices

  • Put the audit fields in one @MappedSuperclass with @EntityListeners(AuditingEntityListener.class), and extend it everywhere.
  • Enable auditing in its own configuration class, not on the main application class.
  • Use Instant for audit dates to avoid time-zone mix-ups.
  • Mark created columns updatable = false and all four columns nullable = false.
  • Read the user from the security context, with an explicit fallback like "system" for jobs.
  • Supply the time from a Clock through a DateTimeProvider, so tests can check exact values.
  • Set audit columns yourself in bulk updates, and increment the version column too.
  • Update entities by loading and changing them, not by saving detached copies built from requests.
  • Create the audit columns in your migrations rather than relying on Hibernate's schema generation.
  • Test both the insert and the update, checking that created fields stay unchanged after an update.

11. Summary

Spring Data JPA auditing fills @CreatedBy, @CreatedDate, @LastModifiedBy and @LastModifiedDate automatically, through JPA's insert and update callbacks. Enable it with @EnableJpaAuditing in a separate configuration class. Put the four fields in a @MappedSuperclass with AuditingEntityListener, supply the user with an AuditorAware that reads Spring Security's context, and supply the time with a DateTimeProvider backed by a Clock. Protect the created columns with updatable = false, and remember that bulk updates and plain SQL bypass auditing, so set those columns yourself. The main recommendation: make every entity extend one auditable base class, and test the audit fields with a fixed clock and a known user, both on insert and on update.