Command Palette

Search for a command to run...

[Spring Boot Basics] Auditing với Spring Data JPA: @CreatedDate, @LastModifiedDate và @CreatedBy

Bài 31 đã đưa schema của catalogue vào Flyway migration với spring.jpa.hibernate.ddl-auto=validate. Một table catalogue còn phải trả lời hai câu hỏi mà ticket hỗ trợ nào cũng bắt đầu bằng: row này được tạo hoặc sửa lần cuối lúc nào, và bởi ai. Bốn column giữ câu trả lời, created_at, updated_at, created_byupdated_by, và Spring Data JPA có thể tự điền chúng ở mỗi INSERT và UPDATE thông qua một entity listener, không cần dòng code nào trong service.

Bài này thêm bốn column đó vào Product của bài 26, rồi chạy thử từng khẳng định thường đi kèm tính năng: kiểu timestamp nào dùng được, mỗi field đổi khi nào, modifyOnCreate thay đổi gì, một detached entity xóa mất created_at ra sao, tên người dùng lấy từ đâu khi chưa có Spring Security, và statement nào không bao giờ đi qua listener. Các ví dụ dùng Spring Boot 4.1.1 và Java 21 với PostgreSQL 18 chạy trong Docker, trên một project Initializr có các dependency web, validation, data-jpa, h2, postgresqlflyway. Ứng dụng chạy ở port 8132 thay vì 8080 mặc định. JVM chạy với time zone Asia/Ho_Chi_Minh (UTC+7), còn các session psql dùng time zone mặc định của PostgreSQL là Etc/UTC; chi tiết này quan trọng ở phần nói về kiểu dữ liệu.

Một row của products với created_at, updated_at, created_by và updated_by đã được điền, một chiếc đồng hồ chỉ vào hai timestamp và một hình người chỉ vào hai column người dùng

Nửa đầu cấu hình auditing và theo dõi từng field thay đổi; nửa sau đi qua các cái bẫy, các lựa chọn thay thế và API. Các lần chạy lab chỉ in tên ngắn của logger và nội dung message (logging.pattern.console=%logger{0}: %msg%n); các lần chạy HTTP dùng log pattern mặc định của Boot.

Vì sao cần column audit, và điều gì hỏng khi service tự gán

Column audit trả lời những câu hỏi vận hành mà dữ liệu nghiệp vụ không trả lời được: giá đổi lần cuối lúc nào, một lần import đã chạm vào những row nào, ai đã tạo ra một sản phẩm lẽ ra không tồn tại. Chúng chỉ giữ lần thay đổi gần nhất, không phải lịch sử; nhật ký thay đổi đầy đủ là một công cụ khác, Hibernate Envers, thuộc khóa Advanced.

Cách trực tiếp nhất là gán các field trong service. Một Product rút gọn với hai field Instant có setter, và một service chỉ nhớ gán chúng ở hai trong ba method:

src/main/java/com/example/demo/product/ProductService.java
@Service
public class ProductService {
 
    private final ProductRepository repository;
 
    public ProductService(ProductRepository repository) {
        this.repository = repository;
    }
 
    @Transactional
    public Product create(Product product) {
        Instant now = Instant.now();
        product.setCreatedAt(now);
        product.setUpdatedAt(now);
        return repository.save(product);
    }
 
    @Transactional
    public Product changePrice(Long id, BigDecimal price) {
        Product product = repository.findById(id).orElseThrow();
        product.setPrice(price);
        product.setUpdatedAt(Instant.now());
        return product;
    }
 
    @Transactional
    public Product restock(Long id, int quantity) {
        Product product = repository.findById(id).orElseThrow();
        product.setStock(product.getStock() + quantity);
        return product;
    }
}

Một runner gọi lần lượt ba method, nghỉ một chút giữa mỗi lần. Trên PostgreSQL, đã bỏ các dòng SELECT và bind:

Text
ProbeRunner: -- create
SQL: insert into products (created_at,name,price,sku,stock,updated_at) values (?,?,?,?,?,?)
ProbeRunner: Product[id=1, sku=KB-01, price=89.90, stock=25, createdAt=2026-09-13T10:41:47.101087Z, updatedAt=2026-09-13T10:41:47.101087Z]
ProbeRunner: -- changePrice
SQL: update products set created_at=?,name=?,price=?,sku=?,stock=?,updated_at=? where id=?
ProbeRunner: Product[id=1, sku=KB-01, price=84.90, stock=25, createdAt=2026-09-13T10:41:47.101087Z, updatedAt=2026-09-13T10:41:48.663469Z]
ProbeRunner: -- restock
SQL: update products set created_at=?,name=?,price=?,sku=?,stock=?,updated_at=? where id=?
ProbeRunner: Product[id=1, sku=KB-01, price=84.90, stock=35, createdAt=2026-09-13T10:41:47.101087Z, updatedAt=2026-09-13T10:41:48.663469Z]
Text
 id |  sku  | price | stock |          created_at           |          updated_at
----+-------+-------+-------+-------------------------------+-------------------------------
  1 | KB-01 | 84.90 |    35 | 2026-09-13 10:41:47.101087+00 | 2026-09-13 10:41:48.663469+00
(1 row)

Stock đã tăng từ 25 lên 35, nhưng updated_at vẫn khẳng định không có gì đổi kể từ 10:41:48. Không có lỗi, không có cảnh báo; method thứ ba chỉ đơn giản là được viết thiếu một dòng. Mỗi đường ghi dữ liệu mới, một lần import, một scheduled job, một service thứ hai, đều phải nhớ gán cả hai field, và không có gì kiểm tra việc đó.

Cấu hình JPA auditing trong Spring Boot

Không cần thêm dependency. @CreatedDate, @LastModifiedDate, @CreatedBy@LastModifiedBy nằm trong org.springframework.data.annotation của spring-data-commons 4.1.1, còn @EnableJpaAuditingAuditingEntityListener nằm trong spring-data-jpa 4.1.1, cả hai đã có sẵn trên classpath nhờ spring-boot-starter-data-jpa. Cần ba thứ: bật auditing, gắn listener vào entity, và đánh annotation cho các field.

@EnableJpaAuditing trong một class cấu hình

src/main/java/com/example/demo/common/AuditingConfig.java
package com.example.demo.common;
 
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
 
@Configuration
@EnableJpaAuditing
class AuditingConfig {
}

@EnableJpaAuditing đăng ký một bean AuditingHandler tên là jpaAuditingHandler cùng bean definition của AuditingEntityListener nhận bean đó. Bốn attribute của nó, đọc bằng javap từ jar 4.1.1, là auditorAwareRef, setDates, modifyOnCreatedateTimeProviderRef; các phần sau dùng ba attribute cuối. Class này nằm trong common, nơi các bean nó cần sẽ được thêm vào sau, thay vì đặt annotation lên DemoApplication.

AuditableEntity: @MappedSuperclass kèm @EntityListeners

src/main/java/com/example/demo/common/AuditableEntity.java
package com.example.demo.common;
 
import java.time.Instant;
 
import jakarta.persistence.Column;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.MappedSuperclass;
 
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;
 
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class AuditableEntity {
 
    @CreatedDate
    @Column(nullable = false)
    private Instant createdAt;
 
    @LastModifiedDate
    @Column(nullable = false)
    private Instant updatedAt;
 
    @CreatedBy
    @Column(length = 64)
    private String createdBy;
 
    @LastModifiedBy
    @Column(length = 64)
    private String updatedBy;
 
    public Instant getCreatedAt() { return createdAt; }
 
    public Instant getUpdatedAt() { return updatedAt; }
 
    public String getCreatedBy() { return createdBy; }
 
    public String getUpdatedBy() { return updatedBy; }
}
  • @MappedSuperclass map các field này vào table của mọi entity kế thừa class. Bản thân nó không có table riêng.
  • @EntityListeners(AuditingEntityListener.class) khiến JPA gọi listener quanh mỗi lần ghi. Trong jar 4.1.1, method touchForCreate của nó mang @PrePersisttouchForUpdate mang @PreUpdate.
  • Bốn annotation đánh dấu những gì cần điền: hai timestamp và hai tên người dùng. Các field có getter mà không có setter, vì không code nào của ứng dụng nên ghi vào chúng.
  • @Column không đặt tên nào: createdAt thành created_at nhờ naming strategy của Boot, như SQL bên dưới cho thấy. Với ddl-auto=validate các attribute không sinh DDL; chúng ghi lại những gì migration tạo ra.

Product kế thừa class này và in thêm các field mới:

src/main/java/com/example/demo/product/Product.java
package com.example.demo.product;
 
import java.math.BigDecimal;
 
import com.example.demo.common.AuditableEntity; 
 
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
 
@Entity
@Table(name = "products")
public class Product { 
public class Product extends AuditableEntity { 
 
    // fields, constructors, getters, setters, equals and hashCode as in article 26
 
    @Override
    public String toString() {
        return "Product[id=" + id + ", sku=" + sku + ", price=" + price + ", stock=" + stock + ", status=" + status + "]"; 
        return "Product[id=" + id + ", sku=" + sku + ", price=" + price + ", stock=" + stock 
                + ", createdAt=" + getCreatedAt() + ", updatedAt=" + getUpdatedAt() 
                + ", createdBy=" + getCreatedBy() + ", updatedBy=" + getUpdatedBy() + "]"; 
    }
}

Project còn có một bean AuditorAware, nguồn của created_by, được trình bày ở phần riêng bên dưới. Bên ngoài một web request nó không trả về gì, nên các lần chạy lab để trống cả hai column người dùng.

Flyway migration cho một table đã có dữ liệu

Entity giờ map bốn column mà table chưa có, và validate không cho ứng dụng khởi động cho đến khi chúng tồn tại. Profile postgres:

src/main/resources/application-postgres.properties
spring.datasource.url=jdbc:postgresql://localhost:55432/catalog
spring.datasource.username=catalog
spring.datasource.password=catalog
spring.jpa.hibernate.ddl-auto=validate

Trong project của bài này, products được tạo bởi V1__create_products_table.sql và đã có ba row:

Text
 id |  sku  | price | stock
----+-------+-------+-------
  1 | KB-01 | 89.90 |    25
  2 | KB-02 | 59.00 |    12
  3 | MS-01 | 24.50 |     3
(3 rows)

Migration lấy version trống kế tiếp: V2 trong project của bài này, và V6__add_audit_columns_to_products.sql trong project của series, sau V5__add_product_description.sql của bài 31; table products ở project đó đã có column title của bài 31 thay cho name, còn các column audit không đụng tới nó. Lần thử đầu tiên, cách viết hiển nhiên nhất:

src/main/resources/db/migration/V2__add_audit_columns_to_products.sql
alter table products
    add column created_at timestamp with time zone not null,
    add column updated_at timestamp with time zone not null,
    add column created_by varchar(64),
    add column updated_by varchar(64);
Bash
./gradlew bootJar
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --spring.profiles.active=postgres --spring.main.web-application-type=none
Text
2026-09-13T17:33:59.179+07:00  INFO 43166 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Current version of schema "public": 1
2026-09-13T17:33:59.184+07:00  INFO 43166 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Migrating schema "public" to version "2 - add audit columns to products"
2026-09-13T17:33:59.193+07:00 ERROR 43166 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Migration of schema "public" to version "2 - add audit columns to products" failed! Changes successfully rolled back.
...
Caused by: org.flywaydb.core.internal.exception.FlywayMigrateException: Failed to execute script V2__add_audit_columns_to_products.sql
SQL State  : 23502
Error Code : 0
Message    : ERROR: column "created_at" of relation "products" contains null values
Location   : db/migration/V2__add_audit_columns_to_products.sql (...)
Line       : 1
Statement  : Run Flyway with -X option to see the actual statement causing the problem

PostgreSQL phải cho ba row có sẵn một giá trị ở column mới, và một column NOT NULL không có default thì không có giá trị nào để cho. SQL State 23502 là lỗi vi phạm not-null. PostgreSQL chạy DDL bên trong transaction, nên "Changes successfully rolled back" đúng theo nghĩa đen: table không đổi, và bảng lịch sử vẫn chỉ có V1.

Bash
docker exec sb-a32-pg psql -U catalog -d catalog -c "select installed_rank, version, description, success from flyway_schema_history"
Text
 installed_rank | version |      description      | success
----------------+---------+-----------------------+---------
              1 | 1       | create products table | t
(1 row)

Một default sẽ điền cho các row có sẵn ngay lúc thêm column:

src/main/resources/db/migration/V2__add_audit_columns_to_products.sql
alter table products
    add column created_at timestamp with time zone not null, 
    add column updated_at timestamp with time zone not null, 
    add column created_at timestamp with time zone not null default now(), 
    add column updated_at timestamp with time zone not null default now(), 
    add column created_by varchar(64),
    add column updated_by varchar(64);

Cùng lệnh đó, sau khi build lại:

Text
2026-09-13T17:35:23.158+07:00  INFO 43428 --- [demo] [           main] o.f.core.internal.command.DbValidate     : Successfully validated 2 migrations (execution time 00:00.011s)
2026-09-13T17:35:23.185+07:00  INFO 43428 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Current version of schema "public": 1
2026-09-13T17:35:23.191+07:00  INFO 43428 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Migrating schema "public" to version "2 - add audit columns to products"
2026-09-13T17:35:23.208+07:00  INFO 43428 --- [demo] [           main] o.f.core.internal.command.DbMigrate      : Successfully applied 1 migration to schema "public", now at version v2 (execution time 00:00.006s)
2026-09-13T17:35:24.240+07:00  INFO 43428 --- [demo] [           main] com.example.demo.DemoApplication         : Started DemoApplication in 1.827 seconds (process running for 2.035)

Started DemoApplication nghĩa là bước kiểm tra schema của Hibernate đã qua: timestamp with time zone được chấp nhận cho field Instantvarchar(64) cho String. Table và các row của nó:

Bash
docker exec sb-a32-pg psql -U catalog -d catalog -c "\d products" -c "select id, sku, created_at, updated_at, created_by, updated_by from products order by id"
Text
                                     Table "public.products"
   Column   |           Type           | Collation | Nullable |             Default
------------+--------------------------+-----------+----------+----------------------------------
 id         | bigint                   |           | not null | generated by default as identity
 name       | character varying(120)   |           | not null |
 sku        | character varying(40)    |           | not null |
 price      | numeric(10,2)            |           | not null |
 stock      | integer                  |           | not null |
 category   | character varying(60)    |           | not null |
 status     | character varying(20)    |           | not null |
 created_at | timestamp with time zone |           | not null | now()
 updated_at | timestamp with time zone |           | not null | now()
 created_by | character varying(64)    |           |          |
 updated_by | character varying(64)    |           |          |
Indexes:
    "products_pkey" PRIMARY KEY, btree (id)
    "products_sku_key" UNIQUE CONSTRAINT, btree (sku)
 
 id |  sku  |          created_at           |          updated_at           | created_by | updated_by
----+-------+-------------------------------+-------------------------------+------------+------------
  1 | KB-01 | 2026-09-13 10:35:23.188558+00 | 2026-09-13 10:35:23.188558+00 |            |
  2 | KB-02 | 2026-09-13 10:35:23.188558+00 | 2026-09-13 10:35:23.188558+00 |            |
  3 | MS-01 | 2026-09-13 10:35:23.188558+00 | 2026-09-13 10:35:23.188558+00 |            |
(3 rows)

Cả ba row nhận cùng một giá trị ở cả hai column: now() trả về thời điểm bắt đầu của transaction hiện tại, và toàn bộ migration chạy trong một transaction. Với các row cũ, giá trị đó là thời điểm chạy migration chứ không phải thời điểm tạo thật, vốn chưa ai ghi lại. Các column người dùng vẫn cho phép null, vì row cũ không có tác giả và không phải lần ghi nào cũng đến từ một request. Default vẫn được giữ trên các column timestamp; nó chỉ có tác dụng khi một INSERT bỏ qua column đó, mà Hibernate thì không bao giờ bỏ qua, như phần tiếp theo cho thấy.

INSERT đầu tiên và listener chạy ở đâu

Các lần chạy lab dùng một ApplicationRunner nằm sau profile lab, khởi động không có web server, chạy các bước được liệt kê trong --lab.steps, cùng một bean chứa các bước cần transaction:

src/main/java/com/example/demo/lab/AuditLab.java
package com.example.demo.lab;
 
import java.math.BigDecimal;
import java.time.ZoneId;
import java.util.List;
 
import com.example.demo.product.CreateProductRequest;
import com.example.demo.product.Product;
import com.example.demo.product.ProductRepository;
import com.example.demo.product.ProductService;
 
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
 
@Component
@Profile("lab")
class AuditLab implements ApplicationRunner {
 
    private static final Logger log = LoggerFactory.getLogger(AuditLab.class);
 
    private final ProductRepository repository;
    private final ProductService service;
    private final LabTransactions tx;
    private final List<String> steps;
 
    AuditLab(ProductRepository repository, ProductService service, LabTransactions tx,
             @Value("${lab.steps}") List<String> steps) {
        this.repository = repository;
        this.service = service;
        this.tx = tx;
        this.steps = steps;
    }
 
    @Override
    public void run(ApplicationArguments args) {
        log.info("JVM time zone: {}", ZoneId.systemDefault());
        for (String step : steps) {
            log.info("-- {}", step);
            try {
                runStep(step);
            } catch (RuntimeException e) {
                for (Throwable t = e; t != null; t = t.getCause()) {
                    log.info("!!! {}: {}", t.getClass().getName(), t.getMessage());
                }
            }
        }
    }
 
    private void runStep(String step) {
        switch (step) {
            case "create" -> {
                Product saved = repository.save(new Product("USB-C hub", "HUB-01", new BigDecimal("39.00"), 10, "Accessories"));
                log.info("returned {}", saved);
            }
            case "create2" -> {
                Product saved = repository.save(new Product("Laptop stand", "AC-02", new BigDecimal("34.90"), 15, "Accessories"));
                log.info("returned {}", saved);
            }
            case "update" -> log.info("returned {}", tx.changePrice("KB-01", "84.90"));
            case "noop" -> log.info("returned {}", tx.readOnly("KB-01"));
            case "saveall" -> {
                List<Product> keyboards = repository.findAll().stream()
                        .filter(p -> p.getCategory().equals("Keyboards")).toList();
                keyboards.forEach(p -> p.setStock(p.getStock() + 1));
                repository.saveAll(keyboards).forEach(p -> log.info("returned {}", p));
            }
            case "bulk" -> log.info("changePrices -> {} rows", service.changePrices("Keyboards", new BigDecimal("1.10")));
            case "native" -> log.info("restock -> {} rows", service.restock("MS-01", 5));
            case "replace" -> {
                Long id = repository.findBySku("HUB-01").orElseThrow().getId();
                log.info("returned {}", service.replace(id, new CreateProductRequest("USB-C hub, 7 ports", "HUB-01",
                        new BigDecimal("42.00"), 8, "Accessories")));
            }
            default -> throw new IllegalArgumentException("unknown step " + step);
        }
    }
}
src/main/java/com/example/demo/lab/LabTransactions.java
package com.example.demo.lab;
 
import java.math.BigDecimal;
 
import com.example.demo.product.Product;
import com.example.demo.product.ProductRepository;
 
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
 
@Component
@Profile("lab")
public class LabTransactions {
 
    private static final Logger log = LoggerFactory.getLogger(LabTransactions.class);
 
    private final ProductRepository repository;
 
    LabTransactions(ProductRepository repository) {
        this.repository = repository;
    }
 
    @Transactional
    public Product changePrice(String sku, String price) {
        Product product = repository.findBySku(sku).orElseThrow();
        product.setPrice(new BigDecimal(price));
        log.info("price set to {}, save() not called, leaving the method", price);
        return product;
    }
 
    @Transactional
    public Product readOnly(String sku) {
        Product product = repository.findBySku(sku).orElseThrow();
        log.info("loaded {}, nothing changed, leaving the method", product.getSku());
        return product;
    }
}

Để thấy ai gọi listener, getCurrentAuditor được thêm tạm một dòng đầu tiên, new Exception("getCurrentAuditor called from").printStackTrace(System.out), bật bằng một system property:

Bash
java -Dlab.trace=true -jar build/libs/demo-0.0.1-SNAPSHOT.jar --spring.profiles.active=postgres,lab --spring.main.web-application-type=none --lab.steps=create

Trên PostgreSQL, stack trace đã được cắt còn các frame quan trọng:

Text
AuditLab: JVM time zone: Asia/Ho_Chi_Minh
AuditLab: -- create
java.lang.Exception: getCurrentAuditor called from
	at com.example.demo.common.RequestHeaderAuditorAware.getCurrentAuditor(RequestHeaderAuditorAware.java:17)
	at org.springframework.data.auditing.AuditingHandler.getAuditor(AuditingHandler.java:103)
	at org.springframework.data.auditing.AuditingHandler.markCreated(AuditingHandler.java:86)
	at org.springframework.data.jpa.domain.support.AuditingEntityListener.touchForCreate(AuditingEntityListener.java:93)
	at org.hibernate.jpa.event.internal.ListenerCallback.performCallback(ListenerCallback.java:56)
	at org.hibernate.jpa.event.internal.CallbackRegistryImpl.callback(CallbackRegistryImpl.java:117)
	at org.hibernate.jpa.event.internal.CallbackRegistryImpl.preCreate(CallbackRegistryImpl.java:66)
	at org.hibernate.event.internal.AbstractSaveEventListener.performSave(AbstractSaveEventListener.java:206)
	...
	at org.hibernate.internal.SessionImpl.persist(SessionImpl.java:692)
	...
	at org.springframework.data.jpa.repository.support.SimpleJpaRepository.save(SimpleJpaRepository.java:664)
	...
	at com.example.demo.lab.AuditLab.runStep(AuditLab.java:57)
SQL: insert into products (category,created_at,created_by,name,price,sku,status,stock,updated_at,updated_by) values (?,?,?,?,?,?,?,?,?,?)
bind: binding parameter (1:VARCHAR) <- [Accessories]
bind: binding parameter (2:TIMESTAMP_UTC) <- [2026-09-13T10:36:02.071310Z]
bind: binding parameter (3:VARCHAR) <- [null]
bind: binding parameter (4:VARCHAR) <- [USB-C hub]
bind: binding parameter (5:NUMERIC) <- [39.00]
bind: binding parameter (6:VARCHAR) <- [HUB-01]
bind: binding parameter (7:VARCHAR) <- [ACTIVE]
bind: binding parameter (8:INTEGER) <- [10]
bind: binding parameter (9:TIMESTAMP_UTC) <- [2026-09-13T10:36:02.071310Z]
bind: binding parameter (10:VARCHAR) <- [null]
AuditLab: returned Product[id=4, sku=HUB-01, price=39.00, stock=10, createdAt=2026-09-13T10:36:02.071310Z, updatedAt=2026-09-13T10:36:02.071310Z, createdBy=null, updatedBy=null]

Đọc từ dưới lên: save trên một entity mới gọi SessionImpl.persist, Hibernate chạy các callback @PrePersist của entity qua CallbackRegistryImpl.preCreate, method này gọi AuditingEntityListener.touchForCreate, và nó gọi tiếp AuditingHandler.markCreated. Handler hỏi AuditorAware về người dùng trước, rồi mới hỏi DateTimeProvider của nó về thời gian: trong bytecode của AuditingHandlerSupport, touchAuditor chạy trước touchDate, và touchDate gọi getNow() đúng một lần rồi gán cả hai timestamp từ giá trị đó. Chỉ sau khi các field đã được gán, Hibernate mới dựng câu INSERT, nên statement đã mang sẵn chúng: cùng giá trị 2026-09-13T10:36:02.071310Zcreated_atupdated_at, và null ở cả hai column người dùng. Mọi column audit đều có trong danh sách column, vì vậy default now() của migration không bao giờ áp dụng cho các row do ứng dụng ghi.

AuditingEntityListener điền các column audit như thế nào: ở nhánh INSERT là repository.save, EntityManager.persist và callback @PrePersist, ở nhánh UPDATE là một field bị sửa hoặc merge, lần flush lúc commit thấy entity dirty và callback @PreUpdate, cả hai cùng đi vào AuditingEntityListener, rồi AuditingHandler, AuditorAware trước và DateTimeProvider sau, các field được gán trên entity, và câu INSERT, UPDATE mang theo alice, bob cùng các timestamp đã bind

Chuyện gì xảy ra khi thiếu @EnableJpaAuditing hoặc entity listener?

Chạy thêm hai lần bước create2, một chiếc giá đỡ laptop có SKU AC-02: một lần đã xóa dòng @EnableJpaAuditing khỏi AuditingConfig, một lần đã xóa dòng @EntityListeners khỏi AuditableEntity. Cả hai ứng dụng đều khởi động mà log không có cảnh báo nào, và lần chạy đầu thất bại ở câu INSERT:

Text
AuditLab: -- create2
SQL: insert into products (category,created_at,created_by,name,price,sku,status,stock,updated_at,updated_by) values (?,?,?,?,?,?,?,?,?,?)
bind: binding parameter (1:VARCHAR) <- [Accessories]
bind: binding parameter (2:TIMESTAMP_UTC) <- [null]
bind: binding parameter (3:VARCHAR) <- [null]
bind: binding parameter (4:VARCHAR) <- [Laptop stand]
bind: binding parameter (5:NUMERIC) <- [34.90]
bind: binding parameter (6:VARCHAR) <- [AC-02]
bind: binding parameter (7:VARCHAR) <- [ACTIVE]
bind: binding parameter (8:INTEGER) <- [15]
bind: binding parameter (9:TIMESTAMP_UTC) <- [null]
bind: binding parameter (10:VARCHAR) <- [null]
error: HHH000247: ErrorCode: 0, SQLState: 23502
error: ERROR: null value in column "created_at" of relation "products" violates not-null constraint
  Detail: Failing row contains (6, Laptop stand, AC-02, 34.90, 15, Accessories, ACTIVE, null, null, null, null).
AuditLab: !!! org.springframework.dao.DataIntegrityViolationException: could not execute statement [ERROR: null value in column "created_at" of relation "products" violates not-null constraint
  Detail: Failing row contains (6, Laptop stand, AC-02, 34.90, 15, Accessories, ACTIVE, null, null, null, null).] [insert into products (category,created_at,created_by,name,price,sku,status,stock,updated_at,updated_by) values (?,?,?,?,?,?,?,?,?,?)]; SQL [insert into products (category,created_at,created_by,name,price,sku,status,stock,updated_at,updated_by) values (?,?,?,?,?,?,?,?,?,?)]; constraint [created_at]
AuditLab: !!! org.hibernate.exception.ConstraintViolationException: could not execute statement [ERROR: null value in column "created_at" of relation "products" violates not-null constraint
  Detail: Failing row contains (6, Laptop stand, AC-02, 34.90, 15, Accessories, ACTIVE, null, null, null, null).] [insert into products (category,created_at,created_by,name,price,sku,status,stock,updated_at,updated_by) values (?,?,?,?,?,?,?,?,?,?)]
AuditLab: !!! org.postgresql.util.PSQLException: ERROR: null value in column "created_at" of relation "products" violates not-null constraint
  Detail: Failing row contains (6, Laptop stand, AC-02, 34.90, 15, Accessories, ACTIVE, null, null, null, null).

Lần chạy không có @EntityListeners in ra đúng các giá trị bind đó và đúng ba exception đó, chỉ khác row lỗi mang id 7. Không lỗi nào tự báo ra. Thiếu @EnableJpaAuditing thì không có handler; thiếu listener thì Hibernate không bao giờ gọi handler; trường hợp nào bốn field cũng giữ null và Hibernate bind bốn giá trị null. Chính constraint NOT NULL trên created_at đã chặn statement, và default now() của column không giúp được gì vì câu INSERT gửi null một cách tường minh. Với các column không có constraint, bốn giá trị null đó là thứ sẽ được lưu. Hãy giữ NOT NULL trên các timestamp: nó biến một cấu hình thiếu thành lỗi ngay ở lần ghi đầu tiên, thay vì những row không có ngày tháng.

Instant, LocalDateTime hay OffsetDateTime cho @CreatedDate?

Câu hỏi về kiểu cần ba entity chỉ khác nhau ở kiểu của hai field, nên nó được chạy trong một project probe riêng, trên database tên probes, với ddl-auto=create. Cấu hình probe chọn DateTimeProvider theo một property; default trả về CurrentDateTimeProvider.INSTANCE, chính provider mà AuditingHandler dùng khi không cấu hình gì:

src/main/java/com/example/demo/probe/ProbeConfig.java
package com.example.demo.probe;
 
import java.time.Instant;
import java.time.OffsetDateTime;
import java.util.Optional;
 
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.auditing.CurrentDateTimeProvider;
import org.springframework.data.auditing.DateTimeProvider;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
 
@Configuration
@EnableJpaAuditing(dateTimeProviderRef = "labDateTimeProvider")
class ProbeConfig {
 
    @Bean
    DateTimeProvider labDateTimeProvider(@Value("${lab.provider:default}") String mode) {
        return switch (mode) {
            case "default" -> CurrentDateTimeProvider.INSTANCE;
            case "instant" -> () -> Optional.of(Instant.now());
            case "offset" -> () -> Optional.of(OffsetDateTime.now());
            default -> throw new IllegalArgumentException(mode);
        };
    }
}
src/main/java/com/example/demo/probe/Entities.java
    @Entity
    @Table(name = "instant_probe")
    @EntityListeners(AuditingEntityListener.class)
    public static class InstantProbe implements Stamped {
        @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;
        private String name;
        @CreatedDate private Instant createdAt;
        @LastModifiedDate private Instant updatedAt;
        // getId, setId, setName and toString
    }

LocalDateTimeProbeOffsetDateTimeProbe giống hệt, chỉ khác kiểu. DDL mà Hibernate sinh cho chúng trên PostgreSQL:

Text
SQL: create table instant_probe (created_at timestamp(6) with time zone, id bigint generated by default as identity, updated_at timestamp(6) with time zone, name varchar(255), primary key (id))
SQL: create table local_date_time_probe (created_at timestamp(6), id bigint generated by default as identity, updated_at timestamp(6), name varchar(255), primary key (id))
SQL: create table offset_date_time_probe (created_at timestamp(6) with time zone, id bigint generated by default as identity, updated_at timestamp(6) with time zone, name varchar(255), primary key (id))

Mỗi probe được persist, rồi một giây sau được load và đổi tên trong transaction thứ hai. Với provider mặc định:

Text
ProbeRunner: -- persist InstantProbe
ProbeRunner: InstantProbe[id=1, name=null, createdAt=2026-09-13T10:41:50.204205Z, updatedAt=2026-09-13T10:41:50.204205Z]
ProbeRunner: -- rename InstantProbe
ProbeRunner: InstantProbe[id=1, name=renamed, createdAt=2026-09-13T10:41:50.204205Z, updatedAt=2026-09-13T10:41:51.423762Z]
ProbeRunner: -- persist LocalDateTimeProbe
ProbeRunner: LocalDateTimeProbe[id=1, name=null, createdAt=2026-09-13T17:41:51.431023, updatedAt=2026-09-13T17:41:51.431023]
ProbeRunner: -- rename LocalDateTimeProbe
ProbeRunner: LocalDateTimeProbe[id=1, name=renamed, createdAt=2026-09-13T17:41:51.431023, updatedAt=2026-09-13T17:41:52.647257]
ProbeRunner: -- persist OffsetDateTimeProbe
ProbeRunner: !!! java.lang.IllegalArgumentException: Cannot convert unsupported date type java.time.LocalDateTime to java.time.OffsetDateTime; Supported types are [java.time.LocalDateTime, java.time.LocalDate, java.time.LocalTime, java.time.Instant, java.util.Date, java.lang.Long, long]

CurrentDateTimeProvider.getNow() trả về LocalDateTime.now(), như bytecode của nó cho thấy. Handler chuyển giá trị đó sang kiểu của field bằng một ConversionService dựng từ DefaultFormattingConversionService của Spring cộng với Jsr310Converters của Spring Data. InstantLocalDateTime có trong danh sách được hỗ trợ; OffsetDateTime thì không, và lệnh save thất bại trước khi có SQL nào được gửi đi.

PostgreSQL lưu gì trong timestamp with time zone và timestamp

Bash
docker exec sb-a32-pg psql -U catalog -d probes -c "show timezone" -c "select * from instant_probe" -c "select * from local_date_time_probe"
Text
 TimeZone
----------
 Etc/UTC
(1 row)
 
          created_at           | id |          updated_at           |  name
-------------------------------+----+-------------------------------+---------
 2026-09-13 10:41:50.204205+00 |  1 | 2026-09-13 10:41:51.423762+00 | renamed
(1 row)
 
         created_at         | id |         updated_at         |  name
----------------------------+----+----------------------------+---------
 2026-09-13 17:41:51.431023 |  1 | 2026-09-13 17:41:52.647257 | renamed
(1 row)
  • Instant trong timestamp with time zone là một thời điểm tuyệt đối. JVM chạy ở UTC+7, psql chạy ở UTC, và giá trị đọc ra là 10:41:50+00, đúng thời điểm row được ghi, dù ai đọc.
  • LocalDateTime trong timestamp giữ giờ đồng hồ của JVM, 17:41:51, không kèm time zone. Việc chuyển từ LocalDateTime.now() đã dùng time zone của JVM; row không ghi lại điều đó, nên người đọc nào cũng phải biết nơi ghi chạy ở time zone nào.
  • Session JDBC không phải session psql. Một lỗi PostgreSQL in ra ở phần sau của bài hiển thị một giá trị timestamp with time zone2026-09-13 17:37:28.930979+07, tức time zone của JVM, trong khi psql hiển thị cùng loại giá trị ở +00. Thời điểm được lưu là một; chỉ cách hiển thị khác.

OffsetDateTime và DateTimeProvider

Với một provider trả về Instant.now(), InstantLocalDateTime lại chạy được, còn probe thứ ba thất bại với một kiểu nguồn khác:

Text
ProbeRunner: !!! java.lang.IllegalArgumentException: Cannot convert unsupported date type java.time.Instant to java.time.OffsetDateTime; Supported types are [java.time.LocalDateTime, java.time.LocalDate, java.time.LocalTime, java.time.Instant, java.util.Date, java.lang.Long, long]

Chỉ khi provider trả về OffsetDateTime.now() thì cả ba mới chạy được:

Text
ProbeRunner: -- persist OffsetDateTimeProbe
ProbeRunner: OffsetDateTimeProbe[id=1, name=null, createdAt=2026-09-13T17:42:01.218922+07:00, updatedAt=2026-09-13T17:42:01.218922+07:00]
ProbeRunner: -- rename OffsetDateTimeProbe
ProbeRunner: OffsetDateTimeProbe[id=1, name=renamed, createdAt=2026-09-13T10:42:01.218922Z, updatedAt=2026-09-13T17:42:02.438637+07:00]
Text
          created_at           | id |          updated_at           |  name
-------------------------------+----+-------------------------------+---------
 2026-09-13 10:42:01.218922+00 |  1 | 2026-09-13 10:42:02.438637+00 | renamed
(1 row)

Offset không được lưu lại: +07:00 do provider gán quay về từ database thành Z trong createdAt, giá trị mà Hibernate đã load để đổi tên.

Kiểu fieldColumn trên PostgreSQLProvider mặc địnhProvider trả về InstantProvider trả về OffsetDateTimeGiá trị được lưu
Instanttimestamp(6) with time zonechạy đượcchạy đượcchạy được2026-09-13 10:41:50.204205+00
LocalDateTimetimestamp(6)chạy đượcchạy đượcchạy được2026-09-13 17:41:51.431023, giờ đồng hồ của JVM
OffsetDateTimetimestamp(6) with time zoneIllegalArgumentExceptionIllegalArgumentExceptionchạy được2026-09-13 10:42:01.218922+00, mất offset

Series dùng Instant: nó chạy được với provider mặc định lẫn provider dựa trên Clock ở phần sau, và column của nó lưu một thời điểm không thể hiểu sai.

@CreatedDate và @LastModifiedDate thay đổi khi nào?

Quay lại project chính trên catalog. Các lần chạy update, noop, saveall, bulknative nối tiếp nhau, nên mỗi output psql bên dưới là trạng thái mà lần chạy kế tiếp bắt đầu.

Khi insert, và modifyOnCreate = false

Câu INSERT đầu tiên ở trên bind 2026-09-13T10:36:02.071310Z vào cả created_at lẫn updated_at. Với mặc định modifyOnCreate = true, handler gán cả các field last-modified khi tạo mới, từ cùng một giá trị. Đổi thành false:

src/main/java/com/example/demo/common/AuditingConfig.java
@Configuration
@EnableJpaAuditing
@EnableJpaAuditing(modifyOnCreate = false) 
class AuditingConfig {
}

Bước create2, trên PostgreSQL:

Text
AuditLab: -- create2
SQL: insert into products (category,created_at,created_by,name,price,sku,status,stock,updated_at,updated_by) values (?,?,?,?,?,?,?,?,?,?)
bind: binding parameter (1:VARCHAR) <- [Accessories]
bind: binding parameter (2:TIMESTAMP_UTC) <- [2026-09-13T10:40:52.354547Z]
bind: binding parameter (3:VARCHAR) <- [null]
bind: binding parameter (4:VARCHAR) <- [Laptop stand]
bind: binding parameter (5:NUMERIC) <- [34.90]
bind: binding parameter (6:VARCHAR) <- [AC-02]
bind: binding parameter (7:VARCHAR) <- [ACTIVE]
bind: binding parameter (8:INTEGER) <- [15]
bind: binding parameter (9:TIMESTAMP_UTC) <- [null]
bind: binding parameter (10:VARCHAR) <- [null]
error: HHH000247: ErrorCode: 0, SQLState: 23502
error: ERROR: null value in column "updated_at" of relation "products" violates not-null constraint
  Detail: Failing row contains (8, Laptop stand, AC-02, 34.90, 15, Accessories, ACTIVE, 2026-09-13 17:40:52.354547+07, null, null, null).
AuditLab: !!! org.springframework.dao.DataIntegrityViolationException: could not execute statement [ERROR: null value in column "updated_at" of relation "products" violates not-null constraint

created_at được gán, còn updated_at bị bind null, và column NOT NULL từ chối nó. modifyOnCreate = false nghĩa là "chưa từng sửa" được lưu thành null, nên nó cần một updated_at cho phép null, và mọi query sắp xếp theo column này phải xử lý null. Phần còn lại của bài giữ giá trị mặc định.

Khi update qua dirty checking

Bước update gọi LabTransactions.changePrice, method này load KB-01 và đổi giá mà không gọi save. Bật lại stack trace:

Text
AuditLab: -- update
SQL: select p1_0.id,p1_0.category,p1_0.created_at,p1_0.created_by,p1_0.name,p1_0.price,p1_0.sku,p1_0.status,p1_0.stock,p1_0.updated_at,p1_0.updated_by from products p1_0 where p1_0.sku=?
bind: binding parameter (1:VARCHAR) <- [KB-01]
LabTransactions: price set to 84.90, save() not called, leaving the method
java.lang.Exception: getCurrentAuditor called from
	at com.example.demo.common.RequestHeaderAuditorAware.getCurrentAuditor(RequestHeaderAuditorAware.java:17)
	at org.springframework.data.auditing.AuditingHandler.getAuditor(AuditingHandler.java:103)
	at org.springframework.data.auditing.AuditingHandler.markModified(AuditingHandler.java:98)
	at org.springframework.data.jpa.domain.support.AuditingEntityListener.touchForUpdate(AuditingEntityListener.java:113)
	at org.hibernate.jpa.event.internal.ListenerCallback.performCallback(ListenerCallback.java:56)
	at org.hibernate.jpa.event.internal.CallbackRegistryImpl.callback(CallbackRegistryImpl.java:117)
	at org.hibernate.jpa.event.internal.CallbackRegistryImpl.preUpdate(CallbackRegistryImpl.java:80)
	at org.hibernate.event.internal.DefaultFlushEntityEventListener.invokeInterceptor(DefaultFlushEntityEventListener.java:341)
	at org.hibernate.event.internal.DefaultFlushEntityEventListener.handleInterception(DefaultFlushEntityEventListener.java:325)
	at org.hibernate.event.internal.DefaultFlushEntityEventListener.scheduleUpdate(DefaultFlushEntityEventListener.java:247)
	at org.hibernate.event.internal.DefaultFlushEntityEventListener.onFlushEntity(DefaultFlushEntityEventListener.java:146)
	...
	at org.hibernate.internal.SessionImpl.managedFlush(SessionImpl.java:498)
	at org.hibernate.internal.SessionImpl.flushBeforeTransactionCompletion(SessionImpl.java:2100)
	...
	at org.springframework.orm.jpa.JpaTransactionManager.doCommit(JpaTransactionManager.java:552)
	...
	at com.example.demo.lab.LabTransactions$$SpringCGLIB$$0.changePrice(<generated>)
	at com.example.demo.lab.AuditLab.runStep(AuditLab.java:64)
SQL: update products set category=?,created_at=?,created_by=?,name=?,price=?,sku=?,status=?,stock=?,updated_at=?,updated_by=? where id=?
bind: binding parameter (1:VARCHAR) <- [Keyboards]
bind: binding parameter (2:TIMESTAMP_UTC) <- [2026-09-13T10:35:23.188558Z]
bind: binding parameter (3:VARCHAR) <- [null]
bind: binding parameter (4:VARCHAR) <- [Mechanical keyboard]
bind: binding parameter (5:NUMERIC) <- [84.90]
bind: binding parameter (6:VARCHAR) <- [KB-01]
bind: binding parameter (7:VARCHAR) <- [ACTIVE]
bind: binding parameter (8:INTEGER) <- [25]
bind: binding parameter (9:TIMESTAMP_UTC) <- [2026-09-13T10:36:50.394160Z]
bind: binding parameter (10:VARCHAR) <- [null]
bind: binding parameter (11:BIGINT) <- [1]
AuditLab: returned Product[id=1, sku=KB-01, price=84.90, stock=25, createdAt=2026-09-13T10:35:23.188558Z, updatedAt=2026-09-13T10:36:50.394160Z, createdBy=null, updatedBy=null]
Text
 id |  sku   | price | stock |          created_at           |          updated_at           | created_by | updated_by
----+--------+-------+-------+-------------------------------+-------------------------------+------------+------------
  1 | KB-01  | 84.90 |    25 | 2026-09-13 10:35:23.188558+00 | 2026-09-13 10:36:50.39416+00  |            |
  2 | KB-02  | 59.00 |    12 | 2026-09-13 10:35:23.188558+00 | 2026-09-13 10:35:23.188558+00 |            |
  3 | MS-01  | 24.50 |     3 | 2026-09-13 10:35:23.188558+00 | 2026-09-13 10:35:23.188558+00 |            |
  4 | HUB-01 | 39.00 |    10 | 2026-09-13 10:36:02.07131+00  | 2026-09-13 10:36:02.07131+00  |            |
(4 rows)

Callback không chạy lúc giá được gán. Nó chạy sau khi method đã return, trong lúc Spring commit transaction: JpaTransactionManager.doCommit flush session, DefaultFlushEntityEventListener thấy KB-01 dirty và lên lịch một lần update, và chỉ khi đó @PreUpdate mới được gọi để touchForUpdate gán updatedAt mới. Câu UPDATE liệt kê mọi column, như bài 26 đã cho thấy với dirty checking; created_at được gửi lại với giá trị đã load. Trong table chỉ row 1 thay đổi, và chỉ ở giá cùng updated_at.

Transaction không thay đổi gì

Bước noop load KB-01 trong một transaction rồi trả về mà không sửa gì:

Text
AuditLab: -- noop
SQL: select p1_0.id,p1_0.category,p1_0.created_at,p1_0.created_by,p1_0.name,p1_0.price,p1_0.sku,p1_0.status,p1_0.stock,p1_0.updated_at,p1_0.updated_by from products p1_0 where p1_0.sku=?
bind: binding parameter (1:VARCHAR) <- [KB-01]
LabTransactions: loaded KB-01, nothing changed, leaving the method
AuditLab: returned Product[id=1, sku=KB-01, price=84.90, stock=25, createdAt=2026-09-13T10:35:23.188558Z, updatedAt=2026-09-13T10:36:50.394160Z, createdBy=null, updatedBy=null]

Không có UPDATE, và psql in ra đúng bốn row như trên. Lần flush không thấy gì dirty, nên không có update nào để lên lịch và không có @PreUpdate nào để gọi. Đọc một entity bên trong transaction đọc-ghi không làm updated_at thay đổi.

saveAll với detached entity

Bước saveall load mọi sản phẩm mà không có transaction, cộng thêm một vào stock của mỗi bàn phím rồi truyền danh sách detached vào saveAll:

Text
AuditLab: -- saveall
SQL: select p1_0.id,p1_0.category,p1_0.created_at,p1_0.created_by,p1_0.name,p1_0.price,p1_0.sku,p1_0.status,p1_0.stock,p1_0.updated_at,p1_0.updated_by from products p1_0
SQL: select p1_0.id,p1_0.category,p1_0.created_at,p1_0.created_by,p1_0.name,p1_0.price,p1_0.sku,p1_0.status,p1_0.stock,p1_0.updated_at,p1_0.updated_by from products p1_0 where p1_0.id=?
bind: binding parameter (1:BIGINT) <- [2]
SQL: select p1_0.id,p1_0.category,p1_0.created_at,p1_0.created_by,p1_0.name,p1_0.price,p1_0.sku,p1_0.status,p1_0.stock,p1_0.updated_at,p1_0.updated_by from products p1_0 where p1_0.id=?
bind: binding parameter (1:BIGINT) <- [1]
SQL: update products set category=?,created_at=?,created_by=?,name=?,price=?,sku=?,status=?,stock=?,updated_at=?,updated_by=? where id=?
bind: binding parameter (1:VARCHAR) <- [Keyboards]
bind: binding parameter (2:TIMESTAMP_UTC) <- [2026-09-13T10:35:23.188558Z]
bind: binding parameter (3:VARCHAR) <- [null]
bind: binding parameter (4:VARCHAR) <- [Compact keyboard]
bind: binding parameter (5:NUMERIC) <- [59.00]
bind: binding parameter (6:VARCHAR) <- [KB-02]
bind: binding parameter (7:VARCHAR) <- [ACTIVE]
bind: binding parameter (8:INTEGER) <- [13]
bind: binding parameter (9:TIMESTAMP_UTC) <- [2026-09-13T10:36:55.037375Z]
bind: binding parameter (10:VARCHAR) <- [null]
bind: binding parameter (11:BIGINT) <- [2]
SQL: update products set category=?,created_at=?,created_by=?,name=?,price=?,sku=?,status=?,stock=?,updated_at=?,updated_by=? where id=?
bind: binding parameter (1:VARCHAR) <- [Keyboards]
bind: binding parameter (2:TIMESTAMP_UTC) <- [2026-09-13T10:35:23.188558Z]
bind: binding parameter (3:VARCHAR) <- [null]
bind: binding parameter (4:VARCHAR) <- [Mechanical keyboard]
bind: binding parameter (5:NUMERIC) <- [84.90]
bind: binding parameter (6:VARCHAR) <- [KB-01]
bind: binding parameter (7:VARCHAR) <- [ACTIVE]
bind: binding parameter (8:INTEGER) <- [26]
bind: binding parameter (9:TIMESTAMP_UTC) <- [2026-09-13T10:36:55.040412Z]
bind: binding parameter (10:VARCHAR) <- [null]
bind: binding parameter (11:BIGINT) <- [1]
AuditLab: returned Product[id=2, sku=KB-02, price=59.00, stock=13, createdAt=2026-09-13T10:35:23.188558Z, updatedAt=2026-09-13T10:36:55.037375Z, createdBy=null, updatedBy=null]
AuditLab: returned Product[id=1, sku=KB-01, price=84.90, stock=26, createdAt=2026-09-13T10:35:23.188558Z, updatedAt=2026-09-13T10:36:55.040412Z, createdBy=null, updatedBy=null]
Text
 id |  sku   | price | stock |          created_at           |          updated_at           | created_by | updated_by
----+--------+-------+-------+-------------------------------+-------------------------------+------------+------------
  1 | KB-01  | 84.90 |    26 | 2026-09-13 10:35:23.188558+00 | 2026-09-13 10:36:55.040412+00 |            |
  2 | KB-02  | 59.00 |    13 | 2026-09-13 10:35:23.188558+00 | 2026-09-13 10:36:55.037375+00 |            |
  3 | MS-01  | 24.50 |     3 | 2026-09-13 10:35:23.188558+00 | 2026-09-13 10:35:23.188558+00 |            |
  4 | HUB-01 | 39.00 |    10 | 2026-09-13 10:36:02.07131+00  | 2026-09-13 10:36:02.07131+00  |            |
(4 rows)

saveAllsave chạy trong vòng lặp bên trong một transaction. Mỗi bàn phím được merge, một SELECT chép nó vào persistence context, và lần flush chạy listener cho từng entity: hai câu UPDATE, hai giá trị updated_at khác nhau cách nhau ba mili giây. saveAll được audit giống như save. Chính thao tác merge giúp chuyện này chạy đúng cũng là nơi cái bẫy tiếp theo nằm.

Bẫy detached entity: save() ghi null vào created_at

PUT dựng một Product mới từ request

Một lối tắt hay gặp cho PUT là dùng lại DTO của thao tác tạo, dựng một Product mới từ nó và gán id lấy từ path:

src/main/java/com/example/demo/product/ProductService.java
    @Transactional
    public ProductResponse replace(Long id, CreateProductRequest request) {
        Product product = mapper.toProduct(request);
        product.setId(id);
        return mapper.toResponse(repository.save(product));
    }
src/main/java/com/example/demo/product/Product.java
    public Long getId() { return id; }
    void setId(Long id) { this.id = id; } 

Object có id, nên save merge nó, tức SELECT rồi UPDATE như bài 26. Nó cũng không có createdAtcreatedBy, vì trong request không có gì gán chúng. Bước replace trên HUB-01, chạy trên PostgreSQL:

Text
AuditLab: -- replace
SQL: select p1_0.id,p1_0.category,p1_0.created_at,p1_0.created_by,p1_0.name,p1_0.price,p1_0.sku,p1_0.status,p1_0.stock,p1_0.updated_at,p1_0.updated_by from products p1_0 where p1_0.sku=?
bind: binding parameter (1:VARCHAR) <- [HUB-01]
SQL: select p1_0.id,p1_0.category,p1_0.created_at,p1_0.created_by,p1_0.name,p1_0.price,p1_0.sku,p1_0.status,p1_0.stock,p1_0.updated_at,p1_0.updated_by from products p1_0 where p1_0.id=?
bind: binding parameter (1:BIGINT) <- [4]
SQL: update products set category=?,created_at=?,created_by=?,name=?,price=?,sku=?,status=?,stock=?,updated_at=?,updated_by=? where id=?
bind: binding parameter (1:VARCHAR) <- [Accessories]
bind: binding parameter (2:TIMESTAMP_UTC) <- [null]
bind: binding parameter (3:VARCHAR) <- [null]
bind: binding parameter (4:VARCHAR) <- [USB-C hub, 7 ports]
bind: binding parameter (5:NUMERIC) <- [42.00]
bind: binding parameter (6:VARCHAR) <- [HUB-01]
bind: binding parameter (7:VARCHAR) <- [ACTIVE]
bind: binding parameter (8:INTEGER) <- [8]
bind: binding parameter (9:TIMESTAMP_UTC) <- [2026-09-13T10:37:28.930979Z]
bind: binding parameter (10:VARCHAR) <- [null]
bind: binding parameter (11:BIGINT) <- [4]
error: HHH000247: ErrorCode: 0, SQLState: 23502
error: ERROR: null value in column "created_at" of relation "products" violates not-null constraint
  Detail: Failing row contains (4, USB-C hub, 7 ports, HUB-01, 42.00, 8, Accessories, ACTIVE, null, 2026-09-13 17:37:28.930979+07, null, null).
AuditLab: !!! org.springframework.dao.DataIntegrityViolationException: could not execute statement [ERROR: null value in column "created_at" of relation "products" violates not-null constraint

SELECT đầu tiên là runner tìm id; SELECT thứ hai là của merge. Merge chép toàn bộ trạng thái của object mới lên instance đang được quản lý, kể cả các giá trị null, nên Product managed mất createdAtcreatedBy. Lúc flush, listener gán updatedAt như đúng nhiệm vụ, và không hề nhìn tới các field created: touchForUpdate chỉ ghi cặp last-modified. Câu UPDATE bind null vào created_atcreated_by. created_by cho phép null, nên giá trị null của nó lẽ ra đã được ghi; chỉ có constraint trên created_at chặn statement lại.

@Column(updatable = false)

Các field created không bao giờ nên nằm trong một câu UPDATE, và JPA cho phép khai báo điều đó:

src/main/java/com/example/demo/common/AuditableEntity.java
    @CreatedDate
    @Column(nullable = false) 
    @Column(nullable = false, updatable = false) 
    private Instant createdAt;
 
    @LastModifiedDate
    @Column(nullable = false)
    private Instant updatedAt;
 
    @CreatedBy
    @Column(length = 64) 
    @Column(length = 64, updatable = false) 
    private String createdBy;

Cùng bước replace:

Text
AuditLab: -- replace
SQL: select p1_0.id,p1_0.category,p1_0.created_at,p1_0.created_by,p1_0.name,p1_0.price,p1_0.sku,p1_0.status,p1_0.stock,p1_0.updated_at,p1_0.updated_by from products p1_0 where p1_0.sku=?
bind: binding parameter (1:VARCHAR) <- [HUB-01]
SQL: select p1_0.id,p1_0.category,p1_0.created_at,p1_0.created_by,p1_0.name,p1_0.price,p1_0.sku,p1_0.status,p1_0.stock,p1_0.updated_at,p1_0.updated_by from products p1_0 where p1_0.id=?
bind: binding parameter (1:BIGINT) <- [4]
SQL: update products set category=?,name=?,price=?,sku=?,status=?,stock=?,updated_at=?,updated_by=? where id=?
bind: binding parameter (1:VARCHAR) <- [Accessories]
bind: binding parameter (2:VARCHAR) <- [USB-C hub, 7 ports]
bind: binding parameter (3:NUMERIC) <- [42.00]
bind: binding parameter (4:VARCHAR) <- [HUB-01]
bind: binding parameter (5:VARCHAR) <- [ACTIVE]
bind: binding parameter (6:INTEGER) <- [8]
bind: binding parameter (7:TIMESTAMP_UTC) <- [2026-09-13T10:43:41.465017Z]
bind: binding parameter (8:VARCHAR) <- [null]
bind: binding parameter (9:BIGINT) <- [4]
AuditLab: returned ProductResponse[id=4, name=USB-C hub, 7 ports, sku=HUB-01, price=42.00, stock=8, category=Accessories, createdAt=null, updatedAt=null]
Text
 id |  sku   | price | stock |          created_at          |          updated_at           | created_by | updated_by
----+--------+-------+-------+------------------------------+-------------------------------+------------+------------
  4 | HUB-01 | 42.00 |     8 | 2026-09-13 10:36:02.07131+00 | 2026-09-13 10:43:41.465017+00 |            |
(1 row)

created_atcreated_by đã biến mất khỏi câu UPDATE, và row giữ nguyên created_at ban đầu. Object trong bộ nhớ thì vẫn sai: DTO ghi createdAt=null, vì instance managed mang giá trị null chép từ object của request, và updatedAt=null vì một lý do mà phần cuối bài giải thích. updatable = false bảo vệ row, không bảo vệ response. Hãy giữ nó như một lớp chặn, và viết update theo cách dirty checking mong đợi: load sản phẩm, chép request vào nó, và để lần flush ghi xuống.

src/main/java/com/example/demo/product/ProductService.java
    @Transactional
    public ProductResponse update(Long id, UpdateProductRequest request) {
        Product product = find(id);
        mapper.update(request, product);
        return mapper.toResponse(product);
    }

UpdateProductRequest mang name, price, stockcategory, và ProductMapper.update chép chúng qua các setter. Từ đây endpoint PUT gọi method này.

@CreatedBy khi chưa có Spring Security: AuditorAware và header X-User

@CreatedBy@LastModifiedBy lấy giá trị từ một bean AuditorAware. Project này chưa có xác thực, nên tên người dùng đến từ một header của request, X-User. Client nào cũng có thể gửi bất kỳ giá trị gì trong đó; nó chỉ đứng tạm chỗ cho một danh tính thật cho đến Chapter 5, nơi header được thay bằng người dùng đã xác thực lấy từ SecurityContext.

src/main/java/com/example/demo/common/RequestHeaderAuditorAware.java
package com.example.demo.common;
 
import java.util.Optional;
 
import org.springframework.data.domain.AuditorAware;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
 
@Component
class RequestHeaderAuditorAware implements AuditorAware<String> {
 
    @Override
    public Optional<String> getCurrentAuditor() {
        if (!(RequestContextHolder.getRequestAttributes() instanceof ServletRequestAttributes attributes)) {
            return Optional.empty();
        }
        String user = attributes.getRequest().getHeader("X-User");
        return StringUtils.hasText(user) ? Optional.of(user) : Optional.empty();
    }
}

@EnableJpaAuditing ở đây không có auditorAwareRef. AuditingBeanDefinitionRegistrarSupport dựng bean definition của AuditingHandler với autowiring theo type, setAutowireMode(2) trong bytecode của nó, nên bean AuditorAware duy nhất trong context được inject tự động. Các lần chạy bên dưới cho thấy nó hoạt động. Khi có nhiều hơn một bean AuditorAware, hãy chỉ định bean cần dùng bằng auditorAwareRef.

Bên trong một request

Ứng dụng web, trên PostgreSQL:

Bash
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=8132 --spring.profiles.active=postgres
curl -i -s -H 'Content-Type: application/json' -H 'X-User: alice' -d '{"name":"Ergonomic keyboard","sku":"KB-04","price":129.00,"stock":7,"category":"Keyboards"}' http://localhost:8132/api/products

Câu INSERT trong log của server, cắt còn các column audit:

Text
2026-09-13T17:38:10.561+07:00 DEBUG 44546 --- [demo] [nio-8132-exec-3] org.hibernate.SQL                        : insert into products (category,created_at,created_by,name,price,sku,status,stock,updated_at,updated_by) values (?,?,?,?,?,?,?,?,?,?)
2026-09-13T17:38:10.562+07:00 TRACE 44546 --- [demo] [nio-8132-exec-3] org.hibernate.orm.jdbc.bind              : binding parameter (2:TIMESTAMP_UTC) <- [2026-09-13T10:38:10.556859Z]
2026-09-13T17:38:10.562+07:00 TRACE 44546 --- [demo] [nio-8132-exec-3] org.hibernate.orm.jdbc.bind              : binding parameter (3:VARCHAR) <- [alice]
2026-09-13T17:38:10.563+07:00 TRACE 44546 --- [demo] [nio-8132-exec-3] org.hibernate.orm.jdbc.bind              : binding parameter (9:TIMESTAMP_UTC) <- [2026-09-13T10:38:10.556859Z]
2026-09-13T17:38:10.563+07:00 TRACE 44546 --- [demo] [nio-8132-exec-3] org.hibernate.orm.jdbc.bind              : binding parameter (10:VARCHAR) <- [alice]

Hai giây sau, một request PUT vào sản phẩm vừa tạo, mang id 5, với tư cách bob:

Bash
curl -i -s -X PUT -H 'Content-Type: application/json' -H 'X-User: bob' -d '{"name":"Ergonomic keyboard","price":119.00,"stock":7,"category":"Keyboards"}' http://localhost:8132/api/products/5
docker exec sb-a32-pg psql -U catalog -d catalog -c "select id, sku, price, created_at, updated_at, created_by, updated_by from products where id = 5"
Text
 id |  sku  | price  |          created_at           |          updated_at           | created_by | updated_by
----+-------+--------+-------------------------------+-------------------------------+------------+------------
  5 | KB-04 | 119.00 | 2026-09-13 10:38:10.556859+00 | 2026-09-13 10:38:12.694614+00 | alice      | bob
(1 row)

Khi tạo, modifyOnCreate đặt alice vào cả hai column người dùng; lần update chỉ thay updated_by.

Bên ngoài request: ApplicationRunner

Mọi lần chạy lab đều là một ApplicationRunner không có web request nào gắn với thread của nó. Ở đó RequestContextHolder.getRequestAttributes() trả về null, method trả về Optional.empty(), và câu INSERT đầu tiên của bài bind created_by <- [null]updated_by <- [null] mà không có lỗi gì.

Một phiên bản ngắn hơn của cùng class này rất phổ biến, và thất bại ở đó:

src/main/java/com/example/demo/common/RequestHeaderAuditorAware.java
    @Override
    public Optional<String> getCurrentAuditor() {
        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.currentRequestAttributes();
        return Optional.ofNullable(attributes.getRequest().getHeader("X-User"));
    }

Bước create2 với phiên bản này:

Text
AuditLab: -- create2
AuditLab: !!! org.springframework.dao.InvalidDataAccessApiUsageException: No thread-bound request found: Are you referring to request attributes outside of an actual web request, or processing a request outside of the originally receiving thread? If you are actually operating within a web request and still receive this message, your code is probably running outside of DispatcherServlet: In this case, use RequestContextListener or RequestContextFilter to expose the current request.
AuditLab: !!! java.lang.IllegalStateException: No thread-bound request found: Are you referring to request attributes outside of an actual web request, or processing a request outside of the originally receiving thread? If you are actually operating within a web request and still receive this message, your code is probably running outside of DispatcherServlet: In this case, use RequestContextListener or RequestContextFilter to expose the current request.

currentRequestAttributes() ném IllegalStateException khi không có request nào được gắn, exception đi ra từ callback @PrePersist, và Spring chuyển nó thành InvalidDataAccessApiUsageException. Không có SQL nào được gửi. Một scheduled job, một message listener hay một runner lúc khởi động mà lưu entity cũng sẽ thất bại y như vậy, nên hãy kiểm tra request bằng getRequestAttributes() và trả về Optional.empty().

Optional.empty() khi update giữ nguyên auditor trước đó

Vẫn sản phẩm đó, được cập nhật bằng một PUT không có header:

Bash
curl -i -s -X PUT -H 'Content-Type: application/json' -d '{"name":"Ergonomic keyboard","price":109.00,"stock":7,"category":"Keyboards"}' http://localhost:8132/api/products/5
Text
 id |  sku  | price  |          created_at           |          updated_at           | created_by | updated_by
----+-------+--------+-------------------------------+-------------------------------+------------+------------
  5 | KB-04 | 109.00 | 2026-09-13 10:38:10.556859+00 | 2026-09-13 10:38:14.906025+00 | alice      | bob
(1 row)

Giá và updated_at đã đổi, còn updated_by vẫn là bob. Trong AuditingHandlerSupport, touchAuditor kiểm tra auditor có tồn tại hay không trước khi gọi setLastModifiedBy; một Optional rỗng để nguyên field như lúc load. Với entity mới điều đó nghĩa là null; với entity đã có, nó nghĩa là người dùng trước bị ghi nhận cho một thay đổi họ không hề làm. Nếu có thể có lần ghi ẩn danh, hãy trả về một giá trị cố định như "anonymous" hoặc "system" thay vì Optional.empty().

Clock cố định với DateTimeProvider

Provider mặc định đọc đồng hồ hệ thống, và một test kiểm tra created_at không thể biết trước giá trị đó. Một DateTimeProvider dựa trên bean Clock giúp thay được nguồn thời gian:

src/main/java/com/example/demo/common/AuditingConfig.java
package com.example.demo.common;
 
import java.time.Clock; 
import java.time.Instant; 
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.jpa.repository.config.EnableJpaAuditing;
 
@Configuration
@EnableJpaAuditing
@EnableJpaAuditing(dateTimeProviderRef = "auditingDateTimeProvider") 
class AuditingConfig {
 
    @Bean
    Clock clock() { 
        return Clock.systemUTC(); 
    } 
 
    @Bean
    DateTimeProvider auditingDateTimeProvider(Clock clock) { 
        return () -> Optional.of(Instant.now(clock)); 
    } 
}

Provider trả về một Instant, kiểu mà các field Instant nhận thẳng không cần chuyển đổi. Để chứng minh clock thật sự được dùng, một profile lab thay nó bằng một clock cố định:

src/main/java/com/example/demo/lab/FixedClockConfig.java
package com.example.demo.lab;
 
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
 
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.context.annotation.Profile;
 
@Configuration
@Profile("fixed-clock")
class FixedClockConfig {
 
    @Bean
    @Primary
    Clock fixedClock() {
        return Clock.fixed(Instant.parse("2026-01-01T09:00:00Z"), ZoneOffset.UTC);
    }
}
Bash
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --spring.profiles.active=postgres,lab,fixed-clock --spring.main.web-application-type=none --lab.steps=create2
Text
AuditLab: -- create2
SQL: insert into products (category,created_at,created_by,name,price,sku,status,stock,updated_at,updated_by) values (?,?,?,?,?,?,?,?,?,?)
bind: binding parameter (1:VARCHAR) <- [Accessories]
bind: binding parameter (2:TIMESTAMP_UTC) <- [2026-01-01T09:00:00Z]
bind: binding parameter (3:VARCHAR) <- [null]
bind: binding parameter (4:VARCHAR) <- [Laptop stand]
bind: binding parameter (5:NUMERIC) <- [34.90]
bind: binding parameter (6:VARCHAR) <- [AC-02]
bind: binding parameter (7:VARCHAR) <- [ACTIVE]
bind: binding parameter (8:INTEGER) <- [15]
bind: binding parameter (9:TIMESTAMP_UTC) <- [2026-01-01T09:00:00Z]
bind: binding parameter (10:VARCHAR) <- [null]
AuditLab: returned Product[id=9, sku=AC-02, price=34.90, stock=15, createdAt=2026-01-01T09:00:00Z, updatedAt=2026-01-01T09:00:00Z, createdBy=null, updatedBy=null]
Text
 id |  sku  | price | stock |       created_at       |       updated_at       | created_by | updated_by
----+-------+-------+-------+------------------------+------------------------+------------+------------
  9 | AC-02 | 34.90 |    15 | 2026-01-01 09:00:00+00 | 2026-01-01 09:00:00+00 |            |
(1 row)

Cả hai column giữ đúng thời điểm mà clock cố định trả về. Cùng bean Clock đó có thể inject vào bất cứ chỗ nào khác trong ứng dụng cần thời gian, và Chapter 6 thay nó bằng một clock cố định trong test.

Những gì bỏ qua auditing: bulk update với @Modifying và native SQL

Listener chỉ chạy cho các entity mà Hibernate flush. Bulk update của bài 27 và một native UPDATE trên cùng repository:

src/main/java/com/example/demo/product/ProductRepository.java
public interface ProductRepository extends JpaRepository<Product, Long> {
 
    Optional<Product> findBySku(String sku);
 
    @Modifying
    @Query("update Product p set p.price = p.price * :factor where p.category = :category")
    int changePrices(String category, BigDecimal factor);
 
    @Modifying
    @Query(value = "update products set stock = stock + :quantity where sku = :sku", nativeQuery = true) 
    int restockNative(String sku, int quantity); 
}

Cả hai được gọi từ các method @Transactional của service, changePricesrestock. Bước bulknative, bắt đầu từ table sau saveAll:

Text
AuditLab: -- bulk
SQL: update products p1_0 set price=(p1_0.price*?) where p1_0.category=?
bind: binding parameter (1:NUMERIC) <- [1.10]
bind: binding parameter (2:VARCHAR) <- [Keyboards]
AuditLab: changePrices -> 2 rows
AuditLab: -- native
SQL: update products set stock = stock + ? where sku = ?
bind: binding parameter (1:INTEGER) <- [5]
bind: binding parameter (2:VARCHAR) <- [MS-01]
AuditLab: restock -> 1 rows
Text
 id |  sku   | price | stock |          created_at           |          updated_at           | created_by | updated_by
----+--------+-------+-------+-------------------------------+-------------------------------+------------+------------
  1 | KB-01  | 93.39 |    26 | 2026-09-13 10:35:23.188558+00 | 2026-09-13 10:36:55.040412+00 |            |
  2 | KB-02  | 64.90 |    13 | 2026-09-13 10:35:23.188558+00 | 2026-09-13 10:36:55.037375+00 |            |
  3 | MS-01  | 24.50 |     8 | 2026-09-13 10:35:23.188558+00 | 2026-09-13 10:35:23.188558+00 |            |
  4 | HUB-01 | 39.00 |    10 | 2026-09-13 10:36:02.07131+00  | 2026-09-13 10:36:02.07131+00  |            |
(4 rows)

Hai bàn phím giờ có giá 93.39 và 64.90, con chuột có 8 cái trong kho, và cả ba giá trị updated_at vẫn y nguyên như trước. Không statement nào load entity, nên không có gì để flush và không có @PreUpdate nào để gọi; SQL không có updated_at vì không ai viết nó vào đó.

Đặt cạnh nhau trên PostgreSQL 18.6: load KB-01, đổi giá rồi commit đã chạy @PreUpdate và dời updated_at từ 10:35:23.188558 sang 10:36:50.39416, trong khi bulk update JPQL với @Modifying và native UPDATE đi thẳng xuống database và để updated_at đứng yên ở 10:36:55.040412, 10:36:55.037375 và 10:35:23.188558

Một bulk statement cần được tính là một lần sửa thì phải tự gán column, ví dụ set p.price = p.price * :factor, p.updatedAt = :now, và updated_by cũng vậy. Điều tương tự áp dụng cho SQL chạy từ migration, từ console của database hay từ một ứng dụng khác.

@CreationTimestamp và @UpdateTimestamp của Hibernate so với Spring Data auditing

Hibernate có riêng một cặp annotation không cần cấu hình và không cần listener. Trong project probe, hai entity mỗi cái có một cặp Instant, cái thứ hai do database sinh giá trị:

src/main/java/com/example/demo/probe/Entities.java
    @Entity
    @Table(name = "hibernate_vm_probe")
    public static class HibernateVmProbe implements Stamped {
        @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;
        private String name;
        @CreationTimestamp private Instant createdAt;
        @UpdateTimestamp private Instant updatedAt;
        // getId, setId, setName and toString
    }
src/main/java/com/example/demo/probe/Entities.java
    @Entity
    @Table(name = "hibernate_db_probe")
    public static class HibernateDbProbe implements Stamped {
        @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;
        private String name;
        @CreationTimestamp(source = SourceType.DB) private Instant createdAt;
        @UpdateTimestamp(source = SourceType.DB) private Instant updatedAt;
        // getId, setId, setName and toString
    }

Theo javap trên jar 7.4.5, cả hai annotation đều mặc định source = SourceType.VM, và không cái nào bị deprecated. Hibernate tạo cả hai table với created_at timestamp(6) with time zone not null, updated_at cũng vậy: khác với annotation của Spring Data, các annotation này thêm not null vào DDL được sinh. Mỗi probe đi qua năm transaction cách nhau một giây: persist, đổi tên, load mà không sửa gì, một JPQL update … set e.name = :name, và merge một object mới có cùng id và timestamp null, load lại row sau hai bước cuối. Probe sinh giá trị bằng JVM trên PostgreSQL, đã bỏ các SELECT sau bulk update và sau merge cùng các dòng bind của id và name:

Text
ProbeRunner: -- persist HibernateVmProbe
SQL: insert into hibernate_vm_probe (created_at,name,updated_at) values (?,?,?)
bind: binding parameter (1:TIMESTAMP_UTC) <- [2026-09-13T10:44:00.612364Z]
bind: binding parameter (3:TIMESTAMP_UTC) <- [2026-09-13T10:44:00.612395Z]
ProbeRunner: HibernateVmProbe[id=1, name=null, createdAt=2026-09-13T10:44:00.612364Z, updatedAt=2026-09-13T10:44:00.612395Z]
ProbeRunner: -- rename HibernateVmProbe (dirty checking)
SQL: select hvp1_0.id,hvp1_0.created_at,hvp1_0.name,hvp1_0.updated_at from hibernate_vm_probe hvp1_0 where hvp1_0.id=?
SQL: update hibernate_vm_probe set name=?,updated_at=? where id=?
bind: binding parameter (2:TIMESTAMP_UTC) <- [2026-09-13T10:44:01.878889Z]
ProbeRunner: HibernateVmProbe[id=1, name=renamed, createdAt=2026-09-13T10:44:00.612364Z, updatedAt=2026-09-13T10:44:01.878889Z]
ProbeRunner: -- load HibernateVmProbe and change nothing
SQL: select hvp1_0.id,hvp1_0.created_at,hvp1_0.name,hvp1_0.updated_at from hibernate_vm_probe hvp1_0 where hvp1_0.id=?
ProbeRunner: HibernateVmProbe[id=1, name=renamed, createdAt=2026-09-13T10:44:00.612364Z, updatedAt=2026-09-13T10:44:01.878889Z]
ProbeRunner: -- bulk update HibernateVmProbe
SQL: update hibernate_vm_probe hvp1_0 set name=?
ProbeRunner: rows=1
ProbeRunner: after bulk, reloaded: HibernateVmProbe[id=1, name=bulk, createdAt=2026-09-13T10:44:00.612364Z, updatedAt=2026-09-13T10:44:01.878889Z]
ProbeRunner: -- merge a new HibernateVmProbe with the same id and null timestamps
SQL: select hvp1_0.id,hvp1_0.created_at,hvp1_0.name,hvp1_0.updated_at from hibernate_vm_probe hvp1_0 where hvp1_0.id=?
core: HHH000502: The [createdAt] property of the [com.example.demo.probe.Entities$HibernateVmProbe] entity was modified, but it won't be updated because the property is immutable.
SQL: update hibernate_vm_probe set name=?,updated_at=? where id=?
bind: binding parameter (2:TIMESTAMP_UTC) <- [2026-09-13T10:44:05.702885Z]
ProbeRunner: HibernateVmProbe[id=1, name=merged, createdAt=null, updatedAt=2026-09-13T10:44:05.702885Z]
ProbeRunner: after merge, reloaded: HibernateVmProbe[id=1, name=merged, createdAt=2026-09-13T10:44:00.612364Z, updatedAt=2026-09-13T10:44:05.702885Z]

Probe do database sinh giá trị, các dòng chính:

Text
ProbeRunner: -- persist HibernateDbProbe
SQL: insert into hibernate_db_probe (created_at,name,updated_at) values (localtimestamp,?,localtimestamp) returning id,created_at,updated_at
ProbeRunner: HibernateDbProbe[id=1, name=null, createdAt=2026-09-13T10:44:05.709053Z, updatedAt=2026-09-13T10:44:05.709053Z]
ProbeRunner: -- rename HibernateDbProbe (dirty checking)
SQL: update hibernate_db_probe set name=?,updated_at=localtimestamp where id=? returning updated_at
ProbeRunner: -- bulk update HibernateDbProbe
SQL: update hibernate_db_probe hdp1_0 set name=?
ProbeRunner: after bulk, reloaded: HibernateDbProbe[id=1, name=bulk, createdAt=2026-09-13T10:44:05.709053Z, updatedAt=2026-09-13T10:44:06.921441Z]
ProbeRunner: -- merge a new HibernateDbProbe with the same id and null timestamps
core: HHH000502: The [createdAt] property of the [com.example.demo.probe.Entities$HibernateDbProbe] entity was modified, but it won't be updated because the property is immutable.
core: HHH000502: The [updatedAt] property of the [com.example.demo.probe.Entities$HibernateDbProbe] entity was modified, but it won't be updated because the property is immutable.
SQL: update hibernate_db_probe set name=?,updated_at=localtimestamp where id=? returning updated_at
Text
          created_at           | id |          updated_at           |  name
-------------------------------+----+-------------------------------+--------
 2026-09-13 10:44:00.612364+00 |  1 | 2026-09-13 10:44:05.702885+00 | merged
(1 row)
 
          created_at           | id |          updated_at           |  name
-------------------------------+----+-------------------------------+--------
 2026-09-13 10:44:05.709053+00 |  1 | 2026-09-13 10:44:10.566356+00 | merged
(1 row)
  • @UpdateTimestamp được gán khi insert. Với nguồn JVM, hai column nhận hai lần đọc đồng hồ riêng biệt, cách nhau 31 micro giây; Spring Data bind cùng một giá trị cho cả hai.
  • @CreationTimestamp bị loại khỏi mọi câu UPDATE. Lần đổi tên gửi set name=?,updated_at=?, và lần merge với createdAt null ghi log HHH000502 và giữ nguyên giá trị đã lưu. Nó có sẵn sự bảo vệ mà updatable = false mang lại cho field của Spring Data, và cũng có giá trị null trong bộ nhớ sau merge như vậy.
  • SourceType.DB chuyển đồng hồ vào trong statement. Hibernate viết localtimestamp vào câu INSERT và UPDATE rồi đọc giá trị về bằng returning, không có bind parameter; thời điểm được lưu khớp với lúc ghi.
  • Bulk update bỏ qua cả hai. update hibernate_vm_probe hvp1_0 set name=? để updated_at đứng yên ở 10:44:01.878889, và probe DB ở 10:44:06.921441.
@CreatedDate / @LastModifiedDate của Spring Data@CreationTimestamp / @UpdateTimestamp của Hibernate
Cần gì@EnableJpaAuditingAuditingEntityListenerkhông cần gì
Last-modified được gán khi insertcó, cùng giá trị với created; không, nếu modifyOnCreate = falsecó, một lần đọc đồng hồ thứ hai (VM) hoặc cùng localtimestamp (DB)
Được gán khi update entity dirtychỉ last-modifiedchỉ @UpdateTimestamp
Giá trị created trong câu UPDATEđược gửi lại; null sau merge nếu thiếu updatable = falsekhông bao giờ, HHH000502 khi merge
Nguồn giá trịDateTimeProvider, thay được bằng bean Clockđồng hồ JVM, hoặc localtimestamp trong SQL với SourceType.DB
NOT NULL trong DDL được sinhkhông
Bulk update @Modifying hoặc JPQLbị bỏ quabị bỏ qua
Ai thực hiện thay đổi@CreatedBy, @LastModifiedBy qua AuditorAwarekhông có tương đương

Hibernate 7.4 còn có @CurrentTimestamp, với giá trị mặc định trong jar là source = SourceType.DB và các event INSERT, UPDATE, FORCE_INCREMENT. Nếu chỉ cần timestamp, annotation của Hibernate là cách cấu hình gọn hơn. Series giữ annotation của Spring Data vì @CreatedBy và vì đồng hồ thay được.

Đưa createdAt và updatedAt vào ProductResponse

DTO của response có thêm hai timestamp, vẫn được map trong service như trước:

src/main/java/com/example/demo/product/ProductResponse.java
package com.example.demo.product;
 
import java.math.BigDecimal;
import java.time.Instant; 
 
public record ProductResponse(Long id, String name, String sku, BigDecimal price, int stock, String category) { 
public record ProductResponse(Long id, String name, String sku, BigDecimal price, int stock, String category, 
                              Instant createdAt, Instant updatedAt) { 
}
src/main/java/com/example/demo/product/ProductMapper.java
    public ProductResponse toResponse(Product product) {
        return new ProductResponse(product.getId(), product.getName(), product.getSku(), product.getPrice(),
                product.getStock(), product.getCategory()); 
                product.getStock(), product.getCategory(), product.getCreatedAt(), product.getUpdatedAt()); 
    }
src/main/java/com/example/demo/product/ProductController.java
    @PutMapping("/{id}")
    public ProductResponse update(@PathVariable Long id, @Valid @RequestBody UpdateProductRequest request) {
        return service.update(id, request);
    }

Các column người dùng không nằm trong response; psql cho thấy chúng. Với AuditableEntityAuditingConfig bản cuối, một request POST và một PUT trên PostgreSQL:

Bash
curl -i -s -H 'Content-Type: application/json' -H 'X-User: alice' -d '{"name":"Webcam 1080p","sku":"AC-05","price":64.00,"stock":14,"category":"Accessories"}' http://localhost:8132/api/products
Text
HTTP/1.1 201
Location: http://localhost:8132/api/products/10
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sun, 13 Sep 2026 10:43:46 GMT
 
{"id":10,"name":"Webcam 1080p","sku":"AC-05","price":64.00,"stock":14,"category":"Accessories","createdAt":"2026-09-13T10:43:46.931802Z","updatedAt":"2026-09-13T10:43:46.931802Z"}

Jackson 3 ghi mỗi Instant thành một chuỗi ISO-8601 ở UTC với độ chính xác micro giây, đúng độ chính xác của giá trị. Hai giây sau:

Bash
curl -i -s -X PUT -H 'Content-Type: application/json' -H 'X-User: bob' -d '{"name":"Webcam 1080p","price":59.00,"stock":14,"category":"Accessories"}' http://localhost:8132/api/products/10
curl -i -s http://localhost:8132/api/products/10
Text
HTTP/1.1 200
Content-Type: application/json
Content-Length: 179
Date: Sun, 13 Sep 2026 10:43:49 GMT
 
{"id":10,"name":"Webcam 1080p","sku":"AC-05","price":59.00,"stock":14,"category":"Accessories","createdAt":"2026-09-13T10:43:46.931802Z","updatedAt":"2026-09-13T10:43:46.931802Z"}
HTTP/1.1 200
Content-Type: application/json
Content-Length: 179
Date: Sun, 13 Sep 2026 10:43:49 GMT
 
{"id":10,"name":"Webcam 1080p","sku":"AC-05","price":59.00,"stock":14,"category":"Accessories","createdAt":"2026-09-13T10:43:46.931802Z","updatedAt":"2026-09-13T10:43:49.079407Z"}

updatedAt trong response của PUT chậm một lần flush

Response của PUT có giá mới nhưng updatedAt cũ; GET ngay sau đó thì hiện 10:43:49.079407Z. update map DTO bên trong transaction, trước khi method return, còn @PreUpdate chỉ chạy ở lần flush trong lúc commit, sau khi DTO đã được tạo. Output của replace trước đó in updatedAt=null cũng vì lý do này. Flush trước khi map sẽ chạy callback trước:

src/main/java/com/example/demo/product/ProductService.java
    @Transactional
    public ProductResponse update(Long id, UpdateProductRequest request) {
        Product product = find(id);
        mapper.update(request, product);
        repository.flush(); 
        return mapper.toResponse(product);
    }
Bash
curl -i -s -X PUT -H 'Content-Type: application/json' -H 'X-User: bob' -d '{"name":"Webcam 1080p","price":54.00,"stock":14,"category":"Accessories"}' http://localhost:8132/api/products/10
Text
HTTP/1.1 200
Content-Type: application/json
Content-Length: 179
Date: Sun, 13 Sep 2026 10:43:52 GMT
 
{"id":10,"name":"Webcam 1080p","sku":"AC-05","price":54.00,"stock":14,"category":"Accessories","createdAt":"2026-09-13T10:43:46.931802Z","updatedAt":"2026-09-13T10:43:52.304028Z"}

flush() gửi câu UPDATE ngay trong method, listener gán updatedAt, và DTO được dựng sau đó. Transaction vẫn commit hoặc rollback như một khối. Để so sánh, phiên bản replace của PUT trên cùng sản phẩm, gửi với X-User: carol, trả về "createdAt":null,"updatedAt":null, trong khi psql cho thấy created_at vẫn là 10:43:46.931802+00, created_by vẫn là aliceupdated_by giờ là carol.

Tổng hợp các annotation auditing

Mọi dòng trong bảng đều lấy từ các lần chạy ở trên.

AnnotationĐược gán khiNguồn giá trịGhi chú
@CreatedDate@PrePersist, bên trong persist, trước câu INSERTDateTimeProvider.getNow(); provider mặc định trả về LocalDateTime.now(), được chuyển sang kiểu của fieldInstantLocalDateTime chạy được với provider mặc định; OffsetDateTime chỉ chạy với provider trả về OffsetDateTime; merge chép null vào nó nếu thiếu updatable = false
@LastModifiedDate@PrePersist với cùng giá trị, và @PreUpdate lúc flush một entity dirtycùng provider, một lần getNow() cho mỗi callbacknull khi insert nếu modifyOnCreate = false; không đổi với transaction không sửa gì, bulk update @Modifying hay native UPDATE
@CreatedBy@PrePersistAuditorAware.getCurrentAuditor(), gọi trước getNow()Optional.empty() để nó null; currentRequestAttributes() bên ngoài request ném InvalidDataAccessApiUsageException
@LastModifiedBy@PrePersist@PreUpdateAuditorAware.getCurrentAuditor()Optional.empty() khi update giữ nguyên giá trị trước đó
@CreationTimestampINSERTđồng hồ JVM theo mặc định, localtimestamp với SourceType.DBkhông bao giờ có trong câu UPDATE; HHH000502 khi merge
@UpdateTimestampINSERT và mọi câu UPDATE của entity dirtyđồng hồ JVM theo mặc định, localtimestamp với SourceType.DBđược gán khi insert với lần đọc đồng hồ riêng; bị bulk update bỏ qua

FAQ

Vì sao @CreatedDate và @LastModifiedDate bị null?

Hoặc thiếu @EnableJpaAuditing, hoặc entity không có @EntityListeners(AuditingEntityListener.class), trên chính nó hay trên @MappedSuperclass của nó. Không lỗi nào ghi cảnh báo lúc khởi động: Spring Boot 4.1.1 khởi động bình thường, Hibernate bind null vào cả bốn column audit, và lỗi duy nhất đến từ constraint NOT NULL của PostgreSQL trên created_at. Khi update, created_at bị null thường có nghĩa là một detached object đã được save; xem câu hỏi thứ tư.

@LastModifiedDate có được gán khi tạo entity không?

Có, theo mặc định. @EnableJpaAuditingmodifyOnCreate = true, và câu INSERT đã bind cùng giá trị 2026-09-13T10:36:02.071310Z vào created_atupdated_at, cùng một người dùng vào created_byupdated_by. Với modifyOnCreate = false, cả hai field last-modified đều null khi insert, nên các column đó phải cho phép null.

Spring Data JPA auditing có hỗ trợ OffsetDateTime không?

Chỉ khi có một DateTimeProvider trả về OffsetDateTime. Với provider mặc định, vốn trả về LocalDateTime, và với provider trả về Instant, Spring Data JPA 4.1.1 ném IllegalArgumentException: Cannot convert unsupported date type … to java.time.OffsetDateTime. Field InstantLocalDateTime chạy được với cả hai provider, và Instant trong column timestamp with time zone là lựa chọn không thể hiểu sai.

Vì sao save() gán created_at thành null khi update?

save merge một object đã có id, và merge chép mọi field từ object đó, kể cả null. Một object dựng từ request không có createdAt, còn listener khi update chỉ gán các field last-modified, nên câu UPDATE bind null vào created_atcreated_by. @Column(updatable = false) loại cả hai column khỏi câu UPDATE; load entity rồi sửa nó thì tránh được cả giá trị null trong bộ nhớ.

Bulk update với @Modifying có cập nhật @LastModifiedDate không?

Không. Listener chỉ chạy cho các entity mà Hibernate flush, còn bulk UPDATE bằng JPQL hay native SQL không load entity nào. Sau khi update products p1_0 set price=(p1_0.price*?) where p1_0.category=? đổi hai row, psql cho thấy cả hai giá trị updated_at không đổi. Hãy tự gán column trong statement nếu thay đổi đó cần được tính. Ngược lại, saveAll được audit cho từng entity.

@CreatedDate và @CreationTimestamp khác nhau thế nào?

@CreatedDate là của Spring Data: cần @EnableJpaAuditing và listener, lấy giá trị từ một DateTimeProvider thay được, và đi kèm @CreatedBy. @CreationTimestamp là của Hibernate: không cần cấu hình, dùng đồng hồ JVM hoặc database, và Hibernate giữ column đó ngoài mọi câu UPDATE. Annotation đi cặp với nó, @UpdateTimestamp, cũng được gán khi insert, từ một lần đọc đồng hồ riêng.

Làm sao điền @CreatedBy khi chưa có Spring Security?

Cung cấp một bean AuditorAware<String>. Bài này đọc header X-User qua RequestContextHolder.getRequestAttributes() và trả về Optional.empty() khi không có request nào được gắn; một bean duy nhất được nhận mà không cần auditorAwareRef. Auditor rỗng để created_by null khi insert và giữ nguyên updated_by trước đó khi update. Chapter 5 sẽ lấy người dùng từ SecurityContext thay cho header.

Kết luận

Auditing cần ba thứ: @EnableJpaAuditing trong một class cấu hình, AuditingEntityListener trên một @MappedSuperclass, và bốn field có annotation, cộng thêm một migration cho các row có sẵn giá trị default trước khi column trở thành NOT NULL. Listener chạy bên trong chính các callback của Hibernate: @PrePersist trong lúc persist, trước câu INSERT, và @PreUpdate trong lần flush lúc commit, chỉ với entity đã thay đổi. Nó hỏi AuditorAware về người dùng, hỏi DateTimeProvider về thời gian, và gán các field trước khi SQL được dựng. Thiếu annotation hoặc listener thì không có gì cảnh báo: các column chỉ đơn giản là null. Instant chạy được với provider mặc định và lưu một timestamp with time zone không thể hiểu sai; OffsetDateTime thất bại trừ khi provider trả về đúng kiểu đó.

Các cái bẫy đều nằm ở những gì listener không thấy. Merge một object dựng từ request ghi null vào created_atcreated_by nếu chúng không có updatable = false; auditor rỗng khi update giữ nguyên người dùng trước; DTO được map trước lần flush hiển thị updatedAt cũ; bulk update với @Modifying và native update để updated_at đứng yên. @CreationTimestamp@UpdateTimestamp của Hibernate tự bảo vệ thời điểm tạo, nhưng không có người dùng và không có đồng hồ thay được.

Bài này khép lại Chapter 4. Từ DataSourceJdbcClient, catalogue giờ đã có entity và quan hệ JPA, derived query và query tự viết, phân trang, transaction, migration có version và column audit. Bài tiếp theo mở đầu Chapter 5 với tổng quan về Spring Security: authentication và authorization khác nhau thế nào, filter chain, và cấu hình SecurityFilterChain.

Bài viết liên quan

[Spring Boot Basics] Truy vấn với Spring Data JPA: derived query method, @Query với JPQL và native query

Query với Spring Data JPA trên Spring Boot 4.1.1, với H2 và PostgreSQL: tên derived query method được phân tích thành JPQL rồi SQL ra sao, bảng keyword kèm SQL mà từng keyword sinh ra, các return type và exception chúng ném ra, lỗi khởi động khi gõ sai tên property, Containing có escape % và _ không, @Query với JPQL và khi nào cần @Param, native full-text search chạy trên PostgreSQL nhưng lỗi trên H2, SQL injection do nối chuỗi, và bulk update với @Modifying cùng cái bẫy persistence context bị stale, giá trị cũ bị ghi lại khi flush, và clearAutomatically.

[Spring Boot Basics] Test với Spring Boot: @SpringBootTest, @WebMvcTest với MockMvcTester và @DataJpaTest

Test trong Spring Boot 4.1.1 với spring-test và spring-security-test: các test starter theo từng feature, những import của Boot 3 không còn compile được và @MockBean được thay bằng @MockitoBean, thời gian khởi động và số bean đo được cho @WebMvcTest, @DataJpaTest và @SpringBootTest, MockMvcTester với các assertion bodyJson cho response ProblemDetail 200, 201, 404, 409, 422 và 500, vì sao web slice chạy security mặc định của Boot cho đến khi bạn @Import SecurityFilterChain của mình, @WithMockUser so với jwt() với 401, 403 và 201, @DataJpaTest với database embedded được thay thế, rollback sau mỗi test và bẫy first-level cache, @SpringBootTest ở MOCK và RANDOM_PORT với RestTestClient, rollback của @Transactional mà thread của server thoát được, @TestConfiguration, @ActiveProfiles và test context cache.

[Spring Boot Basics] Validation trong Spring Boot: Bean Validation, @Valid và custom validator

Bean Validation trong Spring Boot 4.1.1 với Hibernate Validator: spring-boot-starter-validation, @NotNull, @NotEmpty và @NotBlank khác nhau ra sao, @Size, @DecimalMin, @Digits, @Email và @Pattern trên DTO record, @Valid với @RequestBody và response 400 mặc định, object lồng nhau và list, validate @PathVariable và @RequestParam cùng cái bẫy 500 của @Validated, validation group, ValidationMessages.properties và Accept-Language, custom ConstraintValidator và constraint liên quan nhiều field, và validation ở service layer.

[Advanced Spring Boot] Truy vấn động với Spring Data JPA: Specification, Criteria API và Querydsl

Dynamic query cho một search sản phẩm có filter tùy chọn trên Spring Boot 4.1.1 và PostgreSQL: vì sao derived query và mẹo @Query (:x is null or …) không đủ, kèm lỗi lower(bytea) và generic plan đọc 200.403 buffer, Specification kết hợp được với API của Spring Data 4 (allOf, unrestricted, PredicateSpecification, UpdateSpecification, where(null) giờ throw), escape LIKE, join to-many làm count phình ra và page co lại, distinct so với subquery exists, Criteria API trong repository fragment tự viết với metamodel của hibernate-processor và đếm theo category, Querydsl với jakarta classifier, QuerydslPredicateExecutor và JPAQueryFactory, code generation của jOOQ, whitelist cho sort, 400 ProblemDetail khi minPrice lớn hơn maxPrice, và Query by Example.