Spring Data JPA
entity inheritance
object-relational mapping
JPA strategies
database design

Best way of handling entities inheritance in Spring Data JPA

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

Entity inheritance in JPA looks attractive because it maps an object-oriented model to an object-oriented language. The hard part is that relational databases do not have inheritance, so every strategy is a compromise between query simplicity, storage layout, and future schema changes.

Start With the Domain, Not the Annotation

The best strategy depends on what the parent and child entities represent in the database. If the subclasses are genuinely different records with a small shared core, normalized tables usually age better. If the hierarchy is shallow and reads dominate, a single table can be simpler.

Spring Data JPA supports three standard strategies:

  • 'SINGLE_TABLE'
  • 'JOINED'
  • 'TABLE_PER_CLASS'

In practice, JOINED is often the safest default for business applications because it preserves a normalized schema while still letting you query the hierarchy through the base type.

When JOINED Is the Best Tradeoff

With JOINED, the base entity keeps shared fields and each subclass stores only its specific fields. Reads of a subclass require a join, but the schema remains clean and constraints are easier to reason about.

java
1import jakarta.persistence.*;
2
3@Entity
4@Inheritance(strategy = InheritanceType.JOINED)
5public abstract class ContentItem {
6    @Id
7    @GeneratedValue(strategy = GenerationType.IDENTITY)
8    private Long id;
9
10    @Column(nullable = false)
11    private String title;
12
13    @Column(nullable = false)
14    private String author;
15
16    protected ContentItem() {
17    }
18
19    protected ContentItem(String title, String author) {
20        this.title = title;
21        this.author = author;
22    }
23
24    public Long getId() {
25        return id;
26    }
27
28    public String getTitle() {
29        return title;
30    }
31
32    public String getAuthor() {
33        return author;
34    }
35}
36
37@Entity
38public class Article extends ContentItem {
39    private int wordCount;
40
41    protected Article() {
42    }
43
44    public Article(String title, String author, int wordCount) {
45        super(title, author);
46        this.wordCount = wordCount;
47    }
48
49    public int getWordCount() {
50        return wordCount;
51    }
52}
53
54@Entity
55public class Video extends ContentItem {
56    private int durationSeconds;
57
58    protected Video() {
59    }
60
61    public Video(String title, String author, int durationSeconds) {
62        super(title, author);
63        this.durationSeconds = durationSeconds;
64    }
65
66    public int getDurationSeconds() {
67        return durationSeconds;
68    }
69}

A repository for the base type works as expected:

java
1import org.springframework.data.jpa.repository.JpaRepository;
2
3public interface ContentItemRepository extends JpaRepository<ContentItem, Long> {
4}

Saving and loading remain straightforward:

java
1Article article = new Article("JPA Inheritance", "Mira", 1200);
2Video video = new Video("Spring Data Walkthrough", "Evan", 900);
3
4contentItemRepository.save(article);
5contentItemRepository.save(video);
6
7for (ContentItem item : contentItemRepository.findAll()) {
8    System.out.println(item.getClass().getSimpleName() + " -> " + item.getTitle());
9}

This strategy is a good fit when you need polymorphic queries such as “find all content items,” but you still care about column constraints and a database design that does not turn into a large sparse table.

When SINGLE_TABLE Is Better

SINGLE_TABLE stores the whole hierarchy in one table and uses a discriminator column to identify the subtype. This reduces joins and can perform well for read-heavy workloads.

Choose it when these conditions are true:

  • the hierarchy is small
  • most fields are shared
  • null-heavy child columns are acceptable
  • reporting queries frequently scan the full hierarchy

The tradeoff is schema quality. As subclasses diverge, the table accumulates many nullable columns. Database-level validation also becomes weaker because columns required by one subtype must often stay nullable for others.

Why TABLE_PER_CLASS Is Usually a Last Resort

TABLE_PER_CLASS creates a full table for each concrete subclass. That sounds simple until you query the base type. Polymorphic queries become expensive because the provider must combine rows from several tables.

It can work for small systems that rarely query across the hierarchy, but it is usually not the first choice in Spring Data JPA applications. It also complicates identity generation and can surprise teams later when generic repository methods are used more heavily.

Repository Design Still Matters

Even with the right inheritance mapping, repository boundaries can make the design easier or harder to maintain. Two patterns usually work well:

  • keep a base repository only for behavior that genuinely applies to every subtype
  • add subtype repositories when queries are subtype-specific
java
1import org.springframework.data.jpa.repository.JpaRepository;
2import java.util.List;
3
4public interface ArticleRepository extends JpaRepository<Article, Long> {
5    List<Article> findByWordCountGreaterThan(int minimumWords);
6}

This avoids forcing subtype logic into a generic repository that becomes hard to read.

Common Pitfalls

One common mistake is choosing SINGLE_TABLE too early because it looks simpler in code. If the subclasses evolve independently, the database becomes cluttered and migrations get noisy.

Another mistake is assuming inheritance is always better than composition. If the child entities do not need true polymorphic behavior, separate entities with shared embeddables may be cleaner.

A third issue is using eager relationships on top of JOINED. The extra joins from inheritance plus eager loading can create slow queries quickly. Prefer explicit fetch strategies and measure generated SQL.

Finally, do not let the object model hide the database cost. Turn on SQL logging in development and check what findAll() on the base repository actually does.

Summary

  • 'JOINED is often the best default because it balances schema quality and polymorphic access.'
  • 'SINGLE_TABLE is useful for shallow hierarchies with read-heavy workloads.'
  • 'TABLE_PER_CLASS is usually harder to scale and maintain.'
  • Use subtype repositories for subtype-specific queries instead of forcing everything through the base type.
  • Validate the choice by looking at generated SQL and migration impact, not just annotation convenience.

Course illustration
Course illustration

All Rights Reserved.