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
| Pros | Cons |
|---|---|
| Single table — simplest schema | Nullable 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 hierarchies | Sparse 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
| Pros | Cons |
|---|---|
| Normalised schema — no nullable columns | JOIN required for every query |
| Can enforce NOT NULL on subclass columns | Slower polymorphic queries (multiple JOINs) |
| Clean tables, no sparse data | INSERT requires two rows (parent + child) |
| Better for large hierarchies with many fields | More 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
| Pros | Cons |
|---|---|
| Simple table structure per type | Column duplication across tables |
| Fast single-type queries | Slow polymorphic queries (UNION ALL) |
| No JOINs for single-type access | Cannot use IDENTITY key strategy |
| Good when polymorphic queries are rare | Poor for changing the hierarchy |
Best for: Hierarchies where you rarely query polymorphically (always query a specific subclass type).
Choosing the Right Strategy
| Question | Recommendation |
|---|---|
| Small hierarchy (2–4 subclasses), few extra fields | SINGLE_TABLE |
| Large hierarchy with many unique fields per subclass | JOINED |
| Never query parent type polymorphically | TABLE_PER_CLASS |
| Need NOT NULL on subclass columns | JOINED |
| Maximum query simplicity | SINGLE_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, simplestJOINED: separate table per class, JOIN on PK — normalised, slower polymorphic queriesTABLE_PER_CLASS: complete table per concrete class, UNION ALL for polymorphic — avoid for frequently queried hierarchiesSINGLE_TABLEis the JPA default and the best starting point for most hierarchiesTABLE_PER_CLASScannot useGenerationType.IDENTITY— useAUTOor a sequence instead- Use
@DiscriminatorColumnand@DiscriminatorValueto control the discriminator column name and values inSINGLE_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.