1. Дві історії відмови
Коли ви читаєте про UNIQUE, FOREIGN KEY і DataIntegrityViolationException у теорії, здається, що це якісь чужі проблеми і що «у нас так не буде, ми ж акуратно пишемо код». Але справжній шар доступу до даних стає зрілим саме тоді, коли ви усвідомлено відтворюєте дві-три неприємні ситуації й бачите, що система реагує передбачувано. Зараз ми зберемо дві такі історії в нашому shop-data-jpa, щоб у вас у голові склалася проста, повторювана модель: інваріант → constraint → порушення → Spring-виняток → зрозуміла помилка сервісу.
До цього моменту картина вже склалася: інваріант зафіксований у міграції, база тримає constraint, Spring піднімає порушення як DataIntegrityViolationException. Тепер усе це потрібно звести в один робочий сервісний шаблон.
Перша історія — duplicate sku: ми створюємо товар із SKU-1, а потім намагаємося створити ще один такий самий. Друга історія — видалення категорії, у якої є товари: ми намагаємося «вирвати полицю з магазину», поки на ній стоїть товар, а база справедливо каже: «ні».
Перевіряємо схему Flyway
Перш ніж ловити винятки в сервісі, важливо чесно переконатися, що база справді вміє забороняти поганий стан. Інакше вийде класика: сервіс гарно ловить DuplicateSkuException, але якщо хтось піде напряму в базу, там виявиться десять однакових SKU, а ви думатимете, що «Spring щось не те зробив». Тепер схема живе через Flyway, тому обмеження мають бути в міграціях, а не існувати лише як «намір» в анотаціях @Column(unique = true).
Перевірте або згадайте, що в міграції для product є унікальність за sku і що category_id є обовʼязковим та посилається на category(id). Приклад фрагмента міграції може виглядати так:
create table product (
id bigserial primary key,
sku varchar(64) not null,
name varchar(255) not null,
category_id bigint not null,
constraint uk_product_sku unique (sku),
constraint fk_product_category foreign key (category_id) references category(id)
);
Тут уже видно головне: uk_product_sku відповідає за унікальність, а fk_product_category — за зв’язок. Для сценарію видалення категорії нам якраз потрібно, щоб FK працював у «строгому» режимі (зазвичай це RESTRICT/NO ACTION), тобто щоб категорію не можна було видалити, доки на неї хтось посилається.
Якщо ви помітили в себе лише @Column(unique = true), але в міграції unique (sku) немає, це сигнал, що схему потрібно привести до ладу. У світі Flyway база вірить міграціям, а не анотаціям — як би прикро це не звучало для любителів «чистої Java».
2. Інфраструктура: репозиторії та винятки
Обидві операції стосуються каталогу, тож нехай ними опікується один CatalogService: тут одразу видно, як один і той самий шаблон працює і на UNIQUE, і на FOREIGN KEY.
Форму введення ми вже відсікаємо раніше, тож тут залишимо лише сам обʼєкт команди:
import java.math.BigDecimal;
public record CreateProductInput(
String sku,
String name,
BigDecimal price,
Long categoryId
) {
}
Репозиторії тут потрібні зовсім прості: два pre-check методи для товарів і звичайний CategoryRepository для роботи з категоріями.
import org.springframework.data.jpa.repository.JpaRepository;
public interface ProductRepository extends JpaRepository<Product, Long> {
// Швидкий pre-check перед вставкою товару
boolean existsBySku(String sku);
// Швидкий pre-check перед видаленням категорії
boolean existsByCategoryId(long categoryId);
}
public interface CategoryRepository extends JpaRepository<Category, Long> {
}
Винятки теж одразу оформимо в одному стилі: коротке повідомлення сценарію плюс перевантаження з cause, якщо помилка вже прийшла від реального конфлікту з constraint у БД.
public class DuplicateSkuException extends RuntimeException {
public DuplicateSkuException(String sku) {
super("Товар із sku '%s' уже існує".formatted(sku));
}
public DuplicateSkuException(String sku, Throwable cause) {
super("Товар із sku '%s' уже існує".formatted(sku), cause);
}
}
public class CategoryInUseException extends RuntimeException {
public CategoryInUseException(long categoryId) {
super("Категорію %d не можна видалити: її використовують товари".formatted(categoryId));
}
public CategoryInUseException(long categoryId, Throwable cause) {
super("Категорію %d не можна видалити: її використовують товари".formatted(categoryId), cause);
}
}
Зверніть увагу на стиль: повідомлення коротке й по суті. Ми не намагаємося втиснути туди SQL, імʼя constraint і «чому світ несправедливий». Для цього є cause і логи.
3. Історія 1: дубльований SKU
У цьому сценарії ми спеціально робимо подвійний захист: спершу перевіряємо existsBySku(...), щоб швидко й зрозуміло відповісти, а потім усе одно покладаємося на UNIQUE як на остаточну гарантію. Це схоже на ситуацію в аеропорту: спершу вас ввічливо просять показати паспорт, а потім усе одно перевіряють по базі. Якщо ви сподіваєтеся лише на ввічливе запитання, одного дня в літак потрапить хтось дуже впевнений.
Тут є важлива межа. createProduct(...) справді очікує конфлікт за uk_product_sku, але потенційно може натрапити й на іншу проблему цілісності — наприклад, на пошкоджений categoryId. Тому в catch ми перетворюємо лише впізнаваний uk_product_sku; усе інше не перейменовуємо. Імʼя uk_product_sku тут теж не магічне: це рівно те імʼя, яке ми самі дали constraint у міграції.
Ось робочий фрагмент CatalogService:
import org.hibernate.exception.ConstraintViolationException;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class CatalogService {
private final ProductRepository productRepository;
private final CategoryRepository categoryRepository;
// конструктор не наведено
@Transactional
public long createProduct(CreateProductInput input) {
if (productRepository.existsBySku(input.sku())) {
throw new DuplicateSkuException(input.sku());
}
try {
Product product = new Product(
input.sku(),
input.name(),
input.price(),
categoryRepository.getReferenceById(input.categoryId())
);
return productRepository.saveAndFlush(product).getId();
} catch (DataIntegrityViolationException ex) {
logIntegrityProblem(
"createProduct",
"sku=%s, categoryId=%d".formatted(input.sku(), input.categoryId()),
ex
);
if (hasConstraint(ex, "uk_product_sku")) {
throw new DuplicateSkuException(input.sku(), ex);
}
throw ex;
}
}
private boolean hasConstraint(DataIntegrityViolationException ex, String expectedConstraint) {
Throwable current = ex;
while (current != null) {
if (current instanceof ConstraintViolationException cve) {
return expectedConstraint.equals(cve.getConstraintName());
}
current = current.getCause();
}
return false;
}
}
Якщо між existsBySku(...) і INSERT хтось устиг зайняти той самий SKU, спрацює fallback через UNIQUE, і сервіс усе одно підніме зрозумілий DuplicateSkuException. Якщо ж проблема не в uk_product_sku, метод не починає вигадувати й не ховає чужу проблему цілісності під зручним ярликом.
Щоб побачити сценарій «наживо» без веб-рівня, можна тимчасово зробити маленький runner. Він не зобовʼязаний жити в проєкті вічно; це як будівельні риштування: корисно, поки будуємо.
import java.math.BigDecimal;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Bean;
@Bean
CommandLineRunner demoDuplicateSku(CatalogService catalogService) {
return args -> {
// Перший виклик — створює товар
catalogService.createProduct(
new CreateProductInput("SKU-1", "Phone", new BigDecimal("199.00"), 1L)
);
// Другий виклик — відтворює дубльований SKU (має впасти)
catalogService.createProduct(
new CreateProductInput("SKU-1", "Another phone", new BigDecimal("299.00"), 1L)
); // має впасти
};
}
Другий виклик має завершитися вашим DuplicateSkuException, а в таблиці product при цьому залишиться рівно один рядок із SKU-1. Ось це і є «код пояснює, база гарантує»: сервіс пояснив, а база не дозволила зіпсувати дані.
4. save vs saveAndFlush і flush
Тут saveAndFlush() і flush() потрібні не заради стилю. Поки SQL не пішов у базу, constraint ще не може спрацювати. Тому в createProduct(...) ми форсуємо INSERT через saveAndFlush(), а в deleteCategory(...) — DELETE через flush(): так відмова виникає всередині поточного методу сервісу, де її ще можна перевести мовою сценарію.
5. Історія 2: видалення категорії
Друга історія трохи життєвіша, тому що люди справді люблять «почистити довідник» і випадково прибрати те, на що є посилання. Тут головний герой — FOREIGN KEY. Він не дає product.category_id перетворитися на посилання, що веде в нікуди. І якщо ви спробуєте видалити категорію, на яку посилається хоча б один товар, база має відмовити. Ввічливо, але категорично. Як банкомат, який не видає грошей без картки, навіть якщо ви дуже переконливо просите.
З FOREIGN KEY логіка та сама. Ми перетворюємо відмову «категорію вже використовують» лише тоді, коли справді бачимо fk_product_category. І це теж імʼя з міграції, а не спроба вгадати помилку за текстом PostgreSQL.
До того ж у CatalogService додаємо ще один метод:
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.transaction.annotation.Transactional;
@Transactional
public void deleteCategory(long categoryId) {
if (productRepository.existsByCategoryId(categoryId)) {
throw new CategoryInUseException(categoryId);
}
try {
categoryRepository.deleteById(categoryId);
categoryRepository.flush();
} catch (DataIntegrityViolationException ex) {
logIntegrityProblem(
"deleteCategory",
"categoryId=%d".formatted(categoryId),
ex
);
if (hasConstraint(ex, "fk_product_category")) {
throw new CategoryInUseException(categoryId, ex);
}
throw ex;
}
}
І знову видно той самий рисунок. Pre-check відповідає на очікуване запитання сценарію просто зараз. FK залишається останньою лінією оборони на випадок гонки або обходу сервісу. А переведення в доменний виняток відбувається лише для того конфлікту, який цей метод справді вміє назвати по імені.
Щоб відтворити сценарій, знову можна використати тимчасовий runner. Припустімо, у вас уже є категорія 1 і хоча б один товар у цій категорії.
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Bean;
@Bean
CommandLineRunner demoDeleteCategory(CatalogService catalogService) {
// Демо-сценарій: намагаємося видалити категорію, яку використовують товари (має впасти)
return args -> catalogService.deleteCategory(1L); // має впасти "in use"
}
Якщо ви бачите CategoryInUseException, це добре. Якщо категорія видаляється, значить у вас десь не той FK або ви випадково ввімкнули каскадне видалення на рівні БД чи ORM, і сценарій дня ламається — а разом із ним ламається й захист даних.
6. Логи та контекст помилок
Коли ви ловите DataIntegrityViolationException, дуже легко впасти в одну з двох крайнощів. Перша — запанікувати й почати парсити текст помилки PostgreSQL, витягуючи звідти назву constraint. Друга — повністю сховати помилку, щоб «не лякати». Обидва підходи погані. Нам потрібен спокійний середній варіант: сервіс логує контекст сценарію — що саме робили і з якими ідентифікаторами, — зберігає cause і кидає зрозумілий виняток.
Якщо не хочеться дублювати один і той самий warn по сервісу, у той же CatalogService зручно винести маленький допоміжний метод:
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
private static final Logger log = LoggerFactory.getLogger(CatalogService.class);
private void logIntegrityProblem(String action, String details, Exception ex) {
// Важливо: логуємо контекст сценарію і передаємо ex,
// щоб стек-трейс і початкова причина не загубилися.
log.warn("Порушення цілісності під час {}: {}", action, details, ex);
}
Для дубльованого SKU корисно логувати хоча б sku і categoryId, для видалення категорії — categoryId. Це проста річ, але саме вона потім рятує логи від стану «десь щось порушило constraint, шукайте самі».
7. Потік: інваріант → constraint → сервіс
Щоб закріпити модель, корисно один раз побачити її як послідовність кроків. Це не просто «красивий малюнок», а робоча карта: ви зможете за нею відлагоджувати більшість проблем із цілісністю, не впадаючи в містику «Hibernate зламався». Зараз ми зберемо загальний потік, а потім ви зможете подумки підставляти туди sku, categoryId і будь-які інші обмеження вашої схеми.
flowchart TD
A[Інваріант домену
SKU унікальний / категорію не можна видалити, якщо є товари] --> B[Constraint у БД
UNIQUE / FOREIGN KEY + NOT NULL]
B --> C[Код робить pre-check
existsBySku / existsByCategoryId]
C --> D{Pre-check спрацював?}
D -->|Так| E[Зрозуміла помилка сервісу
DuplicateSkuException / CategoryInUseException]
D -->|Ні| F[Пробуємо записати/видалити
saveAndFlush / delete + flush]
F --> G{БД прийняла?}
G -->|Так| H[Сценарій успішний]
G -->|Ні| I[DataIntegrityViolationException
перетворення винятків]
I --> J{Конфлікт очікуваний і розпізнаний?}
J -->|Так| K[Сервіс переводить у зрозумілу помилку
і зберігає cause]
J -->|Ні| L[Не маскуємо: передаємо
технічну integrity-помилку далі]
Зверніть увагу: pre-check не скасовує constraint, constraint не скасовує pre-check, а flush — це ваш спосіб зробити точку відмови видимою саме там, де ви хочете її обробити.
8. Типові помилки в цих двох сценаріях
Коли ви вперше впроваджуєте constraints і починаєте ловити DataIntegrityViolationException, дуже хочеться вибрати один «улюблений» спосіб і вважати, що він закриває все. Зазвичай новачок або робить лише перевірки в сервісі, або лише сподівається, що «нехай база свариться». Але зрілий data-layer зазвичай тримається на комбінації підходів, і помилки тут виникають саме через перекіс.
Помилка № 1: покладатися лише на existsBySku(...) і не мати UNIQUE у схемі.
На маленькому проєкті й в одиночній розробці це може жити місяцями, створюючи ілюзію надійності. Але щойно зʼявиться другий потік виконання або просто два паралельні запити, між перевіркою і вставкою пройде трохи часу, і в це вікно влізе другий «такий самий SKU». Результат — дублікати в таблиці, а виправляти їх потім набагато болючіше, ніж поставити constraint одразу.
Помилка № 2: покладатися лише на UNIQUE/FOREIGN KEY, але не робити pre-check там, де помилка очікувана.
Так, база все одно заборонить поганий стан. Але замість зрозумілого повідомлення «SKU уже зайнятий» ви отримаєте «violates unique constraint uk_product_sku», а далі почнеться класична гра: де це показати, як це логувати і чому користувачу прилітає повідомлення так, ніби він має розуміти внутрішню будову PostgreSQL. Pre-check — це не про «зробити безпечніше», а про «зробити сценарій людянішим».
Помилка № 3: ловити надто широкий Exception і намагатися все перетворити на один доменний виняток.
Якщо ви ловите взагалі все й завжди кидаєте DuplicateSkuException, ви в якийсь момент отримаєте абсолютно іншу проблему — наприклад, NOT NULL на name або FK на category_id — і повідомите користувачу, що «SKU зайнятий». Це не просто незручно, це ламає підтримку: ви лікуватимете не ту хворобу, бо симптоми переплутані. Ловіть DataIntegrityViolationException там, де ви справді готові сказати «це проблема цілісності», і розрізняйте очікувані та неочікувані причини хоча б на рівні сценаріїв.
Помилка № 4: забути про момент синхронізації й дивуватися, чому помилка прилетіла «пізніше».
Якщо ви робите save, а потім у методі ще десять рядків коду, і лише на виході з транзакції все падає, це не «рандомний Spring», а звичайний flush на commit. Для сценаріїв, де вам важливо отримати помилку в конкретній точці й обгорнути її в зрозумілий виняток, використовуйте saveAndFlush() або явний flush().
Помилка № 5: випадково ввімкнути каскадне видалення категорії й тим самим зламати сенс сценарію.
Якщо категорія починає каскадно видаляти товари, то видалення категорії «пройде успішно», але ви несподівано втратите товари. Це приклад того, як технічно «зручне налаштування» може зруйнувати інваріанти домену. У нашому mini-shop категорія — довідник, і видалення категорії за наявності товарів має бути заборонене або принаймні вимагати окремого усвідомленого сценарію, а не відбуватися як побічний ефект.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ