@Embeddable і @Embedded

Spring Data JPA
Рівень 6 , Лекція 2
Відкрита

1. Коли пласка модель стає незручною

Почнімо з відчуття, яке майже всі помічають у реальних проєктах приблизно через 23 тижні після старту: спочатку здається, що «поля — це просто поля», і їх можна спокійно додавати прямо в entity. Але потім у вас з’являється сутність, де половина полів належить до одного смислового блоку, а інша — до іншого, і читати такий код стає складніше, ніж інструкцію до мікрохвильовки японською, знайдену в інтернеті у вигляді скан-копії.

Уявіть замовлення. У нього є адреса доставки: місто, вулиця, будинок, поштовий індекс. Якщо ми зробимо модель пласкою, то в CustomerOrder з’являться deliveryCity, deliveryStreet, deliveryHouse, deliveryPostalCode. Технічно це працює. Але за змістом ми втрачаємо ідею «адреса — це одне поняття», і замість поняття отримуємо розсип рядків. Трохи пізніше ви почнете повторювати ці поля в інших місцях, перейменовувати їх, забувати одне з чотирьох — і у вас з’явиться «адреса без будинку», як «SQL без WHERE» — іноді допустимо, але частіше це аварійна кнопка.

Подивімося на мінімальний «плаский» варіант:

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

@Entity
public class CustomerOrder {
    @Id
    private Long id; // Ідентифікатор замовлення (у цьому прикладі без генерації)

    // Адресу доставки "розмазано" по сутності окремими полями
    private String deliveryCity;
    private String deliveryStreet;
    private String deliveryHouse;
    private String deliveryPostalCode;
}

Навіть у такому короткому фрагменті видно, що адреса «розмазана». А тепер уявіть, що до замовлення додадуться ще «контакти отримувача», «коментар кур’єру», «час доставки», і все це знову буде рядками та числами в одному місці. Вийде entity-комбайн: начебто все в одному класі, зате мозок під час читання коду працює як вентилятор на повну потужність.

Вбудовувані типи в JPA — це спосіб сказати: «Стоп. Цей набір полів — один смисловий об’єкт». У Java ми хочемо бачити order.getDeliveryAddress().getCity(), а не order.getDeliveryCity(). Не тому, що «так модніше», а тому, що так модель починає говорити людською мовою.

2. Value-like об’єкт і @Id

Перед тим як писати анотації, важливо розібратися з ідеєю. Value-подібний об’єкт (його часто називають value object, хоча ми зараз без фанатизму) — це частина моделі, яка не живе окремо і не має власної самостійної ідентичності. У неї немає «власного життєвого шляху», як у Product або Category. Вона осмислена лише як частина власника: адреса доставки має сенс як частина замовлення, але в нашому навчальному домені ми не хочемо звертатися до неї окремо, зберігати її окремою командою або мати на неї окремі зв’язки.

Це дуже схоже на реальне життя. Адреса, записана в замовленні, — це «знімок» адреси на момент оформлення. Вона не зобов’язана бути тим самим об’єктом, що й «адреса користувача в профілі» (якого в нас узагалі немає, бо проєкт свідомо без security та облікових записів). І вже точно ми не хочемо, щоб адреса жила своє самостійне життя, на яке можна «посилатися» за id, як на товар. У адреси немає сенсу «бути тією самою адресою № 42».

У термінах JPA це означає просте правило: якщо об’єкт не має власної таблиці й власного @Id, а має зберігатися всередині таблиці власника, то він чудово підходить під @Embeddable.

Важливо не переплутати критерії. Якщо ви відчуваєте, що об’єкт має жити окремо, мати незалежний життєвий цикл, окремі зміни, окремі зв’язки й окрему унікальність, то це вже кандидат у @Entity (і це буде тема наступних днів, коли ми дійдемо до зв’язків). Але сьогодні ми свідомо лишаємося в зоні «частина моделі, а не окрема сутність».

Щоб не тримати це абстракцією, давайте одразу прив’яжемося до нашого mini-shop. У домені проєкту в CustomerOrder є deliveryAddress. Він ідеально лягає в модель «value-подібний об’єкт усередині замовлення». Ми хочемо зробити окремий клас DeliveryAddress, але не хочемо окрему таблицю delivery_address.

3. @Embeddable: як оголосити вбудовуваний тип

Тепер перейдемо до того, як JPA це бачить. @Embeddable каже фреймворку: «Цей клас — частина персистентної моделі, але він не entity. Його поля зберігатимуться як колонки в таблиці власника». З погляду новачка це звучить майже як «магія», але насправді це дуже чесна угода: ви даєте JPA структуру, а JPA розкладає її по колонках.

Плюс @Embeddable дуже добре дисциплінує мислення. Якщо ви ставите @Embeddable, ви ніби підписуєте контракт: «Я не очікую, що цей об’єкт зберігатиметься окремо. Я не очікую, що в нього буде id. Я не очікую, що він існуватиме без власника». І це одразу знімає половину архітектурних метань.

Мінімальні вимоги до @Embeddable

З практичного боку @Embeddable — це майже такий самий «JPA-об’єкт», як і entity: йому потрібен конструктор без параметрів (зазвичай protected), поля мають бути доступними для Hibernate через reflection, а типи полів — мапованими (рядки, числа, дати тощо). Ми не робимо вбудовуваний об’єкт важким, не пхаємо туди зайву інфраструктуру і не перетворюємо його на DTO.

Зверніть увагу на одну важливу думку, яку легко пропустити: @Embeddable — це не просто «клас без анотацій». Він є частиною персистентної моделі. Тобто його поля теж потрапляють у SQL і в схему. Різниця лише в тому, що він не має @Id і не є окремою сутністю в базі.

DeliveryAddress: перший embeddable у нашому mini-shop

Зробімо DeliveryAddress у пакеті ordering. Ми поки не будуємо повний модуль замовлень із позиціями — нам потрібна лише форма адреси як «шматочок моделі», щоб продемонструвати підхід із embedded.

package com.example.shopdatajpa.ordering.entity;

import jakarta.persistence.Embeddable;

@Embeddable // Кажемо JPA: це вбудовуваний тип, а не окрема сутність
public class DeliveryAddress {

    // Ці поля стануть колонками в таблиці власника (наприклад, customer_order)
    private String city;
    private String street;
    private String house;
    private String postalCode;

    protected DeliveryAddress() {
        // Конструктор без параметрів потрібен JPA/Hibernate для створення об’єкта через reflection
    }
}

Зараз клас виглядає максимально просто: лише поля і protected-конструктор для JPA. На практиці ви додасте конструктор «для людей» (із параметрами) і, найімовірніше, гетери. Але методично нам важливо спочатку побачити базову механіку: клас позначено як вбудовуваний і він не має @Id.

Якщо ви хочете зробити його зручнішим (і в межах навчального проєкту це нормально), можна додати «людський» конструктор. Він помітно підвищує читабельність коду, коли ви створюєте замовлення:

import java.util.Objects;

public DeliveryAddress(String city, String street, String house, String postalCode) {
    // Мінімальний захист від випадкових null просто на вході
    this.city = Objects.requireNonNull(city);
    this.street = Objects.requireNonNull(street);
    this.house = Objects.requireNonNull(house);
    this.postalCode = Objects.requireNonNull(postalCode);
}

Тут ми одразу робимо маленьку корисну річ: захищаємося від випадкового null. Це не «валідація всього світу», але вже мінімальна дисципліна. Адреса без міста й вулиці — зазвичай не адреса, а загадка.

4. @Embedded в entity

Коли в нас є @Embeddable, потрібно сказати JPA: «Ось це поле в entity — вбудовуване, розклади його на колонки». Для цього використовується @Embedded. Це анотація на полі (або гетері), де зберігається ваш value-подібний об’єкт.

Дуже важливо розуміти, що @Embedded не створює зв’язок між таблицями. Це не JOIN. Це саме вбудовування. Тобто в таблиці власника будуть колонки для кожного поля DeliveryAddress. У Java ж у вас буде один об’єкт DeliveryAddress. Це як акуратно запакувати чотири колонки в один смисловий блок.

Нижче візьмімо мінімальний CustomerOrder, лише щоб побачити сам факт embedding. Це не спроба зафіксувати остаточний код замовлення в проєкті; тут нам важливий саме принцип.

Міні-сутність CustomerOrder з вбудованою адресою

Зробімо спрощений CustomerOrder. Без позицій замовлення, без зв’язків і без ускладнень: нам зараз важлива форма моделі.

package com.example.shopdatajpa.ordering.entity;

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

@Entity
public class CustomerOrder {
    @Id
    private Long id; // Для схематичного прикладу id лишаємо без генерації: тут важливий сам факт @Embedded

    @Embedded // Вбудовуємо DeliveryAddress: його поля стануть колонками в таблиці замовлень
    private DeliveryAddress deliveryAddress;
}

Так, поки id без генерації — це нормально для навчального шматка, який ілюструє embedding. У реальному проєкті ви використаєте ту саму стратегію генерації, що й в інших сутностях (ви вже розбирали це на Дні 5). Важливе інше: тепер CustomerOrder читабельніший як модель. У замовлення є адреса доставки як єдиний об’єкт.

За бажання @Embedded можна сприймати ще й як «анотацію для читача». Навіть якщо JPA і без неї зрозуміє, що тип — embeddable, анотація робить код очевиднішим. Для навчального проєкту це плюс: ви читаєте клас і одразу бачите, що це вбудована частина моделі.

5. Embedded і колонки в таблиці

Зараз буде той момент, де корисно ввімкнути SQL-мислення, яке ми на початку курсу спеціально освіжали. На рівні Java все красиво: CustomerOrder містить DeliveryAddress. Але в реляційній базі немає вкладених об’єктів. Там є таблиця й колонки. Отже, JPA має зробити «розпакування» embedded-об’єкта в набір колонок.

Уявіть це так: у Java у вас є коробка DeliveryAddress, у якій лежать чотири значення. У БД коробки зберігати не можна, тому коробку розбирають на деталі й кладуть на полички (колонки). Під час читання — збирають назад.

Ось схематично (і так, це легальний привід для міні-діаграми):

flowchart TD
    CO["CustomerOrder (сутність)"]
    DA["deliveryAddress: DeliveryAddress (@Embeddable)"]
    T["customer_order (таблиця)"]

    CO --> DA
    DA --> C["delivery_address_city (колонка)"]
    DA --> S["delivery_address_street (колонка)"]
    DA --> H["delivery_address_house (колонка)"]
    DA --> P["delivery_address_postal_code (колонка)"]

    C --> T
    S --> T
    H --> T
    P --> T

І ось тут ви можете відчути головну перевагу embedded-підходу: жодних додаткових таблиць і JOIN-запитів для адреси. Адреса — частина замовлення, і вона лежить у тому самому рядку таблиці customer_order.

На практиці це означає, що під час вставки замовлення буде один INSERT, і в ньому будуть усі адресні колонки. Приблизно так (дуже приблизно, тому що точні імена колонок залежать від naming strategy):

-- Один INSERT: адресні поля лежать у тому самому рядку, що й замовлення
insert into customer_order
  (delivery_address_city, delivery_address_street, delivery_address_house, delivery_address_postal_code, id)
values
  (?, ?, ?, ?, ?);

6. Імена колонок і @AttributeOverride

Є один нюанс, який ви помітите швидко: дефолтні імена колонок для embedded-полів іноді виходять… своєрідними. Зазвичай це комбінація імені поля та імені властивості всередині embeddable. З погляду машини — нормально. З погляду людини, яка відкрила таблицю в psql і намагається не плакати, — іноді хочеться простіше.

І ось тут з’являється корисний інструмент: @AttributeOverride (або @AttributeOverrides, якщо їх кілька). Він дозволяє сказати: «Поле city всередині deliveryAddress зберігати в колонці delivery_city», і так далі.

Це не про «красу заради краси». Це про те, що схема БД — частина проєкту, і її теж читають люди. Особливо коли в логах або в даних потрібно розібратися швидко. І тут важливо пам’ятати: @AttributeOverride — один із варіантів іменування, а не обов’язковий baseline проєкту.

Ось приклад, як можна зробити більш людські імена колонок (так, коду більше, зате зрозуміліше):

import jakarta.persistence.AttributeOverride;
import jakarta.persistence.Column;
import jakarta.persistence.Embedded;

@Embedded
// Переозначаємо імена колонок для полів усередині embedded-об’єкта
@AttributeOverride(name = "city", column = @Column(name = "delivery_city"))
@AttributeOverride(name = "street", column = @Column(name = "delivery_street"))
@AttributeOverride(name = "house", column = @Column(name = "delivery_house"))
@AttributeOverride(name = "postalCode", column = @Column(name = "delivery_postal_code"))
private DeliveryAddress deliveryAddress; // У Java це один об’єкт, у БД — кілька колонок

Сенс простий: ви перейменували колонки для embedded-полів прямо на боці власника. Так JPA знає, як їх класти в таблицю, і ви отримуєте схему, яку простіше сприймати очима.

Ту саму задачу можна розв’язати і через явні @Column(name = ...) всередині самого DeliveryAddress; тут @AttributeOverride важлива саме як інструмент, а не як єдиний обов’язковий стиль.

Тут важливо тримати методичну межу. Так, можна налаштовувати embedded-мапінг глибше, можна повторно використовувати той самий embeddable кілька разів в одній entity, можна робити складні overrides — але все це легко перетворюється на «курс з JPA-анотацій». Ми зараз робимо базовий, одинарний кейс і фіксуємо принцип.

7. Міні-сценарій: замовлення з адресою

Щоб ідея стала відчутною, візьмімо навмисно схематичний сценарій. Жодних репозиторіїв тут не потрібно: нам достатньо побачити, що зберігається власник, а embedded-об’єкт їде разом із ним.

Код створення виглядатиме приблизно так:

// 1) Створюємо value-подібний об’єкт: сам по собі він не зберігається
DeliveryAddress address =
        new DeliveryAddress("Kyiv", "Khreshchatyk", "1", "01001");

// 2) Створюємо власника (entity) і "вкладаємо" в нього адресу
CustomerOrder order = new CustomerOrder();
order.setId(1L); // У схематичному прикладі задаємо id вручну: тут важливий не спосіб генерації, а сам факт embedding
order.setDeliveryAddress(address); // Embedded-об’єкт живе всередині замовлення

Тут ключове не те, звідки взявся id, а те, що окремого persist(address) не потрібно. Так і має бути. Адреса зберігається всередині замовлення. Адреса — це частина даних замовлення.

Якщо в цей момент мозок намагається спитати: «А як мені потім знайти адресу окремо?» — це чудове запитання… і чудовий індикатор того, що, можливо, адреса у вашій предметній області не повинна бути embedded. Але в нашому проєкті адреса всередині замовлення — саме знімок для доставки, і окремо нам її шукати не потрібно. Ми шукаємо замовлення, а всередині нього читаємо адресу.

Якщо ввімкнути SQL-логи й зберегти замовлення, ви побачите один INSERT у таблицю замовлень, у якому будуть колонки адреси. Тобто embedded-об’єкт «розгорнувся» в набір колонок, але модель у Java залишилася красивою і смисловою.

8. Коли підходить @Embeddable

Зараз хочеться дати вам «правило на всі випадки життя», але чесніше дати зрозумілі орієнтири. @Embeddable ідеально працює, коли ви хочете поліпшити читабельність моделі та зібрати «групу полів в одне поняття», при цьому ці поля мають жити в тій самій таблиці, що й власник, і не потребують окремого життя.

Щоб не перетворювати це на список заповідей, давайте порівняємо @Entity і @Embeddable через коротку таблицю. Вона корисна саме як «перевірка здорового глузду», а не як формальна теорія.

Ознака @Entity @Embeddable
Чи є власна ідентичність (@Id)? Так Ні
Чи можна зберігати окремо? Так Ні, лише разом із власником
Зазвичай окрема таблиця? Так Ні, колонки в таблиці власника
Самостійний життєвий цикл? Так: можна створити/видалити окремо Ні: живе як частина власника
Гарний приклад у проєкті Product, Category DeliveryAddress усередині CustomerOrder

І тепер «людське» пояснення вибору. Якщо ви бачите, що об’єкт — це радше «структура даних», яка завжди йде поруч із власником, змінюється разом із ним і в базі логічно зберігається в одному рядку, — embedded чудово підходить. Якщо ж об’єкт починає просити окремі операції, окремі права на зміну, окремі зв’язки або окремі запити «покажіть мені всі адреси», — ви майже напевно вже дивитеся в бік @Entity (але це ми будемо робити пізніше й акуратно, без стрибків).

Окремо відзначу один частий junior-патерн: «полів стало багато — зробімо новий клас». Це іноді корисно, але іноді ви просто ховаєте проблему, а не розв’язуєте її. Якщо ви не можете одним словом назвати, що це за клас, і навіщо він існує як єдине поняття, то @Embeddable перетворюється на «склад випадкових полів» — тільки в окремій коробці.

9. Типові помилки з embedded-типами

Помилка №1: плутати @Embeddable з окремою сутністю і намагатися «зберегти адресу окремо».
Якщо рука тягнеться написати щось на кшталт entityManager.persist(address) або «створити репозиторій для адреси», це майже завжди означає, що ви в голові зробили DeliveryAddress entity. Але @Embeddable спеціально потрібен для протилежного: адреса не живе окремо, вона зберігається всередині власника, і окремого id у неї бути не повинно.

Помилка №2: робити embedded-об’єкт «мішком із усього підряд» без змісту.
Іноді здається, що @Embeddable — це спосіб сховати зайві поля, щоб entity виглядала коротшою. Але мета не в довжині класу, а в змісті. Якщо ви виділили DeliveryAddress, але всередині нього опинилися «адреса + email + коментар кур’єру + два прапорці», це вже не value-об’єкт, а коробка з дротами. Такий код спочатку здається акуратним, а потім стає загадковим.

Помилка №3: залишати колонки з непередбачуваними або незручними іменами й дивуватися, що SQL «не читається».
За замовчуванням embedded-поля перетворюються на колонки зі складеними іменами, і це нормально. Але якщо ви вже бачите, що схема житиме і в логах, і в даних, краще один раз витратити час на @AttributeOverride, ніж потім годинами розгадувати, що таке delivery_address_postal_code і чому воно написано саме так. База даних — теж читабельний артефакт.

Помилка №4: вважати, що @Embeddable — це DTO.
Дуже хочеться сказати: «Ну це ж просто клас із полями, отже DTO». Але embedded-тип — це частина персистентної моделі. Він бере участь у мапінгу, впливає на структуру таблиці й має жити за правилами JPA (наприклад, мати no-args constructor). DTO — це об’єкт для передавання даних між шарами. У наступній лекції ми окремо закріпимо цю межу, але вже зараз важливо не змішувати ролі.

Помилка №5: намагатися обговорювати складні кейси (повторне вбудовування, складні overrides, вкладені embeddables) зарано.
Вбудовувані типи можуть бути досить потужними, і там є багато нюансів. Але якщо ви намагаєтеся розв’язати все одразу, ви ризикуєте забути головну ідею: embedded потрібен, щоб модель стала зрозумілішою і ближчою до доменних понять. Почніть із простого й осмисленого DeliveryAddress усередині CustomerOrder. Коли цей патерн стане для вас природним, далі буде значно легше розбиратися з більш складними варіантами.

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