JavaRush /Курси /Spring Data JPA /Зрозумілі помилки цілісності у сервісі

Зрозумілі помилки цілісності у сервісі

Spring Data JPA
Рівень 24 , Лекція 3
Відкрита

1. Помилка БД vs мова бізнес-сценарію

Якщо ви тільки починаєте, дуже легко погодитися на сумний варіант за замовчуванням: «Ну, впало з бази — значить, десь помилка, нехай на вищому рівні розбираються». Але сервісний шар і існує для того, щоб говорити мовою сценаріїв: «створити товар», «видалити категорію», «оформити замовлення». Тому сервіс має вміти пояснити, чому сценарій не можна виконати, а не просто повідомити: «десь унизу хтось поскаржився на constraint».

Уявіть, що ви пишете CatalogService.createProduct(...). У вас є намір: створити новий товар у каталозі. Якщо sku уже зайнятий, бізнес-сенс помилки простий і зрозумілий: «Товар із таким SKU уже існує». І сервіс може це сказати — у нього є контекст: він знає вхідний sku, знає, який сценарій виконується, і може зробити помилку читабельною.

Якщо ж ви пробросите назовні сирий DataIntegrityViolationException, то код, який його викликає, отримає «технічну кашу»: десь усередині може бути PSQLException, десь — ConstraintViolationException, десь — текст про duplicate key value violates unique constraint. Так, це теж інформація, але вона схожа на пояснення лікаря латиною: формально правда, а пацієнт усе одно не зрозумів, чому йому не можна їсти шаурму.

І тут важливий принцип дня: репозиторій «дістає дані та пише дані», сервіс «пояснює сенс». Репозиторій не має перетворюватися на «перекладача помилок у бізнес-терміни», бо він не знає сценарію. Сервіс знає.

2. Двоступенева реакція: pre-check і constraints

Дуже хочеться вибрати щось одне: або «усе перевіряємо в сервісі», або «нічого не перевіряємо, нехай БД сама». Але добра інженерна модель — це не релігія «тільки так», а здоровий компроміс. У нашому проєкті добре працює двоступеневий підхід: спочатку робимо попередню перевірку (pre-check), щоб пояснити часту проблему заздалегідь, а потім усе одно залишаємо constraints як фінальний бар'єр.

Pre-check корисний по-людськи. Він дає змогу сказати: «SKU зайнятий» ще до того, як ми спробуємо вставити рядок у таблицю та отримаємо виняток із глибини стека. Це робить сценарій зрозумілішим і зазвичай дешевшим за ресурсами: ми не робимо INSERT, який усе одно буде відхилено. Наприклад, productRepository.existsBySku(sku) — простий і читабельний спосіб.

Але pre-check не є гарантією. Між перевіркою та фактичним записом є часовий проміжок, де світ може змінитися. Найпростіший сценарій, і він не з області «майбутніх тем», а з повсякденного життя: два паралельні запити.

Момент Транзакція A Транзакція B
t1 existsBySku("SKU-1") → false
t2 INSERT product(sku="SKU-1") → OK
t3 INSERT product(sku="SKU-1") → ЗБІЙ

Це не баг Spring і не «погана вдача». Це нормальна реальність багатокористувацького застосунку. Тому ми й говорили в першій лекції дня: «код пояснює, БД гарантує». Pre-check — це про пояснення. Constraint — про гарантію.

І сьогодні важливий практичний висновок: якщо ви хочете перекладати integrity-помилку на зрозумілу реакцію сценарію, ви зазвичай робите pre-check і все одно тримаєте запасний варіант через DataIntegrityViolationException.

3. Де ловити DataIntegrityViolationException

Початківець часто інтуїтивно пише обробку помилок «якнайближче до місця, де впало». А місце, де впало, — репозиторій. І виникає спокуса: «А давайте в репозиторії ловити DataIntegrityViolationException і кидати DuplicateSkuException». Звучить логічно, але на практиці майже одразу ламає архітектуру.

Репозиторій не має перетворюватися на сценарний компонент. Він не знає, у якому сценарії використання його застосовують. Один і той самий ProductRepository.save(...) викликається під час створення товару, під час оновлення ціни, під час імпорту каталогу, під час фіксації статусу “DISABLED” і так далі. Якщо репозиторій почне «вгадувати», що саме сталося, він дуже швидко стане гігантським «розумним» шаром, у якому бізнес-логіки буде більше, ніж у сервісі. А потім ви дивуватиметеся, чому не можете протестувати сценарій без магії.

Сервіс — інша справа. Сервісний метод зазвичай відповідає за сценарій і вже містить смислову рамку: createProduct(...), deleteCategory(...), placeOrder(...) (так, він у нас уже є), cancelOrder(...). Коли ви ловите DataIntegrityViolationException на рівні сервісу, ви точно знаєте: яка операція виконувалася, які вхідні дані були, якого доменного правила ви хотіли дотриматися. Це ідеальна точка для «перекладу» помилки.

І нарешті, суто технічно «місце падіння» через flush може бути несподіваним. Ви можете змінити об'єкт, викликати save(...), а виняток спрацює під час flush() ближче до кінця транзакції. Якщо сервіс хоче контролювати момент виникнення помилки, він може свідомо зробити saveAndFlush(...) або явний flush() у репозиторії. Репозиторій же не має приймати такі рішення за сценарій: інакше ви отримаєте flush усюди, де він не потрібен.

Тож правило на сьогодні звучить буденно, але рятує від багатьох проблем: ловимо та перекладаємо integrity-помилки в сервісному шарі, а репозиторії залишаємо тонкими.

4. Патерн: pre-check → flush → переклад

Зараз ми зберемо це в один повторюваний патерн. Він особливо корисний для двох типів сценаріїв: «створити щось з унікальним ключем» і «видалити щось, на що посилаються інші таблиці». У нашому mini-shop це якраз duplicate sku і спроба видалити категорію, де ще є товари.

Схема дій у сервісі зазвичай така: спочатку робимо pre-check, щоб у звичайній ситуації швидко й зрозуміло пояснити проблему, потім пробуємо виконати операцію, після чого примусово синхронізуємося з БД (saveAndFlush() або flush()), а якщо прилетіла DataIntegrityViolationException, перекладаємо її на зрозумілий виняток сценарію. При цьому ми обов'язково зберігаємо cause, щоб не втратити діагностичну інформацію.

Але в цього патерну є межа. Сервіс перекладає не будь-яку DataIntegrityViolationException, а лише той конфлікт, який справді очікує від конкретної операції. Якщо createProduct(...) може зламатися і через uk_product_sku, і через пошкоджений categoryId, то називати все підряд duplicate SKU — просто брехати. Невідомий integrity-збій краще пробросити далі, ніж замаскувати красивою, але хибною назвою.

Цю механіку зручно візуалізувати:

flowchart TD
    A["Сервіс: попередня перевірка (existsBy...)"] -->|ok| B["Репозиторій: save/delete"]
    B --> C["flush або saveAndFlush"]
    C -->|успіх| D["Сценарій завершено"]
    C -->|помилка| E["Порушено constraint БД"]
    E --> F["Переклад винятків Spring"]
    F --> G["DataIntegrityViolationException"]
    G --> H{"Конфлікт очікуваний і розпізнаний?"}
    H -->|Так| I["Сервіс: кинути доменний виняток (DuplicateSku/CategoryInUse)"]
    H -->|Ні| J["Сервіс: повторно кинути integrity-помилку"]

І ще один важливий нюанс, який економить нерви: після помилки цілісності поточна транзакція найчастіше стає «токсичною». У PostgreSQL будь-яка помилка всередині транзакції призводить до стану transaction is aborted, і далі БД відмовляється виконувати команди, доки ви не зробите rollback. Тому «зловив, залогував і пішов далі працювати з БД» — погана ідея. У сервісі це має виглядати так: «зловив → переклав → кинув виняток угору», щоб транзакція коректно відкатилася.

5. Доменні винятки сервісу

Щоб сервіс говорив людською мовою, йому потрібні інструменти. Найпростіший і найчесніший інструмент — свої винятки: DuplicateSkuException, CategoryInUseException і, за потреби, якийсь більш загальний тип «integrity зламалася». Початківці іноді бояться плодити класи, але тут саме той випадок, коли 2–3 маленькі exception-класи покращують читабельність сильніше, ніж десять коментарів.

Зручний шаблон дуже простий: один конструктор для pre-check без cause, другий — для fallback після реальної відмови БД зі збереженою причиною.

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);
    }
}

За тією самою схемою живе CategoryInUseException: коротке повідомлення сценарію + перевантаження з cause, якщо сервіс перекладає вже зловлену DataIntegrityViolationException.

6. createProduct: duplicate sku

Тепер застосуємо патерн до createProduct(...). Припустімо, що вхід приходить тим самим CreateProductInput, а репозиторій уміє робити existsBySku(...). Нам потрібен не повний знімок проєкту, а сама логіка: pre-check для зрозумілої відмови, saveAndFlush() для видимої точки збою і переклад лише впізнаваного конфлікту uk_product_sku.

@Transactional
public long createProduct(CreateProductInput input) {
    if (productRepository.existsBySku(input.sku())) {
        throw new DuplicateSkuException(input.sku());
    }

    try {
        var product = new Product(
                input.sku(),
                input.name(),
                input.price(),
                categoryRepository.getReferenceById(input.categoryId())
        );

        productRepository.saveAndFlush(product);
        return product.getId();
    } catch (DataIntegrityViolationException ex) {
        if (isConstraint(ex, "uk_product_sku")) {
            throw new DuplicateSkuException(input.sku(), ex);
        }
        throw ex;
    }
}

isConstraint(...) тут — будь-який невеликий helper, який проходить по ланцюжку cause і перевіряє ім'я очікуваного constraint. Важлива не конкретна utility, а правило: перекладаємо лише uk_product_sku, а не будь-яку integrity-проблему підряд.

Це особливо важливо, бо у createProduct(...) може бути й інший збій. Наприклад, якщо categoryId пошкоджений, проблема вже не в duplicate SKU, і сервіс не має вдавати, ніби все зрозумів лише з однієї назви методу.

7. deleteCategory: FK violation

Із FOREIGN KEY патерн той самий, лише конфлікт інший. Якщо в категорії ще є товари, сервіс може спочатку зробити швидкий pre-check через existsByCategoryId(...), а потім усе одно спертися на FK як на останню лінію оборони.

@Transactional
public void deleteCategory(long categoryId) {
    if (productRepository.existsByCategoryId(categoryId)) {
        throw new CategoryInUseException(categoryId);
    }

    try {
        categoryRepository.deleteById(categoryId);
        categoryRepository.flush();
    } catch (DataIntegrityViolationException ex) {
        if (isConstraint(ex, "fk_product_category")) {
            throw new CategoryInUseException(categoryId, ex);
        }
        throw ex;
    }
}

І тут логіка та сама. Якщо прилетів саме fk_product_category, сервіс чесно говорить: «категорія зайнята». Якщо це інший integrity-збій, ми не маскуємо його красивим винятком.

У цьому й полягає доросла версія «зрозумілої помилки»: не вигадати людині приємний текст будь-якою ціною, а точно назвати ту причину, яку сервіс справді вміє розпізнати.

8. Чого робити не треба: анти-патерни

Наприкінці лекції корисно обговорити не тільки «як правильно», а й «як можна зробити гірше, думаючи, що зробив краще». Бо з integrity-помилками є кілька дуже спокусливих анти-патернів, які в моменті здаються прагматичними.

Перша спокуса — розпізнавати причину за рядком повідомлення PostgreSQL. Так, іноді там буде uk_product_sku, іноді — “duplicate key”, іноді — назва таблиці. І в якийсь момент ви можете написати: «Якщо message contains 'uk_product_sku', то кидаємо DuplicateSkuException». Це працює рівно до першого оновлення драйвера, зміни мови повідомлень або перейменування constraint у міграції. Це крихкий контракт, і ми спеціально сьогодні зафіксували межу: не будуємо бізнес-логіку на парсингу тексту помилок БД.

Друга спокуса — «узагальнити й красиво обробити все». Це виглядає як catch(Exception ex) { throw new SomethingNiceException(); }. На практиці це майже завжди робить систему менш діагностованою. Ви втрачаєте cause, втрачаєте реальну причину і перетворюєте баги, наприклад «зламали міграцію» або «не заповнили обов'язкове поле через помилку в коді», на нібито очікувані сценарні помилки. Це порушує важливу ідею: очікувана помилка сценарію має залишатися очікуваною, а неочікувана — гучною.

Третя спокуса — «зловили DataIntegrityViolationException, залогували й пішли далі працювати з БД». На PostgreSQL це часто закінчується тим, що подальші запити починають падати з чимось на кшталт current transaction is aborted. Причина проста: транзакція вже в стані помилки, і базу не можна «переконати». Тому здоровий варіант — перекладати помилку і негайно завершувати сценарій винятком, щоб транзакція коректно відкатилася.

І четвертий, дуже популярний «мікро-анти-патерн»: використовувати saveAndFlush() взагалі всюди «про всяк випадок». Це схоже на підхід: «Після кожного слова натискати Ctrl+S, інакше страшно». flush — корисний інструмент, але він має ціну: він змушує ORM раніше надсилати SQL у БД і іноді змінює поведінку сценарію. У цій лекції ми використовуємо flush усвідомлено — у місцях, де хочемо отримати integrity-помилку саме в цьому сервісному методі, щоб коректно її перекласти.

9. Типові помилки перекладу integrity-помилок

Помилка № 1: вважати pre-check гарантією і викинути constraint зі схеми.
Іноді після появи existsBySku(...) здається, що UNIQUE більше не потрібен. Це небезпечна впевненість: pre-check — це «я подивився просто зараз», а constraint — це «світ у принципі не може стати неправильним». При паралельних запитах і обходах застосунку без constraint ви отримаєте дублікати, а потім будете розбиратися, як так сталося, уже на проді.

Помилка № 2: не робити flush() і дивуватися, що виняток вилітає «не там».
Якщо ви хочете перекласти помилку на зрозумілу реакцію сценарію всередині методу, але використовуєте лише save() або deleteById() без flush(), то DataIntegrityViolationException може трапитися під час завершення транзакції, коли метод уже закінчився. З погляду логів і під час налагодження це виглядає як «воно впало десь незрозуміло де». У таких сценаріях saveAndFlush() і явний flush() — нормальний інструмент, але застосовувати його потрібно усвідомлено.

Помилка № 3: ловити DataIntegrityViolationException у репозиторії й намагатися «вгадати сценарій».
Репозиторій занадто низькорівневий: він не знає, яку бізнес-операцію зараз виконують. Коли ви починаєте «переклад» на рівні репозиторію, у вас швидко росте «розумний data access шар», який містить бізнес-правила. Це ускладнює підтримку, тестування і призводить до гігантських інтерфейсів і класів.

Помилка № 4: перетворювати будь-яку DataIntegrityViolationException на «дублікат SKU» лише тому, що так простіше.
Так, у createProduct(...) найчастіше нас цікавить саме UNIQUE(sku). Але DataIntegrityViolationException може означати і проблему NOT NULL, і FK violation, і інші обмеження. Якщо ви бездумно все називаєте «duplicate sku», ви починаєте брехати самі собі в логах і помилках. Краще перекладати лише те, що ви дійсно впевнено інтерпретуєте, а невідоме залишати як «неочікувана integrity-проблема».

Помилка № 5: проковтувати cause і втрачати діагностичний ланцюжок.
Іноді заради «краси» роблять throw new DuplicateSkuException(sku) і викидають початковий виняток. У результаті ви втрачаєте інформацію про constraint, SQLState та місце, де справді все зламалося. Правильний компроміс: користувачеві сценарію показуємо зрозумілий сенс (у нас це виняток сервісу), а в cause зберігаємо низькорівневу причину для налагодження.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ