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

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

один объект — три способа разложить его поля по таблицам CardPayment id = 1 amount = 100.00 status = PAID card_last4 = 4242 SINGLE_TABLEполиморфный запрос — один SELECTpaymentidpayment_typeamountcard_last4iban1CARD100.004242NULL2BANK250.00NULLDE89чужие колонки пусты — NOT NULL на них не поставить JOINEDполиморфный запрос — LEFT JOINpaymentidamountstatus1100.00PAIDid — и первичный, и внешний ключcard_paymentidcard_last414242 TABLE_PER_CLASSполиморфный запрос — UNION ALLcard_paymentidamountstatuscard_last41100.00PAID4242bank_transfer_paymentidamountstatusiban2250.00PAIDDE89

Поля у объекта одни и те же — меняется только то, куда они попадают. 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 на 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.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 по всем таблицам — самый дорогой вариант, и именно поэтому эту стратегию не рекомендуют.

SINGLE_TABLE payment JOINED payment LEFT JOIN card_payment LEFT JOIN bank_payment TABLE_PER_CLASS card_payment UNION ALL bank_payment

Один и тот же запрос «все платежи»: смотрите, сколько таблиц база трогает при каждой стратегии.

Отдельно стоит знать про @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 (самый частый случай, когда широкая таблица с десятками пустых колонок стала неуправляемой):

  1. Создать дочерние таблицы с первичным ключом, совпадающим с ключом базовой, и колонками подтипов.
  2. Перелить данные по одному подтипу за раз: INSERT INTO card_payment (id, card_network, masked_number) SELECT id, card_network, masked_number FROM payment WHERE payment_type = 'CARD'; — порциями, если строк много.
  3. Сверить количество строк по каждому подтипу.
  4. Переключить маппинг в коде и выкатить.
  5. Отдельным релизом удалить переехавшие колонки из базовой таблицы.

Порядок важен: между шагами 2 и 4 данные лежат в двух местах, и это нормально — так и работает расширение и сжатие схемы из статьи про миграции. Обратный переезд (из JOINED в SINGLE_TABLE) делается так же, но добавляет колонки в базовую таблицу и требует, чтобы они допускали пустоту.

Когда не наследовать вовсе

Прежде чем выбирать стратегию, стоит проверить, нужна ли иерархия сущностей. Три альтернативы, которые в предметно-ориентированном коде часто оказываются лучше.

Отдельные сущности с общим @Embeddable. Если у карточного и у наличного платежа общего только сумма, валюта и время, это не иерархия, а два разных объекта с одинаковым набором полей. Общее выносят во встраиваемый объект (Money, Audit), и каждая сущность живёт своей таблицей без дискриминаторов и соединений. Полиморфизм при этом теряется — но если код всё равно всегда знает, с каким типом работает, терять нечего.

Одно поле-тип плюс детали в JSONB. Когда подтипов много, они меняются и различаются только набором дополнительных полей, честнее хранить тип строкой и детали документом: payment_type varchar(16) плюс details jsonb. Схема не растёт при добавлении подтипа, миграции не нужны; цена — нет проверок на уровне схемы и нет типизации в коде (её приносят разбором документа в объект при чтении).

Иерархия только в предметной модели, без наследования сущностей. Сущность плоская, а внутри домена из неё собирают нужный объект фабрикой по значению типа. Тогда ORM не участвует в полиморфизме вовсе, и все связанные с ним сложности исчезают.

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

Что выбрать

СтратегияСхемаПолиморфный запросКогда использовать
SINGLE_TABLE1 таблицаПростой SELECTМало подтипов, нет строгих NOT NULL
JOINEDN таблиц (нормализация)JOIN на каждый подтипМного специфичных полей, нужна целостность в БД
TABLE_PER_CLASSN таблиц (денормализация)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.

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