1. The role of lifecycle callbacks
When you start writing entities, a practical desire appears almost immediately: “could createdAt be set automatically?”, “could updatedAt be refreshed automatically?”, “could some auxiliary field be prepared after loading?”. And that is a normal desire: you do not want to duplicate the same small technical routine in every service method, otherwise the code will turn into a sitcom called “Setting createdAt Again, Episode 38”.
Lifecycle callbacks are a JPA/Hibernate mechanism that lets you attach a small method to a specific point in an entity's lifecycle. It is important to get the scale right immediately: this is not a new “architecture layer,” not a way to hide business logic, and definitely not a “secret automation button for everything.” It is more like a small automation system in an apartment building: the light turns on by itself, but it does not renovate the place, pay the bills, or negotiate silence with the neighbors after 11:00 PM.
If we put it in one sentence, a lifecycle callback is a small action that should happen near the insert/update/remove/load of a specific entity and should not change the plot of the business scenario.
To avoid drowning in the list of annotations, it helps to build a map right away. First, it is important to understand that callbacks are built into the entity lifecycle and where this code can live — inside the entity or in a separate listener. Then: at what moments these hooks really fire and why you should not imagine them as “magic methods somewhere around save()”. Once this mechanism becomes clear, the main cost of a mistake is also obvious: hidden side effects, because of which the business meaning suddenly moves from the service into the ORM pipeline. Then you can calmly draw the line: what should stay in a callback, and what should remain an explicit service scenario.
2. Callbacks in Hibernate's runtime model
To avoid treating callbacks as “spells,” it helps to fit them into the runtime picture we already built in the first module. We have a service method, we have a transaction, we have a persistence context, managed entities live there, Hibernate performs dirty checking, and then at some point flush happens, and changes turn into SQL. Callbacks are woven into this lifecycle and fire at predefined points — not because the developer felt like it, but because the ORM provider (Hibernate) goes through specific stages of work.
Flush matters here, but not as a universal “box for all annotations at once.” @PostLoad relates to loading, @PrePersist/@PreRemove relate to entity lifecycle operations, although the actual SQL may be sent later, and @PreUpdate/@PostUpdate are more tightly connected with dirty checking and flush, because the update is born exactly there.
Imagine a very simplified chain. Not as the “exact mechanism of the world down to the last byte,” but as a useful mental model:
flowchart TD
%% A very rough map: it shows that callbacks are built into the ORM lifecycle,
%% rather than describing the same sequence for every annotation
S["Service method @Transactional"] --> PC["Persistence Context: managed entities"]
PC --> OP["Lifecycle operations: persist / remove / load"]
PC --> DC["Dirty checking / update path"]
OP --> CB["Lifecycle callbacks / listeners"]
DC --> CB
DC --> FL["Flush"]
FL --> SQL["INSERT/UPDATE/DELETE"]
The diagram is rough, but useful: callbacks are built into the ORM lifecycle, not living “next to the repository call.” In plain human language, Hibernate sometimes seems to say: “Okay, I am loading/inserting/updating/deleting an entity right now — if it has a hook (callback), I’ll trigger it.”
For now, that is the main perspective to keep in mind. The specific annotations and their timing are needed not as a catalog, but so that you do not confuse small technical automation with a hidden business scenario.
3. Where callback code lives
As soon as you accept the idea “let’s attach small technical automation to the lifecycle,” the next question appears: where should this code live? Directly inside the entity or in a separate class? Both approaches exist because in a real project there are two competing forces: the convenience of locality (when everything is nearby) and the desire not to turn an entity into a “class encyclopedia” (when too much utility code gets stuffed into the entity).
JPA/Hibernate has two basic options.
The first option is a callback method right inside the entity. It looks like an ordinary entity method, only marked with a lifecycle annotation. This approach is usually good when the action is completely local: it touches only this entity's fields and does not require knowledge of the surrounding world.
The second option is an entity listener: a separate class with callback methods that you “attach” to the entity through an annotation. In terms of meaning, it is the same automation; physically, the code simply lives outside the entity. This helps keep the entity more “domain-like” and move repeated utility logic outwards.
A small table to pin down the difference without philosophy:
| Criterion | Callback inside entity | Entity listener |
|---|---|---|
| Where the code lives | In the entity class | In a separate class |
| What is convenient to automate | Local fields: timestamps, technical flags | The same thing, but without cluttering the entity |
| Main advantage | Everything is nearby, easy to see | Reusable, cleaner entity |
| Main risk | The entity grows too large | The illusion appears that “this is a separate layer, so business logic is fine” |
Notice the last row. This is one of the main psychological traps: you move code into a listener — and it starts to feel like “almost a service.” No, not a service. It is just another file.
Callback inside the entity: local automation
The simplest and, frankly, most understandable option is to keep the callback right in the entity. The key intuition here is this: if the action can be described as “the entity itself maintains its technical integrity,” then an entity-level callback looks natural. But if the action sounds like “the entity went out into the external world and did something important,” that is already a warning sign: such logic quickly stops being local technical code.
Let's start with the canonical example: createdAt. This is a technical field that is needed in almost any entity, but that you almost never want to set manually in every create scenario. At the same time, createdAt does not change the business meaning of an order: the order remains an order, we just record the moment of birth.
Example inside PurchaseOrder:
import jakarta.persistence.Entity;
import jakarta.persistence.PrePersist;
import java.time.Instant;
@Entity
public class PurchaseOrder {
// Technical field: record the creation moment, not business state
private Instant createdAt;
@PrePersist
void initCreatedAt() {
// Important: do not overwrite the value if it was set explicitly (for example, in tests)
if (createdAt == null) {
createdAt = Instant.now(); // Set the timestamp right before INSERT
}
}
}
Several things matter here, and it is useful to keep them in mind even before diving into “all annotations in the world.” First, the callback is small and single-purpose: it does not do ten things, it simply guarantees that a new order will have createdAt. Second, it does not require repositories, queries, other entities, or any other “social interaction.” Third, it is resilient to repeated invocation: if createdAt is already set (for example, in a test you wanted a fixed value), the callback does not break your intention.
And that scale of automation is exactly what callbacks were invented for. Small technical work, no “hidden business drama.”
Entity listener: moving utility automation out
Sometimes even good technical automation inside an entity starts to feel annoying. Especially when you have many entities, and all of them need “touch updatedAt”, “init createdAt”, “prepare utility markers”. Entities grow, and they become harder to read: you open InventoryItem, and half the code is about metadata, not stock and reservation.
At that moment, a perfectly reasonable desire appears: “Can I move this into a separate class so the entity is cleaner?” Yes, you can. That is exactly why entity listeners exist. The idea is simple: the entity declares that it has lifecycle listeners, and Hibernate will call the listener's methods at the appropriate moments.
Example with InventoryItem, where on update we want to refresh updatedAt:
import jakarta.persistence.Entity;
import jakarta.persistence.EntityListeners;
import java.time.Instant;
@Entity
// Attach an external listener: the code for "utility automation" lives outside the entity
@EntityListeners(InventoryMetadataListener.class)
public class InventoryItem {
// Technical field: "last modification"
private Instant updatedAt;
public void setUpdatedAt(Instant updatedAt) {
// The listener will call this setter before UPDATE
this.updatedAt = updatedAt;
}
}
And here is the listener itself:
import jakarta.persistence.PreUpdate;
import java.time.Instant;
public class InventoryMetadataListener {
@PreUpdate
public void touch(InventoryItem item) {
// Fires before UPDATE: update the technical field without touching business logic
item.setUpdatedAt(Instant.now());
}
}
From a semantic point of view, nothing fundamentally changed. We still automatically maintain a technical field without interfering with business logic. Only the location of the code changed: it lives separately now, and the entity is easier to read as a domain model.
And this is where an important mental note appears: moving code into a listener does not automatically make the solution better. It only changes code organization. If you move a business transition like “NEW → CONFIRMED” into a listener, it does not become less dangerous. It becomes more hidden. And hidden things in ORM, as we know, are usually expensive.
Stateless listener: a listener without state
Lifecycle listeners often look like “just ordinary objects, like services, you can store fields, you can accumulate statistics, you can keep a little cache...”. And this is where Hibernate usually pulls out a “NOPE” sign (figuratively). A listener should be stateless — that is, it should not keep mutable state between calls. The reason is simple: you do not control exactly how the ORM creates and uses listener instances, in which thread, how often, and you do not want to turn it into a source of races, surprises, and unstable bugs.
The easiest way to feel the problem is to imagine that the listener decided to “remember” the last processed id:
import jakarta.persistence.PreUpdate;
public class BadListener {
// Bad: state between invocations. You do not control the listener's threads/instances.
private Long lastId;
@PreUpdate
public void beforeUpdate(Product p) {
// Dangerous: with parallel updates, the value will "jump" and mislead you
lastId = p.getId();
}
}
At first glance, this looks harmless. At second glance, you start asking: who reads lastId? in which thread? what happens if different entities are updated in parallel? why did the listener suddenly start acting like a tiny in-memory database?
So the practical rule is simple: a listener is a “function in class form.” It takes an entity, performs a small action, and goes away. No memory, no history, no philosophy.
And there is one more important reason: if you start storing state, very quickly you will want to drag dependencies, repositories, and other things in there. And that is already the road to a callback becoming a hidden scenario with queries and side effects. That is exactly what should be avoided here.
Choice: entity callback or listener
There is no eternal right answer to the “inside the entity or in a listener” choice. This is not like choosing int or long, where you can just open a range table. Here, readability and responsibility matter more. If the action is completely local and makes the entity more self-sufficient, a callback inside the entity looks honest: you opened the class and see that it sets its own technical fields. If you have many entities with the same technical routine, a listener helps avoid copying the same code.
It is useful to ask yourself one simple question: “If I show this code to someone in code review without context, will they say ‘okay, clear’ in one sentence?” If yes, then it is a good callback candidate. For example: “we set createdAt before insert,” “we refresh updatedAt before update.” If, to explain it, you have to start with: “well, here the order confirms itself, and also checks stock, and if there is not enough, then...”, that is almost certainly not a callback, but a business scenario. And its place is in a service method under an explicit transaction.
If you remember how we built the transaction boundary and how carefully we approached locking, this rule is especially important for inventory. Everything related to concurrency, locks, reservation, status changes, and coordinating multiple entities should be as visible as possible in the service code. A callback for such things is like hiding an important “Stop” button under the carpet. You can press it, but why?
4. Typical mistakes with callbacks and listeners
Mistake #1: treating a callback as a “backup service layer.”
This usually starts innocently: “I'll set a default status,” then “I'll generate an order number,” then “why not reserve stock right here.” The problem is that the service method stops being a scenario and turns into decoration: the reader sees one thing, while something else is actually changing. The more important the action is for the business, the less hidden it should be.
Mistake #2: writing a callback that is dozens of lines long because “it is more convenient that way, everything in one place.”
A long callback is almost always a sign of wrong responsibility. When the method grows into the size of a “mini-service,” it starts depending on call order, flush details, and which operation is actually happening (insert or update), and diagnosis turns into archaeology. A callback should be short: one action, one meaning.
Mistake #3: making the listener stateful and trying to store “memory” in it.
As soon as the listener gets fields and starts “remembering” something, you enter a world of unpredictable behavior, because you do not control the listener's lifecycle. Even if it “works” today, tomorrow on a different environment or with a different operation order it will start behaving strangely. Stateless is not aesthetics, but basic engineering insurance.
Mistake #4: thinking that moving code into a listener automatically means “doing it right.”
Moving code into a separate class does not change the meaning. If a business effect lives in the listener, it does not become “correct,” only “better hidden.” And in the ORM world, hidden effects are almost always more expensive than explicit ones: they are harder to test, harder to explain from the SQL log, and harder to debug during rollbacks.
Mistake #5: duplicating the same automation both in the entity and in the listener.
This looks minor until you discover that updatedAt is being updated twice, or createdAt is being overwritten, or one place considers a field required while the other does not. Every technical field should have one “owner”: either an entity-level callback, or a listener, or a service. Otherwise you get unexpected data changes and the feeling that the application lives its own life (while you are just moonlighting as an observer).
GO TO FULL VERSION