1. Інваріант і «неможливі» дані
Перш ніж додавати до SQL «красиві» обмеження, корисно зупинитися й запитати себе: а що саме ми захищаємо? Constraint — це не прикраса таблиці й не «бо так заведено». Constraint — це спосіб сказати базі: «ось цей стан даних узагалі не має існувати». І якщо застосунок спробує записати такий стан, база має відповісти твердим «ні» — навіть якщо в коді хтось утомився, помилився або просто написав save() не в тому місці.
У mini-shop у нас доволі життєва доменна картина. У товару є бізнес-ідентифікатор (sku), а не лише технічний id. У категорії є code, бо людям зручно посилатися на «ELECTRONICS», а не на «42». Замовленню потрібен унікальний orderNumber, бо замовлення без номера — як таксі без номера: начебто їде, але потім спробуйте довести, що це було ваше. Також є очевидна звʼязність даних: товар зобовʼязаний належати до категорії, а позиція замовлення — посилатися на замовлення і на товар.
Щоб не перетворювати лекцію на філософію, зафіксуємо кілька інваріантів — тобто того, що вважаємо «неможливим», — і те, якими обмеженнями вони зазвичай захищаються:
| Інваріант домену | Що забороняємо в даних | Constraint, що «тримає удар» |
|---|---|---|
| Category.code унікальний | дві категорії з одним code | UNIQUE(category.code) |
| Category.name обовʼязковий | NULL у name | NOT NULL(category.name) |
| Product.sku унікальний | два товари з одним sku | UNIQUE(product.sku) |
| товар зобовʼязаний мати категорію | category_id = NULL або посилання «в нікуди» | NOT NULL(product.category_id) + FOREIGN KEY(product.category_id) |
| OrderItem не існує без замовлення | order_id = NULL або посилання на неіснуюче замовлення | NOT NULL(order_item.order_id) + FOREIGN KEY(order_item.order_id) |
| OrderItem не існує без товару | product_id = NULL або посилання на неіснуючий товар | NOT NULL(order_item.product_id) + FOREIGN KEY(order_item.product_id) |
| CustomerOrder.orderNumber унікальний | два замовлення з одним номером | UNIQUE(customer_order.order_number) |
Зверніть увагу на одну важливу думку: один інваріант часто вимагає комбінації обмежень. Наприклад, «товар зобовʼязаний мати категорію» — це не лише FOREIGN KEY, тому що FK сам по собі допускає NULL, якщо колонка nullable. Тому в реальному проєкті «обовʼязковий звʼязок» майже завжди означає «NOT NULL + FOREIGN KEY».
2. NOT NULL: обовʼязкові поля й обовʼязкові звʼязки
NOT NULL виглядає найпростішим обмеженням, тому новачки часто ставляться до нього як до чогось факультативного: «ну ми ж у Java перевіримо». Але в NOT NULL є дуже корисна властивість: він перетворює частину багів на гарантовану помилку запису, а не на тихо зламані дані, які спливуть за тиждень у звітах або в продакшені. Простими словами, NOT NULL — це той самий «ой, ні» на рівні бази, який не дає вам зберегти напівпорожній рядок і зробити вигляд, що все нормально.
NOT NULL у схемі
Почнімо з простого SQL. Припустімо, ми хочемо зафіксувати: product.sku і product.name мають бути обовʼязковими. У Flyway це виглядає приблизно так (фрагмент міграції; файл може бути, наприклад, V6__product_not_null.sql або частиною вашої «загальної» міграції з правками):
-- Робимо SKU обовʼязковим: без нього товар у домені не існує
alter table product
alter column sku set not null;
-- Робимо імʼя обовʼязковим: інакше отримаємо товари без назви
alter table product
alter column name set not null;
Це проста, але дуже сильна річ: тепер жоден INSERT/UPDATE не зможе записати NULL у ці колонки. Неважливо, зробив це ваш сервіс, скрипт, тимчасова адмінка чи тест, написаний о 4 ранку.
Трохи цікавіша ситуація зі звʼязками. Наприклад, за доменом Product зобовʼязаний належати Category. На рівні таблиці це означає: колонка category_id обовʼязкова.
-- Обовʼязковий звʼязок: товар не може існувати без категорії
alter table product
alter column category_id set not null;
Якщо category_id nullable, то ви фактично допускаєте товар «без категорії». Іноді це нормально, наприклад для чорновика, але в нашому домені це не те, що ми хочемо.
NOT NULL в entity
У Java-моделі ми теж відображаємо обовʼязковість. Важливо правильно зрозуміти роль цих анотацій: коли ми живемо з Flyway, реальний захист створюється міграцією, а анотації в entity — це домовленість у коді й підказка Hibernate та людині, яка читає код.
Фрагмент Product:
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
@Entity
public class Product {
// SKU — бізнес-ідентифікатор, у домені він обовʼязковий
@Column(name = "sku", nullable = false, length = 64)
private String sku;
// Обовʼязковий звʼязок в обʼєктній моделі: category не має бути null
@ManyToOne(optional = false)
// І на рівні колонки теж фіксуємо обовʼязковість, але справжній захист дає міграція
@JoinColumn(name = "category_id", nullable = false)
private Category category;
}
Тут є два місця, які новачок легко плутає:
@Column(nullable = false) і @JoinColumn(nullable = false) говорять про колонку. Це зручно і для валідації схеми (ddl-auto=validate), і просто як читабельна документація.
@ManyToOne(optional = false) — це радше про семантику звʼязку у світі JPA: обʼєкт category не має бути null. Це допомагає інструментам і іноді впливає на поведінку ORM. Але якщо в базі колонка все ще nullable — справжньої гарантії ви не отримали.
NULL і «порожній рядок»
Тут часто виникає пастка: NOT NULL захищає лише від NULL, але не від порожнього рядка ''. Тобто база цілком може прийняти name = '', якщо ви не додали додаткових обмежень. Це нормально: NOT NULL — це саме «значення має бути», а не «значення має бути осмисленим».
Якщо вам потрібно «не порожньо й не пробіли», це вже частіше зона Bean Validation (@NotBlank) і сервісної логіки. Ми не йдемо зараз у повний зоопарк валідацій, але важливо памʼятати: NOT NULL — про структурну цілісність, а «не порожньо» — про якість значення.
3. UNIQUE: коли значення має бути єдиним
UNIQUE — це той самий охоронець на вході до клубу: його не цікавить ваш настрій, він просто не пустить другу людину з таким самим паспортом. І це чудово, бо унікальність — одна з найчастіших причин появи дивних дублікатів, коли система наче працює, але потім раптом виявляється, що у вас два товари з однаковим SKU, і обидва правильні, і обидва брали участь у замовленнях. Вітаю: ви створили загадку для археологів даних.
Бізнес-ключ vs технічний id
У наших таблицях майже всюди є id bigint primary key. Це технічний ключ, і він унікальний за визначенням.
Але бізнесу зазвичай важливіше інше:
— для категорії важливий code (ELECTRONICS, BOOKS);
— для товару важливий sku (SKU-1, SKU-IPHONE-15);
— для замовлення важливий orderNumber (ORD-2026-000123).
Якщо ці значення не унікальні, система починає брехати. Причому робитиме вона це спокійно: не впаде, а просто працюватиме так, що потім ніхто не зрозуміє, що саме сталося.
UNIQUE у Flyway
Додамо унікальність sku на рівні схеми (знову ж таки, шматок міграції; імена файлів умовні):
-- Забороняємо два товари з однаковим SKU
alter table product
add constraint uk_product_sku unique (sku);
Аналогічно для категорії:
-- Забороняємо дві категорії з однаковим code
alter table category
add constraint uk_category_code unique (code);
І для замовлення:
-- Забороняємо два замовлення з однаковим номером
alter table customer_order
add constraint uk_customer_order_number unique (order_number);
Тут варто привчити себе до неймінгу. Нічого магічного в назвах немає, але хороший стиль дуже допомагає під час діагностики: коли constraint називається uk_product_sku, ви одразу розумієте, що саме зламалося. Якщо ж він називається product_sku_key або something_42, ви дивитиметеся на це як на ребус.
До речі, у PostgreSQL унікальний constraint майже завжди реалізується через унікальний індекс. Тобто фактично база не лише забороняє дублікат, а й робить пошук за цим полем швидким. Це приємний бонус, але не варто перетворювати UNIQUE на «давайте індексувати все підряд». Ми ставимо унікальність там, де вона відображає правило домену.
UNIQUE в entity
У Java-коді теж можна позначити унікальність. Найпростіший варіант — unique = true:
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
@Entity
public class Category {
// code — бізнес-ключ категорії, тому він і обовʼязковий, і унікальний
@Column(name = "code", nullable = false, unique = true, length = 64)
private String code;
}
Це читабельно, але я хочу, щоб ви закріпили думку: після переходу на Flyway це не головний механізм захисту. Навіть якщо в entity написано unique = true, а в міграції constraint не створено, база прийматиме дублікати. І навпаки: якщо в міграції constraint є, а в entity ви забули unique = true, то база все одно захистить дані, але Hibernate на ddl-auto=validate може не сваритися, залежно від того, як ви вмикали перевірку. Тому краще тримати обидва рівні узгодженими.
Якщо хочете більш явно і на рівні схеми, можна описувати унікальність через @Table. Але в навчальному проєкті це зазвичай уже надмірно, і багато новачків починають плутатися: «а що краще». Для початку достатньо unique = true у полі й constraint у міграції.
UNIQUE і pre-check existsBySku(...)
Сервісний pre-check потрібен, щоб дати зрозумілу реакцію на сценарій. Але він не дає залізної гарантії через гонки.
Уявіть дві паралельні транзакції — два користувачі, два потоки, два запити, неважливо. Обидві роблять existsBySku("SKU-1") і бачать «false». Потім обидві намагаються вставити рядок. І лише в момент запису база має сказати: «так, стоп, другий такий SKU я не приймаю».
Ось чому UNIQUE — це не заміна сервісного pre-check, а pre-check — не заміна UNIQUE. Вони працюють разом: сервіс робить поведінку зрозумілою, база — правильною.
4. FOREIGN KEY: цілісні звʼязки
Якщо UNIQUE захищає від дублікатів, то FOREIGN KEY захищає від посилань у порожнечу. І це, чесно кажучи, одна з головних причин, чому реляційні бази взагалі настільки корисні: вони не дають вам випадково побудувати світ, де order_item.product_id = 123, але товару 123 не існує. В обʼєктному світі таке теж можна влаштувати, але там це зазвичай виглядає як «у нас є посилання на обʼєкт, який ніхто не може знайти», тобто теж біль, тільки іншого формату.
FK на прикладі product.category_id
У схемі це виглядає дуже просто:
-- Товар має посилатися на існуючу категорію
alter table product
add constraint fk_product_category
foreign key (category_id) references category(id);
Тепер база не дозволить:
— вставити товар із category_id, якого немає в таблиці category;
— оновити category_id на неіснуюче значення.
А якщо ще й колонка category_id у нас NOT NULL, то товар без категорії теж не зʼявиться.
FK у замовленнях: OrderItem
Позиції замовлення — це класика для FK. OrderItem без замовлення не має сенсу, OrderItem без товару теж.
Фрагмент міграції:
-- Позиція замовлення має посилатися на існуюче замовлення
alter table order_item
add constraint fk_order_item_order
foreign key (order_id) references customer_order(id);
-- Позиція замовлення має посилатися на існуючий товар
alter table order_item
add constraint fk_order_item_product
foreign key (product_id) references product(id);
І тут важливо памʼятати про обовʼязковість: якщо в домені позиція завжди зобовʼязана мати замовлення і товар, то order_id і product_id мають бути NOT NULL. FK сам по собі не забороняє NULL.
Карта звʼязків
Іноді корисно подивитися на це як на просту діаграму:
erDiagram
CATEGORY ||--o{ PRODUCT : містить
CUSTOMER_ORDER ||--o{ ORDER_ITEM : має
PRODUCT ||--o{ ORDER_ITEM : замовляється
Це не про JPA — це про здоровий глузд даних. JPA просто повторює те, що ми вирішили на рівні схеми.
Видалення і FK: обмеження
Ось тут і проявляється практичний сенс FK. За замовчуванням PostgreSQL працює так: якщо на батьківський запис ще посилаються дочірні рядки, видалити його не можна. І це дуже корисно: база не дає тихо порвати звʼязки, а сервіс потім може перевести таку відмову в зрозумілу помилку сценарію.
Наприклад, якщо у вас є товари в категорії, то спроба видалити категорію призведе до помилки на рівні БД. І це, загалом, добра річ: база захищає звʼязність даних краще, ніж будь-яке «якщо» в сервісі.
Є варіанти поведінки через ON DELETE ... (наприклад, CASCADE), але тут легко наступити на граблі: ON DELETE CASCADE для категорії означало б «видаляємо категорію — видаляємо всі товари в ній». Іноді це потрібно, але частіше це шлях до випадкового масового видалення, після якого залишається лише філософськи дивитися на монітор і тихо шепотіти: «ну зате constraint був красивий».
У нашому mini-shop логіка зазвичай така: категорію не можна видалити, доки в ній є товари. Ми або переводимо категорію в active = false, або переносимо товари до іншої категорії, або явно чистимо залежні дані через усвідомлений сценарій використання. Це більш керовано.
5. Flyway: джерело правди та entity
Коли ви тільки починаєте з JPA, великою спокусою є думка: «я поставлю анотацію — і база якось сама стане правильною». Це працює, доки ви живете на ddl-auto=create і щоразу створюєте схему заново. Але щойно ми дорослішаємо, хоча б навчально, і вмикаємо Flyway, схема стає окремою історією, і її потрібно підтримувати дисципліновано.
Constraint у міграції
Після переходу на Flyway правило просте: хочете UNIQUE — додайте його в SQL-міграцію. Хочете NOT NULL — додайте alter column ... set not null. Хочете FK — додайте foreign key (...) references ....
Так, в entity теж можна написати nullable=false і unique=true. І так, це корисно. Але проєкт тримається на міграціях.
Умовний приклад окремої міграції (фрагмент; не обовʼязково виділяти все в один файл, але в навчальному проєкті так часто простіше):
-- V6__catalog_constraints.sql
alter table category
add constraint uk_category_code unique (code);
alter table product
add constraint uk_product_sku unique (sku);
І окремий шматок для FK:
-- V7__ordering_constraints.sql
alter table order_item
add constraint fk_order_item_order
foreign key (order_id) references customer_order(id);
Це не єдиний стиль. Важливо, щоб у репозиторії можна було відкрити db/migration і побачити: ось у нас унікальність, ось обовʼязковість, ось звʼязність.
ddl-auto=validate
Оскільки ми не хочемо, щоб Hibernate намагався сам створювати схему, а Flyway уже робить це як джерело правди, розумно налаштувати JPA так, щоб він перевіряв відповідність мапінгу й схеми. Це допомагає ловити ситуації на кшталт «у коді nullable=false, а в схемі все ще nullable».
Фрагмент application.yml для dev-профілю:
spring:
jpa:
hibernate:
# Hibernate не створює і не змінює схему, а лише перевіряє її відповідність entity
ddl-auto: validate
У такому режимі застосунок не стартує, якщо схема не відповідає мапінгу. Це іноді дратує, особливо коли ви щось швидко змінюєте, але в навчальному проєкті це чудовий спосіб не накопичити розсинхронізацію між кодом і БД.
Чек-лист на зміни схеми
Є простий чек-лист, який я раджу тримати в голові щоразу, коли ви додаєте або змінюєте поле чи звʼязок:
— Якщо поле обовʼязкове в домені, то в entity має бути nullable=false і в міграції має бути NOT NULL.
— Якщо поле унікальне за змістом, то в entity добре б позначити унікальність, але в міграції обовʼязково має зʼявитися UNIQUE.
— Якщо обʼєкт посилається на інший обʼєкт, то в таблиці має бути FK, а індекс на FK-колонці часто теж потрібен, але індекси ми сьогодні не розгортаємо.
Це не магія. Це просто здоровий глузд + дисципліна.
6. Типові помилки при обмеженнях
На обмеженнях схеми найчастіше ламаються не тому, що вони складні, а тому, що розробник намагається змусити їх поводитися як Java-код: «ну я ж перевірив, навіщо база свариться». База свариться, бо вона — остання лінія оборони, і їй байдуже, що ви там перевіряли пʼять мілісекунд тому. Нижче — кілька помилок, які я бачив у навчальних і реальних проєктах занадто багато разів (і так, я сам теж так робив, коли був молодий і хотів жити без міграцій).
Помилка №1: плутати nullable=false в entity із NOT NULL у схемі.
Іноді розробник ставить @Column(nullable = false) і щиро вважає, що база тепер точно не прийме NULL. Але якщо схемою керує Flyway, то анотація сама по собі схему не змінює. У результаті в таблицю все ще можна записати NULL через інший шлях, а потім дивуватися, чому сервіс раптово ловить NullPointerException під час читання. Правильний підхід — зробити NOT NULL у міграції й тримати анотацію як відображення наміру.
Помилка №2: сподіватися на pre-check і не ставити UNIQUE («ми ж перевірили existsBy…»).
Перевірка через existsBySku(...) корисна, але вона не захищає від гонок. Два паралельні запити можуть пройти перевірку й обидва спробувати вставити рядок. Лише UNIQUE на рівні бази робить унікальність справжньою гарантією. Тому правильна схема — pre-check для зрозумілої поведінки + constraint для залізної цілісності.
Помилка №3: робити FK nullable там, де звʼязок обовʼязковий.
Це класика: у Java написали «товар зобовʼязаний мати категорію», а в SQL забули NOT NULL для category_id. У результаті зʼявляються товари з NULL у FK-колонці, і далі весь код починає обростати перевірками на кшталт «якщо категорія є…». На цьому місці модель перетворюється на кашу. Якщо звʼязок обовʼязковий — робіть NOT NULL і FK.
Помилка №4: вмикати ON DELETE CASCADE «щоб не заважало видаляти».
Бажання зрозуміле: ви пробуєте видалити категорію, база не дає, і здається, що constraint заважає жити. Але CASCADE — це не виправлення помилки, а зміна бізнес-семантики: тепер видалення категорії означатиме масове видалення товарів. Це може бути правильно, але має бути усвідомленим. У навчальному mini-shop частіше безпечніше забороняти видалення й реалізувати акуратний use case: деактивувати, перенести, очистити звʼязки.
Помилка №5: не давати constraint-ам імен і потім страждати під час діагностики.
Коли constraint не названо явно, PostgreSQL придумає імʼя сам. Воно буде коректним, але часто не найзручнішим. У момент, коли ви ловите помилку, вам буде простіше побачити uk_product_sku, ніж розшифровувати автозгенероване імʼя. Це дрібниця, яка раптово економить час і нерви, особливо коли помилок стає більше ніж одна.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ