JavaRush /Курсы /Spring Data JPA /Исключения в транзакциях Spring

Исключения в транзакциях Spring

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

1. Исключения как сигнал транзакции

Исключения в Java мы часто воспринимаем как «способ сообщить об ошибке» и, максимум, как «повод показать сообщение пользователю». В транзакционном сервисе у исключения появляется ещё одна роль: оно становится управляющим сигналом для транзакционного механизма Spring. То есть тип исключения влияет на то, будет ли Spring пытаться зафиксировать изменения или откатить их.

Представьте placeOrder() как поезд, который едет по маршруту «проверили вход → нашли товары → проверили остатки → собрали OrderItem → уменьшили остатки → сохранили заказ». Пока поезд едет, он не обязан «сразу строить вокзалы» в базе данных. Но когда поезд доехал до конечной станции — транзакция подтверждается. Если поезд сошёл с рельс (исключение) — нужно решать: это авария, всё отменяем, или «не авария, просто пассажиру стало грустно»?

Spring делает это решение не по настроению, а по правилам. И вот эти правила нужно знать, потому что иначе вы получите самый неприятный баг: метод завершился ошибкой, а данные в базе поменялись.

Небольшой кусочек кода для контекста (просто чтобы мы говорили про одно и то же):

import org.springframework.transaction.annotation.Transactional;

@Transactional
public Long placeOrder(...) {
    // Важно: всё, что мы читаем/проверяем/меняем внутри этого метода,
    // происходит в рамках одной транзакции (если вызов пришёл через Spring-proxy).
    //
    // В какой-то момент мы можем бросить исключение — и именно оно станет сигналом
    // для решения commit/rollback по правилам Spring.
    return 42L;
}

2. Правило по умолчанию: runtime = rollback

Сейчас будет тот момент, где у многих новичков в голове звучит тихий щелчок: «Подождите… так что, не любое исключение откатывает транзакцию?» Да, не любое. По умолчанию Spring делает откат, если из транзакционного метода «вылетело» непроверяемое исключение (RuntimeException) или Error. А вот checked exception (наследник Exception, но не RuntimeException) по умолчанию считается «ожидаемым» и не обязан приводить к rollback.

Это не потому что Spring вредничает. Это историческая и практическая договорённость: checked exceptions в Java часто используют как часть нормального бизнес-контракта («может не получиться, обработай»), а runtime — как «операция не может продолжаться корректно». Spring из коробки предполагает: если это runtime — откатываем, если checked — возможно, вы хотите вернуть ошибку, но сохранить сделанные изменения (бывает редко, но бывает).

Удобно зафиксировать в маленькой таблице:

Что вы бросили из @Transactional метода Это checked? Spring по умолчанию делает rollback? Чем это опасно в placeOrder()
IllegalArgumentException / IllegalStateException нет да безопасный default: ошибка = откат
extends RuntimeException нет да то же самое, но красивее по смыслу
extends Exception да нет можно получить «ошибка наружу, изменения в базе внутри»
Error нет да это уже «пожар», обычно не наш учебный сценарий

Мини-демонстрация на уровне идеи (без полной логики заказа). Runtime-исключение:

@Transactional
public void demoRuntimeRollback() {
    // Здесь предполагается, что мы уже успели что-то поменять в базе.
    // RuntimeException для Spring = «операция провалилась», значит нужен rollback.
    throw new IllegalStateException("Не продолжаем — откатываем");
}

И checked-исключение:

@Transactional
public void demoCheckedNoRollback() throws Exception {
    // Здесь тоже предполагается, что мы уже успели что-то поменять в базе.
    // Но checked exception по умолчанию НЕ обязательно приводит к rollback.
    throw new Exception("Я checked exception");
}

Во втором примере новичок часто ожидает автоматический откат «потому что исключение же было», а по умолчанию может получить commit. Именно поэтому rollbackFor существует и именно поэтому мы вообще об этом говорим.

### Мини-схема решения commit/rollback

Когда смотришь на транзакции только по аннотации, легко представить, что «где-то там магия». Но внутри логика довольно прямолинейная: Spring оборачивает вызов метода, открывает транзакцию, выполняет метод, а дальше смотрит, как он завершился — нормально или с исключением, и какого типа исключение.

Вот простая схема (без внутренних деталей AOP и прокси — мы их уже обсуждали в прошлом дне):

flowchart TD
    A["Вызвали public service method
с @Transactional"] --> B["Spring открыл транзакцию"] B --> C["Код placeOrder() выполняется"] C --> D{Метод завершился?} D -->|Нормально, без исключения| E["Commit транзакции"] D -->|Выброшено исключение| F{Исключение вызывает rollback?} F -->|RuntimeException или Error| G["Rollback транзакции"] F -->|Checked exception| H["Commit (по умолчанию)"] H --> I["Но если есть rollbackFor — будет rollback"]

Эта схема важна тем, что она объясняет, почему «поймал исключение и вернул false» так опасно. В этом случае для Spring метод завершился нормально, и он идёт по ветке commit. И именно поэтому rollbackFor — не «магическая кнопка», а конкретное правило выбора ветки.

3. Типы ошибок в placeOrder()

Чтобы не кидать «всё подряд» одним типом исключений, полезно слегка упорядочить причины отказа. В placeOrder() встречаются как минимум три природы проблем, и у каждой свой характер. Это не философия ради философии: когда вы называете ошибку правильно, у вас становится предсказуемым и rollback, и логирование, и поведение сервиса для вызывающего кода.

Если грубо, то первая категория — это плохой вход: пустой список позиций, количество <= 0, пустой email. Это обычно IllegalArgumentException, потому что проблема в том, что метод вызвали некорректно. Вторая категория — бизнес-ограничение: товар существует, но его недостаточно на складе. Это уже не «неправильный вызов», а нормальная ситуация: бизнес говорит «не могу оформить». Третья категория — технические сбои: база недоступна, сломалась сеть, выстрелило DataAccessException из репозитория — это «операция не завершилась по техническим причинам».

Небольшая таблица-пример (не как догма, а как «карта местности»):

Ситуация Это про что? Что чаще бросают Что важно для транзакции
items пустой входные данные IllegalArgumentException rollback будет (runtime)
productId не найден бизнес/данные ProductNotFoundException rollback будет
остатка не хватает бизнес-отказ runtime или checked + rollbackFor иначе можно получить commit
БД сказала «всё плохо» технический сбой DataAccessException rollback будет

И маленький кусочек кода «в духе нашего проекта», чтобы было ощущение реальности:

// Валидация входа: лучше падать сразу, до любых изменений в БД.
if (items == null || items.isEmpty()) {
    // IllegalArgumentException — runtime, поэтому по умолчанию будет rollback.
    throw new IllegalArgumentException("Заказ должен содержать хотя бы одну позицию");
}

Тут всё просто: если вход невалидный — мы даже не начинаем «строить заказ», а сразу прерываем операцию.

4. Runtime-исключения как безопасный default

Когда вы только начинаете, самый безопасный путь — моделировать отказ операции через runtime exceptions. Это прагматично: вам не нужно помнить про rollbackFor, вам не нужно тащить throws ... по половине приложения, и транзакция по умолчанию откатится.

С точки зрения placeOrder() это выглядит так: не хватает остатка — бросили runtime, операция прервалась, Spring увидел runtime, сделал rollback. Это прямолинейно и отлично подходит для учебного проекта и для многих коммерческих сервисов, где «ошибка оформления» является именно ошибкой выполнения команды.

Например, нехватка остатка (супер-упрощённо):

int quantity = line.getQuantity();

// Проверяем бизнес-условие ДО того, как продолжим делать «необратимые» шаги.
if (stockItem.getAvailableQuantity() < quantity) {
    // RuntimeException = rollback по умолчанию.
    throw new IllegalStateException("Недостаточно товара на складе");
}

Но IllegalStateException — это «универсальный молоток». В учебном коде он допустим, а в более живом коде вам захочется имя, которое не заставляет читателя угадывать: «а это что сломалось?». Поэтому часто заводят доменное runtime-исключение:

// Доменное исключение: по имени сразу понятно, что именно пошло не так.
public class InsufficientStockRuntimeException extends RuntimeException {
    public InsufficientStockRuntimeException(String message) {
        super(message);
    }
}

И используем:

int quantity = line.getQuantity();

// Смысл тот же, но теперь ошибка «подписана» доменным именем.
if (stockItem.getAvailableQuantity() < quantity) {
    // RuntimeException всё так же гарантирует rollback по умолчанию.
    throw new InsufficientStockRuntimeException("Недостаточно остатка для productId=" + product.getId());
}

Транзакционно это так же безопасно (rollback будет), но смысл в логах и стектрейсе уже читается намного приятнее.

5. Checked-исключения и риск коммита

Checked exceptions обычно любят за «принуждение к обработке»: если метод объявил throws InsufficientStockException, то компилятор не даст вам забыть, что такой отказ возможен. Это похоже на знак «осторожно, мокрый пол»: вы как минимум не притворитесь, что пола нет.

Но в мире Spring-транзакций у checked исключений есть минус, который новичок почти всегда пропускает: по умолчанию rollback может не случиться. И это особенно неприятно в placeOrder(), где мы трогаем несколько сущностей и несколько таблиц. Если вы где-то уже успели поменять остатки, а потом бросили checked exception — без настройки rollback вы рискуете зафиксировать изменения, хотя метод «упал».

Сама checked-ошибка выглядит безобидно:

// Checked-исключение: заставляет вызывающий код явно обработать/пробросить ошибку.
public class InsufficientStockException extends Exception {
    public InsufficientStockException(String message) {
        super(message);
    }
}

А теперь «опасный» сценарий (утрированно, но поучительно). Допустим, мы внутри транзакции уменьшили остаток, а потом решили бросить checked exception:

// Опасно: если это изменение уже попало в persistence context,
// а потом мы бросим checked-исключение без rollbackFor — транзакция может закоммититься.
stockItem.setAvailableQuantity(stockItem.getAvailableQuantity() - quantity);

// ... и вдруг:
throw new InsufficientStockException("Не хватает остатка");

Если @Transactional не настроен, Spring может решить, что rollback не нужен, и сделать commit. Тогда внешний код увидит исключение (то есть «заказ не оформился»), но остаток уже уменьшен (то есть «как будто заказ оформился»). Это и есть тот самый «баг на ровном месте», который потом лечат нервами, ручными правками в базе и фразой «ну транзакции же должны были откатиться…».

Поэтому идея простая: checked exceptions можно использовать, но только если вы явно определили для них rollback-правило. Иначе лучше выбрать runtime как дефолт.

6. rollbackFor для checked-исключений

rollbackFor — это настройка @Transactional, которая добавляет исключения, при которых нужно делать rollback, даже если это checked exceptions. То есть вы берёте ответственность за контракт: «Да, исключение checked, но для этой операции это всё равно означает: отменяем всё».

На практике выглядит так (ровно как в плане дня, только чуть аккуратнее):

import org.springframework.transaction.annotation.Transactional;

@Transactional(rollbackFor = InsufficientStockException.class)
public Long placeOrder(...) throws InsufficientStockException {
    // Явно говорим Spring: для этого checked-исключения делаем rollback.
    throw new InsufficientStockException("Недостаточно остатка");
}

Обратите внимание на две вещи. Во-первых, метод теперь честно заявляет throws InsufficientStockException, и вызывающий код обязан с этим считаться. Во-вторых, транзакционное правило стало явным: даже если это checked exception, операция считается проваленной целиком и должна откатиться.

В реальном placeOrder() это будет выглядеть примерно так (кусочек, не весь метод):

int quantity = line.getQuantity();

// Бизнес-проверка: если не хватает, бросаем checked-исключение.
// При наличии rollbackFor транзакция всё равно откатится.
if (stockItem.getAvailableQuantity() < quantity) {
    throw new InsufficientStockException(
            "Недостаточно остатка для productId=" + product.getId()
    );
}

Ещё один нюанс, который полезно знать: rollbackFor работает по принципу «подходит ли тип». Если вы укажете базовый класс, то все его наследники тоже будут попадать под rollback-правило. Например, если бы вы сделали rollbackFor = OrderPlacementException.class, и InsufficientStockException наследовался бы от него, то правило сработало бы и для него. Это может быть удобным способом держать контракт аккуратным: «все ошибки оформления заказа откатывают транзакцию».

7. Не прячьте исключения в транзакции

Есть привычка, которая выглядит как «я молодец, обработал ошибку», но в транзакционном сервисе часто превращается в «я тихо закоммитил проблему». Это привычка ловить исключение внутри @Transactional метода, что-то там напечатать (или вообще ничего не сделать) и продолжить выполнение или вернуть null/false.

Плохой паттерн из плана дня — он плохой по очень понятной причине: метод завершается нормально, значит транзакция думает, что всё ок, и может коммитить:

try {
    // Любая ошибка здесь, если её «проглотить», не дойдёт до Spring как сигнал для rollback.
    stockItemRepository.save(stockItem);
} catch (Exception e) {
    // плохо: ошибка скрыта, транзакция может закоммититься
    // (потому что метод продолжил выполнение и формально завершился успешно)
}

Если очень хочется «сделать красиво», правильнее как минимум логировать и перекидывать исключение дальше (или оборачивать в своё доменное runtime-исключение). Например:

catch (Exception e) {
    // Хорошо: мы явно сигнализируем «операция провалилась».
    // RuntimeException по умолчанию приведёт к rollback.
    throw new IllegalStateException("Не удалось сохранить остаток", e);
}

Пусть это выглядит грубо, зато честно: операция не завершилась, контракт нарушен, откатываем.

Если же вы действительно хотите обработать ошибку и продолжить (редкий случай для placeOrder()), тогда вам нужно понимать, что вы делаете, и как это влияет на целостность данных. В нашем сценарии «оформление заказа» продолжать после ошибок почти всегда нельзя: либо заказ оформился целиком, либо не оформился вообще.

8. Именование и упаковка исключений

Хорошее исключение в сервисе — это не обязательно «у нас 48 классов-ошибок на всё». Хорошее исключение — это такое, по которому через полгода вы открыли логи и сразу поняли: «а, это клиент пытался заказать больше, чем есть» или «а, это база недоступна». И при этом вы не потеряли исходную причину (cause), чтобы было что отлаживать.

Частый компромисс: иметь 1–2 «корневых» доменных исключения и несколько конкретных. Например, для оформления заказа:

// Корневое доменное исключение для сценария «оформление заказа».
public class OrderPlacementException extends RuntimeException {
    public OrderPlacementException(String message, Throwable cause) {
        // Сохраняем cause, чтобы не потерять первопричину (например, исключение из JDBC/репозитория).
        super(message, cause);
    }
}

И теперь, если репозиторий выкинул что-то низкоуровневое, мы можем аккуратно «подписать» это как проблему именно оформления заказа:

import org.springframework.dao.DataAccessException;

catch (DataAccessException e) {
    // DataAccessException — runtime, поэтому транзакция откатится.
    // Мы добавляем понятное доменное сообщение и не теряем исходную причину.
    throw new OrderPlacementException("Ошибка доступа к БД при оформлении заказа", e);
}

Заметьте, мы не уходим в тему DataIntegrityViolationException и constraint-ошибок — это будет позже по курсу. Здесь нам достаточно понимать главное: репозитории и инфраструктура часто кидают runtime-исключения, и они по умолчанию откатывают транзакцию. Мы просто делаем сообщение понятнее и привязываем ошибку к бизнес-операции placeOrder().

Если же вы выбрали checked-подход для бизнес-отказов (например, нехватка остатка), обычно технические сбои всё равно остаются runtime. Это нормально: бизнес-отказ — ожидаемая ветка, технический сбой — «мы не можем продолжить работу».

9. Типичные ошибки при исключениях в транзакциях

Ошибка №1: ожидать откат на любое исключение.
Это очень человеческое ожидание: «ну раз ошибка — значит всё должно откатиться». Но у Spring есть дефолтное правило: rollback по runtime-исключениям, а checked исключения могут не откатывать транзакцию. Если вы выбрали checked-исключение для бизнес-отказа (например, InsufficientStockException extends Exception) и не указали rollbackFor, вы рискуете получить коммит частичных изменений при одновременно “упавшем” методе.

Ошибка №2: использовать checked exception и забыть про rollbackFor.
Эта ошибка особенно коварна тем, что долго может «не проявляться» на простых кейсах. Пока вы делаете все проверки до любых изменений, кажется, что всё нормально. Но как только порядок шагов усложняется (а он усложняется почти всегда), вы внезапно ловите ситуации, где часть изменений уже успела попасть в транзакцию. Лекарство одно: либо runtime, либо rollbackFor — без третьего варианта.

Ошибка №3: ловить исключения внутри placeOrder() и продолжать выполнение.
Форма try/catch (Exception) и внутри ничего создаёт иллюзию контроля, но на деле делает поведение транзакции непредсказуемым. Транзакция может успешно закоммититься, потому что метод формально завершился без исключения. Если операция не может считаться успешной — исключение должно выйти наружу (возможно, в обёрнутом виде), чтобы Spring увидел причину отката.

Ошибка №4: бросать слишком общий Exception или превращать сервис в генератор всего подряд.
Когда вы бросаете new Exception("что-то пошло не так"), вы не помогаете ни себе будущему, ни другим разработчикам. Более того, с точки зрения транзакции это checked-исключение, которое может не вызвать rollback без дополнительных правил. Гораздо лучше либо использовать понятные runtime-исключения (IllegalArgumentException, IllegalStateException), либо делать свои доменные типы, где имя объясняет смысл.

Ошибка №5: смешивать бизнес-отказ и технический сбой в один тип исключения.
Если «нет остатка» и «база упала» сообщаются одинаково, вызывающий код не может нормально реагировать. В одном случае можно честно сказать пользователю «товара не хватает», в другом — это инфраструктурная проблема, и операция не завершилась по техническим причинам. В нашем учебном проекте мы пока не строим полноценный web-error contract, но внутри сервиса разделять смысл ошибок уже стоит: хотя бы разными классами исключений и сообщениями.

1
Задача
Spring Data JPA, 18 уровень, 3 лекция
Недоступна
Custom runtime exception для отсутствующего товара
Custom runtime exception для отсутствующего товара
1
Задача
Spring Data JPA, 18 уровень, 3 лекция
Недоступна
Checked exception и rollbackFor
Checked exception и rollbackFor
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ