skip to content

When do standard JPA lifecycle callbacks stop being enough, so that you reach for Hibernate's Interceptor or its event listener SPI instead? What can those do that an @PreUpdate method cannot?

level: seniorimportance: should knowfreq 30%

answer

  1. onFlushDirty gives currentState AND previousState
  2. return true = I changed currentState, write my version
  3. session-scoped interceptor can hold per-session context safely
  4. event SPI: POST_COMMIT_* and collection events, via Integrator
  5. Hibernate-specific, signature drifts across major versions

basics

~20 s

Reach for them when you need the previous values of an entity, cross-cutting behaviour without annotating entities, collection-change events, or logic that must run after commit. Hibernate's Interceptor receives both current and previous state arrays; the event SPI adds post-commit and collection events.

solid answer

~50 s

JPA callbacks give you one entity, in its current state, at flush time. Three limits push you to Hibernate's own hooks: 1. **No previous values.** `Interceptor.onFlushDirty(entity, id, currentState, previousState, propertyNames, types)` hands you both arrays, so you can compute a real before/after diff — the basis of any change-log audit. 2. **No opt-out of annotating entities.** An `Interceptor` registered on the `SessionFactory` (`hibernate.session_factory.interceptor`) or per session (`sessionFactory.withOptions().interceptor(...)`) applies to everything, including entities you do not own. 3. **No post-commit and no collection events.** The event listener SPI, registered through `EventListenerRegistry` from an `Integrator`, exposes `EventType.POST_COMMIT_INSERT/UPDATE/DELETE` and the collection events (`PRE_COLLECTION_UPDATE`, `POST_COLLECTION_UPDATE`), which callbacks simply do not have. An `Interceptor` can also *change* the values being written: mutate `currentState` and return `true`. The costs are real — the API is Hibernate-specific and unportable, it runs inside flush so the same re-entrancy dangers apply, and a `SessionFactory`-scoped interceptor must be thread-safe.

code

java · 19 lines
java
public class ChangeLogInterceptor implements Interceptor {

    @Override
    public boolean onFlushDirty(Object entity, Object id,
                               Object[] currentState, Object[] previousState,
                               String[] propertyNames, Type[] types) {
        for (int i = 0; i < propertyNames.length; i++) {
            if (!Objects.equals(previousState[i], currentState[i])) {
                changes.add(new FieldChange(entity.getClass(), id,
                        propertyNames[i], previousState[i], currentState[i]));
            }
        }
        return false;   // true would mean "I modified currentState, write it"
    }
}

Session session = sessionFactory.withOptions()
        .interceptor(new ChangeLogInterceptor())
        .openSession();

go deeper

for a junior

Know that Hibernate has hooks beyond the JPA annotations and that they can see the old values of an entity.

for a middle

Name Interceptor.onFlushDirty and its previousState array, and know that interceptors can be registered per session or globally.

for a senior

Compare the three mechanisms, know that only the event SPI offers post-commit and collection events, and address thread safety and the return-true mutation contract.

for a principal

Weigh lock-in and upgrade cost against the capability, and decide whether hand-rolled interception is the right layer at all versus a purpose-built history mechanism or database-side capture.

## The three Hibernate hooks Beyond JPA callbacks, Hibernate offers: - **`Interceptor`** — a single interface with methods called around session operations. Register it per session, `sessionFactory.withOptions().interceptor(myInterceptor).openSession()`, or globally via `hibernate.session_factory.interceptor`. - **Event listeners** — Hibernate's internals are themselves implemented as listeners on typed events (`PRE_UPDATE`, `POST_UPDATE`, `POST_COMMIT_UPDATE`, `PRE_COLLECTION_UPDATE`, `POST_LOAD`, `FLUSH`, and so on). You append your own with `EventListenerRegistry`, usually from an `Integrator` discovered by the service loader. - **JPA callbacks** — the portable subset, implemented on top of the same event machinery. ## What the Interceptor gives you that callbacks do not **Previous state.** The signature is the heart of the matter: `boolean onFlushDirty(Object entity, Object id, Object[] currentState, Object[] previousState, String[] propertyNames, Type[] types)` `previousState` is the snapshot Hibernate took when the entity was loaded — exactly what dirty checking compares against. With it you can emit 'field `status` changed from ACTIVE to CLOSED', which no `@PreUpdate` method can produce because the entity holds only its current values. The matching methods are `onSave` (inserts) and `onDelete`. **Mutating what gets written.** Returning `true` from `onFlushDirty` or `onSave` tells Hibernate that you modified `currentState`, and those modified values go into the statement. That is a genuine interception point, not just an observation point. **Global reach without touching entities.** No annotation on any entity; the interceptor sees every flush in its scope. Good for a policy that must not be opt-in (encryption, tenant stamping, change capture) and for entities defined in a library you cannot annotate. **Other hooks**: `preFlush`/`postFlush`, `afterTransactionCompletion`, `getEntity` for custom resolution, `instantiate` for custom instantiation, `onCollectionUpdate`/`onCollectionRecreate`/`onCollectionRemove` for collection-level changes. ## What the event SPI adds on top **Post-commit events.** `POST_COMMIT_INSERT`, `POST_COMMIT_UPDATE`, `POST_COMMIT_DELETE` fire after the transaction has actually committed, and carry a success flag. This is the one thing that structurally cannot be expressed with a JPA callback, and it is the correct place for 'now that this is durable, do X' — although for anything crossing a process boundary an outbox is still safer, because a crash between commit and listener leaves no trace. **Fine-grained typing.** You register against a specific `EventType` and receive a typed event object carrying the entity, its id, the state arrays and the source session, rather than a single interface with a dozen methods you must stub. **Composition.** Listeners for one event type form a list; you append or prepend yours around Hibernate's own default listener, which is how features like Envers wire themselves in. ## Registration in practice An `Integrator` implementation, declared as a `java.util.ServiceLoader` service, receives the `SessionFactoryServiceRegistry` at bootstrap; from it you take the `EventListenerRegistry` and call `appendListeners(EventType.POST_COMMIT_UPDATE, myListener)`. Session-scoped interceptors, by contrast, are attached when opening the session, which lets you carry per-session context (a user identity, a correlation id) as instance fields safely — a genuine advantage over stateless entity listeners. ## The costs you must acknowledge - **Portability.** These are Hibernate APIs. Committing to them means committing to Hibernate; if that is already true, the cost is mostly rhetorical, but say it out loud. - **Stability.** The interceptor and event SPI are internal-adjacent; signatures have shifted across major Hibernate versions, so an upgrade can require code changes. - **Thread safety.** A `SessionFactory`-scoped interceptor is shared by every session; instance fields are shared mutable state. Session-scoped interceptors are the safe place for per-unit-of-work state. - **Same flush-time hazards.** You are running inside the flush, so calling back into the session to write more entities is as dangerous here as it is in a callback. The supported way to modify data is the state array, not a new persist call. - **Invisibility.** An interceptor rewriting values is action at a distance: nothing at the call site suggests the value being stored differs from the value assigned. Document it prominently or a future maintainer will lose a day. ## Choosing Callbacks for entity-local, portable, current-state-only logic. Interceptor when you need previous values, global reach or to rewrite what is written. Event listeners when you need post-commit or collection-level granularity, or when you are extending Hibernate rather than decorating your domain. And if the requirement is a complete change history, consider whether a purpose-built solution or database-side capture is a better fit than hand-rolling on either SPI.

  • Why can an Interceptor produce a before/after change log when a @PreUpdate method cannot?
    The interceptor's onFlushDirty receives both currentState and previousState arrays — previousState being the snapshot Hibernate captured when the entity was loaded, the same one dirty checking compares against. A @PreUpdate method is handed only the entity object, which holds its current values; the old values were overwritten in memory the moment the setter ran, so there is nothing left to diff against.
  • You register an Interceptor on the SessionFactory and keep the current user in a field. What goes wrong?
    A SessionFactory-scoped interceptor is a single shared instance used by every session on every thread, so that field is shared mutable state and requests will stamp each other's user. Either derive the user from a thread-local, or open a session-scoped interceptor per unit of work with sessionFactory.withOptions().interceptor(...), where per-session instance fields are safe.

saying these in an interview costs you the question

  • Claiming JPA callbacks can see the previous values of a changed entity
  • Storing per-request state in a SessionFactory-scoped interceptor's fields
  • Persisting new entities from inside an interceptor's flush callbacks instead of modifying the state array
  • Assuming these SPIs are portable JPA, or that their signatures are stable across major Hibernate versions
  • Using post-commit listeners as a substitute for an outbox and calling the result reliable delivery

context