← назад к разделу

Иерархия классов — привычный инструмент в Java. Но как её перенести в реляционную базу данных? Таблицы не знают о наследовании, и Hibernate предлагает три стратегии маппинга плюс вспомогательный инструмент @MappedSuperclass.

Зачем маппить иерархию

Предположим, есть платёжная система с тремя типами платежей: CardPayment, BankTransferPayment, CryptoPayment. У всех есть общие поля: id, amount, createdAt, status. Специфика — у каждого своя: у карточного — cardLast4, у банковского — ibanNumber, у крипто — walletAddress.

Без специального маппинга придётся либо продублировать общие поля в трёх таблицах, либо хранить всё вперемешку в одной, либо вручную делать JOIN. Hibernate берёт эту работу на себя — нужно лишь выбрать стратегию.

@Inheritance и три стратегии

Базовый класс иерархии помечается @Inheritance(strategy = ...). Наследники — обычные @Entity. Все три стратегии рассмотрим на одном примере с платежами.

SINGLE_TABLE — одна таблица с колонкой-различителем

Все классы иерархии хранятся в одной таблице. Hibernate добавляет колонку discriminator (dtype по умолчанию), по которой понимает, какой тип записи читать.

@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "payment_type")
public abstract class Payment {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private BigDecimal amount;
    private LocalDateTime createdAt;
    private String status;
}

@Entity
@DiscriminatorValue("CARD")
public class CardPayment extends Payment {
    private String cardLast4;
}

@Entity
@DiscriminatorValue("BANK")
public class BankTransferPayment extends Payment {
    private String ibanNumber;
}

В таблице payment будут колонки всех трёх типов сразу. Для CardPayment-строки iban_number будет NULL, и наоборот.

Плюсы: максимальная простота и производительность — один SELECT без JOIN-ов. Полиморфный запрос «все платежи» — тривиален.

Минусы: колонки дочерних типов не могут иметь NOT NULL на уровне БД (для других типов они всегда NULL). При большом числе подтипов таблица становится широкой и разреженной. Целостность данных поддерживается только на уровне приложения.

Короткая формула: SINGLE_TABLE — выбор по умолчанию, когда подтипов немного и нет строгих требований к NOT NULL.

JOINED — отдельная таблица на каждый тип

Каждый класс иерархии получает свою таблицу. Дочерние таблицы содержат только специфичные поля и внешний ключ на родительскую.

@Entity
@Inheritance(strategy = InheritanceType.JOINED)
public abstract class Payment {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private BigDecimal amount;
    private LocalDateTime createdAt;
    private String status;
}

@Entity
@Table(name = "card_payment")
public class CardPayment extends Payment {
    private String cardLast4;
}

@Entity
@Table(name = "bank_transfer_payment")
public class BankTransferPayment extends Payment {
    private String ibanNumber;
}

Схема в БД:

  • payment(id, amount, created_at, status, dtype)
  • card_payment(id, card_last4)id одновременно PK и FK на payment
  • bank_transfer_payment(id, iban_number) — аналогично

Плюсы: нормализованная схема. Каждое поле в своей таблице, NOT NULL работает как ожидается. Хорошо подходит при большом числе специфичных полей в подтипах.

Минусы: каждый запрос сущности делает JOIN. Полиморфный запрос «все платежи» — это LEFT JOIN на каждый подтип. На больших объёмах данных и широком дереве наследования это может быть заметно.

TABLE_PER_CLASS — отдельная таблица с полным набором полей

Каждый конкретный класс получает таблицу со всеми полями — и собственными, и унаследованными.

@Entity
@Inheritance(strategy = InheritanceType.TABLE_PER_CLASS)
public abstract class Payment {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private BigDecimal amount;
    private LocalDateTime createdAt;
    private String status;
}

@Entity
@Table(name = "card_payment")
public class CardPayment extends Payment {
    private String cardLast4;
}

Таблица card_payment содержит: id, amount, created_at, status, card_last4. Общие поля дублируются.

Плюсы: чтение конкретного типа — простой SELECT без JOIN. Нормализации нет, зато запросы по конкретному типу быстрые.

Минусы: полиморфный запрос («найди все платежи») превращается в UNION ALL по всем таблицам — дорого и неудобно. Auto-increment с GenerationType.IDENTITY не работает корректно, нужен SEQUENCE. Стратегия почти не используется на практике.

Короткая формула: TABLE_PER_CLASS — избегай, если нужны полиморфные запросы.

@MappedSuperclass — переиспользование полей без наследования сущностей

@MappedSuperclass — особый случай. Это не стратегия наследования в смысле JPA: суперкласс не является сущностью, не имеет своей таблицы и не поддерживает полиморфных запросов.

Смысл — вынести общие поля в базовый класс, чтобы не дублировать их в каждой сущности.

@MappedSuperclass
public abstract class BaseEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @CreationTimestamp
    private LocalDateTime createdAt;

    @UpdateTimestamp
    private LocalDateTime updatedAt;
}

@Entity
@Table(name = "card_payment")
public class CardPayment extends BaseEntity {
    private BigDecimal amount;
    private String cardLast4;
}

@Entity
@Table(name = "user_account")
public class UserAccount extends BaseEntity {
    private String email;
}

CardPayment и UserAccount — разные сущности с разными таблицами. Они не связаны полиморфизмом. Hibernate просто «копирует» поля из BaseEntity в каждую таблицу при генерации схемы.

@MappedSuperclass — правильный выбор для общих технических полей (id, createdAt, updatedAt, version). @Inheritance — для предметных иерархий, где нужен полиморфизм.

Что выбрать

СтратегияСхемаПолиморфный запросКогда использовать
SINGLE_TABLE1 таблицаПростой SELECTМало подтипов, нет строгих NOT NULL
JOINEDN таблиц (нормализация)JOIN на каждый подтипМного специфичных полей, нужна целостность в БД
TABLE_PER_CLASSN таблиц (денормализация)UNION ALLПрактически не используется
@MappedSuperclassКак у наследниковНе поддерживаетсяОбщие технические поля

Практическое правило: начинай с SINGLE_TABLE. Переходи к JOINED, когда количество подтипов велико, специфичных полей много, и важна целостность данных на уровне схемы. TABLE_PER_CLASS и @MappedSuperclass — для особых случаев.

Коротко

  • Hibernate поддерживает три стратегии маппинга иерархии: SINGLE_TABLE, JOINED, TABLE_PER_CLASS.
  • SINGLE_TABLE — самая простая и производительная: одна таблица, discriminator-колонка, без JOIN. Ценой — нельзя использовать NOT NULL для полей подтипов.
  • JOINED — нормализованная схема: общие поля в родительской таблице, специфичные — в дочерних. Каждый запрос делает JOIN.
  • TABLE_PER_CLASS — каждый тип в отдельной таблице со всеми полями. Полиморфные запросы через UNION ALL — избегай.
  • @MappedSuperclass — не стратегия наследования, а переиспользование полей. Подходит для технических базовых классов (id, createdAt).
  • По умолчанию выбирай SINGLE_TABLE; переходи к JOINED при необходимости строгой схемы.

Что почитать дальше

  • Маппинг сущностей — как аннотации @Entity, @Table, @Column описывают схему
  • Связи между сущностями — @OneToMany, @ManyToOne, @ManyToMany и их нюансы
  • JPQL и Criteria API — как писать запросы к иерархиям с полиморфизмом