Overview
The common requirement in enterprise application is to store changes of an entity in separate table in structured way. There are multiple to achieve this
- Hibernate
EntityListener
- Hibernate Envers
- Database Triggers
This blog post explains the process to store entity/table/domain changes in separate table using EntityListene
r. We can implement EntityListener
in 2 ways
- Use @
EntityListener
annotation - Extend
Listener
interfaces
Motivation
Imagine a simple Spring Boot book store application with 4 simple tables Book, Book Type, Author and Author Publications. And users need to access book and author information frontend. For every user request, we need to join these 4 tables and create a simple view/DTO class and send it to the frontend. This works well for small number of requests but falls apart when more users try to access the same information, as it involves joining tables for every request.
To avoid this, we can have dedicated database for read and write. But that’s not possible in all enterprises as involves more cost, maintenance and needs proper justification. To solve this we can use
normalized/materialized table
that gets updated every time, these four entities are changed.
Technologies
- Spring Boot
- Spring Data JPA/Hibernate
- MySQL
Deep Dive
Lets start a simple entity called Book with few attributes title, isbn, edition, yearOfPublication, publisher and authors.
Book.java
@Entity
@Table(name = "book")
@Data
public class Book {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
private String isbn;
private Integer edition;
private Integer yearOfPublication;
private String publisher;
@OneToOne(cascade = CascadeType.ALL, orphanRemoval = true, mappedBy = "book")
@JsonManagedReference
private Author author;
}
and Author entity looks as below
Author.java
@Entity
@Table(name = "author")
@Data
public class Author implements Serializable {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String firstName;
private String lastName;
private String email;
private String phoneNumber;
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "book_id")
@JsonBackReference
Book book;
public Author() {
// Default
}
public Author(String firstName, String lastName, String email, String phoneNumber) {
this.firstName = firstName;
this.lastName = lastName;
this.email = email;
this.phoneNumber = phoneNumber;
}
}
Using these two tables, we need to build normalized MaterializedBookAuthor
entity table, that stores joined data of both, which helps to prevent joining of both tables every time users require the data. This use case may seem simple but real world use cases involves much complex joins and drastically reduce app performance and cause slow loading times.
MaterializedBookAuthor
.java
@Entity
@Table(name = "materialized_book_author")
@Data
public class MaterializedBookAuthor {
@Id
private Long bookId;
private String title;
private String isbn;
private Integer edition;
private Integer yearOfPublication;
private String publisher;
private String firstName;
private String lastName;
private String email;
private String phoneNumber;
}
We can add entity listener in couple of ways
@EntityListener annotation
To use EntityListener
annotation, define a listener class and add listed JPA callback methods. You can only use few of the listed callback methods
- @PrePersist
- @PreRemove
- @PostPersist
- @PostRemove
- @PreUpdate
- @PostUpdate
- @PostLoad
See Hibernate docs for complete list of call back methods and descriptions. In out BookEntityListener, we are removing book if it’s deleted and updating the info, if updated. For other methods we are just logging the information
BookEntityListener.java
@Component
public class BookEntityListener {
@Lazy
@Autowired
private MaterializedBookAuthorRepository repository;
public BookEntityListener() {
// Default constructor
}
@PrePersist
@PreRemove
@PreUpdate
@PostLoad
public void prePersist(Book book) {
log.debug("PrePersist/PreRemove/PreUpdate/PostLoad book:{}", book);
}
@PostRemove
public void postRemove(Book book) {
repository.deleteById(book.getId());
}
@PostUpdate
@PostPersist
public void postUpdate(Book book) {
var bookMaterialized = repository.findById(book.getId()).orElse(new
MaterializedBookAuthor(book.getId()));
bookMaterialized.setIsbn(book.getIsbn());
bookMaterialized.setTitle(book.getTitle());
bookMaterialized.setEdition(book.getEdition());
bookMaterialized.setYearOfPublication(book.getYearOfPublication());
bookMaterialized.setPublisher(book.getPublisher());
bookMaterialized.setFirstName(book.getAuthor().getFirstName());
bookMaterialized.setLastName(book.getAuthor().getLastName());
bookMaterialized.setEmail(book.getAuthor().getEmail());
bookMaterialized.setPhoneNumber(book.getAuthor().getPhoneNumber());
repository.save(bookMaterialized);
}
}
AuthorEntityListener.java
@Component
@Slf4j
public class AuthorEntityListener {
@Lazy
@Autowired
private MaterializedBookAuthorRepository repository;
public AuthorEntityListener() {
// Default constructor
}
@PrePersist
@PreRemove
@PreUpdate
@PostLoad
public void prePersist(Author author) {
log.debug("PrePersist/PreRemove/PreUpdate/PostLoad author:{}", author);
}
@PostUpdate
@PostPersist
public void postUpdate(Author author) {
var booksMaterialized = repository.findByAuthorId(author.getId());
if (!booksMaterialized.isEmpty()) {
booksMaterialized.forEach(bookMaterialized -> {
bookMaterialized.setFirstName(author.getFirstName());
bookMaterialized.setLastName(author.getLastName());
bookMaterialized.setEmail(author.getEmail());
bookMaterialized.setPhoneNumber(author.getPhoneNumber());
repository.save(bookMaterialized);
});
}
}
}
Updated Book and Author entities are as below
@Entity
@Table(name = "book")
@Data
@EntityListeners(BookEntityListener.class)
public class Book {
// Rest of the code is same
}
@Entity
@Table(name = "author")
@Data
@EntityListeners(AuthorEntityListener.class)
public class Author implements Serializable {
// Rest of the code is same
}
Now, if you update the book or author, then materialized_book_author gets updated automatically. And removing any book, will remove the data as well.
Injecting Spring Services/Repositories into listener
If you look at the above example, we injected MaterializedBookAuthorRepository @Lazy
annotation but not with constructor. But this can be done in 3 different ways constructor, method or member. For any of this to work, make sure to mark listener with Spring @Component
annotation
- Field injection
@Component
@Slf4j
public class BookEntityListener {
@Lazy
@Autowired
private MaterializedBookAuthorRepository repository;
// Hiding other info for brevity
}
2. Method injection
@Component
@Slf4j
public class BookEntityListener {
static private MaterializedBookAuthorRepository repository;
@Autowired
public void init(@Lazy MaterializedBookAuthorRepository repository) {
BookEntityListener.repository = repository;
}
// Hiding other info for brevity
}
3. Or Constructor injection. Don’t forget mark MaterializedBookAuthorRepository with @Lazy
and update application.yml as below
@Component
@Slf4j
public class BookEntityListener {
private final MaterializedBookAuthorRepository repository;
public BookEntityListener(@Lazy MaterializedBookAuthorRepository repository) {
this.repository = repository;
}
// Hiding other info for brevity
}
Make sure to set hibernate.resource.beans.container
in application.yml as below
spring:
jpa:
generate-ddl: true
hibernate:
resource:
beans:
container: org.springframework.orm.hibernate5.SpringBeanContainer
Extend Listeners
The second way of adding listeners is implementing the hibernate Insert, Update and Delete events. You can find these events org.hibernate.event.spi
package.
We can define two implementations for Pre and Post updates. Both of them should be registered to Hibernate. Define HibernateConfig and bind listeners as below
HibernateConfig
@Configuration
public class HibernateConfig {
private final CustomPreInsertEventListener preInsertEventListener;
private final CustomSaveOrUpdateEventListener saveOrUpdateEventListener;
@PersistenceUnit
private EntityManagerFactory emf;
public HibernateConfig(CustomSaveOrUpdateEventListener saveOrUpdateEventListener, CustomPreInsertEventListener preInsertEventListener) {
this.saveOrUpdateEventListener = saveOrUpdateEventListener;
this.preInsertEventListener = preInsertEventListener;
}
@PostConstruct
protected void init() {
var sessionFactory = emf.unwrap(SessionFactoryImpl.class);
var registry = sessionFactory.getServiceRegistry().getService(EventListenerRegistry.class);
// registry.getEventListenerGroup(EventType.PRE_INSERT).appendListener(preInsertEventListener);
// registry.getEventListenerGroup(EventType.PRE_UPDATE).appendListener(preInsertEventListener);
// registry.getEventListenerGroup(EventType.PRE_DELETE).appendListener(preInsertEventListener);
registry.getEventListenerGroup(EventType.POST_UPDATE).appendListener(saveOrUpdateEventListener);
registry.getEventListenerGroup(EventType.POST_INSERT).appendListener(saveOrUpdateEventListener);
registry.getEventListenerGroup(EventType.POST_DELETE).appendListener(saveOrUpdateEventListener);
}
}
and define CustomSaveOrUpdateEventListener to save updates. CustomPreInsertEventListener implementation can be found in GitHub
CustomSaveOrUpdateEventListener
@Component
public class CustomSaveOrUpdateEventListener
implements PostDeleteEventListener, PostInsertEventListener, PostUpdateEventListener {
private final MaterializedBookAuthorRepository repository;
public CustomSaveOrUpdateEventListener(MaterializedBookAuthorRepository repository) {
this.repository = repository;
}
@Override
public void onPostDelete(PostDeleteEvent event) {
final Object entity = event.getEntity();
System.out.println("CustomSaveOrUpdateEventListener.onPostDelete: " + entity);
if (entity instanceof com.pj.hibernate.entity.listener.domain.Book book) {
repository.deleteById(book.getId());
}
}
@Override
public void onPostInsert(PostInsertEvent event) {
final Object entity = event.getEntity();
System.out.println("CustomSaveOrUpdateEventListener.onPostInsert: " + entity);
if (entity instanceof com.pj.hibernate.entity.listener.domain.Book book) {
var materializedBookAuthor = repository.findById(book.getId()).orElse(new MaterializedBookAuthor(book.getId()));
materializedBookAuthor.setBookId(book.getId());
materializedBookAuthor.setTitle(book.getTitle());
materializedBookAuthor.setIsbn(book.getIsbn());
materializedBookAuthor.setPublisher(book.getPublisher());
materializedBookAuthor.setYearOfPublication(book.getYearOfPublication());
materializedBookAuthor.setAuthorId(book.getAuthor().getId());
materializedBookAuthor.setFirstName(book.getAuthor().getFirstName());
materializedBookAuthor.setLastName(book.getAuthor().getLastName());
materializedBookAuthor.setEmail(book.getAuthor().getLastName());
materializedBookAuthor.setPhoneNumber(book.getAuthor().getPhoneNumber());
repository.save(materializedBookAuthor);
}
}
@Override
public void onPostUpdate(PostUpdateEvent event) {
final Object entity = event.getEntity();
System.out.println("CustomSaveOrUpdateEventListener.onPostUpdate: " + entity);
}
/**
* Does this listener require that after transaction hooks be registered?
*
* @param persister The persister for the entity in question.
*
* @return {@code true} if after transaction callbacks should be added.
*/
@Override
public boolean requiresPostCommitHandling(EntityPersister persister) {
return false;
}
}
Now, updating Book or Author will insert record into materialized_book_author table
Testing
- To test EntityListeners annotation, make sure to uncomment lines in Book and Author and create book via curl or postman with body
{
"title": "Spring Essentials",
"isbn": "9374-84774-88377474",
"edition": 1,
"yearOfPublication": 2019,
"publisher": "O'Really",
"firstName": "John",
"lastName": "Doe",
"email": "jdoe@example.com",
"phoneNumber": "992-847-8474"
}
and response should have created book with response
{
"id": 5,
"title": "Spring Essentials",
"isbn": "9374-84774-88377474",
"edition": 1,
"yearOfPublication": 2019,
"publisher": "O'Really",
"author": {
"id": 5,
"firstName": "John",
"lastName": "Doe",
"email": "jdoe@example.com",
"phoneNumber": "992-847-8474"
}
}
2. To test CustomSaveOrUpdateEventListener, uncomment lines 39-42 and comment lines as shown in previous step.
In both cases the materialized_book_author table has below data
[
{
"book_id": 6,
"author_id": 1,
"edition": 1,
"email": "jdoe@example.com",
"first_name": "John",
"isbn": "9374-84774-88377474",
"last_name": "Doe",
"phone_number": "992-847-8474",
"publisher": "O'Really",
"title": "Spring Essentials",
"year_of_publication": 2019
}
]
Code uploaded to GitHub for reference. Happy Coding 🙂