JavaRush /Курсы /Hibernate deep-dive /Когда композиция лучше наследования

Когда композиция лучше наследования

Hibernate deep-dive
16 уровень , 2 лекция
Открыта

1. Наследование и композиция в persistence

Когда вы пишете “чистый” Java-код без базы, вы можете позволить себе чуть больше свободы: иерархия классов живёт в памяти, и её цена обычно измеряется сложностью кода, а не SQL. Но в persistence-слое у каждого “красивого” extends внезапно появляется тень в виде таблиц, JOIN, дискриминаторов и особенностей полиморфических запросов. И вот тут романтика ООП быстро превращается в бухгалтерию.

Если сказать грубо, то JPA/Hibernate заставляет нас “платить” за абстракции. Наследование в ORM — это не просто наследование, это выбор физической модели хранения. Даже если вы выбрали SINGLE_TABLE и “вроде бы без JOIN”, вы платите шириной таблицы и большим количеством nullable-колонок. Даже если вы выбрали JOINED и “вроде бы красиво нормализовали”, вы платите регулярными join-ами на чтении. И именно в этот момент композиция становится не просто “альтернативой”, а часто здоровым выбором по умолчанию.

После сравнения SINGLE_TABLE, JOINED и TABLE_PER_CLASS следующий вопрос становится очень приземлённым: а как часто вообще надо доводить модель до inheritance mapping? На практике — реже, чем кажется. Гораздо чаще различие оказывается не «новым видом сущности», а «сущностью с дополнительными данными или правилом», и тут композиция даёт более честную схему и более прямой SQL.

Полезно держать в голове такую мысль: большинство доменных различий в реальных системах — это не “разные виды сущности”, а “сущность + дополнительные данные” или “сущность + дополнительное правило/атрибут”. И всё это чаще и проще выражается композицией.

2. Is-a vs has-a: быстрая проверка

Мы уже поставили жёсткий фильтр: наследование оправдано только там, где подтип честно является базовым типом и код реально живёт через общий контракт. Если формулировка звучит как “товар имеет цену”, “товар имеет детали” или “заказ имеет адрес”, то мы почти всегда уже в мире композиции, а не наследования. В persistence-слое это особенно важно: ошибка тут сразу превращается в лишние таблицы, join’ы и странные полиморфические чтения.

Чтобы почувствовать разницу на кончиках пальцев, достаточно начать с очень простого Java-примера (без JPA вообще):

import java.math.BigDecimal;

class Product {
    Money price; // has-a: продукт "имеет" цену, а не "является" ценой
}

record Money(BigDecimal amount, String currency) {
    // Value object: обычно не имеет собственной идентичности, важны только значения полей
}

В этом примере Product не является “каким-то видом Money”. Он просто имеет цену. И как только вы честно произносите “имеет”, мозг обычно сам перестаёт тянуться к extends.

3. Композиция в ORM-проекте

Когда говорят “композиция”, новички иногда представляют что-то мутное вроде “ну это когда классы внутри классов”. В persistence-слое композиция довольно конкретна и, что приятно, хорошо маппится в Hibernate.

Композиция в контексте нашего курса и Commerce Persistence Lab — это когда вы моделируете различия не через подтипы сущности, а через то, что сущность содержит:

встраиваемое значение (@Embeddable + @Embedded), например Money или Address;

связанную сущность (@OneToOne, @ManyToOne, @OneToMany) — например ProductDetails или CustomerAddress;

link entity (сущность связи), когда связь становится отдельным объектом со своим жизненным циклом и полями, например ProductCategoryAssignment.

Эту идею удобно визуализировать в маленькой схеме (без претензии на идеальную UML, зато мозгу легче):

%% Идея: продукт собирается из "деталей" (value objects / связанные сущности), а не из подтипов
classDiagram
    class Product {
      +Long id
      +String sku
      +Money price
      +ProductDetails details
    }

    class Money {
      +BigDecimal amount
      +String currency
    }

    class ProductDetails {
      +String description
      +int warrantyMonths
    }

    Product *-- Money : embedded
    Product o-- ProductDetails : one-to-one

Смысл здесь простой: мы строим модель как LEGO, а не как генеалогическое древо. LEGO легче расширять (добавил деталь), легче тестировать (проверил деталь), и оно обычно честнее отражает “реальную жизнь” данных в базе.

4. Value objects: @Embeddable вместо подтипов

Очень часто наследование пытаются использовать “в бытовых целях”. Например: “У нас есть товары, а часть товаров имеет цену. Давайте сделаем PricedProduct extends Product”. И на этом месте Hibernate обычно достаёт блокнот и начинает записывать вам счёт за вашу фантазию.

В нашем проекте мы уже выбрали другой путь: цена — это часть состояния товара, а не “вид товара”. Поэтому Money — value object, встроенный в Product.

Мини-фрагмент того, как это выглядит в JPA (коротко, по сути):

import jakarta.persistence.Embedded;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;

@Entity
public class Product {
    @Id @GeneratedValue
    private Long id; // Идентичность сущности: отдельное поле, отдельная "личность" в БД

    @Embedded
    private Money price; // Цена — часть состояния товара (обычно хранится в тех же колонках таблицы product)
}

И сам Money как @Embeddable:

import jakarta.persistence.Embeddable;
import java.math.BigDecimal;

@Embeddable
public class Money {
    // Value object: поля будут "встроены" в таблицу владельца, отдельной таблицы для Money обычно нет
    private BigDecimal amount;
    private String currency;
}

Почему это лучше, чем “подтипы товаров”?

Потому что цена в реальности — это характеристика, а не отдельная сущность и не отдельный “вид” товара. И даже если завтра у нас появятся “товары с промо-ценой”, это всё ещё не повод делать PromoPricedProduct extends Product. Скорее всего, это повод добавить ещё один value object или ещё одно поле (или связку сущностей), но не превращать каталог в зоопарк наследования.

Вторая важная причина — эволюция модели. Value object можно поменять более локально. Добавить поле precision, добавить правила округления, добавить конвертер валюты (в доменной логике) — и всё это не заставляет вас пересматривать стратегию inheritance mapping и связанные таблицы.

5. Композиция через связи: OneToOne / ManyToOne

Есть ещё одна популярная ловушка: мы видим, что у части объектов есть “дополнительные поля”, и рука тянется сделать это подтипом. Но иногда эти дополнительные поля — просто другая таблица, которая нужна не всегда. Тогда композиция через связь даёт нам одновременно доменную честность и контролируемый SQL.

В Commerce Persistence Lab классический пример — ProductDetails. Карточка товара (detail view) должна показывать описание, характеристики, гарантию, но список товаров (list view) обычно не должен тащить это всё. Это почти идеальная ситуация для OneToOne как композиции: товар имеет детали, но не “является” “товаром с деталями”.

Мини-фрагмент Product:

import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.Id;
import jakarta.persistence.OneToOne;

@Entity
public class Product {
    @Id
    private Long id;

    @OneToOne(fetch = FetchType.LAZY, mappedBy = "product")
    private ProductDetails details; // LAZY: детали подгружаются только когда действительно нужны
    // mappedBy: это обратная сторона связи, внешний ключ "живёт" на стороне ProductDetails
}

И ProductDetails:

import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.OneToOne;

@Entity
public class ProductDetails {
    @Id
    private Long productId; // Частый приём: PK деталей совпадает с PK продукта (shared primary key)

    @OneToOne
    private Product product; // owning side (упрощённо): здесь обычно будет FK/JoinColumn
}

Да, при чтении деталей может появиться дополнительный SQL (особенно при lazy loading), но это мы уже умеем контролировать через fetch-plan и не смешивать list-use-case и detail-use-case. Главное — мы не плодим подтипы ради “дополнительных колонок”, мы честно моделируем: “есть основной объект, есть дополнительная часть данных”.

Такой подход хорошо масштабируется: если у вас завтра появится, скажем, ProductSeo или ProductMedia, это обычно отдельные сущности и связи, а не “вид товара”.

Link entity: когда связь — это отдельная вещь

Link entity — это вообще один из лучших “анти-аргументов” против лишнего наследования, потому что он показывает: иногда отдельной сущностью должна стать не разновидность объекта, а сама связь.

Мы уже проходили link entity на дне про ManyToMany. Но сейчас важно увидеть это именно как пример композиции: вместо того чтобы делать сложную иерархию или пытаться “прилепить” данные связи к одному из концов, мы выносим связь в отдельный объект.

Мини-фрагмент ProductCategoryAssignment (очень коротко, только суть):

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.ManyToOne;

@Entity
public class ProductCategoryAssignment {
    @Id @GeneratedValue
    private Long id; // Идентичность связи: назначение — самостоятельный объект (а не "скрытая" join-таблица)

    @ManyToOne
    private Product product; // К какому товару относится назначение

    @ManyToOne
    private Category category; // В какую категорию назначили товар
}

Почему это композиция, а не “ещё одна сущность ради сущности”?

Потому что доменная мысль звучит так: “Товар имеет назначения в категории”. То есть “назначение” — самостоятельная часть модели, у которой может быть sortOrder, assignedAt, soft delete, уникальность пары, отдельные правила жизни. И ни один из этих аспектов не требует наследования. Наоборот, наследование тут было бы странным: ProductCategoryAssignment extends Product — это просто абсурд (и, что особенно приятно, абсурд видно даже без SQL-лога).

6. Влияние композиции на SQL и схему

Сейчас будет чуть-чуть “инженерной приземленности”. Не потому что мы хотим убить творчество, а потому что Hibernate всё равно убьёт его раньше, чем мы, если вы будете игнорировать SQL.

Композиция чаще даёт более прямой SQL-профиль. Вы делаете запрос к конкретной таблице, и если вам нужны дополнительные данные — вы либо делаете JOIN к связанной таблице, либо получаете их отдельным запросом (и вы это контролируете). Это обычно проще, чем полиморфическое чтение базового типа, которое в зависимости от стратегии может оборачиваться кучей JOIN (для JOINED) или UNION (для TABLE_PER_CLASS), или широкой таблицей с кучей nullable-колонок (для SINGLE_TABLE).

Чтобы сравнение было не “на словах”, а в голове, можно держать такую табличку:

Ситуация в модели Как часто выглядит в реальности Что обычно дешевле и проще
“Объект имеет дополнительный блок данных, который нужен не всегда” Очень часто (ProductDetails, “профиль пользователя”, “документы”, “описания”) Композиция через связь (OneToOne/ManyToOne)
“Объект имеет значение (деньги, адрес, период, размеры)” Практически всегда Композиция через @Embeddable
“У связи есть свои поля, правила и уникальность” Очень часто в каталогах/заказах Link entity
“Есть один общий тип, и по нему реально делают полиморфические чтения” Реже, чем кажется Наследование, но ограниченно и осознанно

Отдельный плюс композиции — эволюция схемы. Добавить колонку в @Embeddable или добавить связанную таблицу под новый кусок данных обычно проще и “локальнее”, чем расширять иерархию и затем переоценивать стратегию хранения подтипов. И, что важно для сопровождения, композиция делает изменения более предсказуемыми: вы реже получаете эффект “я добавил одно поле — а у меня поменялся SQL во всех запросах к базовому типу”.

7. Мини-алгоритм выбора подхода

Перед тем как тянуться к @Inheritance, обычно хватает трёх быстрых вопросов:

1. Есть ли здесь один общий тип, а не просто похожий набор полей?

2. Будут ли сервисы и запросы реально жить через базовый тип?

3. Не выражается ли различие дешевле через value object, связь или link entity?

Если на любом шаге ответ “нет”, композиция обычно выигрывает. Поэтому в Commerce Persistence Lab иерархия остаётся узким кейсом для PromotionCampaign, а Product, Customer и PurchaseOrder мы собираем из компонентов и связей.

8. Типичные ошибки при выборе между наследованием и композицией

В конце этой лекции хочется оставить не “мораль”, а набор узнаваемых грабель. Это те ошибки, которые чаще всего делают люди, когда начинают активно пользоваться ORM и внезапно обнаруживают, что база данных не разделяет их любовь к красивым иерархиям.

Ошибка №1: наследование делается ради экономии пары полей.
Если единственный мотив — вынести code, active и пару дат в базовый класс, вы почти наверняка уже залезли не туда. Для таких случаев чаще хватает @Embeddable, связи, отдельного объекта-компонента или даже честного повторения нескольких колонок. Цена ORM-иерархии обычно выше этой экономии.

Ошибка №2: подтип создаётся из-за одного “особого” поля.
Например, “у части кампаний есть percentValue, давайте делать подтип”. Но если у вас по факту один тип кампании с одним параметром, часто проще моделировать это как композицию: тип + параметр (встроенное значение или отдельный объект “выгода/benefit”), а не как иерархию. Подтип оправдан тогда, когда он меняет смысл и поведение, а не просто добавляет одну колонку.

Ошибка №3: наследование используется как замена enum/статуса.
Иногда пытаются сделать ActiveProduct extends Product, HiddenProduct extends Product, DeletedProduct extends Product. Это почти всегда плохая идея. Статус — это состояние, а не отдельный вид сущности. Такие решения обычно приводят к “типам ради типов”, усложняют запросы и ломают читаемость. Состояние обычно лучше выражать полем (enum) и правилами изменения статуса в сервисе.

Ошибка №4: игнорирование того, как приложение читает данные.
Inheritance “в вакууме” может выглядеть красиво. Но потом вы начинаете писать реальные read-use-cases, и внезапно выясняется, что вы почти никогда не читаете базовый тип, а всегда читаете конкретный подтип (или наоборот). Если ваше чтение не полиморфическое, наследование часто становится лишней надстройкой, а композиция даёт более прямые запросы и более простое объяснение SQL-логов.

Ошибка №5: композиция выбирается “в лоб”, но потом начинают имитировать наследование вручную.
Бывает обратная крайность: “наследование — зло, всё делаем композицией”, а потом в сущности появляется двадцать nullable-полей и поле type, а в коде — бесконечные if (type == ...). Это тоже форма боли, просто другого дизайна. Композиция хороша, когда она сохраняет ясность модели: value objects для значений, отдельные сущности для отдельно живущих данных, link entity для связи. Если композиция превращается в свалку опциональных колонок, вы просто пересобрали SINGLE_TABLE руками, без помощи Hibernate (что, конечно, “мужественно”, но зачем).

1
Задача
Hibernate deep-dive, 16 уровень, 2 лекция
Недоступна
Цена товара как value object внутри Product
Цена товара как value object внутри Product
1
Задача
Hibernate deep-dive, 16 уровень, 2 лекция
Недоступна
Детали товара как отдельная сущность, а не подтип
Детали товара как отдельная сущность, а не подтип
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ