Skip to main content

Hibernate 7 Interceptors: The State-Array Contract, Global Registration, and What Bulk Updates Skip

Scattering audit and normalization logic across every service method that saves an entity is a bug waiting for a forgotten call site. This guide covers Hibernate 7 interceptors: implementing Interceptor directly (EmptyInterceptor moved to an internal, non-extendable class, not removed), the state-array mutation contract, session-scoped vs. globally-registered interceptors, and why bulk HQL updates never call them at all.

Every entity that gets saved or updated in a real application tends to need the same few extra things done to it — a timestamp set, a value normalized, a change logged. Scattering that logic across every service method that calls save() is exactly the kind of repetition that turns into a bug the first time someone adds a new save path and forgets the extra step.

This is the beginner-through-expert guide to Hibernate interceptors — implementing Interceptor directly, the state-array mutation contract, session-scoped versus globally-registered interceptors, and the one thing interceptors never see — built and verified against a real Spring Boot project. Every code block links to the file it came from and every console line was copied from a real, committed test run in the hibernate-demo companion repository.

Versions used in this post. Hibernate ORM 7.4.5.Final, Spring Boot 4.1.1, JDK 25. The claim that EmptyInterceptor was “removed in Hibernate 7” is corrected below with what javap against the actual jar shows instead.

Why this belongs in the persistence layer, not the service layer

Setting an audit field or normalizing a value inside every Service method that touches an entity works — until the tenth call site forgets to do it, or a new developer adds an eleventh save path without knowing the pattern exists. An interceptor moves that logic to a single place that Hibernate itself calls on every save or update, regardless of which service method triggered it, so there is no call site left to forget.

The mental model: an interceptor mutates a raw array, then says so with a boolean

An interceptor callback like onSave does not get handed the entity object to modify with a normal setter call — it gets the exact array of property values Hibernate is about to turn into an INSERT, indexed in Hibernate’s own internal property order. Changing that array in place is how the interceptor changes what gets persisted, and the return value is not incidental — it is the only way Hibernate knows whether to bother using the (possibly changed) array at all.

The state-array contract: mutate in place, then say so state = [“wash the car”, 3] the array Hibernate built onSave(…) state[0] = “WASH THE CAR” mutated in place return true INSERT … ‘WASH THE CAR’ Hibernate flushes the mutated array Returning false (or leaving the array untouched) tells Hibernate: nothing changed, flush the original values.

Nothing here happens through the entity’s own setters — by the time an interceptor runs, Hibernate has already computed the state it plans to persist, so mutating the entity object directly would not change what gets written. The array is the actual mechanism.

The smallest thing that works: a session-scoped interceptor

public class UppercasingInterceptor implements Interceptor {

    @Override
    public boolean onSave(Object entity, Object id, Object[] state, String[] propertyNames, Type[] types) {
        return uppercaseNameIfPresent(state, propertyNames);
    }

    @Override
    public boolean onFlushDirty(Object entity, Object id, Object[] currentState, Object[] previousState,
            String[] propertyNames, Type[] types) {
        return uppercaseNameIfPresent(currentState, propertyNames);
    }

    private boolean uppercaseNameIfPresent(Object[] state, String[] propertyNames) {
        for (int i = 0; i < propertyNames.length; i++) {
            if ("name".equals(propertyNames[i]) && state[i] instanceof String s) {
                String upper = s.toUpperCase(java.util.Locale.ROOT);
                if (!upper.equals(s)) {
                    state[i] = upper;
                    return true; // tells Hibernate: yes, I changed the state array, flush it
                }
            }
        }
        return false;
    }
}

UppercasingInterceptor.java. The property is looked up by name in propertyNames rather than by a fixed array index — that array’s order is Hibernate’s own internal property ordering, not necessarily the entity’s declaration order, so indexing by name is the only safe way to find a specific field.

try (Session session = sessionFactory.withOptions().interceptor(interceptor).openSession()) {
    session.getTransaction().begin();
    Task task = new Task("wash the car", 3);
    session.persist(task);
    session.getTransaction().commit();
}

InterceptorTest.java persists with the interceptor, then updates through it, then reloads from the database with a plain session each time to prove what was actually stored:

RESULT[interceptor-session-scoped-mutation]: onSave called 1 time(s), onFlushDirty called 1 time(s) -- name after insert, reloaded from the database: 'WASH THE CAR' | name after update, reloaded from the database: 'BUY MILK' -- both mutations happened inside the interceptor's state array, not in application code, and both are visible in what was actually persisted.

Full transcript: 24-session-scoped-mutation.txt. Full chapter: companion repo chapter 24.

The correction: there is no base class to extend any more

Older guidance — including an earlier version of this post — says to extend org.hibernate.EmptyInterceptor and override just the callbacks needed, since implementing Interceptor directly used to mean writing every method in the interface. Disassembling hibernate-core-7.4.5.Final.jar with javap shows that every method on Interceptor — onSave, onFlushDirty, onDelete, onLoad, findDirty, and the rest — is declared default, so there has been no reason to extend a no-op base class since that changed. The public org.hibernate.EmptyInterceptor class was not deleted, but it did move: the class still in the 7.4.5.Final jar is org.hibernate.internal.EmptyInterceptor, a final, singleton-only class (public static final Interceptor INSTANCE) that is not meant to be extended by application code any more — confirmed by disassembly, not assumed from the word “removed.”

A second, smaller signature change worth knowing. onSave/onFlushDirty/onDelete/onLoad all take an identifier parameter typed Object id, not java.io.Serializable id — Hibernate 6 widened this across the whole interface, since an application is free to use a non-Serializable identifier type. Code written against the old Serializable signature will not compile against 7.4.5.Final without this change.

Session-scoped versus globally-registered: the two ways to attach an interceptor

sessionFactory.withOptions().interceptor(myInterceptor).openSession();

attaches the interceptor to that one Session only. A plain sessionFactory.openSession() called anywhere else is completely unaffected — InterceptorTest.java proves this by opening a plain session and confirming a name is left untouched:

RESULT[interceptor-scoping]: a plain sessionFactory.openSession() with no interceptor supplied left the name exactly as the application wrote it -- 'mow the lawn' -- the interceptor used by the previous test is scoped to the specific Session it was passed to via withOptions().interceptor(...), not to the SessionFactory as a whole.

Full transcript: 24-interceptor-scoping.txt.

Session-scoped vs. globally-registered interceptor withOptions().interceptor(i).openSession() this Session: affected every other: untouched hibernate.session_factory.interceptor = i openSession() #1: affected openSession() #2: affected Neither openSession() call on the right explicitly mentions the interceptor — it was set once, at SessionFactory build time, which is exactly what a Spring Boot HibernatePropertiesCustomizer does.

To register one for every Session a SessionFactory ever opens, set hibernate.session_factory.interceptor once at build time — this is exactly the property a Spring Boot HibernatePropertiesCustomizer bean sets under the hood when it calls properties.put("hibernate.session_factory.interceptor", interceptor):

new StandardServiceRegistryBuilder()
        .applySetting("hibernate.session_factory.interceptor", globalInterceptor)
        .build();

InterceptorTest.java builds this on a standalone, non-Spring registry deliberately — registering a global interceptor on this repo’s shared Spring-managed SessionFactory would retroactively affect every other chapter’s tests that reuse the same cached Spring context:

RESULT[interceptor-global-via-property]: hibernate.session_factory.interceptor set once at SessionFactory build time -- onSave fired 2 times across 2 independent openSession() calls that never mentioned the interceptor themselves -- this is the mechanism a Spring Boot HibernatePropertiesCustomizer bean uses to register an interceptor application-wide.

Full transcript: 24-global-via-property.txt.

  • Global interceptors are singletons shared across every thread that opens a session — keep them stateless, or make any internal state thread-safe, since UppercasingInterceptor‘s own call counters use AtomicInteger for exactly this reason.

What breaks: bulk HQL updates never call the interceptor at all

session.createMutationQuery("update Task set name = 'renamed by bulk update' where id = :id")
        .setParameter("id", id)
        .executeUpdate();

InterceptorTest.java attaches the same interceptor used earlier, runs a bulk update through it, and checks whether onFlushDirty fired:

update task t1_0 set name='renamed by bulk update' where t1_0.id=?
RESULT[interceptor-bulk-update-bypass]: onSaveCalls after the initial insert=1 | onFlushDirtyCalls after a bulk 'update Task set name = ...' executeUpdate()=0 (still 0) | actual persisted name: 'renamed by bulk update' -- the bulk HQL statement changed the row directly in the database without loading a Task instance into the persistence context at all, so onFlushDirty (which needs a managed entity's dirty state to fire against) never had anything to call.

Full transcript: 24-bulk-update-bypass.txt.

The fingerprint of this gap in production. Audit fields that are always correct for records saved through normal entity code, but stale or missing on rows touched by a batch job or an admin “bulk update” feature, is exactly what this test demonstrates: a bulk HQL or native SQL statement changes rows directly in the database and never loads a managed entity, so there is nothing for onFlushDirty to fire against. Anything an interceptor is relied on for — auditing, denormalized field maintenance — has to be handled separately for any bulk-write code path, deliberately, not assumed to be covered.
  • StatelessSession never calls interceptor callbacks at all, by design — it exists specifically to skip persistence-context machinery for bulk-style work, which is the same reason it skips the first-level cache and dirty checking entirely.
  • Mutate the state array only when the value actually needs to change — uppercaseNameIfPresent checks !upper.equals(s) before returning true, because an interceptor that mutates on every flush regardless of whether anything changed is a subtle source of extra UPDATE statements. See companion repo chapter 24.
Should you reach for an interceptor at all? For genuinely cross-cutting concerns that apply to every entity in the system — a global audit timestamp, a security check on every load — yes, an interceptor is the right layer. For logic specific to one or two entity types, a JPA @PrePersist/@PreUpdate lifecycle callback on the entity itself is usually the better fit: it is portable across JPA providers, and it does not require reaching into a raw Object[] to make a change. Reach for the state-array-level interceptor specifically when the logic must be provider-wide and entity-agnostic.

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.