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:
Đường đi giữa bốn trạng thái ngắn và một chiều: new cho ra Transient, persist/save đưa sang Managed, hết transaction (hoặc detach/clear) là rơi về Detached, merge/save kéo ngược lại Managed, còn remove đẩy sang Removed rồi biến mất khi flush.
| 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.

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.

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í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
Projectvừa được tạo bằngnew Project("TaskFlow"). Gọiproject.getId()ngay lúc này trả về gì? Sau khirepo.save(project)thì sao? Giải thích theo trạng thái vòng đời. - Q2Vì sao
@PrePersistcallback 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. - 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ùnghashCode() { return Objects.hash(id); }. - Q4So sánh
@PrePersistvà Spring Data@CreatedDate. Khi nào dùng cái nào? - Q5Entity
Projectdùng đúng pattern equals/hashCode. Team lead hỏi: "Tại sao equals dùnginstanceofthay vìgetClass() == o.getClass()?" Giải thích liên quan tới Hibernate proxy.
Bài tiếp theo: Tổng kết module
Bài này đáng gửi cho bạn học cùng?
Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.
Bài này có giúp bạn hiểu bản chất không?
Ôn phỏng vấn
Bài này trả lời được các câu phỏng vấn sau — tự trả lời thử trước khi mở đáp án.
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