1. Наслідування і композиція в persistence
Коли ви пишете «чистий» Java-код без бази даних, можете дозволити собі трохи більше свободи: ієрархія класів живе в памʼяті, а її ціна зазвичай вимірюється складністю коду, а не SQL. Але в шарі збереження за кожним «красивим» extends раптом зʼявляється тінь у вигляді таблиць, JOIN, дискримінаторів і особливостей поліморфних запитів. І тут романтика ООП швидко перетворюється на бухгалтерію.
Якщо сказати грубо, JPA/Hibernate змушує нас «платити» за абстракції. Наслідування в ORM — це не просто наслідування, а вибір фізичної моделі зберігання. Навіть якщо ви обрали SINGLE_TABLE і «ніби без JOIN», ви платите шириною таблиці та великою кількістю стовпців із NULL. Навіть якщо ви обрали JOINED і «ніби гарно нормалізували», ви платите регулярними JOIN під час читання. Саме тут композиція стає не просто альтернативою, а часто здоровим вибором за замовчуванням.
Після порівняння SINGLE_TABLE, JOINED і TABLE_PER_CLASS наступне питання стає дуже приземленим: а як часто взагалі треба доводити модель до inheritance mapping? На практиці — рідше, ніж здається. Набагато частіше відмінність виявляється не новим видом сутності, а сутністю з додатковими даними або правилом, і тут композиція дає чеснішу схему та пряміший SQL.
Корисно тримати в голові таку думку: більшість доменних відмінностей у реальних системах — це не різні види сутності, а сутність із додатковими даними або сутність із додатковим правилом чи атрибутом. І все це частіше й простіше виражається композицією.
2. Is-a vs has-a: швидка перевірка
Ви вже маєте жорсткий фільтр: наслідування виправдане лише там, де підтип справді є базовим типом і код реально живе через спільний контракт. Якщо формулювання звучить як «товар має ціну», «товар має деталі» або «замовлення має адресу», то ви майже завжди вже у світі композиції, а не наслідування. У шарі збереження це особливо важливо: помилка тут одразу перетворюється на зайві таблиці, 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-проєкті
Коли говорять «композиція», новачки іноді уявляють щось туманне на кшталт «це коли класи всередині класів». У шарі збереження композиція набагато конкретніша і, що приємно, добре мапиться в 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 : вбудовано
Product o-- ProductDetails : один-до-одного
Сенс тут простий: модель будується як 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) або широкою таблицею з масою стовпців із NULL (для 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 «у вакуумі» може виглядати красиво. Але потім ви починаєте писати реальні сценарії читання, і раптом з’ясовується, що майже ніколи не читаєте базовий тип, а завжди читаєте конкретний підтип (або навпаки). Якщо ваше читання не поліморфне, наслідування часто стає зайвою надбудовою, а композиція дає пряміші запити та простіше пояснення SQL-логів.
Помилка № 5: композиція обирається «в лоб», але потім починають імітувати наслідування вручну.
Буває протилежний край: «наслідування — зло, усе робимо композицією», а потім у сутності зʼявляється двадцять nullable-полів і поле type, а в коді — нескінченні if (type == ...). Це теж форма болю, просто іншого дизайну. Композиція хороша, коли вона зберігає ясність моделі: value objects для значень, окремі сутності для даних, що живуть окремо, link entity для зв’язку. Якщо композиція перетворюється на звалище опціональних стовпців, ви просто пересклали SINGLE_TABLE вручну, без допомоги Hibernate (що, звісно, «мужньо», але навіщо).
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ