JavaRush /Курси /Hibernate deep-dive /Коли ієрархія в моделі потрібна

Коли ієрархія в моделі потрібна

Hibernate deep-dive
Рівень 16 , Лекція 0
Відкрита

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 потім чесно спробує подати це як одну модель — і ви отримаєте дивні компроміси.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ