JavaRush /Курсы /Spring Data JPA /@Transactional на сер...

@Transactional на сервисе

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

1. Что делает @Transactional

Когда начинающий разработчик впервые видит @Transactional, часто хочется думать про неё как про “режим серьёзности”: мол, без неё код работает понарошку, а с ней — по-взрослому. На практике всё чуть приземлённее и потому полезнее: @Transactional говорит Spring’у “вокруг выполнения этого метода должна быть транзакция базы данных”. То есть для БД это одна операция: либо всё фиксируем (commit), либо всё откатываем (rollback).

Представьте, что вы не “сохраняете Product”, а оформляете покупку в супермаркете. Вы не хотите сценарий “я оплатил хлеб, а молоко не оплатил, потому что касса зависла на втором товаре”. Вам нужно, чтобы чек был либо полностью, либо никак. Вот транзакция примерно про это — только вместо хлеба и молока у нас INSERT, UPDATE, DELETE.

В Spring Boot приложении, когда метод сервиса помечен @Transactional, фреймворк берёт на себя инфраструктурную работу: открыть транзакцию в начале, выполнить ваш код, и в конце либо сделать commit, либо rollback, если что-то пошло не так.

Небольшая схема, чтобы не держать это только в голове:

flowchart TD
    %% Общая идея: сервисный метод выполняется "в рамке" транзакции
    A[Вызов сервисного метода] --> B[Spring открывает транзакцию]
    B --> C[Внутри метода: несколько действий с БД]
    C --> D{Метод завершился успешно?}
    D -->|Да| E[COMMIT]
    D -->|Нет, было исключение| F[ROLLBACK]

Важно: здесь нет требования “пишите commit() руками”. Вы не JDBC-герой из древних легенд. Вы пишете прикладной код, а Spring аккуратно делает то, что должен делать инфраструктурный слой.

2. @Transactional на сервисе, не в репозитории

Одна из главных причин, почему начинающие быстро попадают в ловушки, — они начинают думать “транзакция = метод репозитория”. Это логично, ведь репозиторий напрямую работает с БД. Но проблема в том, что репозиторий выражает шаг доступа к данным, а сервис выражает прикладную операцию.

Репозиторий — это как “получить коробку с полки” или “положить коробку на полку”. Сервис — это “собрать заказ клиента”. Заказ — это последовательность действий: взять коробку, проверить срок годности, добавить вторую коробку, пересчитать сумму, и только потом закрыть коробку скотчем. Если скотч закончился на последнем шаге, мы не хотим оставить “заказ почти готов” в базе данных.

Отдельный нюанс, который важно понимать именно в Spring Data JPA: многие методы репозитория действительно выполняются в транзакции (часто очень короткой) если вокруг нет более большой транзакции. И это удобно для простых случаев, но опасно как “архитектурный фундамент”. Если операция состоит из нескольких вызовов репозитория, то без транзакции на сервисе эти вызовы могут стать набором независимых “мини-транзакций”. А независимые мини-транзакции — это как попытка расплатиться на кассе частями: сегодня оплатил хлеб, завтра оплатил молоко, послезавтра выяснилось, что вы вообще хотели воду.

Сервисный метод — естественная точка, потому что там виден весь сценарий и там же можно честно сказать: “всё это — одно действие”.

Для закрепления — маленькая таблица-памятка. Не как “истина в последней инстанции”, а как хороший ориентир для новичка:

Где поставить @Transactional Что вы выражаете Типичный эффект Почему это (не)удачно
На сервисном методе Бизнес-операцию / unit of work Один общий commit/rollback для всех шагов Это обычно и есть правильный default
На методах репозитория Отдельные шаги доступа к данным Транзакции “кусочками” Может работать, но легко получить частично выполненный сценарий
На контроллере Web-запрос целиком Транзакция растягивается на web-уровень Как default — плохая идея (подробности сегодня не углубляем)

Мы ещё будем аккуратно обсуждать границы слоёв, но уже сейчас достаточно: сервис — это место, где сценарий целиком. Значит, и транзакция, как граница целостности, живёт там же.

3. Один метод — одно действие

Когда вы начинаете расставлять @Transactional, появляется соблазн пойти двумя крайностями. Первая крайность — “давайте поставим на все методы вообще, чтобы точно работало”. Вторая — “не буду ставить нигде, потому что репозиторий и так что-то там делает”. Обе крайности обычно заканчиваются загадочными багами и ощущением, что “Spring всё делает непредсказуемо”.

В здоровом варианте вы сначала формулируете сервисный метод как действие из предметной области. Именно поэтому имена вроде process(), handle() и doStuff() — не просто некрасивые, а вредные: они скрывают смысл. А смысл и нужен, чтобы выбрать границу транзакции.

В нашем проекте shop-data-jpa хорошие имена обычно выглядят как глагол + объект: createProduct, renameCategory, setAvailableQuantity, deactivateProduct. С таким названием легко договориться и в команде, и в своей голове: “вот операция, вот граница”.

Мини-пример, где уже видно “одно действие” и естественное место для транзакции:

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

@Service
public class CatalogService {

    @Transactional
    public void renameCategory(Long categoryId, String newName) {
        // Загружаем категорию: если не нашли — считаем это ошибкой сценария
        Category category = categoryRepository.findById(categoryId).orElseThrow();

        // Меняем состояние агрегата в памяти
        category.setName(newName);

        // Явно сохраняем (даже если JPA может сделать flush позже — новичку так понятнее)
        categoryRepository.save(category);
    }
}

Код нарочно выглядит простым. В этом и суть: @Transactional мы ставим не “только на сложное”, а на то, что является операцией изменения данных. Даже если сейчас она короткая, она всё равно имеет смысловую границу: “переименовать категорию”.

4. Несколько вызовов репозитория без транзакции

Самая неприятная особенность ошибок с транзакциями в том, что они часто не падают сразу. Код компилируется, тесты “на глаз” проходят, данные вроде как появляются. А потом вы ловите в базе состояние, которое бизнес вообще не предусматривает: “товар создан, но записи об остатках нет”, “категория обновилась, но товар не переехал”, “часть изменений сохранилась, часть нет”.

Чтобы почувствовать проблему, давайте возьмём жизненный сценарий из нашего домена: создать товар и сразу создать для него запись об остатках (StockItem). Это два отдельных save() в два разных репозитория. И у этого есть риск “между шагами что-то пошло не так”.

Плохой вариант (без общей границы) может выглядеть так:

public Long createProductUnsafe(Long categoryId, String sku) {
    // 1) Сначала читаем данные (вне общей транзакции это будет отдельная маленькая история)
    Category category = categoryRepository.findById(categoryId).orElseThrow();

    // 2) Создаём товар и сохраняем
    Product product = new Product();
    product.setCategory(category);
    product.setSku(sku);
    productRepository.save(product);

    // 3) "Что-то пошло не так" между шагами — дальше сценарий уже не выполняется
    throw new IllegalStateException("Oops. Something failed.");

    // stockItemRepository.save(...) уже не вызовется
}

Это выглядит как искусственная ошибка (мы специально бросили исключение), но в реальности вместо throw может быть что угодно: ошибка валидации, случайный NullPointerException, логика, которая решила “нельзя создавать товар в неактивной категории” и выбросила исключение, или даже банальное “вторая запись не сохранилась из-за ограничения в БД”. Смысл один: шаги разъехались.

Теперь правильный вариант: мы говорим Spring’у “это одна операция” и ставим @Transactional на сервисный метод:

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

@Service
public class CatalogService {

    @Transactional
    public Long createProduct(Long categoryId, String sku) {
        // Вся операция целиком выполняется в одной транзакции
        Category category = categoryRepository.findById(categoryId).orElseThrow();

        // Шаг 1: создаём и сохраняем Product
        Product product = new Product();
        product.setCategory(category);
        product.setSku(sku);
        productRepository.save(product);

        // Шаг 2: создаём связанную запись об остатках
        StockItem stockItem = new StockItem();
        stockItem.setProduct(product);
        stockItemRepository.save(stockItem);

        // Возвращаем результат сценария (id товара)
        return product.getId();
    }
}

Сейчас это выглядит как “та же самая логика плюс одна аннотация”, но инженерно это другая картина: если внутри операции что-то падает, мы не оставляем базу в полусобранном состоянии. Либо создан и товар, и остатки, либо не создано ничего.

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

-- внутри одной транзакции (логика одна — команд несколько)
SELECT ... FROM category WHERE id = ?;
INSERT INTO product (...);
INSERT INTO stock_item (...);
COMMIT;

Именно ради этого эффекта мы и оформляем границу на сервисе: база данных должна “увидеть” то же цельное действие, что и мы в коде.

Но не всякий сервисный метод что-то меняет. Как только write-boundary стала понятной, сразу встаёт симметричный вопрос: как помечать чистые чтения так, чтобы код не притворялся записью и не скрывал своё намерение.

5. Типичные ошибки при использовании @Transactional на сервисе

Ошибка №1: ставить @Transactional “на всякий случай” на всё подряд.
Иногда руки чешутся поставить аннотацию на весь класс сервиса, на все методы и вообще “чтобы наверняка”. Проблема не в том, что транзакции сами по себе плохи, а в том, что вы теряете смысл. Если метод на самом деле только читает данные, а вы оформляете его как запись, вы путаете себя и команду. В итоге код становится хуже читаемым: транзакция перестаёт быть сигналом намерения.

Ошибка №2: надеяться, что репозиторий “сам всё сделает”, и не оформлять границу бизнес-операции.
Методы репозитория действительно умеют выполнять свою маленькую работу корректно. Но когда один сервисный сценарий состоит из нескольких шагов, вам нужна единая граница. Без неё вы рискуете получить частично выполненную операцию. Новичку это особенно опасно тем, что ошибка может проявиться не сразу, а “через неделю в проде”, то есть в самый неудобный момент.

Ошибка №3: делать один огромный транзакционный метод “на всё”, потому что так проще.
Если вы превращаете транзакционный метод в “комбайн”, который и создаёт товар, и обновляет категорию, и массово деактивирует товары, и ещё что-то делает “заодно”, транзакция превращается в мешок, в котором всё перемешано. Такой код сложно тестировать, сложно читать и легко сломать. Лучше держать принцип: один публичный сервисный метод — одно понятное действие.

Ошибка №4: расставлять @Transactional не там, где есть бизнес-смысл, а там, где “удобнее технически”.
Иногда аннотацию ставят на “внутренний helper” метод, потому что “ну он же сохраняет”. Но смысл транзакции в том, чтобы объединить несколько шагов, а не отметить место, где есть save(). Если граница должна охватывать чтение, проверку и запись — то и стоять она должна на методе, который выражает весь сценарий целиком.

Ошибка №5: думать, что @Transactional — это про потоки, блокировки в Java и “синхронизацию”.
Аннотация @Transactional не делает метод synchronized и не превращает ваш сервис в “монитор с очередью”. Она управляет транзакцией базы данных. То есть это про целостность данных и commit/rollback на стороне БД, а не про то, чтобы два потока в JVM “не пересеклись”. Если в голове смешать эти миры, потом очень сложно понять, почему всё работает “не так”.

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