1. Обмеження @OneToOne
У @OneToOne найнеприємніша пастка не в синтаксисі, а в оманливому відчутті простоти. Щойно модель справді гарантує правило «не більше одного», наступне запитання вже дуже приземлене: де саме живе цей зв’язок у базі та хто ним керує.
У Java легко намалювати два красиві посилання — product.getDetails() і details.getProduct(). Але база так не працює. Вона зберігає зв’язок через один конкретний зовнішній ключ. Тому JPA і змушує нас обрати сторону, яка реально керує цим зв’язком у БД. Без цього @OneToOne швидко перетворюється на набір анотацій, що виглядають симетрично, а зберігаються зовсім несиметрично — іноді навіть із сюрпризом у вигляді зайвої проміжної таблиці.
One-to-one у БД і UNIQUE
Якщо зв’язок реалізовано через product_details.product_id з обмеженням UNIQUE, то фізична картина дуже проста: зовнішній ключ лежить у таблиці product_details, і саме вона зберігає one-to-one. Звідси одразу випливає правило JPA: стороною-власником буде та сутність, у таблиці якої лежить FK.
Для Product ↔ ProductDetails це означає одне: ProductDetails буде owning side, тому що саме там живе product_id. Тепер переведімо цю картину в @JoinColumn та mappedBy.
2. Owning side: зовнішній ключ
Тепер переводимо SQL-картину в JPA-анотації. У JPA стороною-власником (owning side) є та, яка керує зовнішнім ключем, тобто саме на ній ми ставимо @JoinColumn. Це означає: коли Hibernate зберігатиме або оновлюватиме зв’язок, він орієнтуватиметься саме на цю сторону. Інша сторона може існувати лише для навігації в Java, але «командувати базою» не буде. Це принципово важливо: ви можете змінити посилання на зворотній стороні в коді, але якщо власник залишився тим самим, SQL-оновлення не буде або буде не тим, на яке ви очікували.
У нашому проєкті shop-data-jpa стороною-власником для ProductDetails логічно зробити саму сутність деталей. Тоді в неї буде поле product і зовнішній ключ product_id.
import jakarta.persistence.*;
@Entity
public class ProductDetails {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE)
private Long id;
// Власна сторона: саме це поле керує зовнішнім ключем у таблиці product_details
@OneToOne(optional = false) // На рівні ORM: «деталі» не можуть існувати без товару
@JoinColumn(name = "product_id", nullable = false, unique = true) // На рівні БД: FK + NOT NULL + UNIQUE
private Product product;
}
Тут зверніть увагу на дрібниці, які насправді не дрібниці. name = "product_id" робить схему читабельною та такою, що збігається з нашою SQL-ментальною моделлю. nullable = false відображає доменне правило «деталі не існують без товару». unique = true — це не «опція для краси», а технічний спосіб перетворити to-one на справжній one-to-one на рівні схеми.
Кілька слів про optional = false. Це налаштування на боці JPA: воно говорить, що посилання обов’язкове. У зв’язці з nullable = false ви отримуєте зрозумілий контракт: і ORM, і схема не люблять «деталі без товару». Коли у вашій моделі є такі явні заборони, усе працює значно спокійніше й передбачуваніше.
3. Обернена сторона: mappedBy
Тепер хочеться зробити це по-людськи: щоб із Product можна було викликати getDetails() і не робити зайвих запитів чи репозиторіїв у кожному місці. Для цього ми додаємо зворотну сторону, але додаємо її чесно: кажемо JPA «я не володію зв’язком, зовнішній ключ живе там». Саме для цього існує mappedBy. Він указує, яким полем на owning side описано зв’язок. І ось тут часта пастка: mappedBy — це імʼя Java-поля, а не імʼя колонки й не імʼя таблиці.
На боці Product це виглядає так:
import jakarta.persistence.*;
@Entity
public class Product {
@Id
private Long id;
// Обернена сторона: це «дзеркало», воно не керує FK у БД
@OneToOne(mappedBy = "product") // mappedBy указує на Java-поле ProductDetails.product
private ProductDetails details;
}
Рядок mappedBy = "product" означає: «дивись, зв’язок описано полем product у класі ProductDetails». Якщо ви напишете mappedBy = "product_id", Hibernate не знайде такого поля і впаде під час запуску застосунку. І це, чесно кажучи, корисне падіння: краще отримати помилку під час запуску, ніж тиху неправду в базі.
Дуже важливо розуміти цю семантику: обернена сторона не керує зовнішнім ключем. Вона працює як дзеркало. Дзеркало корисне, але якщо ви спробуєте голитися дзеркалом, буде боляче. Тобто читати зручно, але писати зв’язок треба через власника.
І так, ось тут ще одна типова пастка: «а давайте для надійності поставимо @JoinColumn і там, і там». Не треба. У one-to-one ви обираєте одного власника зв’язку. Друга сторона — лише mappedBy (або взагалі без зворотної навігації, якщо вона вам не потрібна).
4. Одно- і двонапрямлений @OneToOne
Щойно в нас з’являються два поля (Product.details і ProductDetails.product), з’являється і новий обов’язок: тримати їх узгодженими. Тому, перш ніж додавати двонапрямленість «про всяк випадок», варто запитати себе: а чи потрібна вона взагалі? В одному напрямку зв’язок зазвичай простіший, коду менше, шансів зробити цикли й випадково роздути об’єктний граф теж менше. Але інколи двонапрямленість робить код природнішим: картка товару майже завжди хоче дотягнутися до деталей, і тоді product.getDetails() виглядає дуже доречно.
Зручно мислити це як інженерний компроміс:
| Питання | Однонапрямлений @OneToOne | Двонапрямлений @OneToOne |
|---|---|---|
| Скільки коду й «зобов’язань» | Менше, простіше жити | Більше: треба синхронізувати обидві сторони |
| Навігація в домені | Лише «туди» | «Туди й назад» |
| Ризик, що посилання в пам’яті роз’їдуться | Майже немає | Є, якщо змінювати лише одну сторону |
| Ризик перевантажити модель зайвими зв’язками | Нижчий | Вищий (особливо якщо «додати зворотне посилання всюди») |
Для Product ↔ ProductDetails двонапрямленість зазвичай виправдана: деталі — це логічне розширення картки товару, і ви справді часто ходитимете від товару до деталей.
Для StockItem ситуація тонша. Так, концептуально це «один залишок на товар». Але за архітектурою проєкту inventory живе у feature-пакеті, а catalog — окремо. Якщо ви додасте поле stockItem у Product, ви почнете тягнути inventory у catalog прямо через модель. Іноді це нормально, а іноді — починає заважати. Тому на старті дуже практичний варіант — зробити зв’язок однонапрямленим зі сторони StockItem на Product і отримувати залишки через StockItemRepository за productId. Так ви зберігаєте акуратніші межі.
5. Синхронізація обох сторін
Якщо ви все-таки робите зв’язок двонапрямленим (а для ProductDetails ми саме так і робимо), то у вас виникає класична проблема: Java-об’єкти можуть розсинхронізуватися. Наприклад, product.getDetails() поверне один об’єкт, а details.getProduct() — інший (або null). JPA при цьому орієнтуватиметься на owning side, тобто на ProductDetails.product, і якщо ви змінювали лише зворотну сторону, база може взагалі не побачити зв’язок. Такі баги особливо неприємні тим, що вони виглядають як «іноді працює, іноді ні», а насправді просто порушено контракт двонапрямленості.
Правильний базовий прийом — зробити в сутності helper-методи, які змінюють обидві сторони як одну атомарну операцію. У нашому Product це може бути так:
public void attachDetails(ProductDetails details) {
// Оновлюємо inverse side (Product.details)
this.details = details;
// Оновлюємо owning side (ProductDetails.product) — саме це поле пише FK у БД
if (details != null) {
details.setProduct(this);
}
}
І симетрично — «від’єднати»:
public void detachDetails() {
if (this.details != null) {
// Спочатку чистимо owning side, щоб FK став null (або ORM видалив orphan — залежно від налаштувань)
this.details.setProduct(null);
// Потім чистимо inverse side
this.details = null;
}
}
Зверніть увагу: ми змінюємо і Product.details, і ProductDetails.product. Це не примха. Це спосіб гарантувати, що модель у пам’яті не суперечлива.
Тепер код у сервісі стає читабельним і безпечним за замовчуванням:
Product product = productRepository.findById(id).orElseThrow(); // завантажили агрегат (зазвичай усередині транзакції)
ProductDetails details = new ProductDetails(); // створили «дочірній» об’єкт
product.attachDetails(details); // синхронізували обидві сторони зв’язку
productRepository.save(product); // details теж збережеться, якщо налаштовано cascade
Коли ви бачите attachDetails, намір одразу зрозумілий: «підв’яжи деталі до товару». А коли десь у коді бачите product.setDetails(details) без синхронізації другої сторони, це вже червоний прапорець: можливо, ви саме зараз вирощуєте баг на потім.
6. cascade і orphanRemoval в one-to-one
Після того як ви налаштували owning/inverse сторони та синхронізацію посилань, виникає наступне практичне запитання: хто і як зберігає ProductDetails? Якщо ви щоразу окремо викликатимете productDetailsRepository.save(details), а потім ще й productRepository.save(product), код почне розростатися і дублюватися. І ось тут каскади та orphanRemoval стають дуже доречними: у one-to-one зі спільним життєвим циклом вони часто дають саме ту поведінку, якої ви очікуєте. Але важливо пам’ятати: каскад — це не магічне покращення, а чітко визначена команда ORM, яка може випадково знести дані, якщо поставити її за звичкою.
Для Product ↔ ProductDetails логічно зробити так: деталі живуть разом із товаром, а при від’єднанні мають видалятися. Тоді на боці Product можна оголосити:
import jakarta.persistence.*;
@Entity
public class Product {
@Id
private Long id;
// Обернена сторона (mappedBy), але каскади/сироти задаємо тут як бізнес-правило життєвого циклу
@OneToOne(mappedBy = "product",
cascade = CascadeType.ALL, // зберігаємо/видаляємо ProductDetails разом із Product
orphanRemoval = true) // під час від’єднання details він має бути видалений як «сирота»
private ProductDetails details;
}
CascadeType.ALL тут доречний саме тому, що ProductDetails — «ексклюзивна дитина» товару. Він не перевикористовується між товарами і не має сенсу сам по собі. Якщо ви видаляєте товар, у більшості випадків ви хочете видалити і його деталі. Якщо ви від’єднуєте деталі (detachDetails()), ви, як правило, хочете, щоб вони зникли з бази, а не бовталися сиротою без товару.
Водночас дисципліна залишається важливою: каскадування не замінює розуміння того, хто володіє зв’язком. Воно лише говорить: «коли я зберігаю або видаляю власника бізнес-операції (у нашому випадку Product), зроби те саме і з дочірнім об’єктом».
7. Приклади mini-shop
Тепер зберемо все в контексті наскрізного проєкту, щоб це не залишалося «анотаціями у вакуумі». У catalog у нас є Product і ProductDetails. У inventory у нас з’явиться StockItem. Обидва пов’язані з Product, але зв’язок улаштований по-різному через зміст і архітектурні межі. Ми робимо Product ↔ ProductDetails двонапрямленим зв’язком (картка товару), а StockItem поки залишаємо однонапрямленим (залишки як окрема зона відповідальності). Важливо, що в обох випадках owning side там, де зберігається product_id, і саме вона має командувати зовнішнім ключем.
Отже, по ProductDetails:
- FK у product_details.product_id;
- owning side: ProductDetails.product (@JoinColumn);
- inverse side: Product.details (mappedBy="product");
- helper-методи в Product для синхронізації.
По StockItem базовий варіант (однонапрямлений):
import jakarta.persistence.*;
@Entity
public class StockItem {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE)
private Long id;
// Однонапрямлений зв’язок: StockItem «знає» Product, але Product про StockItem не знає
@OneToOne(optional = false) // залишок не має сенсу без товару
@JoinColumn(name = "product_id", nullable = false, unique = true) // UNIQUE гарантує «один залишок на один товар»
private Product product;
}
Тут нам не потрібні helper-методи, бо зворотного посилання немає. Ми не ризикуємо отримати ситуацію «в один бік зв’язали, в інший забули». А ще не змушуємо catalog знати про inventory через поле в Product. Пізніше, коли стане зрозуміліше, як ви насправді читаєте товар і залишки, можна буде свідомо вирішити, чи потрібна зворотна навігація. Поки що тримаємо модель акуратною.
І ще одна маленька, але корисна порада, що економить час: у @JoinColumn краще явно писати імʼя product_id. Hibernate і сам придумає назву, але він зробить це за своїми правилами, а ви потім читатимете DDL як детектив: «чому в мене колонка називається product_product_id і хто взагалі це вигадав?». Краще вигадати її самим.
8. Типові помилки під час роботи з @OneToOne
У one-to-one зв’язках помилки часто не виглядають як «червона помилка компіляції». Вони виглядають як «нібито все працює, але іноді дані не зберігаються» або «чому раптом з’явилася зайва таблиця». Тому корисно заздалегідь знати найпоширеніші граблі, щоб упізнавати їх на слух. На щастя, більшість проблем вирішується одним і тим самим способом: чітко вибрати власника зв’язку, явно описати зовнішній ключ і дисципліновано синхронізувати обидві сторони в пам’яті, якщо зв’язок двонапрямлений.
Помилка №1: @JoinColumn на обох сторонах.
Коли розробник ставить @JoinColumn і в Product, і в ProductDetails, він ніби каже Hibernate: «у нас два різні зв’язки». У результаті ORM може спробувати створити два зовнішні ключі або почати поводитися неочікувано. У one-to-one обираємо одного власника, а на іншій стороні використовуємо mappedBy (або взагалі не робимо зворотну сторону).
Помилка №2: mappedBy указує на колонку, а не на поле.
mappedBy = "product_id" — дуже поширена помилка. mappedBy має посилатися на Java-поле owning side, наприклад mappedBy = "product". Якщо помилитися, застосунок упаде під час запуску, і це той рідкісний випадок, коли падіння — подарунок: ви одразу розумієте, де проблема.
Помилка №3: забули про unique = true і отримали «не зовсім one-to-one».
Якщо в схемі немає унікальності на product_id, то технічно ви дозволили «багато деталей на один товар», просто поки що даних мало і це не проявилося. @OneToOne без UNIQUE у базі — як табличка «не паркуватися», намальована крейдою на асфальті: виглядає переконливо, але нічого не захищає.
Помилка №4: змінили лише inverse side і здивувалися, що в базі нічого не змінилося.
Якщо ви присвоїли product.details = details, але не зробили details.setProduct(product), сторона-власник залишилася порожньою. Саме owning side пише зовнішній ключ, тому зв’язок може не зберегтися, або зберегтися як NULL, або призвести до помилки через nullable=false. Лікується helper-методами і звичкою змінювати зв’язок через них.
Помилка №5: зробили двонапрямленість «про всяк випадок» і забули синхронізацію.
Двонапрямленість — це контракт: ви зобов’язані підтримувати узгодженість двох посилань. Якщо не готові, краще залишити зв’язок однонапрямленим, особливо для другорядних сценаріїв (наприклад, для залишків на старті).
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ