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 нас интересует именно БД-вариант, иначе вы будете «лечить» не ту причину.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ