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ái | Mô tả | ID | Persistence context track? |
|---|---|---|---|
| Transient | Vừa new, chưa biết đến DB | null | Không |
| Managed | Đang trong transaction, tracked | có (sau persist) | Có |
| Detached | Transaction kết thúc, đã rời khỏi context | có | Không |
| Removed | Đã đánh dấu xoá, chờ flush | có | Có (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:
| Callback | Khi nào kích hoạt | ID có sẵn? | Use case điển hình |
|---|---|---|---|
@PrePersist | Trước INSERT | Không (null nếu IDENTITY) | Set createdAt, default value |
@PostPersist | Sau INSERT, id đã gán | Có | Publish event, audit log |
@PreUpdate | Trước UPDATE | Có | Set updatedAt |
@PostUpdate | Sau UPDATE | Có | Invalidate cache |
@PreRemove | Trước DELETE | Có | Cleanup, validation |
@PostRemove | Sau DELETE | Có | Notify, log |
@PostLoad | Sau load từ DB | Có | Tính derived field |
@PrePersist và @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()vàhashCode()dựa trên tất cả field — vi phạm cả 3 điểm trêntoString()gọi tất cả getter — có thể trigger lazy load, gâyLazyInitializationExceptionngoà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
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ế:
instanceofthay vìgetClass() == o.getClass(): CGLIB proxy là subclass —getClass()trả class proxy, không phảiProject.instanceofpass cả proxy.id != null && id.equals(p.id): khiid == 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 trongHashMap.
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
end5.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ính | Mục đích | Ví dụ |
|---|---|---|
name | Tên bảng DB | "projects" |
schema | Schema Postgres | "taskflow" |
indexes | Hint index cho schema generation | @Index(...) |
uniqueConstraints | Composite unique | @UniqueConstraint(...) |
@Table(indexes = ...) và @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: setcreatedAt, default value.@PreUpdate: setupdatedAt.@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:
equalsnull-safe trên ID (id != null && id.equals(p.id)),hashCodeconstant (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
Q1Entity 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.▸
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 vì @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.
Q2Vì 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.▸
@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); }.▸
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ưupvào bucket0. - Sau
save(p):p.id = 42.hashCode()giờ trảObjects.hash(42) = 42. projects.contains(p): HashSet tìm bucket42— rỗng nên trả vềfalse. Entity vẫn nằm trong bucket0như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.
Q4So sánh @PrePersist và Spring Data @CreatedDate. Khi nào dùng cái nào?▸
@PrePersist và Spring Data @CreatedDate. Khi nào dùng cái nào?Khác biệt cơ chế:
| Aspect | @PrePersist | @CreatedDate |
|---|---|---|
| Spec | JPA standard (Jakarta Persistence) | Spring Data extension |
| Cấu hình thêm | Không — method trong entity | Cần @EnableJpaAuditing + @EntityListeners(AuditingEntityListener.class) |
| Dependency | Không phụ thuộc Spring | Phụ thuộc Spring Data JPA |
| User tracking | Tự lấy từ SecurityContext | AuditorAware<String> bean tự động |
| Kế thừa | Method 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 →
@MappedSuperclasstránh lặp code. - Cần
createdBy/lastModifiedBytự độ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.
Q5Entity 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.▸
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 classProjectProxy$$...khácProject.classnên trả vềfalse— equals fail, không tìm được entity loaded qua lazy load trong Set.instanceof Project: proxy là subclass củaProject→ 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
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