Inheritance Strategies: SINGLE_TABLE, JOINED, TABLE_PER_CLASS

Introduction

Object-oriented code uses inheritance to share behaviour. Relational databases have no concept of inheritance. JPA bridges this gap with three strategies for mapping a class hierarchy to tables. Understanding when each is appropriate prevents schema headaches and performance problems.


The Domain Example

An e-commerce system has different types of discount:

Discount (abstract)
├── PercentageDiscount (e.g., 10% off)
└── FixedAmountDiscount (e.g., $5 off)

All discounts share: id, name, validFrom, validUntil. PercentageDiscount adds: percentage (e.g., 10.0). FixedAmountDiscount adds: amount (e.g., 5.00).


Strategy 1: SINGLE_TABLE

All classes in the hierarchy are mapped to one table. A discriminator column identifies which subclass each row represents.

@Entity
@Table(name = "discounts")
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "discount_type", discriminatorType = DiscriminatorType.STRING)
@Getter @Setter @NoArgsConstructor
public abstract class Discount {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, length = 100)
    private String name;

    @Column(name = "valid_from", nullable = false)
    private LocalDate validFrom;

    @Column(name = "valid_until", nullable = false)
    private LocalDate validUntil;

    @Column(name = "active", nullable = false)
    private boolean active = true;
}
@Entity
@DiscriminatorValue("PERCENTAGE")
@Getter @Setter @NoArgsConstructor
public class PercentageDiscount extends Discount {

    @Column(nullable = false, precision = 5, scale = 2)
    private BigDecimal percentage;  // e.g., 10.00 = 10%
}
@Entity
@DiscriminatorValue("FIXED")
@Getter @Setter @NoArgsConstructor
public class FixedAmountDiscount extends Discount {

    @Column(nullable = false, precision = 10, scale = 2)
    private BigDecimal amount;  // e.g., 5.00 = $5 off
}

Resulting table

CREATE TABLE discounts (
    id            BIGINT       NOT NULL AUTO_INCREMENT,
    discount_type VARCHAR(31)  NOT NULL,   -- discriminator
    name          VARCHAR(100) NOT NULL,
    valid_from    DATE         NOT NULL,
    valid_until   DATE         NOT NULL,
    active        TINYINT(1)   NOT NULL DEFAULT 1,
    -- PercentageDiscount columns (NULL for FixedAmountDiscount rows)
    percentage    DECIMAL(5,2),
    -- FixedAmountDiscount columns (NULL for PercentageDiscount rows)
    amount        DECIMAL(10,2),
    PRIMARY KEY (id)
) ENGINE=InnoDB;

Working with SINGLE_TABLE

// Save polymorphically
discountRepository.save(new PercentageDiscount("Summer Sale", 10.0, from, until));
discountRepository.save(new FixedAmountDiscount("$5 off", 5.00, from, until));

// Polymorphic query — returns all discounts (both types)
List<Discount> allDiscounts = discountRepository.findAll();

// Type-specific query
@Query("SELECT d FROM PercentageDiscount d WHERE d.percentage >= :min")
List<PercentageDiscount> findHighPercentage(@Param("min") BigDecimal min);

Pros and cons

ProsCons
Single table — simplest schemaNullable columns for non-applicable subclass fields
Best polymorphic query performance (no JOIN)Cannot add NOT NULL constraints on subclass columns
Simple inserts (one table)Table grows wide with many subclasses
Efficient for small hierarchiesSparse data if subclasses differ a lot

Best for: Small, stable hierarchies where subclasses have few extra fields. SINGLE_TABLE is the default in JPA for a reason.


Strategy 2: JOINED

Each class in the hierarchy has its own table. The parent table holds shared columns; subclass tables hold type-specific columns and join to the parent by primary key.

@Entity
@Table(name = "discounts")
@Inheritance(strategy = InheritanceType.JOINED)
@Getter @Setter @NoArgsConstructor
public abstract class Discount {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;
    private LocalDate validFrom;
    private LocalDate validUntil;
    private boolean active = true;
}
@Entity
@Table(name = "percentage_discounts")
@PrimaryKeyJoinColumn(name = "discount_id")
@Getter @Setter @NoArgsConstructor
public class PercentageDiscount extends Discount {

    @Column(nullable = false, precision = 5, scale = 2)
    private BigDecimal percentage;
}
@Entity
@Table(name = "fixed_discounts")
@PrimaryKeyJoinColumn(name = "discount_id")
@Getter @Setter @NoArgsConstructor
public class FixedAmountDiscount extends Discount {

    @Column(nullable = false, precision = 10, scale = 2)
    private BigDecimal amount;
}

Resulting schema

CREATE TABLE discounts (
    id          BIGINT NOT NULL AUTO_INCREMENT,
    name        VARCHAR(100) NOT NULL,
    valid_from  DATE NOT NULL,
    valid_until DATE NOT NULL,
    active      TINYINT(1) NOT NULL DEFAULT 1,
    PRIMARY KEY (id)
);

CREATE TABLE percentage_discounts (
    discount_id BIGINT NOT NULL,
    percentage  DECIMAL(5,2) NOT NULL,
    PRIMARY KEY (discount_id),
    FOREIGN KEY (discount_id) REFERENCES discounts(id)
);

CREATE TABLE fixed_discounts (
    discount_id BIGINT NOT NULL,
    amount      DECIMAL(10,2) NOT NULL,
    PRIMARY KEY (discount_id),
    FOREIGN KEY (discount_id) REFERENCES discounts(id)
);

Generated SQL for JOINED queries

-- Load a PercentageDiscount
SELECT d.*, pd.percentage
FROM discounts d
INNER JOIN percentage_discounts pd ON pd.discount_id = d.id
WHERE d.id = ?

-- Polymorphic query (load all discounts — both types)
SELECT d.id, d.name, d.active,
       pd.percentage,
       fd.amount
FROM discounts d
LEFT OUTER JOIN percentage_discounts pd ON pd.discount_id = d.id
LEFT OUTER JOIN fixed_discounts fd ON fd.discount_id = d.id

Pros and cons

ProsCons
Normalised schema — no nullable columnsJOIN required for every query
Can enforce NOT NULL on subclass columnsSlower polymorphic queries (multiple JOINs)
Clean tables, no sparse dataINSERT requires two rows (parent + child)
Better for large hierarchies with many fieldsMore complex schema

Best for: Large hierarchies where subclasses have many unique fields, or when data integrity (NOT NULL) on subclass columns is important.


Strategy 3: TABLE_PER_CLASS

Each concrete class has its own complete table. No parent table — all shared columns are duplicated in each subclass table.

@Entity
@Inheritance(strategy = InheritanceType.TABLE_PER_CLASS)
@Getter @Setter @NoArgsConstructor
public abstract class Discount {

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;  // cannot use IDENTITY — must use AUTO or SEQUENCE

    private String name;
    private LocalDate validFrom;
    private LocalDate validUntil;
    private boolean active;
}
@Entity
@Table(name = "percentage_discounts")
public class PercentageDiscount extends Discount {
    private BigDecimal percentage;
}
@Entity
@Table(name = "fixed_discounts")
public class FixedAmountDiscount extends Discount {
    private BigDecimal amount;
}

Resulting schema

CREATE TABLE percentage_discounts (
    id          BIGINT NOT NULL,
    name        VARCHAR(100),
    valid_from  DATE,
    valid_until DATE,
    active      TINYINT(1),
    percentage  DECIMAL(5,2),
    PRIMARY KEY (id)
);

CREATE TABLE fixed_discounts (
    id          BIGINT NOT NULL,
    name        VARCHAR(100),
    valid_from  DATE,
    valid_until DATE,
    active      TINYINT(1),
    amount      DECIMAL(10,2),
    PRIMARY KEY (id)
);

No parent table. Each table contains all columns.

Polymorphic queries — UNION ALL

-- Load all discounts (polymorphic)
SELECT * FROM percentage_discounts
UNION ALL
SELECT * FROM fixed_discounts

This UNION ALL query runs across all tables in the hierarchy. Performance degrades with more subclasses.

Cannot use IDENTITY key generation — Hibernate cannot guarantee unique ids across two separate tables using each table’s own auto-increment. Must use GenerationType.AUTO (TABLE strategy) or a sequence.

Pros and cons

ProsCons
Simple table structure per typeColumn duplication across tables
Fast single-type queriesSlow polymorphic queries (UNION ALL)
No JOINs for single-type accessCannot use IDENTITY key strategy
Good when polymorphic queries are rarePoor for changing the hierarchy

Best for: Hierarchies where you rarely query polymorphically (always query a specific subclass type).


Choosing the Right Strategy

QuestionRecommendation
Small hierarchy (2–4 subclasses), few extra fieldsSINGLE_TABLE
Large hierarchy with many unique fields per subclassJOINED
Never query parent type polymorphicallyTABLE_PER_CLASS
Need NOT NULL on subclass columnsJOINED
Maximum query simplicitySINGLE_TABLE

Most real-world cases: use SINGLE_TABLE unless the hierarchy is large and data integrity on subclass columns is a requirement.


Key Takeaways

  • SINGLE_TABLE: one table, discriminator column, nullable subclass columns — fastest, simplest
  • JOINED: separate table per class, JOIN on PK — normalised, slower polymorphic queries
  • TABLE_PER_CLASS: complete table per concrete class, UNION ALL for polymorphic — avoid for frequently queried hierarchies
  • SINGLE_TABLE is the JPA default and the best starting point for most hierarchies
  • TABLE_PER_CLASS cannot use GenerationType.IDENTITY — use AUTO or a sequence instead
  • Use @DiscriminatorColumn and @DiscriminatorValue to control the discriminator column name and values in SINGLE_TABLE

What’s Next

Article 15 covers @MappedSuperclass — a simpler alternative for sharing fields like id, createdAt, and updatedAt across unrelated entities without creating a parent table.