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 thatEmptyInterceptorwas “removed in Hibernate 7” is corrected below with whatjavapagainst 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.
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/onLoadall take an identifier parameter typedObject id, notjava.io.Serializable id— Hibernate 6 widened this across the whole interface, since an application is free to use a non-Serializableidentifier type. Code written against the oldSerializablesignature 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.
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 useAtomicIntegerfor 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.
StatelessSessionnever 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 —
uppercaseNameIfPresentchecks!upper.equals(s)before returningtrue, because an interceptor that mutates on every flush regardless of whether anything changed is a subtle source of extraUPDATEstatements. 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/@PreUpdatelifecycle callback on the entity itself is usually the better fit: it is portable across JPA providers, and it does not require reaching into a rawObject[]to make a change. Reach for the state-array-level interceptor specifically when the logic must be provider-wide and entity-agnostic.
Further reading
- hibernate-demo companion repository — chapter 24 and all four tests behind this post
- Hibernate 7 Aggregate Functions
- Hibernate 7 Pagination
- Sorting in Hibernate 7
- Official Hibernate ORM 7.4 User Guide — Interceptors
No Comments yet!