The best way to map the SINGLE_TABLE inheritance with JPA and Hibernate

Imagine having a tool that can automatically detect JPA and Hibernate performance issues. Wouldn’t that be just awesome?

Well, Hypersistence Optimizer is that tool! And it works with Spring Boot, Spring Framework, Jakarta EE, Java EE, Quarkus, or Play Framework.

So, enjoy spending your time on the things you love rather than fixing performance issues in your production system on a Saturday night!

Introduction

Java, like any other object-oriented programming language, makes heavy use of inheritance and polymorphism. Inheritance allows defining class hierarchies that offer different implementations of a common interface.

Conceptually, the Domain Model defines both data (e.g. persisted entities) and behavior (business logic). Nevertheless, inheritance is more useful for varying behavior rather than reusing data (composition is much more suitable for sharing structures).

Even if the data (persisted entities) and the business logic (transactional services) are decoupled, inheritance can still help varying business logic (e.g. Visitor pattern).

In this article, we are going to see what is the best way to map the SINGLE_TABLE inheritance, which, not only is the default inheritance strategy, but it’s usually the most efficient way to model entity inheritance.

Domain Model

To illustrate how entity inheritance works, consider the following model diagram:

Single table inheritance class diagram

The root entity of this Domain Model is the Board entity because, either directly or indirectly, all the other entities are associated with a Board

@Entity 
@Table(name = "board")
public class Board {

    @Id 
    @GeneratedValue
    private Long id;

    private String name;

    //Getters and setters omitted for brevity
}

The end-user can submit either a Post or an Announcement on a particular Board. Because the Post and the Announcement share the same functionality (differing only in data), they both inherit from a Topic base class.

The Topic class defines a relationship to a Board entity, hence the Post and the Announcement entities can also be associated with a Board instance.

@Entity 
@Table(name = "topic")
public class Topic {

    @Id 
    @GeneratedValue
    private Long id;

    private String title;

    private String owner;

    @Temporal(TemporalType.TIMESTAMP)
    private Date createdOn = new Date();

    @ManyToOne(fetch = FetchType.LAZY)
    private Board board;

    //Getters and setters omitted for brevity
}

Both the Post and the Announcement entities extend the Topic class and define their own specific attributes.

@Entity
public class Post extends Topic {

    private String content;

    //Getters and setters omitted for brevity
}

@Entity
public class Announcement extends Topic {

    @Temporal(TemporalType.TIMESTAMP)
    private Date validUntil;

    //Getters and setters omitted for brevity
}

The TopicStatistics is at the bottom of this Domain Model as it is only needed for monitoring purposes, without being directly associated with the main business logic. Because statistics are needed for both Post and Announcement entities, the TopicStatistics defines a Topic entity association.

@Entity 
@Table(name = "topic_statistics")
public class TopicStatistics {

    @Id @GeneratedValue
    private Long id;

    @OneToOne 
    @MapsId
    private Topic topic;

    private long views;

    //Getters and setters omitted for brevity
}

SINGLE_TABLE inheritance mapping

The single table inheritance is the default JPA strategy, funneling a whole inheritance Domain Model hierarchy into a single database table.

To employ this strategy, the Topic entity class must be mapped with one of the following annotations:

  • @Inheritance (being the default inheritance model, it is not mandatory to supply the strategy when using single table inheritance).
  • @Inheritance(strategy = InheritanceType.SINGLE_TABLE).

The Post and the Announcement entities do not need any extra mapping (the Java inheritance semantics being sufficient). Preserving the same layout as depicted in the Domain Model class diagram, the table relationships associated with this inheritance strategy look like this:

Single table inheritance database table relationship

The topic table contains columns associated with the Topic base class as well as columns related to attributes from Post and Announcement entities.

In the following example, one Post and one Announcement entities are going to be persisted along with their associated @OneToOne TopicStatistics relations.

Post post = new Post();
post.setOwner("John Doe");
post.setTitle("Inheritance");
post.setContent("Best practices");
post.setBoard(board);

entityManager.persist(post);

Announcement announcement = new Announcement();
announcement.setOwner("John Doe");
announcement.setTitle("Release x.y.z.Final");
announcement.setValidUntil(
    Timestamp.valueOf(LocalDateTime.now().plusMonths(1))
);
announcement.setBoard(board);

entityManager.persist(announcement);

TopicStatistics postStatistics = 
    new TopicStatistics(post);
postStatistics.incrementViews();
entityManager.persist(postStatistics);

TopicStatistics announcementStatistics = 
    new TopicStatistics(announcement);
announcementStatistics.incrementViews();
entityManager.persist(announcementStatistics);

Both the Post and the Announcement entities are saved in the topic table whose primary key is shared with the topic_statistics table.

INSERT INTO topic (
    board_id, 
    createdOn, 
    owner, 
    title, 
    content, 
    DTYPE, 
    id
) 
VALUES (
    1, 
    '2016-01-17 09:22:22.11', 
    'John Doe', 
    'Inheritance', 
    'Best practices', 
    'Post', 
    1
)

INSERT INTO topic (
    board_id, 
    createdOn, 
    owner, 
    title, 
    validUntil, 
    DTYPE, 
    id
) 
VALUES (
    1, 
    '2016-01-17 09:22:22.11', 
    'John Doe', 
    'Release x.y.z.Final', 
    '2016-02-17 09:22:22.114', 
    'Announcement', 
    2
)
    
INSERT INTO topic_statistics (views, id) VALUES (1, 2)

INSERT INTO topic_statistics (views, id) VALUES (1, 3)

One advantage of using inheritance in the Domain Model is the support for polymorphic queries. When the application developer issues a select query against the Topic entity:

List<Topic> topics = entityManager.createQuery(
    "select t from Topic t where t.board.id = :boardId", 
    Topic.class)
.setParameter("boardId", 1L)
.getResultList();

Hibernate goes to the topic table, and, after fetching the result set, it maps every row to its associated subclass instance (e.g. Post or Announcement) by analyzing the discriminator column (e.g. DTYPE) value.

SELECT 
    t.id AS id2_1_, 
    t.board_id AS board_id8_1_, 
    t.createdOn AS createdO3_1_, 
    t.owner AS owner4_1_, 
    t.title AS title5_1_, 
    t.content AS content6_1_,
    t.validUntil AS validUnt7_1_, 
    t.DTYPE AS DTYPE1_1_
FROM topic t
WHERE t.board_id = 1

Domain Model inheritance allows base class entity associations to be automatically resolved upon being retrieved. When loading a TopicStatistics along with its Topic relation:

TopicStatistics statistics = entityManager.createQuery(
    "select s from TopicStatistics s join fetch s.topic t where t.id = :topicId", 
    TopicStatistics.class)
.setParameter("topicId", topicId)
.getSingleResult();

Hibernate joins the topic_statistics and the topic tables so that it can create a TopicStatistics entity with an actual Post or Announcement attribute object reference.

SELECT 
    ts.id AS id1_2_0_, 
    t.id AS id2_1_1_, 
    ts.views AS views2_2_0_, 
    t.board_id AS board_id8_1_1_, 
    t.createdOn AS createdO3_1_1_,
    t.owner AS owner4_1_1_, 
    t.title AS title5_1_1_, 
    t.content AS content6_1_1_,
    t.validUntil AS validUnt7_1_1_, 
    t.DTYPE AS DTYPE1_1_1_
FROM topic_statistics ts
INNER JOIN topic t ON ts.id = t.id
WHERE t.id = 2

Even if not practical in this particular example, @OneToMany associations are also possible.

The Board entity can map a bidirectional @OneToMany relationship as follows:

@OneToMany(mappedBy = "board") 
private List<Topic> topics = new ArrayList<>();

Fetching the collection lazily generates a separate select statement, identical to the aforementioned Topic entity query. When fetching the collection eagerly, Hibernate requires a single table join.

Board board = entityManager.createQuery(
    "select b from Board b join fetch b.topics where b.id = :id", 
    Board.class)
.setParameter("id", id)
.getSingleResult();
SELECT 
    b.id AS id1_0_0_, 
    t.id AS id2_1_1_, 
    b.name AS name2_0_0_,
    t.board_id AS board_id8_1_1_, 
    t.createdOn AS createdO3_1_1_,
    t.owner AS owner4_1_1_, 
    t.title AS title5_1_1_, 
    t.content AS content6_1_1_,
    t.validUntil AS validUnt7_1_1_, 
    t.DTYPE AS DTYPE1_1_1_,
    t.board_id AS board_id8_1_0__, 
    t.id AS id2_1_0__
FROM board b
INNER JOIN topic t ON b.id = t.board_id
WHERE b.id = 1

Data integrity constraints

Because all subclass attributes are collocated in a single table, NOT NULL constraints are not allowed for columns belonging to subclasses. Being automatically inherited by all subclasses, the base class attributes may be non-nullable.

From a data integrity perspective, this limitation defeats the purpose of Consistency (guaranteed by the ACID properties). Nevertheless, the data integrity rules can be enforced through database trigger procedures or CHECK constraints (a column non-nullability is accounted for based on the class discriminator value).

Another approach is to move the check into the data access layer. Bean Validation can validate @NotNull attributes at runtime. JPA also defines callback methods (e.g. @PrePersist, @PreUpdate) as well as entity listeners (e.g. @EntityListeners) which can throw an exception when a non-null constraint is violated.

The SQL standard defines the CHECK constraint which can be used to apply a row-level verification for each table record that is inserted. Depending on the underlying database, the CHECK constraint can be enforced (e.g. Oracle, SQL Server, PostgreSQL) or ignored (e.g. MySQL).

For the aforementioned database tables, the content column must never be null if the underlying record is a Post, and the validUntil column should not be null if the database row represents an Announcement entity. Luckily, the default DTYPE column specifies the entity type associated with each particular table row.

To ensure the aforementioned data integrity rules, the following CHECK constraints need to be added:

ALTER TABLE Topic 
ADD CONSTRAINT post_content_check 
CHECK (
    CASE 
        WHEN DTYPE = 'Post'
        THEN 
            CASE 
                WHEN content IS NOT NULL 
                THEN 1 
                ELSE 0 
            END
        ELSE 1
    END = 1
)

ALTER TABLE Topic 
ADD CONSTRAINT announcement_validUntil_check 
CHECK (
    CASE 
        WHEN DTYPE = 'Announcement'
        THEN 
            CASE 
                WHEN validUntil IS NOT NULL 
                THEN 1 
                ELSE 0 
            END
        ELSE 1
    END = 1
)

With these CHECK constraints in place, when trying to insert a Post entity without a content:

entityManager.persist(new Post());

PostgreSQL generates the following error message:

INSERT INTO topic (
    board_id, 
    createdOn, 
    owner, 
    title, 
    content, 
    DTYPE, 
    id
)
VALUES (
    (NULL(BIGINT), 
    '2016-07-15 13:45:16.705', 
    NULL(VARCHAR), 
    NULL(VARCHAR),
    NULL(VARCHAR), 
    'Post', 
    4
)

-- SQL Error: 0, SQLState: 23514
-- new row for relation "topic" violates check constraint "post_content_check"

Starting with the 8.0.16 version, MySQL supports custom SQL CHECK constraints. For more details, check out this article.

For MySQL prior to version 8.0.16, the same result can be achieved with a TRIGGER instead.

CREATE
TRIGGER post_content_insert_check BEFORE INSERT
ON topic
FOR EACH ROW
BEGIN
   IF NEW.DTYPE = 'Post'
   THEN
       IF NEW.content IS NULL
       THEN
           signal sqlstate '45000'
           set message_text = 'Post content cannot be NULL';
       END IF;
   END IF;
END;

CREATE 
TRIGGER post_content_update_check BEFORE UPDATE 
ON topic 
FOR EACH ROW 
BEGIN 
   IF NEW.DTYPE = 'Post' 
   THEN 
       IF NEW.content IS NULL 
       THEN 
           signal sqlstate '45000' 
           set message_text = 'Post content cannot be NULL'; 
       END IF; 
   END IF; 
END;

CREATE
TRIGGER announcement_validUntil_insert_check BEFORE INSERT
ON topic
FOR EACH ROW
BEGIN
   IF NEW.DTYPE = 'Announcement'
   THEN
       IF NEW.validUntil IS NULL
       THEN
           signal sqlstate '45000'
           set message_text = 'Announcement validUntil cannot be NULL';
       END IF;
   END IF;
END;

CREATE 
TRIGGER announcement_validUntil_update_check BEFORE UPDATE 
ON topic 
FOR EACH ROW 
BEGIN 
   IF NEW.DTYPE = 'Announcement' 
   THEN 
       IF NEW.validUntil IS NULL 
       THEN 
           signal sqlstate '45000' 
           set message_text = 'Announcement validUntil cannot be NULL'; 
       END IF; 
   END IF; 
END;

When running the previous Post insert, MySQL generates the following output:

INSERT INTO topic (
    board_id, 
    createdOn, 
    owner, 
    title, 
    content, 
    DTYPE, 
    id
)
VALUES (
    (NULL(BIGINT), 
    '2016-07-15 13:50:51.989', 
    NULL(VARCHAR), 
    NULL(VARCHAR),
    NULL(VARCHAR), 
    'Post', 
    4
)

-- SQL Error: 1644, SQLState: 45000
-- Post content cannot be NULL

I'm running an online workshop on the 20-21 and 23-24 of November about High-Performance Java Persistence.

If you enjoyed this article, I bet you are going to love my Book and Video Courses as well.

Conclusion

Since only one table is used for storing entities, both reads and writes are fast. Even when using a @ManyToOne or a @OneToOne base class association, Hibernate needs a single join between parent and child tables.

The @OneToMany base class entity relationship is also efficient since it either generates a secondary select or a single table join.

Although a little bit verbose, the CHECK and TRIGGER constraints are very useful to ensure data integrity when using single table inheritance.

Transactions and Concurrency Control eBook

Leave a Reply

Your email address will not be published. Required fields are marked *

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