Hibernate & Entity Mapping

Inheritance Mapping

18 min Lesson 9 of 13

Inheritance Mapping

Object-oriented design naturally reaches for inheritance to model real-world hierarchies: a Payment base type with concrete subclasses CreditCardPayment, BankTransferPayment, and CryptoPayment. Relational databases, however, have no native concept of subtyping. JPA bridges this mismatch with three distinct inheritance mapping strategies, each with measurable trade-offs in schema complexity, query performance, and nullable column discipline. Choosing the right one up front avoids painful schema migrations later.

The Three Strategies at a Glance

  • SINGLE_TABLE — one table for the entire hierarchy, a discriminator column identifies the subtype.
  • JOINED — one table per class; subtype tables share the same primary key and join back to the parent.
  • TABLE_PER_CLASS — one standalone table per concrete class; no shared columns, no joins.
Default strategy: If you annotate a class with @Inheritance but omit strategy, JPA defaults to SINGLE_TABLE. Knowing this prevents accidental schema decisions.

Strategy 1 — SINGLE_TABLE

All classes in the hierarchy map to one table. The @DiscriminatorColumn annotation adds a column whose value tells Hibernate which subtype a given row represents. Subtype-specific columns that do not apply to a given row are stored as NULL.

import jakarta.persistence.*; @Entity @Inheritance(strategy = InheritanceType.SINGLE_TABLE) @DiscriminatorColumn(name = "payment_type", discriminatorType = DiscriminatorType.STRING) @Table(name = "payments") public abstract class Payment { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private java.math.BigDecimal amount; private java.time.LocalDateTime createdAt; // getters / setters omitted for brevity } @Entity @DiscriminatorValue("CREDIT_CARD") public class CreditCardPayment extends Payment { private String maskedCardNumber; // e.g. "****-****-****-4242" private String cardholderName; } @Entity @DiscriminatorValue("BANK_TRANSFER") public class BankTransferPayment extends Payment { private String iban; private String bankCode; }

The generated DDL for this hierarchy is a single payments table with columns for all three types plus the payment_type discriminator. A CreditCardPayment row has NULL in iban and bank_code; a BankTransferPayment row has NULL in masked_card_number and cardholder_name.

SINGLE_TABLE is the fastest for polymorphic queries. Fetching all Payment entities requires exactly one SELECT with no joins. It is the best default when the hierarchy is shallow and the subtype-specific columns are few or nullable by nature. Enforce NOT NULL constraints at the application layer (Bean Validation @NotNull) rather than at the DB level when you choose this strategy.
Watch for wide, sparse tables. If a hierarchy has many subtypes each with many unique columns, a SINGLE_TABLE schema becomes a wide table littered with NULLs. Database optimizers handle this less efficiently and the schema becomes difficult to understand. Switch to JOINED at that point.

Strategy 2 — JOINED

Each class in the hierarchy gets its own table. The parent table holds shared columns; each subtype table holds only the columns unique to that subtype and shares the same primary key value as the parent row. Hibernate generates a JOIN whenever it needs to materialize a full subtype object.

@Entity @Inheritance(strategy = InheritanceType.JOINED) @Table(name = "payments") public abstract class Payment { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private java.math.BigDecimal amount; private java.time.LocalDateTime createdAt; } @Entity @Table(name = "credit_card_payments") public class CreditCardPayment extends Payment { private String maskedCardNumber; private String cardholderName; } @Entity @Table(name = "bank_transfer_payments") public class BankTransferPayment extends Payment { private String iban; private String bankCode; }

Loading a CreditCardPayment by ID generates:

SELECT p.id, p.amount, p.created_at, c.masked_card_number, c.cardholder_name FROM payments p INNER JOIN credit_card_payments c ON c.id = p.id WHERE p.id = ?

A polymorphic query — SELECT p FROM Payment p — requires a LEFT OUTER JOIN across every subtype table. With three subtypes Hibernate generates three joins. This is correct but increasingly expensive as the hierarchy grows.

JOINED gives you a normalised schema. Subtype columns are NOT NULL at the database level (they live in their own table), foreign-key integrity is enforced naturally, and reporting queries against a single subtype are fast. Pay the join cost only when loading polymorphically.

Discriminator Columns with JOINED

By default, JOINED does not require a discriminator column — Hibernate can infer the subtype from which join tables are present. However, adding one is recommended for readability and for tools that query the database directly:

@Entity @Inheritance(strategy = InheritanceType.JOINED) @DiscriminatorColumn(name = "payment_type") @Table(name = "payments") public abstract class Payment { ... }

Strategy 3 — TABLE_PER_CLASS

Each concrete class has a completely independent table that repeats the parent's columns. There is no shared parent table and no join between sibling tables.

@Entity @Inheritance(strategy = InheritanceType.TABLE_PER_CLASS) public abstract class Payment { @Id @GeneratedValue(strategy = GenerationType.TABLE) // IDENTITY not allowed private Long id; private java.math.BigDecimal amount; private java.time.LocalDateTime createdAt; } @Entity @Table(name = "credit_card_payments") public class CreditCardPayment extends Payment { private String maskedCardNumber; private String cardholderName; } @Entity @Table(name = "bank_transfer_payments") public class BankTransferPayment extends Payment { private String iban; private String bankCode; }

Notice GenerationType.TABLE instead of IDENTITY. Because there is no shared parent table, database-level identity columns cannot guarantee uniqueness across all subtype tables. A JPA table sequence or UUID primary key is required.

A polymorphic query — SELECT p FROM Payment p — forces Hibernate to issue a UNION ALL across all concrete tables:

SELECT id, amount, created_at, masked_card_number, cardholder_name, NULL AS iban, NULL AS bank_code FROM credit_card_payments UNION ALL SELECT id, amount, created_at, NULL, NULL, iban, bank_code FROM bank_transfer_payments
TABLE_PER_CLASS is the least-recommended strategy for most use cases. Polymorphic queries become UNION ALL operations that cannot use indexes effectively on large tables. Use it only when you never need to query the hierarchy polymorphically — for example, when subtypes are always queried directly by their concrete type and the hierarchy is stable.

Choosing a Strategy — Decision Guide

  • Shallow hierarchy, few subtypes, nullable fields acceptableSINGLE_TABLE. Highest read performance, simplest schema.
  • Deep hierarchy, strong DB integrity needed, NOT NULL constraints matterJOINED. Normalized, flexible, pays join cost on polymorphic load.
  • Subtypes are always queried independently, no polymorphic JPQL neededTABLE_PER_CLASS. Avoid unless you are certain of the query pattern.

Polymorphic Queries and Repository Layer

Spring Data JPA works seamlessly with all three strategies. Declare a base repository against the parent type to query the whole hierarchy, and concrete repositories for subtype-specific finders:

// Queries the full hierarchy — one SELECT (SINGLE_TABLE) or UNION ALL (TABLE_PER_CLASS) public interface PaymentRepository extends JpaRepository<Payment, Long> { List<Payment> findByAmountGreaterThan(java.math.BigDecimal threshold); } // Queries only credit-card rows — no UNION, no extra joins public interface CreditCardPaymentRepository extends JpaRepository<CreditCardPayment, Long> { List<CreditCardPayment> findByCardholderName(String name); }

Summary

JPA provides three inheritance mapping strategies to bridge OOP hierarchies to relational tables. SINGLE_TABLE stores everything in one table with a discriminator — fastest queries, but columns are nullable. JOINED normalizes each class into its own table — best integrity guarantees, pays a join per polymorphic load. TABLE_PER_CLASS repeats parent columns in every concrete table — avoid polymorphic queries, requires a non-IDENTITY key generator. Pick the strategy that matches your schema integrity requirements and your dominant query pattern, and resist changing it later — migration between strategies requires DDL changes on production data.