Иерархия классов — привычный инструмент в Java. Но как её перенести в реляционную базу данных? Таблицы не знают о наследовании, и Hibernate предлагает три стратегии маппинга плюс вспомогательный инструмент @MappedSuperclass.
Поля у объекта одни и те же — меняется только то, куда они попадают. SINGLE_TABLE кладёт всю иерархию в одну таблицу, и колонки чужого типа остаются пустыми. JOINED разносит один объект по двум строкам: общие поля в родительской таблице, своё — в дочерней, связка по id. TABLE_PER_CLASS даёт каждому типу полную таблицу, и рамка показывает цену — общие колонки продублированы.
Зачем маппить иерархию
Предположим, есть платёжная система с тремя типами платежей: CardPayment, BankTransferPayment, CryptoPayment. Общие поля у всех одни: id, amount, createdAt, status. Своё — у каждого: cardLast4, ibanNumber, walletAddress.
Без специального маппинга придётся либо продублировать общие поля в трёх таблицах, либо хранить всё вперемешку в одной, либо вручную делать JOIN. Hibernate берёт эту работу на себя — нужно лишь выбрать стратегию.
@Inheritance и три стратегии
Базовый класс иерархии помечается @Inheritance(strategy = ...). Наследники — обычные @Entity. Если аннотацию не поставить вовсе, JPA возьмёт SINGLE_TABLE.
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 колонки всех трёх типов сразу: у карточной строки пуст iban_number, у банковской — card_last4.
Плюсы: простота и скорость — один SELECT без JOIN-ов, полиморфный запрос «все платежи» тривиален.
Минусы: колонки дочерних типов не могут иметь NOT NULL — для других типов они всегда NULL; обязательность такого поля держится на CHECK-ограничении или на коде. При многих подтипах таблица становится широкой и разреженной.
Сама разреженность при этом дешевле, чем кажется: PostgreSQL хранит NULL не значением, а битом в заголовке строки, так что несколько десятков пустых колонок по месту почти ничего не стоят. Платите вы читаемостью схемы и потерянным NOT NULL, а не диском.
JOINED — отдельная таблица на каждый тип
Каждый класс иерархии получает свою таблицу. Дочерние таблицы содержат только специфичные поля и внешний ключ на родительскую.
@Entity
@Inheritance(strategy = InheritanceType.JOINED)
public abstract class Payment {
// поля те же, что выше
}
@Entity
@Table(name = "card_payment")
public class CardPayment extends Payment {
private String cardLast4;
}
У родителя @Table нет, поэтому имя таблицы Hibernate собрал сам: взял имя класса и прогнал через стратегию именования — вышло payment. В маппинге сущностей мы договорились ставить @Table явно, и родительская таблица иерархии — ровно тот случай, где угадывать имя не стоит: на него будут ссылаться внешние ключи всех дочерних таблиц.
Схема в БД:
payment(id, amount, created_at, status)— колонки-дискриминатора здесь нет: тип Hibernate определяет по тому, в какой из дочерних таблиц нашлась строка. Добавить@DiscriminatorColumnможно, и тогда SQL полиморфного запроса будет проще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.SEQUENCE, generator = "payment_seq")
@SequenceGenerator(name = "payment_seq", sequenceName = "payment_id_seq", allocationSize = 50)
private Long id;
private BigDecimal amount;
private LocalDateTime createdAt;
private String status;
}
Таблица card_payment содержит: id, amount, created_at, status, card_last4. Общие поля дублируются.
Плюсы: чтение конкретного типа — простой SELECT без JOIN и без разреженных колонок.
Минусы: полиморфный запрос («найди все платежи») превращается в UNION ALL по всем таблицам — дорого и неудобно. GenerationType.IDENTITY здесь не годится: каждая таблица нумерует строки сама, а идентификатор должен быть уникален на всю иерархию — нужна одна последовательность на все таблицы, как в примере выше. Колонка-дискриминатор для этой стратегии не работает: спецификация её тут не описывает, а Hibernate поставленную @DiscriminatorColumn просто игнорирует — молча, без ошибки. Ждать падения на старте не стоит: схема соберётся, колонки в ней не будет. Стратегия почти не используется на практике.
Одни и те же поля — три раскладки
Разница видна лучше всего на одной паре платежей. Класс ниже печатает строки, которые окажутся в базе при каждой стратегии:
живой пример
import java.util.List;
public class InheritanceLayoutDemo {
record Payment(long id, String type, String amount, String own) {}
static String table(Payment p) {
return p.type().equals("CARD") ? "card_payment" : "bank_transfer_payment";
}
public static void main(String[] args) {
List<Payment> all = List.of(new Payment(1, "CARD", "100.00", "4242"),
new Payment(2, "BANK", "250.00", "DE89"));
System.out.println("SINGLE_TABLE: payment(id, payment_type, amount, card_last4, iban)");
for (Payment p : all) {
System.out.printf(" %d %s %s %s %s%n", p.id(), p.type(), p.amount(),
p.type().equals("CARD") ? p.own() : "NULL",
p.type().equals("BANK") ? p.own() : "NULL");
}
System.out.println("JOINED: payment(id, amount) + дочерняя(id, своё поле)");
for (Payment p : all) {
System.out.printf(" payment %d %s | %s %d %s%n", p.id(), p.amount(), table(p), p.id(), p.own());
}
System.out.println("TABLE_PER_CLASS: своя таблица с полным набором колонок");
for (Payment p : all) {
System.out.printf(" %s %d %s %s%n", table(p), p.id(), p.amount(), p.own());
}
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
В выводе видно, за что платит каждая стратегия: в SINGLE_TABLE половина ячеек пустая, в JOINED один платёж превращается в две строки, в TABLE_PER_CLASS колонка amount есть в обеих таблицах — и полиморфный запрос читает обе.
@MappedSuperclass — переиспользование полей без наследования сущностей
У каждой сущности есть id, createdAt и updatedAt, и писать их в двадцатый класс руками надоедает. Для технических полей есть @MappedSuperclass: общие поля выносят в базовый класс, а каждая сущность получает их в свою таблицу. Это не стратегия наследования в смысле JPA: суперкласс не является сущностью, не имеет своей таблицы и не поддерживает полиморфных запросов. Отсюда и правило: @MappedSuperclass для технических полей, @Inheritance для предметных иерархий.
@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;
}
@CreationTimestamp и @UpdateTimestamp в этом примере — из org.hibernate.annotations, а не из jakarta.persistence. Hibernate сам проставит поле при вставке и при каждом обновлении; в стандарте JPA аналога нет, так что при переезде на другую реализацию эти две строки придётся чем-то заменить.
CardPayment и UserAccount — разные сущности с разными таблицами, полиморфизмом они не связаны: Hibernate просто «копирует» поля из BaseEntity в каждую таблицу при генерации схемы.
@MappedSuperclass — правильный выбор для общих технических полей (id, createdAt, updatedAt, version). @Inheritance — для предметных иерархий, где нужен полиморфизм.
Как этим пользоваться: полиморфные запросы
Иерархию разложили по таблицам — теперь надо уметь спрашивать. Запрос по базовому классу возвращает все подтипы, и это работает в любой стратегии:
List<Payment> all = em.createQuery("select p from Payment p where p.orderId = :id", Payment.class)
.setParameter("id", orderId)
.getResultList();
Hibernate сам подставит нужные соединения или условие по дискриминатору и вернёт объекты правильных классов. Отфильтровать по конкретному типу позволяет type, а обратиться к полю подтипа — treat:
// только карточные
select p from Payment p where type(p) = CardPayment
// сразу два подтипа
select p from Payment p where type(p) in (CardPayment, SbpPayment)
// условие по полю, которого нет у базового класса
select p from Payment p where treat(p as CardPayment).cardNetwork = 'VISA'
Как это выглядит в SQL, зависит от стратегии. В SINGLE_TABLE type(p) = CardPayment превращается в where payment_type = 'CARD' — обычное условие по колонке. В JOINED тип определяется тем, в какой дочерней таблице нашлась строка, поэтому запрос идёт с соединениями. В TABLE_PER_CLASS выборка по базовому классу собирается через UNION ALL по всем таблицам — самый дорогой вариант, и именно поэтому эту стратегию не рекомендуют.
Один и тот же запрос «все платежи»: смотрите, сколько таблиц база трогает при каждой стратегии.
Отдельно стоит знать про @Polymorphism и про то, что выборку по одному подтипу можно писать и напрямую по его классу (select c from CardPayment c) — тогда в SINGLE_TABLE условие по дискриминатору добавится само, а в JOINED соединение будет только с одной дочерней таблицей.
Индексы под выбранную стратегию
Про план запроса в этой теме забывают чаще всего, а он определяется стратегией напрямую.
SINGLE_TABLE. Все строки в одной таблице, и почти каждый запрос содержит условие по дискриминатору. Значит, обычный индекс по колонке подтипа бесполезен, если подтипов мало (селективность плохая), а нужен составной индекс, где дискриминатор стоит рядом с реальным условием: (payment_type, order_id). Ещё лучше работает частичный индекс — он меньше и обслуживает конкретный подтип:
CREATE INDEX ix_payment_card_order ON payment (order_id) WHERE payment_type = 'CARD';
Плюс отдельная история с NOT NULL: колонки подтипов обязаны допускать пустоту, потому что у других подтипов их нет, — то есть проверки «поле обязательно» уходят из схемы в код.
JOINED. Соединения идут по первичному ключу дочерних таблиц — индекс там есть по определению, и точечные запросы дешёвы. Дорого становится на выборке по базовому классу: select from Payment — это левое соединение со всеми дочерними таблицами. На трёх подтипах это незаметно, на двенадцати план превращается в дерево из двенадцати соединений, и время ответа растёт заметно даже при попадании по индексу. Практический ориентир: до пяти-шести подтипов JOINED ведёт себя предсказуемо; дальше стоит либо не запрашивать базовый класс вовсе (работать с конкретными подтипами), либо пересмотреть саму иерархию.
Переезд между стратегиями
Вопрос «поняли, что нужна другая стратегия» решается миграцией данных, и это не переименование колонки.
Из SINGLE_TABLE в JOINED (самый частый случай, когда широкая таблица с десятками пустых колонок стала неуправляемой):
- Создать дочерние таблицы с первичным ключом, совпадающим с ключом базовой, и колонками подтипов.
- Перелить данные по одному подтипу за раз:
INSERT INTO card_payment (id, card_network, masked_number) SELECT id, card_network, masked_number FROM payment WHERE payment_type = 'CARD';— порциями, если строк много. - Сверить количество строк по каждому подтипу.
- Переключить маппинг в коде и выкатить.
- Отдельным релизом удалить переехавшие колонки из базовой таблицы.
Порядок важен: между шагами 2 и 4 данные лежат в двух местах, и это нормально — так и работает расширение и сжатие схемы из статьи про миграции. Обратный переезд (из JOINED в SINGLE_TABLE) делается так же, но добавляет колонки в базовую таблицу и требует, чтобы они допускали пустоту.
Когда не наследовать вовсе
Прежде чем выбирать стратегию, стоит проверить, нужна ли иерархия сущностей. Три альтернативы, которые в предметно-ориентированном коде часто оказываются лучше.
Отдельные сущности с общим @Embeddable. Если у карточного и у наличного платежа общего только сумма, валюта и время, это не иерархия, а два разных объекта с одинаковым набором полей. Общее выносят во встраиваемый объект (Money, Audit), и каждая сущность живёт своей таблицей без дискриминаторов и соединений. Полиморфизм при этом теряется — но если код всё равно всегда знает, с каким типом работает, терять нечего.
Одно поле-тип плюс детали в JSONB. Когда подтипов много, они меняются и различаются только набором дополнительных полей, честнее хранить тип строкой и детали документом: payment_type varchar(16) плюс details jsonb. Схема не растёт при добавлении подтипа, миграции не нужны; цена — нет проверок на уровне схемы и нет типизации в коде (её приносят разбором документа в объект при чтении).
Иерархия только в предметной модели, без наследования сущностей. Сущность плоская, а внутри домена из неё собирают нужный объект фабрикой по значению типа. Тогда ORM не участвует в полиморфизме вовсе, и все связанные с ним сложности исчезают.
Отдельно про третий выбор, который делают постоянно, не замечая: абстрактный класс без аннотаций против @MappedSuperclass против @Inheritance. Обычный абстрактный класс без аннотаций для JPA не существует: его поля в таблицу не попадут, он годится только для методов. @MappedSuperclass даёт переиспользование колонок без иерархии сущностей — запросить его нельзя, полиморфизма нет, каждая дочерняя сущность живёт своей таблицей. @Inheritance создаёт настоящую иерархию сущностей, по которой можно спрашивать. Правило простое: нужны общие колонки — @MappedSuperclass; нужен запрос по базовому типу — @Inheritance; нужны только общие методы — обычный класс.
Что выбрать
| Стратегия | Схема | Полиморфный запрос | Когда использовать |
|---|---|---|---|
SINGLE_TABLE | 1 таблица | Простой SELECT | Мало подтипов, нет строгих NOT NULL |
JOINED | N таблиц (нормализация) | JOIN на каждый подтип | Много специфичных полей, нужна целостность в БД |
TABLE_PER_CLASS | N таблиц (денормализация) | UNION ALL | Практически не используется |
@MappedSuperclass | Как у наследников | Не поддерживается | Общие технические поля |
Коротко
- Hibernate поддерживает три стратегии маппинга иерархии:
SINGLE_TABLE,JOINED,TABLE_PER_CLASS; без@InheritanceберётсяSINGLE_TABLE. SINGLE_TABLE— самая простая и производительная: одна таблица, discriminator-колонка, без JOIN. Ценой — нельзя использовать NOT NULL для полей подтипов.JOINED— нормализованная схема: общие поля в родительской таблице, специфичные — в дочерних. Каждый запрос делает JOIN.TABLE_PER_CLASS— каждый тип в отдельной таблице со всеми полями. Полиморфные запросы через UNION ALL,IDENTITYне работает — нуженSEQUENCE.@MappedSuperclass— не стратегия наследования, а переиспользование полей. Подходит для технических базовых классов (id,createdAt).- По умолчанию —
SINGLE_TABLE;JOINEDберут, когда подтипов и специфичных полей много и целостность нужна на уровне схемы. - Полиморфный запрос: по базовому классу приезжают все подтипы,
type(p) = CardPaymentфильтрует по типу,treat(...)даёт доступ к полю подтипа. - В
SINGLE_TABLEнужен составной или частичный индекс с дискриминатором; вJOINEDвыборка по базовому классу соединяет все дочерние таблицы, и на десятке подтипов это заметно. - Переезд между стратегиями — миграция данных по подтипам со сверкой, а не правка аннотации; и часто честнее не наследовать: общий
@Embeddable, тип плюс JSONB или@MappedSuperclass.
Что почитать дальше
- Маппинг сущностей — как аннотации
@Entity,@Table,@Columnописывают схему - Связи между сущностями —
@OneToMany,@ManyToOne,@ManyToManyи их нюансы - JPQL и Criteria API — как писать запросы к иерархиям с полиморфизмом
- Типичные ошибки с Hibernate — грабли, на которые чаще всего наступают с этими же сущностями