JavaRush /Курсы /Spring Data JPA /Spring и DataIntegrityViol...

Spring и DataIntegrityViolationException

Spring Data JPA
24 уровень , 2 лекция
Открыта

1. Где рождается ошибка БД

Когда мы слышим «ошибка базы данных», рука новичка обычно тянется к мысли: «Ага, значит сейчас полетит SQLException, я её поймаю и всё будет хорошо». Но в Spring Boot приложении между вашим сервисом и базой стоит целый «бутерброд» слоёв: JDBC-драйвер, Hibernate, Spring ORM, Spring Data. Ошибка действительно рождается в базе, но до вашего кода она доезжает уже в более «цивилизованной упаковке».

Представим простой сценарий: мы пытаемся вставить в таблицу product вторую строку с тем же sku, хотя в миграции есть constraint uk_product_sku unique (sku). На стороне PostgreSQL это не «исключение Java», а отказ выполнить INSERT. База формирует ответ об ошибке с кодом и описанием. JDBC-драйвер (org.postgresql) превращает этот ответ в исключение Java уровня драйвера (обычно PSQLException, которое наследуется от SQLException). Дальше Hibernate ловит это, оборачивает в свои исключения (часто это что-то вроде ConstraintViolationException из Hibernate). И только потом Spring говорит: «Окей, я понял, это проблема целостности данных» — и превращает всё это в DataIntegrityViolationException.

Удобно держать в голове такую «трассу доставки ошибки»:

flowchart TD
    A[PostgreSQL: constraint violation] --> B["JDBC driver: SQLException / PSQLException"]
    B --> C["Hibernate: JDBCException / ConstraintViolationException"]
    C --> D["Spring ORM/JPA: exception translation"]
    D --> E[Spring Data: DataAccessException]
    E --> F[Ваш сервис: DataIntegrityViolationException]

То есть вы ловите не «сырой» голос базы, а «переведённый» и более стабильный сигнал. И это не магия, а вполне инженерное решение, которое защищает ваш код от привязки к конкретному драйверу, диалекту и текстам ошибок.

2. Идея DataAccessException

Если вы когда-нибудь ловили ошибку базы руками, вы могли заметить: тексты исключений у разных БД разные, классы исключений у разных драйверов разные, и даже один и тот же сценарий (например, UNIQUE violation) может называться и выглядеть по-разному. Spring придумал способ не тащить эту «зоологию» в прикладной код: он переводит низкоуровневые ошибки в собственную иерархию исключений DataAccessException.

Смысл DataAccessException — быть единым контрактом для слоя доступа к данным. Если вы работаете через JPA сегодня, а завтра в каком-то проекте встретите JDBC Template, а послезавтра — вообще другой persistence-подход, вы всё равно будете понимать: «DataIntegrityViolationException — значит нарушили целостность, DeadlockLoserDataAccessException — значит был дедлок» (да, названия иногда звучат как рок-группа, но это уже особенности жанра).

Важно также, что DataAccessException — это runtime exception (unchecked). Spring сознательно сделал слой данных таким: большинство ошибок базы на практике всё равно не исправляются «на месте» внутри репозитория; они либо превращаются в понятную реакцию use case на уровне сервиса, либо приводят к rollback транзакции. Поэтому исключения не заставляют вас писать throws в каждом методе.

Чтобы не быть голословным, вот мини-пример: мы не привязаны к SQLException, мы ловим именно Spring-контракт:

import org.springframework.dao.DataAccessException;
import org.springframework.stereotype.Service;

@Service
public class DataLayerExample {

    public void doSomething() {
        try {
            // Здесь обычно будет вызов репозитория / EntityManager.
            // Важно: мы не ловим SQLException, мы работаем с контрактом Spring.
        } catch (DataAccessException ex) {
            // Общий контракт Spring для ошибок data-layer.
            // Внутри ex.getCause() может быть драйверное/hibernate-исключение,
            // но наружу мы "держим" единый тип ошибок.
        }
    }
}

Это не означает, что надо ловить DataAccessException «везде и всегда» (это скорее антипаттерн). Но понимать идею контракта важно: Spring делает ошибки переносимыми и понятными на уровне архитектуры, а не на уровне «какой драйвер стоял на машине».

3. Exception translation в JPA-стеке

Кажется, что «где-то внутри Spring» сидит маленький переводчик, который переводит с языка PostgreSQL на язык Java. На практике переводчик действительно есть, просто он не маленький и не один. В JPA-мире перевод происходит в двух ключевых местах: на уровне репозиториев и на уровне транзакции (commit/flush). Это важно, потому что ошибка может всплыть не сразу, а при синхронизации с БД.

Если говорить простыми словами, Spring пытается сделать так, чтобы наружу из data-layer вылезали исключения из пакета org.springframework.dao, а не «зоопарк» из java.sql, org.hibernate и драйвера. За это отвечают механизмы из Spring ORM и Spring TX.

Важная идея: репозитории Spring Data являются Spring-бинами и участвуют в инфраструктуре перевода. Классическая Spring-механика использует аннотацию @Repository (или роль репозитория в контейнере) как маркер: «Если из этого компонента вылетела persistence-ошибка — переведи её». У Spring Data репозитории создаются как прокси-бин и, их поведение уже «встроено» в эту схему.

Ещё один слой — транзакционный менеджер. В нашем приложении, когда мы пишем @Transactional на сервисе, commit транзакции делает JpaTransactionManager. И если во время commit/flush случилась ошибка, именно он часто становится тем местом, где исключение переводится в Spring-форму и вылетает наружу.

С точки зрения разработчика это выглядит просто: вы работаете с JpaRepository, а в случае проблем видите DataIntegrityViolationException. Но внутри участвуют:

JDBC-драйвер (возвращает SQLException), Hibernate (оборачивает), Spring ORM (умеет распознавать типы ошибок), Spring TX (завершает транзакции и там же может «выпасть» ошибка), Spring Data (поднимает репозиторий как компонент в контейнере).

Да, это многослойно. Зато ваш сервисный код не превращается в курс «угадай ошибку по SQLSTATE», а остаётся нормальным прикладным кодом.

4. flush и место появления ошибки

Самая популярная эмоция новичка в JPA-мире звучит так: «Я вызвал save(), а ошибка прилетела через пять строк и вообще в конце метода. Hibernate что, копил обиду?» На самом деле почти всегда виноват flush и то, что JPA работает через persistence context: вы меняете Java-объекты, а SQL улетает в базу не обязательно сразу.

Мы уже обсуждали persistence context и dirty checking раньше: пока сущность в managed-состоянии, Hibernate может копить изменения, а потом отправить их пачкой при синхронизации. Эта синхронизация и есть flush. Важный нюанс: save() — это не команда «срочно выполнить SQL прямо сейчас». Часто это команда «поместить сущность в контекст и подготовить изменения». Реальный INSERT/UPDATE происходит при flush.

А flush в типичном сценарии происходит:

— автоматически при commit транзакции;
— иногда перед выполнением query (чтобы запрос видел актуальные данные);
— вручную, если вы вызываете flush() или saveAndFlush().

Из этого вытекает практическая вещь: нарушение UNIQUE, NOT NULL или FK может быть обнаружено не в момент изменения поля, не в момент save(), а в момент flush/commit. И это нормально: база узнаёт о проблеме только тогда, когда ей реально отправили SQL.

Небольшая «линейка времени» для ощущения:

sequenceDiagram
    participant S as "Service (@Transactional)"
    participant PC as "Persistence Context"
    participant DB as PostgreSQL

    S->>PC: productRepository.save(product)
    Note over PC: Изменения в контексте, SQL может ещё не уйти
    S->>S: ...ещё бизнес-логика...
    S->>PC: flush (обычно на commit)
    PC->>DB: INSERT INTO product ...
    DB-->>PC: ERROR: unique violation / FK violation / not null
    PC-->>S: Исключение (переведённое Spring)

Если держать эту схему в голове, «внезапные» места появления ошибки перестают быть внезапными. Ошибка просто приходит туда, где SQL реально дошёл до базы.

5. save(), flush() и saveAndFlush()

Когда вы впервые сталкиваетесь с DataIntegrityViolationException, часто хочется понять: «Окей, а как сделать так, чтобы ошибка всплыла прямо здесь, где мне удобно, а не где-то на выходе из транзакции?» Для этого Spring Data JPA даёт очень простой и практичный инструмент: принудительный flush — либо отдельным вызовом, либо через метод saveAndFlush().

Сначала зафиксируем смысл. save() в рамках транзакции может только «подготовить» изменения. flush() — заставляет Hibernate отправить накопившиеся SQL в базу сейчас. saveAndFlush() делает оба шага подряд. Это не «магия», а всего лишь управление моментом синхронизации. В контексте дня 24 это особенно полезно, потому что мы хотим увидеть нарушения ограничений в понятной точке сценария.

Вот компактная таблица, которую реально стоит запомнить:

Метод Что делает Когда обычно нужен
save(entity) Добавляет/обновляет сущность в persistence context Почти всегда как базовый шаг
flush() Отправляет накопленные SQL в БД прямо сейчас Когда нужно «поймать» constraint-ошибку в конкретной точке
saveAndFlush(entity) save() + flush() Когда вы создаёте/обновляете и хотите сразу знать, приняла ли это БД

Пример «ловим ошибку прямо здесь» в нашем CatalogService. Обратите внимание: код намеренно короткий, потому что сейчас мы изучаем механику, а не строим идеальный API сервиса.

import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class CatalogService {

    @Transactional
    public void createProductAndFailFast(Product product) {
        try {
            // saveAndFlush = "сохрани и прямо сейчас отправь SQL в БД",
            // чтобы constraint-ошибка проявилась в этой точке, а не на commit.
            productRepository.saveAndFlush(product);
        } catch (DataIntegrityViolationException ex) {
            // На этом уровне мы ловим Spring-контракт.
            // Драйверные детали (PSQLException) будут глубже в cause.
            throw ex; // пока просто пробрасываем, "смысл" будет в лекции 4
        }
    }
}

Иногда удобнее сделать save() и отдельно flush() — например, если вы сохраняете несколько сущностей и хотите понять, на каком шаге база отказала:

import org.springframework.transaction.annotation.Transactional;

@Transactional
public void createCategoryAndProduct(Category cat, Product p) {
    // Сначала складываем изменения в persistence context.
    categoryRepository.save(cat);
    productRepository.save(p);

    // А теперь принудительно синхронизируемся с БД.
    // Если нарушены UNIQUE/FK/NOT NULL — ошибка проявится именно здесь.
    productRepository.flush();
}

Это не «правильнее» и не «быстрее» по умолчанию. Это инструмент, который помогает сделать сценарий наблюдаемым и управляемым. А в учебном проекте — ещё и помогает понять, что реально происходит.

6. Что внутри DataIntegrityViolationException

Когда вы видите DataIntegrityViolationException, возникает второй популярный вопрос: «А как понять, что именно сломалось — UNIQUE, FK или NOT NULL?» На верхнем уровне Spring часто не пытается угадать слишком конкретно: он говорит «нарушена целостность», и это честно. Но внутри исключения почти всегда есть цепочка cause, в которой лежит более конкретная причина.

Типичная цепочка для PostgreSQL может выглядеть так (упрощённо):

DataIntegrityViolationException (Spring)
ConstraintViolationException (Hibernate, про DB constraints)
PSQLException (PostgreSQL driver)

Самое важное методическое предупреждение дня: не перепутайте две разные ConstraintViolationException.

1) jakarta.validation.ConstraintViolationException — это про Bean Validation (аннотации вроде @NotBlank).
2) org.hibernate.exception.ConstraintViolationException — это про constraint-ошибки базы (UNIQUE, FK, NOT NULL и т.д.).

Они однофамильцы. В реальной жизни это как два разных человека по имени «Саша» в одной команде: если не уточнять фамилию — начинается хаос.

В нашем проекте (уровень 24) мы говорим именно о нарушениях схемы БД, поэтому чаще будем видеть Hibernate-вариант.

Чтобы посмотреть корневую причину, не надо писать велосипед. В Spring есть утилита NestedExceptionUtils:

import org.springframework.core.NestedExceptionUtils;
import org.springframework.dao.DataIntegrityViolationException;

public class IntegrityLogHelper {

    public static String rootMessage(DataIntegrityViolationException ex) {
        // Берём "самую конкретную" причину в цепочке (часто это PSQLException).
        Throwable root = NestedExceptionUtils.getMostSpecificCause(ex);

        // Полезно для логов/диагностики: вы видите, что реально сказала БД/драйвер.
        return root.getClass().getSimpleName() + ": " + root.getMessage();
    }
}

Для корневого текста это нормально: нам нужен самый нижний голос драйвера. Но для имени constraint такой shortcut уже не подходит. Самая глубокая причина часто будет PSQLException, а имя ограничения обычно живёт уровнем выше — в Hibernate ConstraintViolationException.

Если вы хотите вытащить имя constraint (а оно у нас аккуратно задано в миграциях, например uk_product_sku), лучше пройти всю цепочку cause, а не смотреть только в самую глубокую причину:

import org.hibernate.exception.ConstraintViolationException;
import org.springframework.dao.DataIntegrityViolationException;

public class ConstraintNameExtractor {

    public static String tryGetConstraintName(DataIntegrityViolationException ex) {
        Throwable current = ex;

        // Идём по всей цепочке cause: конкретное имя constraint
        // может лежать не в самом глубоком exception.
        while (current != null) {
            if (current instanceof ConstraintViolationException cve) {
                return cve.getConstraintName();
            }
            current = current.getCause();
        }

        // Не всегда возможно определить constraint name — это нормальный сценарий.
        return null;
    }
}

Здесь важно: мы не парсим текст сообщения PostgreSQL («Key (sku)=(...) already exists»), потому что это хрупко. Мы работаем с более структурированной информацией (класс причины, имя constraint). Не всегда оно будет доступно, но если вы сами называете constraints в Flyway — шанс заметно выше.

И ещё один нюанс. Даже если вы достали constraint name, не надо превращать это в «бизнес-логику на SQL-кодах». Это полезно для логов и точечной диагностики, но это ещё не готовый бизнес-критерий. DataIntegrityViolationException, прилетевшую из createProduct(), нельзя автоматически назвать DuplicateSkuException: сначала нужно понять, действительно ли конфликт ожидаемый и относится ли он к тому constraint, который вас интересует.

7. Типичные ошибки с DataIntegrityViolationException

Ошибка №1: ждать SQLException в сервисе и строить на этом логику.
В Spring Data JPA вы почти всегда получите исключение из иерархии DataAccessException. Если вы пишете catch (SQLException e) — это обычно признак того, что вы пытаетесь разговаривать не с тем уровнем абстракции. Драйверные ошибки будут спрятаны внутри cause, а основной контракт — Spring.

Ошибка №2: думать, что DataIntegrityViolationException означает только UNIQUE.
На практике это может быть и NOT NULL, и FOREIGN KEY, и даже что-то более экзотическое. Spring честно говорит «нарушена целостность», но не обещает, что это именно дубликат ключа. Поэтому нельзя автоматически превращать каждую такую ошибку в «SKU уже существует».

Ошибка №3: не учитывать момент flush и удивляться “почему не на save()”.
Если SQL уходит в базу при commit/flush, то и ошибка появится там же. В сервисной транзакции это часто конец метода. Для понятного поведения иногда нужно делать saveAndFlush() или flush() в контрольной точке — особенно когда вы хотите обработать ошибку как часть сценария.

Ошибка №4: парсить текст ошибки PostgreSQL как бизнес-контракт.
Сообщение драйвера может меняться, локализоваться, отличаться по версии. Если вы делаете if (ex.getMessage().contains("duplicate key")) — вы строите дом на песке. Лучше работать с constraint name (если он доступен), SQLSTATE (если вы точно понимаете, что делаете), или просто трактовать ситуацию на уровне сценария без “угадываний”.

Ошибка №5: путать Hibernate- и Bean Validation-исключения с одинаковым именем.
jakarta.validation.ConstraintViolationException — это про валидацию аннотациями в Java. org.hibernate.exception.ConstraintViolationException — это про отказ БД из-за constraint. Они разные по смыслу и по месту появления. В день 24 нас интересует именно БД-вариант, иначе вы будете «лечить» не ту причину.

1
Задача
Spring Data JPA, 24 уровень, 2 лекция
Недоступна
Поймать `DataIntegrityViolationException` на `saveAndFlush()`
Поймать `DataIntegrityViolationException` на `saveAndFlush()`
1
Задача
Spring Data JPA, 24 уровень, 2 лекция
Недоступна
Показать, что ошибка `FOREIGN KEY` появляется на `flush()`
Показать, что ошибка `FOREIGN KEY` появляется на `flush()`
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ