1. Вступ
Коли розробник уперше бачить JPA-анотації для наслідування, рука тягнеться до них так само швидко, як до @Data у Lombok, і це саме по собі вже тривожний симптом. Але inheritance mapping — це не «красивий спосіб не писати однакові поля двічі». В ORM наслідування — це завжди угода: ви погоджуєтеся на додаткову складність в обмін на поліморфізм і доменну чесність. Якщо поліморфізм вам не потрібен, ви просто підписуєте собі щомісячний платіж у вигляді складності SQL і міграцій.
Почнімо з простого симптому. Найчастіше ідея наслідування зʼявляється так: у вас є дві сутності, у них збігаються 5–6 полів, і вам шкода копіпастити. Це людське бажання, тут немає чого соромитися. Але ORM — не конкурс «хто менше написав рядків», ORM — це про передбачувану поведінку і коректну модель. Якщо ви обираєте наслідування лише заради усунення дублювання, ви оптимізуєте не те місце. Дублювання полів — дрібниця порівняно з тим, що ви можете отримати: дивні запити, таблиці, які складно індексувати, неочевидні обмеження схеми та «чому воно вантажиться ось так».
Тут корисно тримати просте правило: спершу доменне формулювання, потім техніка. Тому спочатку корисно мислити так, ніби @Inheritance ще не існує. Спершу ми зʼясовуємо, чи потрібна ієрархія як частина моделі. І лише якщо відповідь «так», зʼявляється сенс обговорювати, як саме ORM буде це зберігати.
2. Перевірка is-a: чесний підтип
Наслідування в Java дуже легко сплутати з «у них однакові поля, отже базовий клас». Але доменна модель вимагає суворішої логіки. Головне питання: відношення між типами — це “is-a” (є) чи “has-a” (має)? Якщо ви не можете чесно промовити фразу «X є Y» і не відчути незручності, значить наслідування підозріле.
Уявіть, що ви моделюєте промокампанії. «Відсоткова кампанія є промокампанією» звучить нормально. «Фіксована знижка є промокампанією» теж нормально. А ось «Адреса є клієнтом» — уже дивно. «Категорія є товаром» — зовсім погано. Там не ієрархія, там інші відношення: звʼязок, композиція, value object, link entity — що завгодно, тільки не “is-a”.
Є корисна перевірка чесності підтипу: підтип має підставлятися замість базового типу без того, щоб код починав робити «а коли це ось той підтип, то…» і обростати instanceof. У світі ООП це зазвичай називають принципом підстановки Лісков, але можна простіше: якщо ваш сервіс приймає базовий тип, він не повинен щоразу вгадувати, кого саме йому передали.
Погляньмо на мінімальний приклад ієрархії без ORM — просто як на доменну ідею:
import com.example.commerce.common.jpa.Money;
public abstract class PromotionCampaign {
// Загальні поля кампанії, які є у будь-якого підтипу
private String code;
private boolean active;
// Важливо: базовий тип задає контракт, а підтип реалізує конкретику
public abstract Money applyDiscount(Money originalPrice);
// Припустімо, що в сутності є керування активністю (для прикладів сервісу нижче)
public void setActive(boolean active) {
this.active = active;
}
}
Тут базовий сенс зрозумілий: у кампанії є код, вона активна або неактивна, і вона вміє застосувати знижку. А вже як саме — вирішує підтип. Оце і є «варіанти одного сенсу», заради яких наслідування справді існує.
Щоб закріпити інтуїцію, корисно бачити не тільки правильні приклади, а й «майже правильні». Ось приклад, де “is-a” виглядає натягнутим:
public class ProductWithDetails extends Product {
// Це виглядає як "підтип", але з погляду домену найчастіше це "has-a":
// у товару є деталі, а не "товар є деталями".
private String description;
private Integer warrantyMonths;
}
На словах здається зручно, але з погляду домену це зазвичай неправда. ProductDetails — це не «підтип товару», це «деталі товару», тобто “has-a”. У нашому проєкті ми якраз робили окрему сутність ProductDetails, і це була свідома композиція, а не наслідування.
3. Ознаки живої ієрархії
Щоб не перетворювати курс на набір субʼєктивних смаків, давайте сформулюємо критерії. Ми не будемо робити з цього список із «10 заповідей» (ORM і так любить псевдорелігії), але зберемо стійку модель ухвалення рішення.
Наслідування виправдане, коли у вас справді є один спільний тип, який живе в системі як поняття, а його варіанти — це не випадкові відхилення, а стійкі підвиди. Зазвичай це помітно за кодом і сценаріями використання. Якщо у вас є сценарій «отримати активні кампанії» і далі обробляти їх однаково: вимикати, перевіряти період дії, застосовувати до ціни, логувати та показувати в списку адмінки — базовий тип стає корисним.
Дуже допомагає питання: чи існують поліморфне читання і поліморфний код? Тобто чи буде у вас місце, де ви працюєте з PromotionCampaign, а не з конкретним PercentPromotionCampaign. Якщо ні — наслідування перетворюється на «схему заради краси» (а краса схеми без звʼязку із запитами — це шлях до страждань).
Ще один хороший критерій: підтип має додавати сенс, а не колонку. Якщо ви додали підтип лише заради одного nullable-поля, найчастіше це сигнал, що модель можна виразити простіше. Підтип виграє, коли має власні інваріанти, наприклад: відсоткова знижка зобовʼязана мати percentValue, а фіксована знижка зобовʼязана мати discountAmount. Це справді різні правила, і їх приємно тримати в різних типах, щоб не жити в «nullable-царстві».
Покажу цю ідею у вигляді невеликої схеми ухвалення рішення. Так, це трохи «співбесіда в голові», але вона заощаджує тижні життя.
flowchart TD
A["Є 2+ варіанти сутності"] --> B{"Це один сенс? is-a"}
B -->|ні| C["Композиція / звʼязок / value object / link entity"]
B -->|так| D{"Є спільний код/читання через базовий тип?"}
D -->|ні| E["Подумайте: окремі сутності або один тип із полем-типом"]
D -->|так| F{"У підтипів різні інваріанти/поведінка?"}
F -->|ні| G["Зазвичай достатньо одного типу + поля"]
F -->|так| H["Наслідування виправдане (переходимо до ORM-стратегії)"]
Зверніть увагу: на діаграмі наслідування — не перша відповідь, а доволі пізня. І це нормально. Наслідування в ORM — це як ніж на кухні: штука корисна, але якщо ви ним намагаєтеся робити все підряд, ви швидко починаєте розуміти, чому кухарі такі нервові.
4. Користь базового типу: поліморфізм
Наслідування в моделі — це не лише про таблиці, це про код. Якщо базовий тип не спрощує код, він майже завжди шкідливий. У хорошій ієрархії ви отримуєте можливість писати сервісні методи, яким узагалі не важливо, який конкретний підтип прийшов. Вони працюють зі спільним контрактом.
Найпростіший приклад — деактивація кампанії. Це не «бізнес-магія», а цілком чесна операція: будь-якій кампанії можна поставити active=false.
public class PromotionCampaignService {
public void deactivate(PromotionCampaign campaign) {
// Ми працюємо через базовий тип: підтип неважливий
campaign.setActive(false);
}
}
Тут базовий тип корисний, тому що операція не залежить від виду знижки. Це дрібниця, але вона показує ідею: у базового типу є спільна частина поведінки.
Тепер покажемо більш «смачний» приклад, який розкриває силу підтипів: застосувати знижку до ціни. Ми можемо зробити так, що бізнес-код узагалі не знає, яка кампанія перед ним — він просто просить кампанію застосувати себе до ціни.
import com.example.commerce.common.jpa.Money;
public class PromotionEngine {
public Money apply(PromotionCampaign campaign, Money originalPrice) {
// Ключова ідея: один виклик замість if/switch за типами
return campaign.applyDiscount(originalPrice);
}
}
Оце вже приємна модель: немає if, немає switch, немає instanceof. Є один виклик. Кожна кампанія відповідає за себе. І, що важливо для persistence layer, ви таким чином ще й утримуєте модель від перетворення на «анемічну» (коли сутності — просто мішки з полями, а вся логіка в сервісах і if-else).
Щоб відчути різницю, подивімося на «погану» версію того самого коду, де наслідування начебто є, але поліморфізму немає — і сервіс перетворюється на вгадування:
import com.example.commerce.common.jpa.Money;
public class PromotionEngineBad {
public Money apply(PromotionCampaign campaign, Money price) {
// Червоний прапорець: замість поліморфізму — ручне розгалуження за підтипами
if (campaign instanceof PercentPromotionCampaign percent) {
return percent.applyDiscount(price);
}
if (campaign instanceof FixedAmountPromotionCampaign fixed) {
return fixed.applyDiscount(price);
}
// Небезпечно: додасться новий підтип — і він "мовчки" не буде застосовуватися
return price;
}
}
Така конструкція — червоний прапорець. Вона говорить: «у нас є ієрархія, але ми їй не довіряємо». І це майже завжди означає, що або ієрархія не потрібна, або базовий контракт спроєктовано погано.
5. Приклад: PromotionCampaign у Commerce Lab
У нашому проєкті Commerce Persistence Lab промокампанії навмисно задумано як обмежений випадок наслідування, а не як стиль «давайте тепер усе зробимо через ієрархії». Це важливо підкреслити: якщо почати «розмножувати наслідування» на Product, Customer і PurchaseOrder, ви дуже швидко перетворите лабораторію на музей страждань.
Почнімо з доменної форми, ще без ORM-анотацій. Базовий тип описує те, що спільне для будь-якої кампанії: код, активність і (зазвичай) період дії. Я триматиму поля мінімальними, щоб не засмічувати приклад.
import java.time.LocalDate;
public abstract class PromotionCampaign {
// Ідентифікатор кампанії в бізнес-сенсі (не обов’язково DB id)
private String code;
// Загальний прапорець: будь-яку кампанію можна увімкнути/вимкнути
private boolean active;
// Загальний період дії (якщо він потрібен у моделі)
private LocalDate validFrom;
private LocalDate validTo;
}
Далі зʼявляються підтипи, кожен зі своєю «особливою» частиною. Відсоткова кампанія містить відсоток.
public class PercentPromotionCampaign extends PromotionCampaign {
// Інваріант підтипу: відсоток існує лише тут, а не "nullable в загальній таблиці"
private Integer percentValue;
}
Фіксована кампанія містить суму знижки. І тут якраз красиво підключається Money, який ми робили раніше як @Embeddable value object (тобто ми продовжуємо розвивати одну й ту саму кодову базу, а не малюємо абстрактні класи у вакуумі).
import com.example.commerce.common.jpa.Money;
public class FixedAmountPromotionCampaign extends PromotionCampaign {
// Інваріант підтипу: фіксована знижка існує лише тут
private Money discountAmount;
}
На цьому рівні вже видно, чому підтипи корисні. Вони дають змогу виразити обмеження моделі чесніше. Відсоткова знижка без відсотка — це не «просто null», це зламана сутність. Фіксована знижка без суми — теж. Якщо ви замість наслідування робите один клас із полями percentValue і discountAmount, ви створюєте стан «і те null, і це null» — і потім героїчно ловите баги в рантаймі.
Тепер додамо поведінку. Я спеціально зроблю реалізацію максимально навчальною і короткою, не перетворюючи Money на мінібухгалтерію. Важливо побачити сам принцип: підтип реалізує свою частину.
import com.example.commerce.common.jpa.Money;
public class PercentPromotionCampaign extends PromotionCampaign {
@Override
public Money applyDiscount(Money originalPrice) {
// Тут має бути логіка "помножити на (100 - percent) / 100"
// Зараз залишаємо заглушку, тому що приклад про розподіл відповідальності
return originalPrice; // спрощено для прикладу
}
}
import com.example.commerce.common.jpa.Money;
public class FixedAmountPromotionCampaign extends PromotionCampaign {
@Override
public Money applyDiscount(Money originalPrice) {
// Тут має бути логіка "originalPrice - discountAmount" із перевірками
// (наприклад, чи не виходимо у відʼємну вартість)
return originalPrice; // спрощено для прикладу
}
}
Так, тут «заглушки», тому що реальна математика Money у вас уже оформлена з попередніх днів (і ви її точно зробили акуратніше, ніж я зараз у пʼять рядків). Ми зараз не про гроші, ми про те, хто відповідає за поведінку.
І от тепер базовий тип починає справді працювати як контракт. Ви можете в коді тримати список кампаній єдиним типом:
import java.util.List;
public class PromotionCampaignRegistry {
// Зберігаємо єдиним списком: це і є "поліморфне зберігання/читання"
private final List<PromotionCampaign> campaigns;
public PromotionCampaignRegistry(List<PromotionCampaign> campaigns) {
this.campaigns = campaigns;
}
}
Це здається банальним, але тут важлива думка: якщо у вас є місце, де ви зберігаєте або повертаєте кампанії як PromotionCampaign, значить поліморфізм вам справді потрібен. І саме такі місця потім сильно впливають на те, яке ORM-відображення має сенс.
Альтернативи наслідуванню
Навіть якщо ви чесно пройшли перевірку “is-a”, наслідування все одно не зобовʼязане бути єдиним рішенням. На практиці у вас майже завжди є щонайменше три варіанти: окремі сутності без спільного базового типу, один клас із полем-типом та опційними полями і, нарешті, ієрархія.
Щоб не забігати наперед, зафіксуємо лише сенс: ви обираєте модель не за естетикою, а за тим, як нею користуватимуться сервіси та запити. Якщо ви ніколи не читаєте «всі кампанії разом», а завжди працюєте окремо з відсотковими і окремо з фіксованими, базовий тип може бути просто зайвим. Якщо ж у вас є спільний список кампаній і спільний механізм застосування до замовлення, базовий тип починає приносити користь.
| Підхід | Коли доречний | Де найчастіше виникають проблеми |
|---|---|---|
| Один клас + type + nullable-поля | Варіантів мало, відмінності мінімальні, логіка майже однакова | Невалідні стани (усе null), купа if, складно тримати інваріанти |
| Дві окремі сутності без базового | Майже немає спільних сценаріїв використання, різні правила життєвого циклу | Дублювання в коді та запитах, складніше будувати єдиний список |
| Наслідування | Є спільний сенс і спільний поліморфний код/читання | Ціна мапінгу та SQL стає частиною вашого життя |
Головна думка: наслідування — це не «правильніше», це «дорожче, але інколи чесніше». І якщо чесність моделі справді приносить користь коду та запитам, ціна може бути виправданою.
6. Типові помилки під час наслідування
У наслідуванні є особлива пастка: воно виглядає «розумним» самим фактом існування. Клас AbstractSomething вселяє повагу, навіть якщо всередині він просто склад випадкових полів. Тому помилки тут доволі типові й повторюються в проєктах із завидною стабільністю — як N+1, тільки без SQL-логу (хоча потім SQL-лог теж буде).
Помилка №1: робити ієрархію лише заради усунення дублювання полів.
Це найпопулярніша причина появи наслідування і одна з найслабших. Дублювання пари полів майже завжди дешевше, ніж наслідки складної ORM-ієрархії. Хороший тест — запитати себе: чи має базовий тип власну роль у коді та запитах? Якщо базовий тип ніде не використовується, окрім як «щоб поля не повторювалися», то це не база, а зайвий вантаж.
Помилка №2: виносити в базовий тип усе підряд «про всяк випадок».
Так зʼявляються монстри на кшталт BaseEntity із 20 полями, з яких половина стосується лише частини підтипів. У підсумку підтипи стають «змушені жити» з чужими полями, а модель перестає виражати сенс. Базовий тип має містити лише те, що справді спільне для всіх варіантів і не ламає їхні інваріанти.
Помилка №3: будувати глибоку ієрархію наперед.
В навчальному проєкті й у реальному бекенді глибокі ієрархії майже завжди створюють більше проблем, ніж дають користі. Чим більше рівнів, тим складніше зрозуміти, де знаходиться стан, як він оновлюється і як це відображається в схемі. Практичний підхід — тримати наслідування плоским і вузьким: базовий тип і 2–3 підтипи, не більше.
Помилка №4: «наслідування є, але поліморфізму немає».
Якщо ви всюди пишете if (...) і instanceof, значить базовий контракт не працює. Це або ознака поганого дизайну базового типу, або ознака того, що вам узагалі не потрібна ієрархія, а потрібна інша модель (наприклад, композиція або окремі сутності). Наслідування без поліморфізму — це як велосипед, який ви носите в руках: формально транспорт, фактично — гиря.
Помилка №5: змішувати в одній ієрархії обʼєкти з різним життєвим циклом.
Підтипи однієї ієрархії мають бути «одного класу життя»: однакові правила активності, приблизно однаковий життєвий цикл, схожі сценарії читання та оновлення. Якщо один підтип живе «хвилину і видаляється», а інший живе «роками і аудитується», це тривожний сигнал. ORM потім чесно спробує подати це як одну модель — і ви отримаєте дивні компроміси.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ