Сервисный слой и транзакции (@Transactional)

Сервис — дом бизнес-логики. А @Transactional гарантирует, что группа операций с базой выполнится целиком или не выполнится вовсе.
Суть: @Transactional оборачивает метод в транзакцию. Если внутри вылетает исключение — все изменения откатываются. Это защищает данные от половинчатых обновлений.

Бизнес-операция часто состоит из нескольких шагов. Перевод денег: списать у одного, зачислить другому. Если после списания случится сбой, а зачисление не пройдёт — деньги исчезнут. Транзакция превращает группу операций в неделимое целое: либо все шаги, либо ни одного.

Сервис с транзакцией

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

@Service
public class TransferService {

    private final AccountRepository accounts;

    public TransferService(AccountRepository accounts) {
        this.accounts = accounts;
    }

    @Transactional
    public void transfer(Long fromId, Long toId, BigDecimal amount) {
        Account from = accounts.findById(fromId).orElseThrow();
        Account to = accounts.findById(toId).orElseThrow();

        from.withdraw(amount);   // шаг 1
        to.deposit(amount);      // шаг 2
        // если между шагами вылетит исключение -> откат обоих
    }
}

@Transactional на методе создаёт границу транзакции: она открывается на входе и фиксируется (commit) на выходе. Если вылетает непроверяемое исключение (RuntimeException), Spring делает откат (rollback).

Как работает под капотом

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

  Вызов transfer(...)
        |
        v
  ПРОКСИ Spring
   ┌──────────────────────────┐
   │  BEGIN TRANSACTION        │
   │      твой метод           │
   │   ok?  -> COMMIT          │
   │   исключение? -> ROLLBACK │
   └──────────────────────────┘
        |
        v
  база в согласованном состоянии

Смоделируем транзакцию «всё или ничего»:

# Транзакция: применяем изменения только если все шаги прошли
accounts = {"A": 100, "B": 50}

def transfer(from_acc, to_acc, amount):
    snapshot = dict(accounts)            # запоминаем состояние (для отката)
    try:
        if accounts[from_acc] < amount:
            raise ValueError("Недостаточно средств")
        accounts[from_acc] -= amount     # шаг 1
        accounts[to_acc] += amount       # шаг 2
        print("COMMIT:", accounts)
    except Exception as e:
        accounts.clear()
        accounts.update(snapshot)        # ROLLBACK
        print("ROLLBACK (", e, "):", accounts)

transfer("A", "B", 30)    # успех
transfer("A", "B", 999)   # откат, состояние не изменилось

Нажмите «Попробуй сам ▶»: при ошибке состояние откатывается к снимку — это и есть атомарность транзакции.

Частые ошибки

  • Самовызов @Transactional-метода. Вызов через this.method() идёт мимо прокси — транзакция не откроется.
  • Ожидать откат на checked-исключении. По умолчанию Spring откатывает только на RuntimeException; для проверяемых нужен rollbackFor.
  • @Transactional на контроллере. Транзакции — забота сервисного слоя, не веба.

Best practices

  • Ставьте @Transactional на методы сервиса, объединяющие несколько операций с БД.
  • Для чтения используйте @Transactional(readOnly = true) — это подсказка оптимизатору.
  • Держите транзакции короткими: не зовите внутри них долгие внешние вызовы (HTTP).

Итог: сервис содержит бизнес-логику, а @Transactional делает группу операций атомарной. Помните про прокси (самовызов не работает) и про правила отката (по умолчанию — RuntimeException).

Закрепим главное

Транзакция превращает набор операций в неделимое целое: либо применяются все изменения, либо ни одного. Это свойство атомарности защищает данные от половинчатых состояний, которые иначе пришлось бы вычищать вручную. Ставьте @Transactional там, где бизнес-операция объединяет несколько шагов с базой, — именно сервисный слой, а не контроллер, отвечает за границы транзакций.

Две ловушки заслуживают того, чтобы запомнить их наизусть. Первая — самовызов: транзакциями управляет прокси вокруг бина, и вызов this.method() внутри того же класса идёт мимо прокси, поэтому транзакция не откроется. Если нужен транзакционный внутренний вызов — выносите метод в отдельный бин. Вторая — правила отката: по умолчанию Spring откатывает транзакцию только при непроверяемых исключениях (RuntimeException), а для проверяемых нужно явно указать rollbackFor. Не зная этого, легко получить «зафиксированную наполовину» операцию там, где ждали отката.

Проверьте себя
1. Что делает @Transactional при возникновении RuntimeException внутри метода?
AИгнорирует исключение
BОткатывает (rollback) все изменения в базе, сделанные в этой транзакции
CФиксирует изменения частично
DПерезапускает метод
2. Почему вызов @Transactional-метода из другого метода того же класса может не открыть транзакцию?
AТак задумано для скорости
BСамовызов через this идёт мимо Spring-прокси, который и управляет транзакцией
CТранзакции работают только в контроллерах
DИз-за ошибки компиляции