1. Коректність даних та інваріанти
Якщо ви лише починаєте, мозок дуже любить просту думку: «Я все перевірю в коді — і база буде щаслива». Це хороша установка, але з підступом: вона змішує два різні питання. Перше — «вхідні дані взагалі схожі на адекватні?» (наприклад, sku не порожній, ціна не null). Друге — «чи можу я гарантувати, що в базі ніколи не з’явиться неможливий стан?» (наприклад, два товари з одним sku). Це як різниця між «перевірити, що людина твереза» і «поставити замок на двері». Тверезість можна оцінити очима, але замок потрібен, бо очі не контролюють увесь світ 24/7.
Давайте одразу введемо корисне слово: інваріант. Це правило, яке має бути істинним завжди — не «зазвичай», не «в середньому», а в будь-який момент у даних. У нашому мінішопі такими правилами є, наприклад, унікальність sku у товару, обов’язковість категорії для продукту та неможливість посилання «в порожнечу» в order_item.product_id. Інваріанти — це не просто «бажання сервісу», а фундаментальна частина моделі даних.
Коли ми говоримо про перевірки в коді, то найчастіше маємо на увазі два механізми. Перший — Bean Validation: анотації на кшталт @NotBlank, які допомагають відловити очевидний мотлох до запису в БД. Другий — попередня перевірка в сервісі, наприклад existsBySku(...), щоб дати зрозумілу реакцію сценарію («SKU вже зайнято») до того, як база почне обурюватися. Обидва механізми корисні, але жоден із них не замінює обмеження в схемі БД, бо вони живуть на різних рівнях відповідальності.
2. Bean Validation: фільтр на вході
Bean Validation легко полюбити: ви ставите кілька анотацій — і здається, що дані автоматично стають правильними. І це справді зручно, особливо коли ви хочете захистити код від «порожніх рядків», null і від’ємних чисел. Але важливо розуміти межу: Bean Validation перевіряє форму даних, а не «істину світу». Вона чудово відповідає на питання «це взагалі схоже на коректний ввід?», але майже не вміє відповідати на питання «а чи не зайняте таке значення вже в таблиці?».
У нашому проєкті зручно тримати вхід для сценарію у вигляді невеликого об’єкта-команди. Навіть якщо у нас немає «товстого» вебшару, команда на створення товару все одно з’являється: сервісу потрібно отримати sku, name, price, categoryId і далі працювати з ними.
package com.example.shopdatajpa.catalog.service.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.PositiveOrZero;
import java.math.BigDecimal;
/**
* Команда (input) для сценарію "створити товар".
* Важливо: це перевірка форми входу, а не гарантія інваріантів таблиці.
*/
public record CreateProductInput(
@NotBlank String sku, // sku має бути непорожнім (відсікаємо явний некоректний ввід)
@NotBlank String name, // імʼя теж не повинне бути порожнім
@NotNull @PositiveOrZero BigDecimal price, // ціна має бути заданою і не може бути від’ємною
@NotNull @Positive Long categoryId // id категорії має бути передано як коректне число
) { }
Ці перевірки корисні одразу з кількох причин. По-перше, вони ловлять помилки максимально рано: «порожній sku» — це не привід іти в базу та робити там якісь запити. По-друге, вони документують очікування від входу: коли ви через тиждень відкриєте код, ви побачите, що price не має бути від’ємною, і не доведеться читати десяток if-ів. По-третє, вони допомагають не перетворювати сервіс на нескінченне полотно ручних перевірок.
І окремо тримайте в голові: @NotNull і @Positive на categoryId говорять лише «id передано, і він схожий на нормальний», а не «така категорія справді існує». Існування категорії — це вже історія про FK і базу.
Але ось у чому важливий момент: Bean Validation не вміє гарантувати унікальність. Так, теоретично можна написати кастомну анотацію @UniqueSku, яка полізе в репозиторій, але це майже завжди погана ідея: ви змішаєте «перевірку форми даних» із «доступом до даних», а головне — усе одно не отримаєте залізної гарантії. Чому? Тому що унікальність — це правило про всю таблицю, а не про поточний об’єкт.
Щоб валідація справді спрацьовувала на межі сервісного методу, Spring зазвичай використовує @Validated + @Valid. Нам не потрібно заглиблюватися в механіку AOP, достатньо розуміти: так ми вмикаємо перевірку аргументів методу на вході.
import jakarta.validation.Valid;
import org.springframework.validation.annotation.Validated;
@Validated // вмикає перевірку параметрів методів (у зв’язці з @Valid)
public class CatalogService {
public void createProduct(@Valid CreateProductInput input) {
// Якщо input.sku порожній — метод навіть не почне виконувати бізнес-логіку:
// буде викинуто виняток валідації на межі виклику.
}
}
Тут важливо не «зазубрити анотації», а вловити сенс: Bean Validation працює як фільтр, який не дає продовжувати сценарій, якщо вхід уже явно неправильний. Але вона не може бути вашим «замком» від неможливих станів у БД. І це нормально: у кожного механізму своя роль, і найгірше — намагатися змусити один механізм робити все одразу.
3. Попередня перевірка в сервісі: existsBySku(...)
Коли ми говоримо «давайте перевіримо в коді», найчастіше маємо на увазі саме це: перед тим як зберігати товар, запитаємо в бази, чи існує такий sku. На практиці це справді робить сценарій приємнішим. Користувач — або код, що викликає, — отримує нормальну помилку «SKU зайнято», а не «порушено constraint uk_product_sku», від чого в новачка зазвичай починається паніка, а в досвідченого розробника з’являється бажання випити чаю й не чіпати цей код.
Репозиторій для цього виглядає дуже простим: похідний метод existsBySku — це класика Spring Data.
package com.example.shopdatajpa.catalog.repository;
import com.example.shopdatajpa.catalog.entity.Product;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ProductRepository extends JpaRepository<Product, Long> {
// Похідний запит: Spring Data сам побудує SQL для перевірки існування за sku.
boolean existsBySku(String sku);
}
Тепер сервіс може виконати передбачувану й «людську» перевірку. Зверніть увагу: сенс цієї перевірки — не гарантувати унікальність назавжди. Сенс — дати зрозумілу реакцію конкретному сценарію «створити товар».
import org.springframework.transaction.annotation.Transactional;
@Transactional
public void createProduct(CreateProductInput input) {
// Попередня перевірка для зрозумілої реакції сценарію (UX/логіка сценарію),
// але це НЕ "залізна" гарантія унікальності за паралельних запитів.
if (productRepository.existsBySku(input.sku())) {
throw new DuplicateSkuException(input.sku());
}
// Далі створюємо Product і зберігаємо.
// Важливо: інваріант "sku унікальний" має забезпечуватися UNIQUE у БД.
}
Зазвичай тут кидають свій runtime-виняток рівня сценарію, наприклад DuplicateSkuException. Конкретна форма такого класу для цього місця не така важлива, як сам принцип: сервіс відповідає зрозумілою мовою сценарію, а не викидає назовні технічну кашу зі шару даних.
Такий підхід дуже добре працює в «звичайних» випадках, коли конфлікт не конкурентний, а просто хтось намагається повторно створити товар із тим самим sku. Але далі починається найцікавіша частина: чому навіть така перевірка все одно не робить інваріант залізним.
4. Гонка між перевіркою та INSERT
Є підступна річ, яку люблять демонструвати на співбесідах і яка в реальному житті трапляється набагато частіше, ніж хотілося б: race condition (гонка). Це ситуація, коли між «перевірили умову» і «зробили дію» світ устиг змінитися. І так, світ змінюється не тому, що він злий, а тому, що ваш сервер обробляє багато запитів паралельно, а база даних — це спільний ресурс.
Уявіть, що два адміністратори — або два потоки, або два сервіси, або одна й та сама адмінка у двох вкладках браузера — одночасно створюють товар із sku = "SKU-1". Обидва роблять попередню перевірку: «чи існує SKU-1?». У момент перевірки в таблиці ще нічого немає. Обидва отримують false. І обидва йдуть далі на INSERT. І ось тут тільки база даних здатна сказати фінальне слово.
Щоб це стало зовсім відчутним, подивімося на послідовність подій:
sequenceDiagram
participant A as Запит A
participant B as Запит B
participant DB as PostgreSQL
A->>DB: "перевірка existsBySku('SKU-1') — false"
B->>DB: "перевірка existsBySku('SKU-1') — false"
A->>DB: "INSERT product(sku='SKU-1') -> OK"
B->>DB: "INSERT product(sku='SKU-1') -> FAIL (UNIQUE)"
Зверніть увагу на мораль: обидва запити діяли «логічно». У них не було помилки в перевірці. Вони чесно запитали базу — база чесно відповіла. Але відповідь була «на момент запиту», а не «на весь подальший життєвий шлях цього сервісу». І саме тому попередня перевірка — це зручність і UX на рівні сценарію, але не механізм гарантії.
Можна спробувати «лікувати» це транзакціями, наприклад: «ну я ж у @Transactional, отже все атомарно». Атомарно — всередині кожної окремої транзакції. Але у вас дві транзакції в двох паралельних запитах. Вони живуть окремо, і база — єдине місце, яке бачить картину цілком і може утримувати глобальні інваріанти таблиці.
І тут з’являється дуже доросла думка: якщо ви хочете, щоб неможливий стан не міг опинитися в таблиці, вам потрібне правило на боці БД. Інакше ваша «гарантія» триматиметься на надії, що «ну в проді ніхто одночасно не натисне дві кнопки». Спойлер: натиснуть. Прод любить такі жарти.
5. Обмеження БД: остання лінія оборони
Обмеження (constraint) у базі даних — це не «ще одна перевірка». Це правило, яке база застосовує до будь-якого INSERT і UPDATE, що намагається змінити таблицю. Неважливо, чи прийшов цей INSERT із вашого Spring Boot застосунку, з ручного SQL-скрипта, з міграції, з консолі DBA або із сусіднього інструмента. База не питає: «а ви там точно робили @NotBlank?». Вона просто каже: «у цій таблиці так не можна».
Для унікальності sku це виглядає максимально прямолінійно:
-- Залізна гарантія інваріанта на рівні таблиці:
-- два однакові sku фізично не зможуть співіснувати в product.
alter table product
add constraint uk_product_sku unique (sku);
І ось тепер інваріант «sku унікальний» стає реальним, фізичним і залізобетонним. Якщо два паралельні запити спробують вставити однаковий sku, один із них буде відхилено. Так, це означає помилку на рівні запису, і ми ще поговоримо, як Spring перетворює її на зрозумілий виняток. Але головне — таблиця не буде зіпсована.
Після переходу на Flyway такі обмеження мають жити в міграціях. Це і є дисципліна «схема як код». Анотація @Column(unique = true) на entity може залишатися як документація наміру, але джерело істини — міграція. Інакше ви знову повернетеся в режим «на моїй машині було так, а в колеги — інакше».
У термінах відповідальності це виглядає дуже красиво. Код може бути ввічливим, людяним і пояснювати помилки, але база — це суворий, проте справедливий «охоронець на вході до клубу даних». Він не ведеться на вмовляння і не вірить «я більше так не буду». Він просто не пропускає те, що заборонено.
6. Типові помилки під час перевірок та обмежень
Помилка №1: прирівняти анотації в коді до обмежень БД.
На цій темі студенти найчастіше помиляються не в синтаксисі, а в моделі світу. Перша типова помилка — вважати, що @NotBlank і @NotNull «майже те саме», що UNIQUE і NOT NULL у базі. Це схоже лише за назвами, але не за силою гарантії: анотація працює там, де ви її запустили, а обмеження бази працює завжди, навіть якщо до вашої бази підключився інший інструмент або ви забули викликати валідатор.
Помилка №2: вважати existsBySku(...) гарантією унікальності.
Друга часта помилка — покладатися лише на existsBySku(...) і щиро вірити, що це гарантія унікальності. Така перевірка робить сценарій зручним, але між перевіркою та вставкою у вас залишається вікно, у яке може «влізти» інший паралельний запит. У спокійному dev-режимі це може не проявлятися тижнями, а потім раптом «вилазити» в найневдаліший момент — зазвичай перед релізом, бо релізи живляться вашим стресом.
Помилка №3: сховати доступ до БД усередині Bean Validation.
Третя помилка — намагатися сховати доступ до бази всередині Bean Validation (кастомні анотації унікальності). У результаті валідація стає не «швидкою перевіркою форми», а прихованим доступом до даних, який складніше тестувати, складніше пояснювати і який усе одно не розв’язує проблему гонки. Якщо вже ви робите попередню перевірку по базі, робіть її явно в сервісі, де у вас є контекст сценарію і де код читається як бізнес-сценарій.
Помилка №4: вважати обмеження БД факультативними.
І остання помилка — сприймати обмеження БД як «щось факультативне, для DBA». У реальному бекенді схема — це частина архітектури. Flyway у нашому курсі з’явився не для краси: він потрібен, щоб обмеження були відтворюваними, перевірюваними й однаковими для всієї команди. База даних не читає ваші коментарі і не поважає ваші добрі наміри, зате вона чудово поважає UNIQUE, FOREIGN KEY і NOT NULL — якщо ви справді створили їх у міграціях.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ