CodeGym /Kursy /Hibernate deep-dive /Responsibility boundary in callbacks

Responsibility boundary in callbacks

Hibernate deep-dive
Poziom 22, Lekcja 3
Dostępny

1. Introduction

When a developer first learns about callbacks, their inner voice usually says: “Oh, cool! I can make it so that it does things by itself!” This is the same voice that later suggests “let's enable EAGER everywhere” and “let's open OSIV so it does not crash.” The problem is not the desire to simplify life, but the fact that ORM simplifications can easily turn into inexplicable magic, especially when a project lives for months and different people read the code.

A responsibility boundary is needed not for theoretical beauty. It is needed so that your OrderService reads like a clear story: “created an order → reserved stock → confirmed it” — and not like a detective story where half the events happen “off-screen” in @PreUpdate, and you learn about them only from strange SQL in the logs. In the Hibernate deep-dive course, this is especially important: we learn to connect Java code, persistence context, and SQL. If business logic is hidden in callbacks, this connection breaks.

2. Model: callback inside flush

If you want to keep one thought in mind, let it be this: a callback is not “part of your service”, but part of ORM synchronization mechanics. It can run on commit, during flush-before-query, during an intermediate flush, and sometimes at a moment you as a developer would not expect at all (we have already discussed this in the flush/dirty checking topics).

Look at the chain, as it usually appears in a Spring Boot application with Hibernate:

flowchart TD
    A["Service method @Transactional"] --> B["Change managed entity"]
    B --> C["Hibernate dirty checking"]
    C --> D["Flush cycle"]
    D --> E["Lifecycle callbacks / listeners"]
    E --> F["Generated SQL: INSERT/UPDATE/DELETE"]
    F --> G["Commit"]

That is why a callback should be a “micro-action” that is safe to perform at any point in this cycle, without turning flush into a plot twist.

In Commerce Persistence Lab, we are already used to this: the transactional boundary lives in the service layer, open-in-view=false, and we do not want entities to “secretly” change business meaning. A callback is the place for minimal technical automation that helps the infrastructure, but does not take over the scenario.

3. Technique vs business effect

The words “technical” and “business-related” sound abstract, so let's ground them. Technical automation is something that does not change the meaning of the operation for the user and the domain. A business effect is something that should be obvious from the service method name and from the business story itself.

For example, setting createdAt on insert is technical automation. The user does not perceive it as a “business process step”; it is just metadata. But “confirm the order” or “generate the order number” are business events. They change what the order “means” in the system, and they should be visible in an explicit place.

In a project, this becomes very obvious on the PurchaseOrder entity. Suppose you hide the automatic status transition NEW → CONFIRMED in @PreUpdate. What does the service reader see then?

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class OrderService {

    @Transactional
    public void updateDeliveryAddress(Long orderId) {
        // On the surface: just updated the address.
        // In reality: somewhere in a callback the order status may have changed.
        // Important: the reader of the method does not see this business-story step.
    }
}

The service looks harmless, while the SQL and final state already contain a surprise. That is why the responsibility boundary starts with an honest question: “If I remove the callback, will the business still be understandable?” If the answer is “no”, then this is not callback logic.

4. Sign #1: radius of influence

When we say “a callback should be small”, this is not about the number of lines alone (although that too). It is about the radius of influence. A good callback usually touches only the entity it is attached to, and only its technical fields.

An example of “normal” automation for InventoryItem: update updatedAt before update. This affects only one row in the same table and does not change the business rules for reservation.

import jakarta.persistence.PreUpdate;
import java.time.Instant;

public class InventoryMetadataListener {

    @PreUpdate
    public void touch(InventoryItem item) {
        // Technical automation: set a modification timestamp.
        // Important: do not change business state and do not hit the DB.
        item.setUpdatedAt(Instant.now());
    }
}

Now imagine someone decided to “help” and, in that same callback, perform a business action: if availableQty drops below zero, “fix” it or “reserve automatically”. That is no longer automation, but interference with business rules, and at flush time. If you have optimistic/pessimistic locks, if you have invariant checks, if you need a clear failure — all of that should happen in the service scenario, not in a listener’s dark corner.

An analogy is useful here: a callback is an “automatic door closer”. It should gently close the door (update metadata) and must not suddenly change the apartment layout (move walls and start a renovation).

5. Sign #2: database queries

After the phrase “you cannot call EntityManager or Query inside a callback,” the usual question is: “Why not, if technically you can?” And that is a great question, because the answer here is not religious, but engineering-related.

First, a callback runs inside the ORM synchronization process. If you start making queries during flush, you may get very strange effects: repeated flushes, extra graph loading, unexpected SQL ordering, and sometimes recursive chains that are hard to diagnose.

Second, a database query means the logic is no longer “local”, but depends on the current data state. That immediately raises the question: “In which transaction? Under which lock? What if something changed concurrently?” These questions belong to the world of the service layer, not to the world of callbacks.

Here is a typical anti-example that many people try to write (and yes, sometimes even manage to make it work):

import jakarta.persistence.PreUpdate;

public class ProductBadListener {

    @PreUpdate
    public void doSomething(Product product) {
        // Bad: the logic requires DB access, but a callback is not the place for that.
        // Reason: we are inside the flush cycle, and additional queries may trigger repeated flushes/recursion.
        // entityManager.createQuery("update ...").executeUpdate();
    }
}

If the logic requires “finding something from the database”, it should live in the service, where you can clearly see the unit of work, the transaction, and the consequences. For example, generating an orderNumber based on a sequence/table is definitely service logic (or a separate component invoked by the service), because it is a domain-significant action and almost always requires consistency.

6. Sign #3: unit of work step

One of the best tests for the right boundary sounds like this: read the service method out loud (yes, it is funny, but it works). If the service method, by its name and body, does not explain what happens to domain entities — then part of the story is hidden.

In our project, PurchaseOrder is an aggregate, and changing its status is an important domain action. Therefore, order confirmation should be explicit:

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class OrderService {

    private final PurchaseOrderRepository orderRepository;

    public OrderService(PurchaseOrderRepository orderRepository) {
        // Explicit dependency: the service controls the scenario (unit of work), not the callback.
        this.orderRepository = orderRepository;
    }

    @Transactional
    public void confirm(Long orderId) {
        // Load the aggregate within the transaction.
        PurchaseOrder order = orderRepository.getReferenceById(orderId);

        // The business transition should be a visible step in the scenario.
        order.confirm(); // business transition is visible here, not hidden in a listener
    }
}

Notice a subtle thing. We are not arguing where it is better to keep the business logic — in the entity method confirm() or directly in the service. In the training project, we often keep simple transitions in entity methods, while the service is responsible for orchestration. But the important thing is different: the very fact of the transition must be visible. If it is hidden in @PreUpdate, the service reader does not understand what happened, and you lose control.

7. Sign #4: time and side effects

Time is a tricky thing. Instant.now() in a callback for updatedAt is usually acceptable: it is a technical timestamp, and if it differs by a couple of milliseconds, the world will not collapse. But once you start using time to generate business identifiers or “smart rules”, you enter the zone of: “this must be visible and controllable”.

A bad example (specifically as business logic) is generating orderNumber inside a listener:

import jakarta.persistence.PrePersist;
import java.time.Instant;

public class PurchaseOrderBadListener {

    @PrePersist
    public void beforeInsert(PurchaseOrder order) {
        // Bad: this is a business identifier, not a technical timestamp.
        // Problem: the scenario "create order" becomes implicitly "create order + generate number".
        order.setOrderNumber("PO-" + Instant.now().toEpochMilli()); // business identifier in the shadows
    }
}

Why is this bad specifically as a responsibility boundary, even if it “works”?

Because orderNumber is a business key, and uniqueness, search, integrations, and report references depend on it. If it is generated in a callback, you get implicit behavior: the service “creates an order”, but in fact it “creates an order and generates a number”. That should be in an explicit creation scenario so that, when reading the code, there is no “suddenly a number appeared” effect.

An even stricter ban is any external side effects (sending an email, creating a record in another table via a separate repository, publishing events). Such things must be part of the service, otherwise you get the perfect mix: “implicit”, “hard to test”, and “hard to roll back on failure”.

8. Table: callback or service layer

To avoid guessing, it is useful to have a “cheat sheet”. Not as a strict law, but as a quick review filter. In the context of our course, it helps keep the project predictable.

Question about the logic If the answer is “yes” — more often callback If the answer is “yes” — more often service layer
Is it a purely technical field (createdAt, updatedAt)? Yes No
Does the action affect only the current entity? Yes No (affects other entities/aggregates)
Does it not require queries/repositories/EntityManager to execute? Yes No
Does this action not change business meaning (statuses, numbers, product availability)? Yes No
Should the behavior be the same for all “paths” of update/delete? Sometimes yes, but be careful Yes (the service controls the scenario)
Must the action be visible in code as a unit of work step? No Yes
Does the action depend on concurrency, locking, retries? No Yes

Notice that there is no magical word “listener” here. Moving code out of an entity into a separate listener is about organization, not about “making business logic safe”. If the business logic is hidden — it is hidden both in an entity callback and in a listener.

9. Boundary examples in Commerce Lab

It is important that our theory does not hang in the air. In the lab project, we can make three very characteristic examples: one for @PrePersist, one for @PreUpdate, and one for something that is definitely not a callback.

An example of a technical createdAt inside PurchaseOrder (local, short, no story):

import jakarta.persistence.Entity;
import jakarta.persistence.PrePersist;
import java.time.Instant;

@Entity
public class PurchaseOrder {

    private Instant createdAt;

    @PrePersist
    void initCreatedAt() {
        // Technical creation timestamp: useful, local, and without domain meaning.
        // Important: do not touch statuses/numbers/integrations — only metadata.
        if (createdAt == null) createdAt = Instant.now();
    }
}

Here the action is easy to explain in one sentence: “If we create an order, set createdAt.” It does not change the domain story; it is just a timestamp.

An example of a technical updatedAt in a listener for InventoryItem (same thing, but moved outside):

import jakarta.persistence.PreUpdate;
import java.time.Instant;

public class InventoryMetadataListener {

    @PreUpdate
    public void touch(InventoryItem item) {
        // Technical update timestamp: safe at any moment in the flush cycle.
        item.setUpdatedAt(Instant.now());
    }
}

Again — the “door closer”: useful, local, and without scenario magic.

And an example that should live in the service: order confirmation.

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class OrderService {

    @Transactional
    public void confirm(PurchaseOrder order) {
        // Explicit business-scenario step: the service reader should see the status change.
        // Important: this is not "technical automation", but a domain action.
        order.setStatus(OrderStatus.CONFIRMED);
    }
}

Yes, the code is short. But it is short in the right place. A status transition is a domain-significant step. Even if it takes one line, that does not make it “callback logic”. Sometimes the most dangerous thing is precisely “business logic in one line, hidden so that nobody sees it”.

10. Typical mistakes when choosing where logic lives

Mistake #1: “Since it is only one line, let it live in the callback.”
The most common trap is measuring a decision by the number of lines. As a result, an “innocent” order.setStatus(CONFIRMED) lives in @PreUpdate, and then you spend a week looking for why the status changes “by itself”. The criterion is not code length, but whether the business meaning changes. If it changes — it is a service step, even if it is short.

Mistake #2: generating the business order number in @PrePersist.
An order number is not a “technical field”. It participates in search, integrations, and often uniqueness. If it is generated in a callback, the service scenario becomes incomplete: “create an order” actually means “create an order and assign it a number”. This should be explicit in the service (or in a separate generator, but invoked by the service) so that the code reads honestly.

Mistake #3: trying to hit the database from a listener (via EntityManager, a repository, or a “clever” Spring bean).
Sometimes people think: “I will just inject a repository and check something.” But as soon as a callback starts depending on queries, you move logic that should live in the unit of work scenario into the flush cycle. This worsens diagnostics and creates dangerous effects: extra queries, unexpected recursion, and failure points that are hard to explain.

Mistake #4: mixing technique and business in one callback.
A very typical monster: one listener sets createdAt, generates orderNumber, sets status, and at the same time “adjusts” order items. Such a mix makes entity behavior unreadable: to understand what actually happens on save, you have to remember the listener rules. Better: keep technical concerns in a minimal callback, business concerns in the service.

Mistake #5: duplicating logic — both in the service and in the callback.
Sometimes it happens the other way around: you first made updatedAt in a callback, then somebody “just in case” added updatedAt = now() in the service. As a result, the field changes twice, and sometimes this even causes extra UPDATEs, because dirty checking sees an extra modification. Each automatic field should have one “owner” — either the callback or the service.

1
Zadanie
Hibernate deep-dive, poziom 22, lekcja 3
Niedostępne
Techniczny callback i jawna aktywacja w serwisie
Techniczny callback i jawna aktywacja w serwisie
1
Zadanie
Hibernate deep-dive, poziom 22, lekcja 3
Niedostępne
`@PostLoad` dla pola pomocniczego i oddzielna metoda biznesowa `archive`
`@PostLoad` dla pola pomocniczego i oddzielna metoda biznesowa `archive`
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION