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 (что, конечно, “мужественно”, но зачем).
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ