How to save Hibernate Entity changes to Database

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

  1. Hibernate EntityListener
  2. Hibernate Envers
  3. Database Triggers

This blog post explains the process to store entity/table/domain changes in separate table using EntityListener. We can implement EntityListener in 2 ways

  1. Use @EntityListener annotation
  2. 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

  1. Spring Boot
  2. Spring Data JPA/Hibernate
  3. 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

  1. @PrePersist
  2. @PreRemove
  3. @PostPersist
  4. @PostRemove
  5. @PreUpdate
  6. @PostUpdate
  7. @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

  1. 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

  1. 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 🙂

Pavan Kumar Jadda
Pavan Kumar Jadda
Articles: 36

Leave a Reply

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