02/10/2023 18 Minutes read

Photo by Christian Lue on Unsplash

This article is about listening to events for entity changes in the database.

We will see why this solution is a good alternative to Aspect-oriented programming and how to address the pitfalls of Hibernate’s internal workings.

If you want to directly see the final code without reading the article, the content is available here:

https://github.com/ekino/article-hibernate-entity-event-listeners

Traditional approach

When you develop an application that manipulates data through a database, you are often faced with the need to trigger actions when information is modified. For example, you may need to reindex data used by Elasticsearch or send an email to confirm a modification, and so on.

A technique often used to address this requirement is to use the aspect-oriented programming paradigm (AOP). The idea is to create an aspect that automatically encompasses all Spring Data CrudRepository#save methods to execute post-save processing, and it looks something like this:

@Aspect
@Component
public class FooAspect {

@Around("execution(* org.springframework.data.repository.CrudRepository+.save(..))")
public Object saveAspect(ProceedingJoinPoint joinPoint) throws Throwable {
Object savedObject = joinPoint.proceed();
// do something with it
return savedObject;
}
}

While this technique works, without even mentioning the complexity of defining the aspect and the “pointcuts” to be defined, there are several things that bother me:

  • The expression of the “pointcut” requires a specific verbose syntax that one must know.
  • It is easy to make mistakes and alter the content of the instance normally returned by Spring during the save operation.
  • The variety of methods to support can make the Aspect complex. For instance, some methods have different parameters to manage, such as saveAll, which takes a collection of entities, and deleteById, which takes only the entity’s identifier.
  • If the developer (who may not be explicitly aware that the aspect will alter their code) makes multiple calls to the CrudRepository#save method, the processing will be executed multiple times, even though the data’s saved state will correspond to the last occurrence.
  • If the associated transaction fails, the processing will be performed on data that was assumed to be saved but will be invalidated by a rollback. Most of the time, that’s something you don’t want to happen.
  • If the transaction is not marked as readOnly=true, any entity modification will persist even if no explicit call to CrudRepository#save is made, and the aspect’s processing will not be executed.

In summary, if we want to send an email after a modification, we want to ensure that the email is sent only once (regardless of the number of times CrudRepository#save is called) and only if the transaction is successful.

To achieve this, it is preferable to use Hibernate events rather than aspects.

Some Hibernate events at disposal

Hibernate provides a set of events triggered during various actions performed on entities. However, the documentation for these events is almost non-existent. Here’s how to find them and understand what they do.

The list of available events can be found in the class org.hibernate.event.spi.EventType. Here’s an extract:

/**
* Enumeration of the recognized types of events, including meta-information about each.
*
* @author Steve Ebersole
*/
public final class EventType<T> {
// ...
public static final EventType<PostUpdateEventListener> POST_UPDATE = create("post-update", PostUpdateEventListener.class);
public static final EventType<PostInsertEventListener> POST_INSERT = create("post-insert", PostInsertEventListener.class);
public static final EventType<PostDeleteEventListener> POST_COMMIT_DELETE = create("post-commit-delete", PostDeleteEventListener.class);
public static final EventType<PostUpdateEventListener> POST_COMMIT_UPDATE = create("post-commit-update", PostUpdateEventListener.class);
public static final EventType<PostInsertEventListener> POST_COMMIT_INSERT = create("post-commit-insert", PostInsertEventListener.class);
public static final EventType<PreCollectionRecreateEventListener> PRE_COLLECTION_RECREATE = create("pre-collection-recreate", PreCollectionRecreateEventListener.class);
// ...
}

From there, you need to navigate to the listener class that interests you to learn more about its functionality. For example, if we take the event type POST_COMMIT_INSERT, here is the content of the associated listener interface org.hibernate.event.spi.PostInsertEventListener:

/**
* Called after inserting an item in the datastore
*
* @author Gavin King
* @author Steve Ebersole
*/
public interface PostInsertEventListener extends Serializable, PostActionEventListener {
void onPostInsert(PostInsertEvent event);
}

And finally, if you delve into the method’s argument, you will find:

/**
* Occurs after inserting an item in the datastore
*
* @author Gavin King
*/
public class PostInsertEvent extends AbstractEvent {
// ...
}

That’s it; you won’t learn much more.

As a reminder, we want to listen to events that concern entities only after the transaction has been successfully committed. Between the events POST_INSERT and POST_COMMIT_INSERT, it is appropriate to choose POST_COMMIT_INSERT.

Here are the events that will be of interest to us:

  • POST_COMMIT_INSERT: Called after an entity insert is committed to the datastore.
  • POST_COMMIT_UPDATE: Called after an entity update is committed to the datastore.
  • POST_COMMIT_DELETE: Called after an entity delete is committed to the datastore.

Listening to Hibernate Events

Implement the interfaces of desired event types.

To listen to the event types listed above, you need to implement the corresponding interfaces:

  • PostCommitInsertEventListener
  • PostCommitUpdateEventListener
  • PostCommitDeleteEventListener

Although the language used in this implementation is Kotlin, nothing is specific to it; everything can be done exactly the same way in Java.

Let’s create a Spring component that implements these interfaces:

import org.hibernate.event.spi.PostCommitDeleteEventListener
import org.hibernate.event.spi.PostCommitInsertEventListener
import org.hibernate.event.spi.PostCommitUpdateEventListener
import org.hibernate.event.spi.PostDeleteEvent
import org.hibernate.event.spi.PostInsertEvent
import org.hibernate.event.spi.PostUpdateEvent
import org.hibernate.persister.entity.EntityPersister
import org.springframework.stereotype.Component

@Component
class HibernateEntityEventListener :
PostCommitInsertEventListener,
PostCommitUpdateEventListener,
PostCommitDeleteEventListener {

override fun requiresPostCommitHanding(persister: EntityPersister): Boolean = true

override fun onPostInsert(event: PostInsertEvent) {
// TODO(leo): do something with this event
}
override fun onPostInsertCommitFailed(event: PostInsertEvent) {
// TODO(leo): do something with this event
}
override fun onPostUpdate(event: PostUpdateEvent) {
// TODO(leo): do something with this event
}
override fun onPostUpdateCommitFailed(event: PostUpdateEvent) {
// TODO(leo): do something with this event
}
override fun onPostDelete(event: PostDeleteEvent) {
// TODO(leo): do something with this event
}
override fun onPostDeleteCommitFailed(event: PostDeleteEvent) {
// TODO(leo): do something with this event
}
}

As you can see, there are two common things for each implemented listener:

Firstly, there is a method called requiresPostCommitHanding that must return true to work in the “post commit” phase. You can find more information about how this method works here: What does Hibernate PostInsertEventListener.requiresPostCommitHanding do?

Additionally, besides the method for handling the event during a successful transaction (e.g., onPostUpdate), you also have the option of listening to events of a transaction failure (e.g., onPostUpdateCommitFailed).

Registering the listener with Hibernate

To enable Hibernate to notify our custom listener, it needs to be added for each type of event through the EventListenerRegistry. For this purpose, we can use a configuration class:

import com.ekino.example.hibernateentityeventlisteners.listener.hibernate.HibernateEntityEventListener
import org.hibernate.event.service.spi.EventListenerRegistry
import org.hibernate.event.spi.EventType
import org.hibernate.internal.SessionFactoryImpl
import org.springframework.context.annotation.Configuration
import javax.annotation.PostConstruct
import javax.persistence.EntityManagerFactory

@Configuration
class HibernateEntityEventListenerConfig(
private val entityManagerFactory: EntityManagerFactory,
private val entityEventListener: HibernateEntityEventListener,
) {

@PostConstruct
fun registerListeners() {
entityManagerFactory.unwrap(SessionFactoryImpl::class.java)
.serviceRegistry
.getService(EventListenerRegistry::class.java)
.apply {
appendListeners(EventType.POST_COMMIT_INSERT, entityEventListener)
appendListeners(EventType.POST_COMMIT_UPDATE, entityEventListener)
appendListeners(EventType.POST_COMMIT_DELETE, entityEventListener)
}
}
}

Nothing particular to mention here, it’s a fairly standard class that uses constructor dependency injection and registers our listener after the context initialization.

Testing the triggering of event notifications

Let’s create a data model and a test to verify that modifications of entities trigger the desired events.

Class diagram of entities

Which can be represented by the following classes:

@Entity
class Author : AbstractAuditingEntity() {
var name: String? = null
var age: Int? = null
}

@Entity
class Category : AbstractAuditingEntity() {
var name: String? = null
}

@Entity
class Article : AbstractAuditingEntity() {
var title: String? = null
var content: String? = null
@ManyToOne(fetch = FetchType.LAZY)
var author: Author? = null
@ManyToMany(fetch = FetchType.LAZY)
var categories: MutableSet<Category> = mutableSetOf()
}

@Entity
class Comment : AbstractAuditingEntity() {
var content: String? = null
@ManyToOne(optional = false, fetch = FetchType.LAZY)
var article: Article? = null
@ManyToOne(optional = false, fetch = FetchType.LAZY)
var author: Author? = null
}

Finally, here is a test that allows us to make modifications to entities and trigger the corresponding events.

@Test
fun `should trigger entity events`() {
lateinit var authorId: EntityId<Author>
inTransaction(title = "Data initialization") {
Author().apply {
name = "Léo"
}
.let(authorRepository::save)
.also { authorId = it.entityId() }
}

inTransaction(title = "Data modification") {
authorId.find()
.apply {
name = "Léo Millon"
}
.let(authorRepository::save)
}

inTransaction(title = "Data deletion") {
authorRepository.deleteById(authorId.entityId)
}
}

As you can see, the Spring Data repositories were created beforehand and are used conventionally here.

Some clarifications about the presented code:

  • The Spring Data repositories were created conventionally (example: interface AuthorRepository : JpaRepository<Author, String>).
  • inTransaction is a utility method that allows executing the code passed as a lambda in a new dedicated transaction and logs a title for better readability in the logs.
  • EntityId is an object that contains the identifier and Java class of an entity to automate retrieval via a generic find (equivalent to entityManager.find(type, id)).

Let’s add logging to the implementation of our listeners:

override fun onPostInsert(event: PostInsertEvent) {
logger.trace { "Hibernate ${event.typeName()} for ${entityIdentifier(event.id, event.entity)}" }
}

override fun onPostUpdate(event: PostUpdateEvent) {
logger.trace { "Hibernate ${event.typeName()} for ${entityIdentifier(event.id, event.entity)}" }
}

override fun onPostDelete(event: PostDeleteEvent) {
logger.trace { "Hibernate ${event.typeName()} for ${entityIdentifier(event.id, event.entity)}" }
}

private fun EntityEvent.entityIdentifier() = entityIdentifier(entityId, entity)

private fun entityIdentifier(id: Serializable, entity: Any) = "${entity.typeName()}($id)"

private fun Any.typeName() = javaClass.simpleName

When running the test, here’s what you can see in the logs:

>>> Transaction #1 'Data initialization' started...
Hibernate PostInsertEvent for Author(a4d9d048-56d3-4e1c-971c-46666edfe973)
<<< Transaction #1 ended with status 'COMMITTED'

>>> Transaction #2 'Data modification' started...
Hibernate PostUpdateEvent for Author(a4d9d048-56d3-4e1c-971c-46666edfe973)
<<< Transaction #2 ended with status 'COMMITTED'

>>> Transaction #3 'Data deletion' started...
Hibernate PostDeleteEvent for Author(a4d9d048-56d3-4e1c-971c-46666edfe973)
<<< Transaction #3 ended with status 'COMMITTED'

Okay, it seems to be working correctly!

Knowing which fields have been modified

Identifying changes in “simple” fields.

One frequently desirable aspect during the update of an entity is to identify which fields have been modified. This allows triggering specific actions based on these precise changes, for example:

  • Only reindexing the information of this entity if one of the relevant fields has changed.
  • Initiating specific processes only when some status changes from one state to another. For instance, sending an email only when a status changes from CREATED to VALIDATED, but not when it changes from ARCHIVED to VALIDATED.

For this purpose, the Hibernate event provides valuable information:

/**
* Occurs after the datastore is updated
*
* @author Gavin King
*/
public class PostUpdateEvent extends AbstractEvent {
private Object entity;
private EntityPersister persister;
private Object[] state;
private Object[] oldState;
private Serializable id;
//list of dirty properties as computed by Hibernate during a FlushEntityEvent
private final int[] dirtyProperties;

// ...
}

We can observe the following concepts:

  • Object[] state: An array of “state”
  • Object[] oldState: An array of “old state”
  • int[] dirtyProperties: An array of indexes of properties considered “dirty”, which means when the value of a property changed (in other words: state != oldState)

This means that when our Author has a name updated from “Léo” to “Léo Millon”, we would have:

// with `name` having the index `0` and `age` having the index `1`
// properties = ["name", "age"]

state:["Léo Millon", null]
oldState:["Léo", null]
dirtyProperties:[0] // only property with index 0 is dirty

By updating the implementation of the update event as follows:

override fun onPostUpdate(event: PostUpdateEvent) {
logger.trace {
buildString {
appendLine("Hibernate ${event.typeName()} for ${entityIdentifier(event.id, event.entity)}")
event
.persister
.entityMetamodel
.properties
.asSequence()
.forEachIndexed { index, attribute ->
append("• ${attribute.name}")
if (event.dirtyProperties.contains(index)) {
append(" changed from '${event.oldState[index]}' to '${event.state[index]}'")
} else {
append(" kept value '${event.state[index]}'")
}
appendLine()
}
}
}
}

Here’s what we get with the same test:

...
>>> Transaction #2 'Data modification' started...
Hibernate PostUpdateEvent for Author(b4fef4be-0040-47fb-a12b-ae3d73208456)
• createdDate kept value '2023-07-11T15:03:46.757687Z'
• lastModifiedDate changed from '2023-07-11T15:03:46.757687Z' to '2023-07-11T15:03:46.831150Z'
• age kept value 'null'
• name changed from 'Léo' to 'Léo Millon'

<<< Transaction #2 ended with status 'COMMITTED'
...

Let’s modify the test to verify:

  • We only have one event even when calling the CrudRepository#save method multiple times.
  • No event is emitted if the CrudRepository#save method is called, but no modification is made.
  • An event is emitted even if no calls to the CrudRepository#save method are made within the transaction.
@Test
fun `should trigger entity events`() {
lateinit var authorId: EntityId<Author>
inTransaction(title = "Data initialization") {
Author().apply {
name = "Léo"
}
.let(authorRepository::save)
.also { authorId = it.entityId() }
}

inTransaction(title = "Multiple saves") {
// multiple saves on the same entity in the same transaction should trigger only one update event
authorId.find()
.apply {
age = 18
}
.let(authorRepository::save)
.apply {
age = 34
}
.let(authorRepository::save)
}

inTransaction(title = "Save without modification") {
authorId.find()
.let(authorRepository::save)
}

inTransaction(title = "Omitted save call") {
// save call on repository is optional
authorId.find()
.apply {
name = "Léo Millon"
}
}
}
>>> Transaction #1 'Data initialization' started...
Hibernate PostInsertEvent for Author(506b3091-099e-47e2-b295-a7a0f8400cab)
<<< Transaction #1 ended with status 'COMMITTED'

>>> Transaction #2 'Multiple saves' started...
Hibernate PostUpdateEvent for Author(506b3091-099e-47e2-b295-a7a0f8400cab)
• createdDate kept value '2023-07-11T16:45:32.812136Z'
• lastModifiedDate changed from '2023-07-11T16:45:32.812136Z' to '2023-07-11T16:45:32.874683Z'
• age changed from 'null' to '34'
• name kept value 'Léo'

<<< Transaction #2 ended with status 'COMMITTED'

>>> Transaction #3 'Save without modification' started...
<<< Transaction #3 ended with status 'COMMITTED'

>>> Transaction #4 'Omitted save call' started...
Hibernate PostUpdateEvent for Author(506b3091-099e-47e2-b295-a7a0f8400cab)
• createdDate kept value '2023-07-11T16:45:32.812136Z'
• lastModifiedDate changed from '2023-07-11T16:45:32.874683Z' to '2023-07-11T16:45:32.889674Z'
• age kept value '34'
• name changed from 'Léo' to 'Léo Millon'

<<< Transaction #4 ended with status 'COMMITTED'

Everything works as expected! 🎉

Now we know how to identify field-level changes during entity updates.

However, there is one case that Hibernate does not cover if we stop here: updating a collection-type relationship (such as @OneToMany and @ManyToMany relationships).

Identifying changes in fields of type “Collection.”

If we now change the test to update a “simple” field and a “to-many” relationship like this:

@Test
fun `should trigger entity events`() {
lateinit var kotlinCategoryId: EntityId<Category>
lateinit var articleId: EntityId<Article>
inTransaction(title = "Data initialization") {
val (
hibernateCategory,
eventCategory,
kotlinCategory,
) = sequenceOf(
"Hibernate",
"Event",
"Kotlin",
)
.map {
Category().apply {
name = it
}
}
.map(categoryRepository::save)
.toList()
kotlinCategoryId = kotlinCategory.entityId()

val leo = Author().apply {
name = "Léo"
}
.let(authorRepository::save)

Article().apply {
title = "Awesome entity event listeners!"
content = "Some article content..."
author = leo
categories.apply {
add(hibernateCategory)
add(eventCategory)
}
}
.let(articleRepository::save)
.also { articleId = it.entityId() }
}

inTransaction(title = "Modification on existing set and on simple property") {
articleId.find()
.apply {
content = "Some fixed article content..."
categories.apply {
clear()
add(kotlinCategoryId.reference())
}
}
}
}

Here’s what we get:

>>> Transaction #1 'Data initialization' started...
Hibernate PostInsertEvent for Category(1ec5e902-df46-40e9-b338-d4c4fa8dc51f)
Hibernate PostInsertEvent for Category(ff410719-0ae4-4564-989c-a3e052070fcb)
Hibernate PostInsertEvent for Category(a8aaae11-4424-4c48-b85c-f98fc59acd36)
Hibernate PostInsertEvent for Author(bd8276c6-98e3-4727-b4d1-4329189d1f2e)
Hibernate PostInsertEvent for Article(d0135947-08a7-468e-96be-450ed166a16b)
<<< Transaction #1 ended with status 'COMMITTED'

>>> Transaction #2 'Modification on existing set and on simple property' started...
Hibernate PostUpdateEvent for Article(d0135947-08a7-468e-96be-450ed166a16b)
• createdDate kept value '2023-07-11T15:23:02.138646Z'
• lastModifiedDate changed from '2023-07-11T15:23:02.138646Z' to '2023-07-11T15:23:02.200355Z'
• author kept value 'Author(bd8276c6-98e3-4727-b4d1-4329189d1f2e)'
• categories kept value '[Category(a8aaae11-4424-4c48-b85c-f98fc59acd36)]'
• content changed from 'Some article content...' to 'Some fixed article content...'
• title kept value 'Awesome entity event listeners!'

<<< Transaction #2 ended with status 'COMMITTED'

Have you noticed what’s wrong?

• categories kept value '[Category(a8aaae11-4424-4c48-b85c-f98fc59acd36)]'

The instance of the collection used remains the same as before; only the collection’s content has changed. From Hibernate’s perspective, this collection is not considered dirty (similarly, the oldState and state are identical). Therefore, we cannot use this mechanism to detect changes in this field.

To address this, we need to look into new Hibernate events:

  • POST_COLLECTION_RECREATE : Called after recreating a collection
  • POST_COLLECTION_UPDATE : Called after updating a collection
  • POST_COLLECTION_REMOVE : Called after removing a collection

Let’s add these new types to the configuration:

// ...
@PostConstruct
fun registerListeners() {
entityManagerFactory.unwrap(SessionFactoryImpl::class.java)
.serviceRegistry
.getService(EventListenerRegistry::class.java)
.apply {
appendListeners(EventType.POST_COMMIT_INSERT, entityEventListener)
appendListeners(EventType.POST_COMMIT_UPDATE, entityEventListener)
appendListeners(EventType.POST_COMMIT_DELETE, entityEventListener)
appendListeners(EventType.POST_COLLECTION_RECREATE, entityEventListener)
appendListeners(EventType.POST_COLLECTION_UPDATE, entityEventListener)
appendListeners(EventType.POST_COLLECTION_REMOVE, entityEventListener)
}
}
// ...

And let’s implement these new listeners:

@Component
class HibernateEntityEventListener :
PostCommitInsertEventListener,
PostCommitUpdateEventListener,
PostCommitDeleteEventListener,
PostCollectionRecreateEventListener,
PostCollectionUpdateEventListener,
PostCollectionRemoveEventListener {

// ...
override fun onPostRecreateCollection(event: PostCollectionRecreateEvent) {
logger.trace { "Hibernate ${event.typeName()} for ${entityIdentifier(event.affectedOwnerIdOrNull, event.affectedOwnerOrNull)}" }
}

override fun onPostUpdateCollection(event: PostCollectionUpdateEvent) {
logger.trace { "Hibernate ${event.typeName()} for ${entityIdentifier(event.affectedOwnerIdOrNull, event.affectedOwnerOrNull)}" }
}

override fun onPostRemoveCollection(event: PostCollectionRemoveEvent) {
logger.trace { "Hibernate ${event.typeName()} for ${entityIdentifier(event.affectedOwnerIdOrNull, event.affectedOwnerOrNull)}" }
}
// ...
}

Please note that the events are slightly different from the previous ones; they all inherit from the following type:

package org.hibernate.event.spi;

// ...
/**
* Defines a base class for events involving collections.
*
* @author Gail Badner
*/
public abstract class AbstractCollectionEvent extends AbstractEvent {
private final PersistentCollection collection;
private final Object affectedOwner;
private final Serializable affectedOwnerId;
private final String affectedOwnerEntityName;

/**
* Constructs an AbstractCollectionEvent object.
*
* @param collection - the collection
* @param source - the Session source
* @param affectedOwner - the owner that is affected by this event;
* can be null if unavailable
* @param affectedOwnerId - the ID for the owner that is affected
* by this event; can be null if unavailable
* that is affected by this event; can be null if unavailable
*/
public AbstractCollectionEvent(CollectionPersister collectionPersister,
PersistentCollection collection,
EventSource source,
Object affectedOwner,
Serializable affectedOwnerId) {
// ...

Now let’s modify the test to verify the behavior of these new events:

@Test
fun `should trigger entity events`() {
lateinit var kotlinCategoryId: EntityId<Category>
lateinit var hibernateCategoryId: EntityId<Category>
lateinit var articleId: EntityId<Article>
inTransaction(title = "Data initialization") {
val (
hibernateCategory,
eventCategory,
kotlinCategory,
) = sequenceOf(
"Hibernate",
"Event",
"Kotlin",
)
.map {
Category().apply {
name = it
}
}
.map(categoryRepository::save)
.toList()
kotlinCategoryId = kotlinCategory.entityId()
hibernateCategoryId = hibernateCategory.entityId()

val leo = Author().apply {
name = "Léo"
}
.let(authorRepository::save)

Article().apply {
title = "Awesome entity event listeners!"
content = "Some article content..."
author = leo
categories.apply {
add(hibernateCategory)
add(eventCategory)
}
}
.let(articleRepository::save)
.also { articleId = it.entityId() }
}

inTransaction(title = "Modification on existing set") {
articleId.find()
.apply {
categories.apply {
clear()
add(kotlinCategoryId.reference())
}
}
}

inTransaction(title = "Modification on existing set and on simple property") {
articleId.find()
.apply {
content = "Some fixed article content..."
categories.apply {
clear()
add(hibernateCategoryId.reference())
}
}
}

inTransaction(title = "Modification with new empty set") {
articleId.find()
.apply {
categories = mutableSetOf()
}
}

inTransaction(title = "Modification using new set") {
articleId.find()
.apply {
categories = mutableSetOf(kotlinCategoryId.reference())
}
}
}

And here’s what we get when running the test:

>>> Transaction #1 'Data initialization' started...
Hibernate PostCollectionRecreateEvent for Article(68b54c76-7b91-4fa0-a339-ae8fee25da8c)
Hibernate PostInsertEvent for Category(85554f76-68c4-41e6-a64d-d12428fee967)
Hibernate PostInsertEvent for Category(28bb2bde-9b98-4735-b92e-119957141c95)
Hibernate PostInsertEvent for Category(b32dbc0b-05e5-4e66-a8c7-aa78d0eed8bb)
Hibernate PostInsertEvent for Author(21b7b2ba-1090-41b0-8203-ba27034bed23)
Hibernate PostInsertEvent for Article(68b54c76-7b91-4fa0-a339-ae8fee25da8c)
<<< Transaction #1 ended with status 'COMMITTED'

>>> Transaction #2 'Modification on existing set' started...
Hibernate PostCollectionUpdateEvent for Article(68b54c76-7b91-4fa0-a339-ae8fee25da8c)
<<< Transaction #2 ended with status 'COMMITTED'

>>> Transaction #3 'Modification on existing set and on simple property' started...
Hibernate PostCollectionUpdateEvent for Article(68b54c76-7b91-4fa0-a339-ae8fee25da8c)
Hibernate PostUpdateEvent for Article(68b54c76-7b91-4fa0-a339-ae8fee25da8c)
• createdDate kept value '2023-07-12T07:08:03.130429Z'
• lastModifiedDate changed from '2023-07-12T07:08:03.130429Z' to '2023-07-12T07:08:03.192443Z'
• author kept value 'Author(21b7b2ba-1090-41b0-8203-ba27034bed23)'
• categories kept value '[Category(85554f76-68c4-41e6-a64d-d12428fee967)]'
• content changed from 'Some article content...' to 'Some fixed article content...'
• title kept value 'Awesome entity event listeners!'

<<< Transaction #3 ended with status 'COMMITTED'

>>> Transaction #4 'Modification with new empty set' started...
Hibernate PostCollectionRemoveEvent for Article(68b54c76-7b91-4fa0-a339-ae8fee25da8c)
Hibernate PostCollectionRecreateEvent for Article(68b54c76-7b91-4fa0-a339-ae8fee25da8c)
Log message invocation failed: org.hibernate.LazyInitializationException: failed to lazily initialize a collection, could not initialize proxy - no Session
<<< Transaction #4 ended with status 'COMMITTED'

>>> Transaction #5 'Modification using new set' started...
Hibernate PostCollectionRemoveEvent for Article(68b54c76-7b91-4fa0-a339-ae8fee25da8c)
Hibernate PostCollectionRecreateEvent for Article(68b54c76-7b91-4fa0-a339-ae8fee25da8c)
Log message invocation failed: org.hibernate.LazyInitializationException: failed to lazily initialize a collection, could not initialize proxy - no Session
<<< Transaction #5 ended with status 'COMMITTED'

There are several things to notice in what just happened, but first, let’s focus on the errors during log message production: Log message invocation failed: org.hibernate.LazyInitializationException: failed to lazily initialize a collection, could not initialize proxy – no Session .

In some cases, when the relationship is configured with fetch = FetchType.LAZY, the state may not be loaded by Hibernate and thus not available during the event. The same applies to the oldState. A useful trick to check if a value is already loaded in the persistence context is to use this method: entityManager.entityManagerFactory.persistenceUnitUtil.isLoaded(entity.field).

Let’s modify the code for log message production to fix this issue:

override fun onPostUpdate(event: PostUpdateEvent) {
logger.trace {
buildString {
appendLine("Hibernate ${event.typeName()} for ${entityIdentifier(event.id, event.entity)}")
event
.persister
.entityMetamodel
.properties
.asSequence()
.forEachIndexed { index, attribute ->
append("• ${attribute.name}")
if (event.dirtyProperties.contains(index)) {
append(" changed from '${event.oldState[index].loadedValueOrDefaultMessage()}' to '${event.state[index].loadedValueOrDefaultMessage()}'")
} else {
append(" kept value '${event.state[index].loadedValueOrDefaultMessage()}'")
}
appendLine()
}
}
}
}

private fun Any?.isLoadedEntity() = entityManager.entityManagerFactory.persistenceUnitUtil.isLoaded(this)
private fun Any?.loadedValueOrDefaultMessage() = if(isLoadedEntity()) this else "<not loaded>"

Also, during the initialization of an entity, the associated collection triggers the PostCollectionRecreateEvent even though it never existed before. This is not very relevant for the rest, but it’s good to know that this situation exists.

Let’s also modify the logs for events related to collections to determine the name of the field in the entity that contains the affected collection:

override fun onPostRecreateCollection(event: PostCollectionRecreateEvent) {
logger.trace { "Hibernate ${event.typeName()} for ${entityIdentifier(event.affectedOwnerIdOrNull, event.affectedOwnerOrNull)} on property '${event.affectedPropertyOrNull().orEmpty()}'" }
}

override fun onPostUpdateCollection(event: PostCollectionUpdateEvent) {
logger.trace { "Hibernate ${event.typeName()} for ${entityIdentifier(event.affectedOwnerIdOrNull, event.affectedOwnerOrNull)} on property '${event.affectedPropertyOrNull().orEmpty()}'" }
}

override fun onPostRemoveCollection(event: PostCollectionRemoveEvent) {
logger.trace { "Hibernate ${event.typeName()} for ${entityIdentifier(event.affectedOwnerIdOrNull, event.affectedOwnerOrNull)} on property '${event.affectedPropertyOrNull().orEmpty()}'" }
}

private fun AbstractCollectionEvent.affectedPropertyOrNull() =
session.persistenceContext.getCollectionEntryOrNull(collection)
?.role
?.removePrefix(affectedOwnerEntityName)
?.removePrefix(".")

Here is the new result:

>>> Transaction #1 'Data initialization' started...
Hibernate PostCollectionRecreateEvent for Article(0629cd41-7874-47b7-afe8-6ad1ca7861b1) on property 'categories'
Hibernate PostInsertEvent for Category(c6e2b0cb-5465-4dc0-8c5d-117a20710684)
Hibernate PostInsertEvent for Category(6bd28d41-38e5-4c52-9423-59de39b86d6f)
Hibernate PostInsertEvent for Category(df63a2fe-b81e-4b67-b591-20527b7665ef)
Hibernate PostInsertEvent for Author(8df49aae-6fe2-408e-a608-b943d475e7d3)
Hibernate PostInsertEvent for Article(0629cd41-7874-47b7-afe8-6ad1ca7861b1)
<<< Transaction #1 ended with status 'COMMITTED'

>>> Transaction #2 'Modification on existing set' started...
Hibernate PostCollectionUpdateEvent for Article(0629cd41-7874-47b7-afe8-6ad1ca7861b1) on property 'categories'
<<< Transaction #2 ended with status 'COMMITTED'

>>> Transaction #3 'Modification on existing set and on simple property' started...
Hibernate PostCollectionUpdateEvent for Article(0629cd41-7874-47b7-afe8-6ad1ca7861b1) on property 'categories'
Hibernate PostUpdateEvent for Article(0629cd41-7874-47b7-afe8-6ad1ca7861b1)
• createdDate kept value '2023-07-12T13:14:48.633962Z'
• lastModifiedDate changed from '2023-07-12T13:14:48.633962Z' to '2023-07-12T13:14:48.707874Z'
• author kept value '<not loaded>'
• categories kept value '[Category(c6e2b0cb-5465-4dc0-8c5d-117a20710684)]'
• content changed from 'Some article content...' to 'Some fixed article content...'
• title kept value 'Awesome entity event listeners!'

<<< Transaction #3 ended with status 'COMMITTED'

>>> Transaction #4 'Modification with new empty set' started...
Hibernate PostCollectionRemoveEvent for Article(0629cd41-7874-47b7-afe8-6ad1ca7861b1) on property ''
Hibernate PostCollectionRecreateEvent for Article(0629cd41-7874-47b7-afe8-6ad1ca7861b1) on property 'categories'
Hibernate PostUpdateEvent for Article(0629cd41-7874-47b7-afe8-6ad1ca7861b1)
• createdDate kept value '2023-07-12T13:14:48.633962Z'
• lastModifiedDate changed from '2023-07-12T13:14:48.707874Z' to '2023-07-12T13:14:48.721591Z'
• author kept value '<not loaded>'
• categories changed from '<not loaded>' to '[]'
• content kept value 'Some fixed article content...'
• title kept value 'Awesome entity event listeners!'

<<< Transaction #4 ended with status 'COMMITTED'

>>> Transaction #5 'Modification using new set' started...
Hibernate PostCollectionRemoveEvent for Article(0629cd41-7874-47b7-afe8-6ad1ca7861b1) on property ''
Hibernate PostCollectionRecreateEvent for Article(0629cd41-7874-47b7-afe8-6ad1ca7861b1) on property 'categories'
Hibernate PostUpdateEvent for Article(0629cd41-7874-47b7-afe8-6ad1ca7861b1)
• createdDate kept value '2023-07-12T13:14:48.633962Z'
• lastModifiedDate changed from '2023-07-12T13:14:48.721591Z' to '2023-07-12T13:14:48.731196Z'
• author kept value '<not loaded>'
• categories changed from '<not loaded>' to '[Category(df63a2fe-b81e-4b67-b591-20527b7665ef)]'
• content kept value 'Some fixed article content...'
• title kept value 'Awesome entity event listeners!'

<<< Transaction #5 ended with status 'COMMITTED'

We observe a distinction in behavior between a collection that is modified and one that is replaced with a new collection.

  • Transaction #2: If only a collection is directly modified, the only event triggered is a PostCollectionUpdateEvent.
  • Transaction #3: If a collection is directly modified along with a “simple” field, a PostCollectionUpdateEvent is triggered, followed by a PostUpdateEvent in which the field containing the collection is considered unchanged.
  • Transaction #5: If a collection is replaced with a new one, three events are triggered successively: first a PostCollectionRemoveEvent, then a PostCollectionRecreateEvent, and finally a PostUpdateEvent.

All these combinations make it challenging to correctly identify modifications and avoid duplicate processing.

Therefore, we need a system to unify these behaviors.

Version used in Production

In real-life scenarios, we need a reliable and straightforward solution to work with.

To address the previously observed undesirable effects, we will attempt to consolidate the different events concerning the same “source entity” of a single transaction into a single update event. For this purpose, we will:

  • Add an abstraction to Hibernate’s event model to better prepare the data for processing.
  • Merge the PostUpdateEvent and/or multiple PostCollectionUpdateEvent events of the same entity into a single modification event.
  • Find a way to easily consume these events synchronously and/or asynchronously.

Custom Event Model

Here’s a class diagram of the model responsible for abstracting Hibernate events:

We have 3 types of events:

  • EntityCreatedEvent
  • EntityUpdatedEvent
  • EntityDeleteEvent

Each event contains at least the entity’s identifier and the relevant instance.

Only the EntityUpdatedEvent contains the details of the different fields: the change status and the states before and after modification.

An essential point to note is that the loaded notion allows us to determine whether the field’s value is loaded in the persistence context or not. This helps avoid LazyLoadingException.

Now that we have our own classes, we can use org.springframework.context.ApplicationEventPublisher#publishEvent(java.lang.Object) to publish our own content as a new event.

For example, to notify the EntityCreatedEvent we just need to create it and give it to the publishEvent:

override fun onPostInsert(event: PostInsertEvent) {
logger.trace { "Hibernate ${event.typeName()} for ${entityIdentifier(event.id, event.entity)}" }

EntityCreatedEvent(
entityId = event.id,
entity = event.entity,
)
.also {
logger.debug { "Publishing ${it.typeName()} for ${it.entityIdentifier()}" }
}
.also(applicationEventPublisher::publishEvent)
}

We will see later how our application can consume these events.

Merging Modification Events

To facilitate the handling of modification events for the same entity within the same transaction, it would be beneficial to merge them into a single modification event containing all the details.

For this purpose, we can utilize a mechanism provided by Spring, org.springframework.transaction.support.TransactionSynchronizationManager#registerSynchronization :

Register a new transaction synchronization for the current thread. Typically called by resource management code.

With this system, we can register all events related to the same entity and merge them into a new event that will contain the most accurate state for each property of all these events.

The implementation is quite substantial, so here’s an excerpt (the rest is available in the project’s source code):

override fun onPostUpdate(event: PostUpdateEvent) {
logger.trace { "Hibernate ${event.typeName()} for ${entityIdentifier(event.id, event.entity)}" }

trackAndMergeUpdateEvents(event.id, event.entity::class.java, event)
}

override fun onPostUpdateCollection(event: PostCollectionUpdateEvent) {
logger.trace { "Hibernate ${event.typeName()} for ${event.entityIdentifier()} on property '${event.affectedPropertyOrNull().orEmpty()}'" }

trackAndMergeUpdateEvents(event.affectedOwnerIdOrNull, event.affectedOwnerOrNull::class.java, event)
}

private fun trackAndMergeUpdateEvents(entityId: Serializable, entityClass: Class<*>, event: Any) {
trackerOfCurrentTransactionEntity(entityId, entityClass).apply {
track(event)
TransactionSynchronizationManager.registerSynchronization(
object : TransactionSynchronization {
override fun afterCompletion(status: Int) {
retrieveObjectsAndClear()
.takeIf { it.isNotEmpty() }
?.also {
try {
mergeUpdateEvents(it)
} catch (e: Exception) {
logger.error(e) { "Error trying to merge hibernate update events of ${entityClass.name}(${entityId})" }
throw e
}
}
}
},
)
}
}

private fun mergeUpdateEvents(hibernateEvents: List<Any>) {
val postCollectionEvents = hibernateEvents.filterIsInstance<AbstractCollectionEvent>()
val postUpdateEvent = hibernateEvents.filterIsInstance<PostUpdateEvent>().takeIf { it.isNotEmpty() }?.single()

val (entityId, entity) = findEntityIdWithEntityOrNull(postUpdateEvent, postCollectionEvents) ?: return

val updatedCollectionStateByPropertyName = extractDiffStateByPropertyName(postCollectionEvents)

val allPropertyStates = updatedCollectionStateByPropertyName.values +
postUpdateEvent?.extractDiffStatesOfProperties(updatedCollectionStateByPropertyName.keys).orEmpty()

EntityUpdatedEvent(
entityId = entityId,
entity = entity,
properties = allPropertyStates,
)
.also { logger.debug { eventWithDetailedProperties(it) } }
.also(applicationEventPublisher::publishEvent)
}

Consuming Our New Events

For usage within the application, a simple Spring component with @EventListener methods is sufficient.

For example, if you want to perform an action upon the creation of an author and another action only when the author’s name has changed, here’s how you can do it:

private val logger = KotlinLogging.logger {}

@Component
class AuthorNameListener {

@EventListener
fun onEntityCreated(event: EntityCreatedEvent) {
event.ifEntityOfType<Author> { author ->
logger.info { "New author named ${author.name}" }
}
}

@EventListener
fun onEntityUpdated(event: EntityUpdatedEvent) {
event.ifEntityOfType<Author> {
event.properties[Author::name]
?.takeIf { it.diffStatus == StateDiffStatus.CHANGED }
?.also { nameProp ->
logger.info { """Author previously named "${nameProp.oldState.loadedValueOrNull()}" is now named "${nameProp.state.loadedValueOrNull()}"""" }
}
}
}
}

Another example is if we want to notify the author of an article by email for each created comment, asynchronously, I just need to add @Async:

private val logger = KotlinLogging.logger {}

@Component
class CommentNotificationListener {

@Async
@EventListener
fun onEntityCreated(event: EntityCreatedEvent) {
event.ifEntityOfType<Comment> { comment ->
logger.info { "Sending email to author ${comment.author?.name}" }
}
}
}

Let’s update the test to see the result:

@Test
fun `should trigger entity events`() {
lateinit var kotlinCategoryId: EntityId<Category>
lateinit var hibernateCategoryId: EntityId<Category>
lateinit var authorId: EntityId<Author>
lateinit var articleId: EntityId<Article>
lateinit var commentId: EntityId<Comment>
inTransaction(title = "Data initialization") {
val (
hibernateCategory,
eventCategory,
kotlinCategory,
) = sequenceOf(
"Hibernate",
"Event",
"Kotlin",
)
.map {
Category().apply {
name = it
}
}
.map(categoryRepository::save)
.toList()
kotlinCategoryId = kotlinCategory.entityId()
hibernateCategoryId = hibernateCategory.entityId()

val leo = Author().apply {
name = "Léo"
}
.let(authorRepository::save)
.also { authorId = it.entityId() }
val article = Article().apply {
title = "Awesome entity event listeners!"
content = "Some article content..."
author = leo
categories.apply {
add(hibernateCategory)
add(eventCategory)
}
}
.let(articleRepository::save)
.also { articleId = it.entityId() }
Comment().apply {
content = "Some nice comment ;)"
this.author = leo
this.article = article
}
.let(commentRepository::save)
.also { commentId = it.entityId() }
}

inTransaction(title = "Multiple saves") {
// multiple saves on the same entity in the same transaction should trigger only one update event
authorId.find()
.apply {
age = 18
}
.let(authorRepository::save)
.apply {
age = 34
}
.let(authorRepository::save)
}

inTransaction(title = "Save without modification") {
authorId.find()
.let(authorRepository::save)
}

inTransaction(title = "Omitted save call") {
// save call on repository is optional
authorId.find()
.apply {
name = "Léo Millon"
}
}

inTransaction(title = "Modification on existing set") {
articleId.find()
.apply {
categories.apply {
clear()
add(kotlinCategoryId.reference())
}
}
}

inTransaction(title = "Modification on existing set and on simple property") {
articleId.find()
.apply {
content = "Some fixed article content..."
categories.apply {
clear()
add(hibernateCategoryId.reference())
}
}
}

inTransaction(title = "Modification with new empty set") {
articleId.find()
.apply {
categories = mutableSetOf()
}
}

inTransaction(title = "Modification using new set") {
articleId.find()
.apply {
categories = mutableSetOf(kotlinCategoryId.reference())
}
}

inTransaction(title = "Article deletion") {
commentRepository.delete(commentId.reference())
articleRepository.delete(articleId.reference())
}
}

Here is the result:

>>> Transaction #1 'Data initialization' started...
Hibernate PostCollectionRecreateEvent for Article(d429662d-1753-4cda-947f-4463eb41bcf9) on property 'categories'
Hibernate PostInsertEvent for Category(4c409778-5aac-4a62-8f7a-248f76659edd)
Publishing EntityCreatedEvent for Category(4c409778-5aac-4a62-8f7a-248f76659edd)
Creating Async executor with a thread pool size of 10
Hibernate PostInsertEvent for Category(b43e08ab-50a0-4656-b742-390474255647)
Publishing EntityCreatedEvent for Category(b43e08ab-50a0-4656-b742-390474255647)
Hibernate PostInsertEvent for Category(6b5ce797-8d73-4d72-9740-c08ef0e3b2b0)
Publishing EntityCreatedEvent for Category(6b5ce797-8d73-4d72-9740-c08ef0e3b2b0)
Hibernate PostInsertEvent for Author(77164348-6d7f-425e-96d4-ad1627a7a4f8)
Publishing EntityCreatedEvent for Author(77164348-6d7f-425e-96d4-ad1627a7a4f8)
New author named Léo
Hibernate PostInsertEvent for Article(d429662d-1753-4cda-947f-4463eb41bcf9)
Publishing EntityCreatedEvent for Article(d429662d-1753-4cda-947f-4463eb41bcf9)
Hibernate PostInsertEvent for Comment(e7d25b3c-1adb-4576-8bfe-fe875219784e)
Publishing EntityCreatedEvent for Comment(e7d25b3c-1adb-4576-8bfe-fe875219784e)
<<< Transaction #1 ended with status 'COMMITTED'

>>> Transaction #2 'Multiple saves' started...
Sending email to author Léo
Hibernate PostUpdateEvent for Author(77164348-6d7f-425e-96d4-ad1627a7a4f8)
<<< Transaction #2 ended with status 'COMMITTED'

Publishing EntityUpdatedEvent for Author(77164348-6d7f-425e-96d4-ad1627a7a4f8) with properties:
- age (CHANGED):
old: "null"
new: "34"
- createdDate (EQUAL):
value: "2023-07-26T09:05:53.718884Z"
- lastModifiedDate (CHANGED):
old: "2023-07-26T09:05:53.718884Z"
new: "2023-07-26T09:05:53.857012Z"
- name (EQUAL):
value: "Léo"

>>> Transaction #3 'Save without modification' started...
<<< Transaction #3 ended with status 'COMMITTED'

>>> Transaction #4 'Omitted save call' started...
Hibernate PostUpdateEvent for Author(77164348-6d7f-425e-96d4-ad1627a7a4f8)
<<< Transaction #4 ended with status 'COMMITTED'

Publishing EntityUpdatedEvent for Author(77164348-6d7f-425e-96d4-ad1627a7a4f8) with properties:
- age (EQUAL):
value: "34"
- createdDate (EQUAL):
value: "2023-07-26T09:05:53.718884Z"
- lastModifiedDate (CHANGED):
old: "2023-07-26T09:05:53.857012Z"
new: "2023-07-26T09:05:53.902927Z"
- name (CHANGED):
old: "Léo"
new: "Léo Millon"

Author previously named "Léo" is now named "Léo Millon"

>>> Transaction #5 'Modification on existing set' started...
Hibernate PostCollectionUpdateEvent for Article(d429662d-1753-4cda-947f-4463eb41bcf9) on property 'categories'
<<< Transaction #5 ended with status 'COMMITTED'
Publishing EntityUpdatedEvent for Article(d429662d-1753-4cda-947f-4463eb41bcf9) with properties:
- categories (CHANGED):
old: "<not loaded>"
new: "[Category(6b5ce797-8d73-4d72-9740-c08ef0e3b2b0)]"

>>> Transaction #6 'Modification on existing set and on simple property' started...
Hibernate PostCollectionUpdateEvent for Article(d429662d-1753-4cda-947f-4463eb41bcf9) on property 'categories'
Hibernate PostUpdateEvent for Article(d429662d-1753-4cda-947f-4463eb41bcf9)
<<< Transaction #6 ended with status 'COMMITTED'

Publishing EntityUpdatedEvent for Article(d429662d-1753-4cda-947f-4463eb41bcf9) with properties:
- author (UNKNOWN):
old: "<not loaded>"
new: "<not loaded>"
- categories (CHANGED):
old: "<not loaded>"
new: "[Category(4c409778-5aac-4a62-8f7a-248f76659edd)]"
- content (CHANGED):
old: "Some article content..."
new: "Some fixed article content..."
- createdDate (EQUAL):
value: "2023-07-26T09:05:53.741290Z"
- lastModifiedDate (CHANGED):
old: "2023-07-26T09:05:53.741290Z"
new: "2023-07-26T09:05:53.959540Z"
- title (EQUAL):
value: "Awesome entity event listeners!"

>>> Transaction #7 'Modification with new empty set' started...
Hibernate PostCollectionRemoveEvent for Article(d429662d-1753-4cda-947f-4463eb41bcf9) on property ''
Hibernate PostCollectionRecreateEvent for Article(d429662d-1753-4cda-947f-4463eb41bcf9) on property 'categories'
Hibernate PostUpdateEvent for Article(d429662d-1753-4cda-947f-4463eb41bcf9)
<<< Transaction #7 ended with status 'COMMITTED'

Publishing EntityUpdatedEvent for Article(d429662d-1753-4cda-947f-4463eb41bcf9) with properties:
- author (UNKNOWN):
old: "<not loaded>"
new: "<not loaded>"
- categories (CHANGED):
old: "<not loaded>"
new: "[]"
- content (EQUAL):
value: "Some fixed article content..."
- createdDate (EQUAL):
value: "2023-07-26T09:05:53.741290Z"
- lastModifiedDate (CHANGED):
old: "2023-07-26T09:05:53.959540Z"
new: "2023-07-26T09:05:53.971389Z"
- title (EQUAL):
value: "Awesome entity event listeners!"

>>> Transaction #8 'Modification using new set' started...
Hibernate PostCollectionRemoveEvent for Article(d429662d-1753-4cda-947f-4463eb41bcf9) on property ''
Hibernate PostCollectionRecreateEvent for Article(d429662d-1753-4cda-947f-4463eb41bcf9) on property 'categories'
Hibernate PostUpdateEvent for Article(d429662d-1753-4cda-947f-4463eb41bcf9)
<<< Transaction #8 ended with status 'COMMITTED'

Publishing EntityUpdatedEvent for Article(d429662d-1753-4cda-947f-4463eb41bcf9) with properties:
- author (UNKNOWN):
old: "<not loaded>"
new: "<not loaded>"
- categories (CHANGED):
old: "<not loaded>"
new: "[Category(6b5ce797-8d73-4d72-9740-c08ef0e3b2b0)]"
- content (EQUAL):
value: "Some fixed article content..."
- createdDate (EQUAL):
value: "2023-07-26T09:05:53.741290Z"
- lastModifiedDate (CHANGED):
old: "2023-07-26T09:05:53.971389Z"
new: "2023-07-26T09:05:54.000573Z"
- title (EQUAL):
value: "Awesome entity event listeners!"

>>> Transaction #9 'Article deletion' started...
Hibernate PostCollectionRemoveEvent for Article(d429662d-1753-4cda-947f-4463eb41bcf9) on property ''
Hibernate PostDeleteEvent for Comment(e7d25b3c-1adb-4576-8bfe-fe875219784e)
Publishing EntityDeletedEvent for Comment(e7d25b3c-1adb-4576-8bfe-fe875219784e)
Hibernate PostDeleteEvent for Article(d429662d-1753-4cda-947f-4463eb41bcf9)
Publishing EntityDeletedEvent for Article(d429662d-1753-4cda-947f-4463eb41bcf9)
<<< Transaction #9 ended with status 'COMMITTED'

In this result, we can observe that the multiple modification events from Hibernate are effectively merged into a single event of our new model, which contains the changes from these various events:

>>> Transaction #6 'Modification on existing set and on simple property' started...
Hibernate PostCollectionUpdateEvent for Article(d429662d-1753-4cda-947f-4463eb41bcf9) on property 'categories'
Hibernate PostUpdateEvent for Article(d429662d-1753-4cda-947f-4463eb41bcf9)
<<< Transaction #6 ended with status 'COMMITTED'

Publishing EntityUpdatedEvent for Article(d429662d-1753-4cda-947f-4463eb41bcf9) with properties:
...
- categories (CHANGED):
old: "<not loaded>"
new: "[Category(4c409778-5aac-4a62-8f7a-248f76659edd)]"
- content (CHANGED):
old: "Some article content..."
new: "Some fixed article content..."
...

Finally, if we reduce the logs to only display those related to the use of the new events, here’s what we get:

[Test worker]  AuthorNameListener               : New author named Léo
[async-exec6] CommentNotificationListener : Sending email to author Léo
[Test worker] AuthorNameListener : Author previously named "Léo" is now named "Léo Millon"

We can clearly see that the email sending is executed in a thread from the pool dedicated to asynchronous processing.

Conclusion

We have seen how Hibernate events can be a good alternative to Aspects for performing actions on entity save.

We have studied the different advantages and drawbacks of this solution and found an improved implementation that allows for a simple and effective use of these events.

The entire source code is available here:

https://github.com/ekino/article-hibernate-entity-event-listeners

This solution is heavily inspired by the one implemented in a currently running project.

I hope this article will help you automate and improve the quality of actions performed during the update of your entities.

Feel free to share your feedback and experiences!


How to listen to entity changes with Hibernate and Spring Data JPA was originally published in ekino-france on Medium, where people are continuing the conversation by highlighting and responding to this story.