JPA event-driven development with entity listeners

Nowadays more and more applications are composed by several different data layers, beginning from the classical RDBMS and finishing in more adapted to some situations (search or graph relationships for example) NoSQL solutions. An interesting feature of JPA allows to notify no-JPA layer about some changes made on entities, exactly as in other solutions coming from event-driven development approach.

In this article we'll focus on JPA possibilities to dispatch event information to observing objects. The first part will explain this concept while the second its implementation in Hibernate. At the end we'll write a simple test case illustrating the working of JPA events.

JPA listeners and callbacks

Methods invoked after JPA lifecycle events, as entity persisting, removing or updating, must be annotated with appropriate annotations. These methods can be called before executing given event (that means, before saving changes in the database) or after event execution (after saving changes in the database). This rule is applied for all writing events (persist, update, remove) but no for reading one (load). The loading allows only callback after event execution and it's logical because before loading, entity holding callbacks doesn't exist. To resume this part, there are all available annotations for callback methods:

To define a listener for JPA's events, we need to annotate entity with @EntityListeners and specify inside one or several listeners to apply to events defined in given class. Methods responsible to handle events must be annotated with the same annotations as methods responsible for launching event from entity (@PostLoad, @PrePersist, @PostPersist, @PreUpdate, @PostUpdate, @PreRemove and @PostRemove).

Exceptions caused by callback methods or listener handling methods occur before the invocation of EntityManager's flush() method, making changes persistent. It explains the fact that these exceptions won't cancel all pending changes.

Hibernate's implementation of JPA events

To see how events and listeners are implemented by Hibernate, let's begin by produce an exception inside callback method. It could give stack trace looking like:

java.lang.RuntimeException: Thrown by listener
  (...)
  at org.hibernate.jpa.event.internal.jpa.ListenerCallback.performCallback(ListenerCallback.java:49)
  at org.hibernate.jpa.event.internal.jpa.CallbackRegistryImpl.callback(CallbackRegistryImpl.java:112)
  at org.hibernate.jpa.event.internal.jpa.CallbackRegistryImpl.postCreate(CallbackRegistryImpl.java:71)
  at org.hibernate.jpa.event.internal.core.JpaPostInsertEventListener.onPostInsert(JpaPostInsertEventListener.java:55)

In returned stack trace, the last one JpaPostInsertEventListener is the most interesting. Because Hibernate is not exclusively JPA solution, it uses its owns events listeners, declared inside org.hibernate.event.spi package. You can find there listeners for data inserting, updating, deleting or loading, exactly as these ones defined in JPA specification.

All lifecycle callbacks and listeners are stored as the implementations of org.hibernate.jpa.event.internal.jpa.CallbackRegistry interface. One from implementations is CallbackRegistryImpl. It contains private field keeping the track of callbacks to invoke for each entity:

private HashMap<Class, Callback[]> preCreates = 
  new HashMap<Class, Callback[]>();
private HashMap<Class, Callback[]> postCreates = 
  new HashMap<Class, Callback[]>();
private HashMap<Class, Callback[]> preRemoves = 
  new HashMap<Class, Callback[]>();
private HashMap<Class, Callback[]> postRemoves = 
  new HashMap<Class, Callback[]>();
private HashMap<Class, Callback[]> preUpdates = 
  new HashMap<Class, Callback[]>();
private HashMap<Class, Callback[]> postUpdates = 
  new HashMap<Class, Callback[]>();
private HashMap<Class, Callback[]> postLoads = 
  new HashMap<Class, Callback[]>();

Appropriated callbacks are after called inside listening methods, as for @PostPersist:

@Override
public void postCreate(Object bean) {
  callback( postCreates.get( bean.getClass() ), bean );
}

Note that listeners defined in superclasses are invoked before listeners from children classes. This behaviour can be changed by excluding superclasses listeners with javax.persistence.ExcludeSuperclassListeners annotation.

Example of JPA listener

To show explained concepts in the code, let's write some test cases. But before, we need to define listeners and callback methods inside ours entities:

@Entity
@Table(name = "letter")
@EntityListeners({QueueStoreJpaListener.class, LetterListener.class})
public class Letter extends Document {

  private boolean loaded;
  private boolean prePersisted;
  private boolean postPersisted;
  private boolean preUpdated;
  private boolean postUpdated;
  private boolean preRemoved;
  private boolean postRemoved;
  private Queue<String> executedEvents = new LinkedBlockingQueue<String>();

  @Transient
  public boolean isLoaded() {
    return this.loaded;
  }

  @Transient
  public boolean isPrePersisted() {
    return this.prePersisted;
  }

  @Transient
  public boolean isPostPersisted() {
    return this.postPersisted;
  }

  @Transient
  public boolean isPreUpdated() {
    return this.preUpdated;
  }

  @Transient
  public boolean isPostUpdated() {
    return this.postUpdated;
  }

  @Transient
  public boolean isPreRemoved() {
    return this.preRemoved;
  }

  @Transient
  public boolean isPostRemoved() {
    return this.postRemoved;
  }

  // JPA's callback methods
  @PostLoad
  public void loadEvent() {
    this.loaded = true;
  }

  @PrePersist
  public void prePersistEvent() {
    this.prePersisted = true;
  }

  @PostPersist
  public void postPersistEvent() {
    this.postPersisted = true;
  }

  @PreUpdate
  public void preUpdateEvent() {
    this.preUpdated = true;
  }

  @PostUpdate
  public void postUpdateEvent() {
    this.postUpdated = true;
  }

  @PreRemove
  public void preRemoveEvent() {
    this.preRemoved = true;
  }

  @PostRemove
  public void postRemoveEvent() {
    this.postRemoved = true;
  }

  public void addExecutedEvents(String event) {
    executedEvents.add(event);
  }

  @Transient
  public Queue<String> getExecutedEvents() {
    return executedEvents;
  }

  @Override
  public String toString() {
    return MoreObjects.toStringHelper(this).add("address", this.address).toString();
  }
}

// Document.java
@EntityListeners(DocumentListener.class)
public abstract class Document {
  // ...
}

Applied listeners look like:

// QueueStoreJpaListener.java
public class QueueStoreJpaListener {

  public static final Queue<String> EVENTS = new LinkedBlockingQueue<String>();

  @PostPersist
  public void sendNewLetterMail(Letter letter) {
    EVENTS.add("Add-"+letter.getAddress());
  }

  @PostUpdate
  public void sendLetterUpdatedMail(Letter letter) {
    EVENTS.add("Update-"+letter.getAddress());
  }

  @PostRemove
  public void sendLetterRemoveMail(Letter letter) {
    EVENTS.add("Remove-"+letter.getAddress());
  }

}

// DocumentListener.java
public class DocumentListener {

  public static final String NAME = "DOCUMENT";

  @PostLoad
  public void loadLow(Letter letter) {
    letter.addExecutedEvents(NAME);
  }

}

// LetterListener.java
public class LetterListener {

  public static final String NAME = "LETTER";

  @PostLoad
  public void loadLow(Letter letter) {
    letter.addExecutedEvents(NAME);
  }

}

And the test cases used to manipulate JPA events and listeners :

public class CallbackListenersTest extends AbstractJpaTester {

  @Test
  public void testPostLoadEvent() {
      Query query = entityManager.createQuery("SELECT l FROM Letter l WHERE l.id = 1");
      Letter letter = (Letter) query.getSingleResult();
      assertTrue("Category should be loaded with @PostLoad annotation", letter.isLoaded());
  }

  @Test
  public void testPostLoadOrderWithSuperclassEvent() {
    Query query = entityManager.createQuery("SELECT l FROM Letter l WHERE l.id = 1");
    Letter letter = (Letter) query.getSingleResult();
    assertEquals("Listener from superclass should be invoked before the others",
      DocumentListener.NAME, letter.getExecutedEvents().poll());
    assertEquals("Listener from class should be invoked after the listener of the listener of superclass",
      LetterListener.NAME, letter.getExecutedEvents().poll());
  }

  @Test
  public void testPersistEvents() {
    EntityTransaction transaction = entityManager.getTransaction();
    try {
      transaction.begin();
      Letter letter = new Letter();
      letter.setAddress("Address 1");

      // *Persisted callbacks will be invoked now
      entityManager.persist(letter);
      assertTrue("Callback for @PrePersist should be invoked after persist() call", letter.isPrePersisted());
      assertTrue("Callback for @PostPersist should be invoked after persist() call " +
              " (directly after execution of INSERT INTO statement)", letter.isPostPersisted());
      entityManager.flush();

      checkEvent("Add-Address 1");
    } finally {
      transaction.rollback();
    }
  }

  @Test
  public void testUpdateEvents() {
    EntityTransaction transaction = entityManager.getTransaction();
    try {
      transaction.begin();
      Query query = entityManager.createQuery("SELECT l FROM Letter l WHERE l.id = 1");
      Letter letter = (Letter) query.getSingleResult();
      checkIfAllAreFalse(letter);

      // *Persisted callbacks will be invoked now
      letter.setAddress("Address changed");
      entityManager.persist(letter);
      entityManager.flush();
      assertTrue("Pre updated callback should be invoked at this stage", letter.isPreUpdated());
      assertTrue("Post updated callback should  be invoked at this stage", letter.isPostUpdated());

      checkEvent("Update-Address changed");
    } finally {
      transaction.rollback();
    }
  }

  @Test
  public void testRemoveEvents() {
    EntityTransaction transaction = entityManager.getTransaction();
    try {
      transaction.begin();
      Query query = entityManager.createQuery("SELECT l FROM Letter l WHERE l.id = 1");
      Letter letter = (Letter) query.getSingleResult();
      checkIfAllAreFalse(letter);

      // *Removed callbacks will be invoked now
      entityManager.remove(letter);
      entityManager.flush();
      assertTrue("Pre removed callback should be invoked at this stage", letter.isPreRemoved());
      assertTrue("Post removed callback should  be invoked at this stage", letter.isPostRemoved());

      checkEvent("Remove-39 street, 99999 City");
    } finally {
      transaction.rollback();
    }
  }

  @Test
  public void testPrePersistCallbackWithException() {
    EntityTransaction transaction = entityManager.getTransaction();
    try {
      transaction.begin();
      Category category = new Category();
      category.setName(Category.TEST_NAME);
      category.setSeo(new Seo());

      entityManager.persist(category);
      entityManager.flush();

      fail("Saving entity with @PrePersist causing RuntimeException shouldn't allow to arrive here");
    } catch (RuntimeException re) {
      // left empty
    } finally {
      transaction.rollback();
    }
  }

  @Test
  public void testPostPersistCallbackWithException() {
    EntityTransaction transaction = entityManager.getTransaction();
    try {
      transaction.begin();
      Category category = new Category();
      category.setName(Category.TEST_NAME);
      category.setSeo(new Seo());

      entityManager.persist(category);
      entityManager.flush();

      fail("Saving entity with @PostPersist causing RuntimeException shouldn't allow to arrive here");
    } catch (RuntimeException re) {
      // left empty
    } finally {
      transaction.rollback();
    }
  }

  @Test
  public void testExceptionThrownByListener() {
    String testName = "Other test class";
    EntityTransaction transaction = entityManager.getTransaction();
    transaction.begin();
    try {
      Category category = new Category();
      category.setName(testName);
      category.setSeo(new Seo());
      category.getSeo().setDescription("desc Test");
      category.getSeo().setKeywords("keywords Test");
      category.getSeo().setTitle("title Test");

      // Listener exception occurs inside persist() method, so flush() is not called. It means that changes won't be
      // pushed to database storage, ie. try to retrieve inserted category will fail - proof in the next try-catch-finally
      // block
      entityManager.persist(category);
      entityManager.flush();
    } catch (RuntimeException e) {
      // left empty for test purposes
    }
    String expectedFragment = "null id in";
    String errorMsg = "";
    try {
      Query query = entityManager.createQuery("SELECT c FROM Category c WHERE c.name = :name");
      query.setParameter("name", testName);
      Category category = (Category) query.getSingleResult();
    } catch (AssertionFailure af) {
      errorMsg = af.getMessage();
    }
    finally {
      transaction.rollback();
    }
    assertTrue("Error on missing ID should be thrown when reading category inserted with listener failure",
      errorMsg.contains(expectedFragment));
  }

  private void checkEvent(String expected) {
    String event = QueueStoreJpaListener.EVENTS.poll();
    assertEquals("Bad event was caught", expected, event);
  }

  private void checkIfAllAreFalse(Letter letter) {
    assertFalse("Post persist callback shouldn't be invoked at this stage", 
      letter.isPostPersisted());
    assertFalse("Post removed callback shouldn't be invoked at this stage", 
      letter.isPostRemoved());
    assertFalse("Pre removed callback shouldn't be invoked at this stage", 
      letter.isPreRemoved());
    assertFalse("Pre updated callback shouldn't be invoked at this stage", 
      letter.isPreUpdated());
    assertFalse("Post updated callback shouldn't be invoked at this stage", 
      letter.isPostUpdated());
  }
}

This articles shows which mechanisms are provided with JPA to deal implement a kind of event-driven system, able to link JPA with, for example, other storage solutions (for example: NoSQL). At the begin, we discovered the annotations used to handle entities lifecycle. After that, we explained the implementation of JPA events in Hibernate. The last part was about some real use-cases showing the use of presented concepts.


If you liked it, you should read:

📚 Newsletter Get new posts, recommended reading and other exclusive information every week. SPAM free - no 3rd party ads, only the information about waitingforcode!