Spring REST API & Data JPA/Entity lifecycle & equals/hashCode — @PrePersist, @PostLoad, và pitfall Lombok @Data
27/46
Bài 27 / 46~12 phútJPA FundamentalsMiễn phí lượt xem

Entity lifecycle & equals/hashCode — @PrePersist, @PostLoad, và pitfall Lombok @Data

JPA entity sống qua 4 trạng thái (transient, managed, detached, removed). Bài này giải thích 7 lifecycle callback, tại sao equals/hashCode khó khi ID có thể null, và vì sao Lombok @Data phá vỡ entity trong Set/Map.

TL;DR: Entity JPA không chỉ là POJO thụ động — Hibernate quản lý nó qua 4 trạng thái vòng đời (transient, managed, detached, removed) và gọi 7 callback tại các điểm chuyển trạng thái (@PrePersist, @PostPersist, @PreUpdate, @PostUpdate, @PreRemove, @PostRemove, @PostLoad). Pitfall nguy hiểm nhất: equals/hashCode dựa trên ID nhưng ID null trước khi persist — entity transient cho hashCode = 0, sau persist hashCode = 42, HashSet mất dấu entity. Giải pháp: equals null-safe trên ID, hashCode constant per class. Không dùng Lombok @Data — nó sinh equals/hashCode trên tất cả field, phá vỡ mọi bất biến trên.

1. Bốn trạng thái vòng đời entity

JPA (Jakarta Persistence API — spec định nghĩa ORM cho Java EE/Jakarta EE, Hibernate là implementation phổ biến nhất) quản lý entity qua persistence context — một "bộ nhớ cache cấp 1" theo từng transaction, tracking mọi entity đã load hoặc save. Mọi entity tại một thời điểm thuộc một trong 4 trạng thái:

stateDiagram-v2
  [*] --> Transient: new Project()
  Transient --> Managed: em.persist() / repo.save()
  Managed --> Detached: tx end / em.detach() / em.clear()
  Detached --> Managed: em.merge() / repo.save()
  Managed --> Removed: em.remove()
  Removed --> [*]: flush to DB
  Detached --> [*]: GC
Trạng tháiMô tảIDPersistence context track?
TransientVừa new, chưa biết đến DBnullKhông
ManagedĐang trong transaction, trackedcó (sau persist)
DetachedTransaction kết thúc, đã rời khỏi contextKhông
RemovedĐã đánh dấu xoá, chờ flushCó (sẽ DELETE)

Tại sao 4 trạng thái quan trọng? Cơ chế dirty checking của Hibernate — phát hiện thay đổi và sinh câu UPDATE tự động — chỉ hoạt động khi entity ở trạng thái Managed. Entity Detached không được track, sửa field trên đó không tự động flush xuống DB.

// Entity managed — dirty check hoat dong
@Transactional
public void updateName(Long id, String name) {
    Project p = repo.findById(id).orElseThrow();   // p = Managed
    p.setName(name);                                 // Hibernate track thay doi
    // Khi tx commit: Hibernate phat hien dirty, sinh UPDATE tu dong
}

// Entity detached — khong duoc track
public void wrongUpdate(Project p) {   // p den tu ngoai tx = Detached
    p.setName("New Name");              // Hibernate khong biet
    // Khong co UPDATE nao duoc sinh ra
}

2. Bảy lifecycle callback

JPA cung cấp 7 annotation đánh dấu method được gọi tại điểm chuyển trạng thái. Method phải void, có thể protected hoặc package-private:

@Entity
public class Project {

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

    private String name;
    private Instant createdAt;
    private Instant updatedAt;
    private ProjectStatus status;

    // --- 7 lifecycle callbacks ---

    @PrePersist
    protected void onCreate() {
        // Goi truoc INSERT — entity van chua co ID
        if (createdAt == null) createdAt = Instant.now();
        if (status == null) status = ProjectStatus.PLANNING;
    }

    @PostPersist
    protected void afterInsert() {
        // Goi sau INSERT — id da duoc DB gan, flush xong
        // Use case: publish domain event, ghi audit log
    }

    @PreUpdate
    protected void onUpdate() {
        // Goi truoc UPDATE — dirty check da phat hien thay doi
        updatedAt = Instant.now();
    }

    @PostUpdate
    protected void afterUpdate() {
        // Goi sau UPDATE flush thanh cong
    }

    @PreRemove
    protected void onDelete() {
        // Goi truoc DELETE — entity van con trong DB
        // Use case: cleanup related resource, validation
    }

    @PostRemove
    protected void afterDelete() {
        // Goi sau DELETE flush
    }

    @PostLoad
    protected void afterLoad() {
        // Goi sau khi Hibernate load entity tu DB (findById, JPQL, ...)
        // Use case: compute derived/transient field
    }
}

Bảng tóm tắt thứ tự kích hoạt:

CallbackKhi nào kích hoạtID có sẵn?Use case điển hình
@PrePersistTrước INSERTKhông (null nếu IDENTITY)Set createdAt, default value
@PostPersistSau INSERT, id đã gánPublish event, audit log
@PreUpdateTrước UPDATESet updatedAt
@PostUpdateSau UPDATEInvalidate cache
@PreRemoveTrước DELETECleanup, validation
@PostRemoveSau DELETENotify, log
@PostLoadSau load từ DBTính derived field
@PrePersist vs Spring Data @CreatedDate

@PrePersist@PreUpdate hoạt động thuần JPA — không cần Spring Data. Nếu dùng Spring Data JPA, @CreatedDate / @LastModifiedDate (kết hợp @EnableJpaAuditing) là lựa chọn gọn hơn và tự động populate từ AuditorAware. Hai cơ chế không xung đột nếu chỉ dùng một. Bài tổng kết module sẽ so sánh đầy đủ.

2.1 Cơ chế bên dưới — Hibernate gọi callback thế nào

Hibernate không dùng Spring AOP hay reflection thông thường để gọi callback. Khi EntityManagerFactory khởi động, Hibernate scan metadata của mỗi entity class và đăng ký callback method vào EntityPersister tương ứng. Tại thời điểm flush, ActionQueue của Hibernate chứa danh sách các action (INSERT, UPDATE, DELETE), mỗi action gọi callbackRegistry.preCreate() / postCreate() trước/sau SQL statement.

flowchart LR
  A["repo.save(project)"] --> B["EntityManager.persist()"]
  B --> C["ActionQueue.addInsertAction()"]
  C --> D["@PrePersist callback"]
  D --> E["SQL: INSERT INTO projects ..."]
  E --> F["@PostPersist callback"]
  F --> G["id assigned (IDENTITY strategy)"]

Vì callback chạy bên trong transaction, exception ném ra từ @PrePersist sẽ roll back toàn bộ transaction — đây vừa là tính năng (validation), vừa là nguy hiểm nếu để side effect (gọi external API từ callback).

3. Tại sao equals/hashCode khó với entity JPA

Đây là pitfall tinh tế nhất khi làm việc với entity trong Set, Map, hoặc kiểm tra contains. Khó ở 3 điểm:

3.1 ID null trước khi persist

Entity mới tạo (new Project(...)) chưa có ID — Hibernate chỉ gán ID sau khi INSERT với strategy IDENTITY. Điều này tạo ra window nguy hiểm:

Project p = new Project("TaskFlow");
// p.getId() == null  <-- TRANSIENT, chua persist

Set<Project> set = new HashSet<>();
set.add(p);              // hash = f(null) = constant

repo.save(p);            // p.getId() = 42 sau INSERT
// p van o trong set, nhung hashCode da thay doi!

set.contains(p);         // FALSE nếu hashCode(42) != hashCode(null)

3.2 Proxy và lazy loading

Hibernate tạo CGLIB proxy (subclass) cho entity khi lazy load. Proxy có field null cho đến khi truy cập lần đầu. Nếu equals đọc bất kỳ field nào ngoài ID, có thể trigger load không mong muốn hoặc NPE.

3.3 Mutable field làm hashCode không ổn định

Bất kỳ field nào có setter đều có thể thay đổi giá trị sau khi entity đã vào Set. Khi hashCode phụ thuộc field mutable, entity "biến mất" khỏi collection sau mỗi lần set.

4. Vì sao không dùng Lombok @Data

Lombok @Data là annotation tiện lợi nhưng không phù hợp cho JPA entity vì nó tự động sinh:

  • equals()hashCode() dựa trên tất cả field — vi phạm cả 3 điểm trên
  • toString() gọi tất cả getter — có thể trigger lazy load, gây LazyInitializationException ngoài transaction
  • @RequiredArgsConstructor — xung đột với no-arg constructor JPA yêu cầu
// SAI — @Data pha entity
@Data
@Entity
public class Project {
    @Id @GeneratedValue private Long id;
    private String name;
    @OneToMany private List<Task> tasks;   // LAZY by default
}

// Doan nay phat sinh loi:
Project p1 = new Project();
Project p2 = repo.findById(1L).orElseThrow();
// p1.equals(p2) → doc tat ca field → trigger lazy load tasks → LazyInitializationException
// p1.hashCode() → dua tren name null → hash = 0
// sau set name: p1.hashCode() thay doi → mat trong HashSet
Lombok @EqualsAndHashCode(of = "id") — vẫn sai

Dùng @EqualsAndHashCode(of = "id") tưởng an toàn hơn, nhưng vẫn sai: hashCode dựa trên id — khi id == null (transient) hashCode là một giá trị, sau persist hashCode đổi. Entity vẫn "biến mất" khỏi HashSet. Pattern đúng duy nhất: hashCode constant per class.

5. Pattern equals/hashCode đúng

Pattern được Vlad Mihalcea (Hibernate expert) recommend và Spring Data JPA documentation confirm:

@Entity
@Table(name = "projects")
public class Project {

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

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

    // No-arg constructor cho JPA
    protected Project() {}

    public Project(String name) {
        this.name = name;
    }

    public Long getId() { return id; }
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        // instanceof check xu ly proxy: CGLIB subclass van pass
        if (!(o instanceof Project p)) return false;
        // null-safe: 2 transient entity khac nhau khong equal
        return id != null && id.equals(p.id);
    }

    @Override
    public int hashCode() {
        // CONSTANT per class — khong phu thuoc id
        // Doi thu: HashMap lookup degraded O(N), nhung hiem khi store entity in Map
        return getClass().hashCode();
    }

    @Override
    public String toString() {
        // Chi in field khong lazy — tranh trigger load ngoai tx
        return "Project{id=" + id + ", name=" + name + "}";
    }
}

Giải thích từng quyết định thiết kế:

  • instanceof thay vì getClass() == o.getClass(): CGLIB proxy là subclass — getClass() trả class proxy, không phải Project. instanceof pass cả proxy.
  • id != null && id.equals(p.id): khi id == null (entity transient) — không bao giờ equal với entity khác. Đây là semantic đúng: hai object chưa persist là hai object khác nhau.
  • getClass().hashCode() constant: stable suốt lifecycle từ transient đến managed đến detached. Trade-off chấp nhận được vì entity hiếm khi là key trong HashMap.
flowchart TB
  subgraph WRONG["Sai: hashCode = id.hashCode()"]
    direction TB
    W1["new Project() -- id=null, hash=0"]
    W2["set.add(p)"]
    W3["repo.save(p) -- id=42, hash=1052"]
    W4["set.contains(p) -- FALSE, hash thay doi"]
    W1 --> W2 --> W3 --> W4
  end
  subgraph RIGHT["Dung: hashCode = getClass().hashCode()"]
    direction TB
    R1["new Project() -- id=null, hash=C"]
    R2["set.add(p)"]
    R3["repo.save(p) -- id=42, hash=C"]
    R4["set.contains(p) -- TRUE, hash on dinh"]
    R1 --> R2 --> R3 --> R4
  end

5.1 Khi entity có business identifier

Nếu entity có field unique + immutable từ business (vd slug, email, sku), có thể dùng nó thay ID:

@Entity
public class Project {
    @Id @GeneratedValue private Long id;

    @NaturalId
    @Column(nullable = false, unique = true)
    private String slug;    // immutable business key

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Project p)) return false;
        return slug != null && slug.equals(p.slug);
    }

    @Override
    public int hashCode() {
        return slug != null ? slug.hashCode() : 0;
    }
}

@NaturalId (Hibernate extension) thông báo cho Hibernate cache entity theo business key, cho phép session.byNaturalId(Project.class).using("slug", "taskflow").load(). Xem thêm về @Column, @Enumerated, @Embeddable trong bài Column mapping và value objects.

6. @Table customization

@Table cho phép kiểm soát tên bảng, schema, index, và ràng buộc unique ở mức DDL:

@Entity
@Table(
    name = "projects",
    schema = "taskflow",
    indexes = {
        @Index(name = "idx_project_status", columnList = "status"),
        @Index(name = "idx_project_created_at", columnList = "created_at DESC")
    },
    uniqueConstraints = {
        @UniqueConstraint(name = "uk_project_name_per_owner",
                          columnNames = {"name", "owner_id"})
    }
)
public class Project { ... }
Thuộc tínhMục đíchVí dụ
nameTên bảng DB"projects"
schemaSchema Postgres"taskflow"
indexesHint index cho schema generation@Index(...)
uniqueConstraintsComposite unique@UniqueConstraint(...)
@Table với ddl-auto

@Table(indexes = ...)@Table(uniqueConstraints = ...) chỉ có hiệu lực khi Hibernate sinh DDL (spring.jpa.hibernate.ddl-auto=create hoặc update). Production nên dùng Flyway migration — Hibernate DDL generation không phải migration tool. Annotation @Table khi đó chỉ là documentation metadata.

Liên hệ các bài khác

  • Column mapping và value objects: @Column, @Enumerated(EnumType.STRING), @Embeddable — cùng module, bài này cover phần còn lại của entity annotation. Lifecycle callback và equals/hashCode ở bài này apply trên mọi entity dù dùng annotation nào.
  • JpaRepository và derived queries: repo.save(entity) là entry point trigger @PrePersist; repo.findById() trigger @PostLoad. Hiểu lifecycle giúp debug khi callback không chạy như mong đợi.
  • @Entity & @Id generation: đã cover @Entity, @Id, @GeneratedValue, naming strategy — bài này bổ sung phần lifecycle + equals/hashCode còn thiếu.

Tóm tắt

  • Entity sống qua 4 trạng thái: transient → managed → detached → removed. Dirty check chỉ hoạt động trên Managed.
  • 7 lifecycle callback (@PrePersist, @PostPersist, @PreUpdate, @PostUpdate, @PreRemove, @PostRemove, @PostLoad) — method void, chạy trong transaction, exception roll back.
  • @PrePersist: set createdAt, default value. @PreUpdate: set updatedAt. @PostLoad: tính derived field.
  • equals/hashCode khó vì: ID null trước persist, CGLIB proxy đọc field trigger lazy load, field mutable phá hashCode.
  • Pattern đúng: equals null-safe trên ID (id != null && id.equals(p.id)), hashCode constant (getClass().hashCode()).
  • Không dùng Lombok @Data: sinh equals/hashCode trên tất cả field, toString gọi lazy field, xung đột no-arg constructor.
  • @Table: tên bảng, schema, index, unique constraint — hint DDL. Production dùng Flyway, không rely vào Hibernate auto DDL.

Tự kiểm tra

Tự kiểm tra
Q1
Entity Project vừa được tạo bằng new Project("TaskFlow"). Gọi project.getId() ngay lúc này trả về gì? Sau khi repo.save(project) thì sao? Giải thích theo trạng thái vòng đời.

Ngay sau new Project("TaskFlow"): entity ở trạng thái Transient — Hibernate chưa biết đến nó, chưa có bản ghi DB. getId() trả về null@Id với strategy IDENTITY chỉ được DB gán sau khi INSERT thành công.

Sau repo.save(project): Spring Data gọi EntityManager.persist(), Hibernate sinh câu INSERT INTO projects (...) VALUES (...) RETURNING id, DB trả ID (ví dụ 42), Hibernate inject vào field id qua reflection. Entity chuyển sang trạng thái Managed. getId() giờ trả 42L.

Đây chính là lý do pitfall equals/hashCode: nếu thêm entity vào HashSet trước khi save (khi id == null), rồi save, hashCode thay đổi và set.contains(project) trả false nếu hashCode dựa trên ID.

Q2
Vì sao @PrePersist callback KHÔNG nên gọi external API (ví dụ HTTP request tới service khác)? Giải thích theo cơ chế transaction.

Callback @PrePersist chạy trong cùng transaction đang active. Nếu bên trong callback gọi HTTP request tới external service:

  • Transaction block: toàn bộ thread + DB connection bị giữ trong khi HTTP call đang chạy. Với timeout mặc định HTTP 30s, transaction có thể giữ connection pool 30s/request — nghẽn cổ chai nghiêm trọng.
  • Partial failure: HTTP call thành công nhưng DB commit thất bại (vd constraint violation) → external service đã nhận event nhưng entity không tồn tại trong DB — inconsistency.
  • Exception roll back toàn bộ: nếu HTTP call ném exception, transaction roll back — mất insert dù logic business không lỗi.

Pattern đúng: dùng @PostPersist + transactional outbox pattern hoặc Spring @TransactionalEventListener(phase = AFTER_COMMIT) để publish event sau khi commit thành công, không trong transaction.

Q3
Đoạn code sau có bug gì?
Set<Project> projects = new HashSet<>();
Project p = new Project("Alpha");
projects.add(p);
projectRepository.save(p);
System.out.println(projects.contains(p));
Giải thích kết quả nếu entity dùng hashCode() { return Objects.hash(id); }.

Kết quả: in ra false — đây là bug điển hình.

Diễn giải:

  • Khi projects.add(p): p.id == null, Objects.hash(null) = 0. HashSet lưu p vào bucket 0.
  • Sau save(p): p.id = 42. hashCode() giờ trả Objects.hash(42) = 42.
  • projects.contains(p): HashSet tìm bucket 42 — rỗng nên trả về false. Entity vẫn nằm trong bucket 0 nhưng không ai tìm đúng chỗ nữa.

Fix: dùng hashCode constant

Thay bằng @Override public int hashCode() { return getClass().hashCode(); }. Khi đó bucket không đổi trước và sau save — contains(p) trả true.

Trade-off: tất cả entity cùng class có cùng hashCode → HashSet/HashMap chứa nhiều entity cùng class sẽ có collision rate 100%, lookup degraded từ O(1) xuống O(N). Tuy nhiên trong thực tế hiếm khi lưu nhiều entity trong Map với key là chính entity — trade-off chấp nhận được.

Q4
So sánh @PrePersist và Spring Data @CreatedDate. Khi nào dùng cái nào?

Khác biệt cơ chế:

Aspect@PrePersist@CreatedDate
SpecJPA standard (Jakarta Persistence)Spring Data extension
Cấu hình thêmKhông — method trong entityCần @EnableJpaAuditing + @EntityListeners(AuditingEntityListener.class)
DependencyKhông phụ thuộc SpringPhụ thuộc Spring Data JPA
User trackingTự lấy từ SecurityContextAuditorAware<String> bean tự động
Kế thừaMethod trong từng entity@MappedSuperclass Auditable dùng chung

Khi nào dùng @PrePersist:

  • Logic phức tạp hơn đơn giản set timestamp: validate state, set default enum, compute derived field.
  • Không dùng Spring Data (plain JPA, Quarkus, Micronaut).
  • Cần kiểm soát chính xác thứ tự callback.

Khi nào dùng @CreatedDate:

  • Chỉ cần timestamp + user tracking đơn giản.
  • Nhiều entity cùng pattern → @MappedSuperclass tránh lặp code.
  • Cần createdBy / lastModifiedBy tự động từ Spring Security.

Kết hợp được không? Có — nhưng không nên set cùng field từ cả hai nơi. Chọn một cơ chế cho mỗi field.

Q5
Entity Project dùng đúng pattern equals/hashCode. Team lead hỏi: "Tại sao equals dùng instanceof thay vì getClass() == o.getClass()?" Giải thích liên quan tới Hibernate proxy.

Lý do: Hibernate dùng CGLIB proxy subclass cho lazy loading.

Khi bạn có quan hệ @ManyToOne lazy:

Project p = taskRepository.findById(1L).orElseThrow().getProject();

p thực ra là object kiểu ProjectProxy$$EnhancerByCGLIB$$abc123 — subclass của Project do Hibernate tạo runtime. Proxy có field null và chỉ load từ DB khi truy cập getter lần đầu.

So sánh hai cách kiểm tra kiểu:

  • getClass() == o.getClass(): proxy class ProjectProxy$$... khác Project.class nên trả về false — equals fail, không tìm được entity loaded qua lazy load trong Set.
  • instanceof Project: proxy là subclass của Project → pass → equals hoạt động đúng.

Pattern Java 16+ với pattern matching: if (!(o instanceof Project p)) return false; — vừa kiểm tra kiểu, vừa cast an toàn trong 1 dòng.

Lưu ý bổ sung: instanceof trong equals vi phạm symmetry nếu subclass override equals. Nhưng với JPA entity không nên subclass business entity — đây là acceptable trade-off với proxy mechanism.

Bài tiếp theo: Tổng kết module

Bài này có giúp bạn hiểu bản chất không?

Hỏi đáp về bài này

Chưa có câu hỏi

Đặt câu hỏi

Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).

Đặt câu hỏi đầu tiên