1. Коли пласка модель стає незручною
Почнімо з відчуття, яке майже всі помічають у реальних проєктах приблизно через 2–3 тижні після старту: спочатку здається, що «поля — це просто поля», і їх можна спокійно додавати прямо в 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. Коли цей патерн стане для вас природним, далі буде значно легше розбиратися з більш складними варіантами.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ