Иерархия классов — привычный инструмент в 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 наpaymentbank_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_TABLE | 1 таблица | Простой SELECT | Мало подтипов, нет строгих NOT NULL |
JOINED | N таблиц (нормализация) | JOIN на каждый подтип | Много специфичных полей, нужна целостность в БД |
TABLE_PER_CLASS | N таблиц (денормализация) | 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 — как писать запросы к иерархиям с полиморфизмом