JavaRush /Курсы /Spring Data JPA /Как работает @Transactional...

Как работает @Transactional

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

1. Главное правило: внешний public-вход

Если коротко, рабочий default звучит так: @Transactional ставим на внешний public-метод сервиса, который выражает use case. Именно на этой границе Spring может открыть транзакцию до вызова метода и завершить её после него. Если вызов проходит мимо этой точки входа, аннотация не получает шанса сработать — и отсюда уже растут почти все типичные поломки.

Поэтому здесь полезно держать в голове не список разрозненных caveat-ов, а одну картину: контроллер, runner или другой bean вызывает public-метод CatalogService/InventoryService; Spring-прокси видит этот вход и оборачивает его транзакцией. А private helper-ы, self-invocation и объект, созданный через new, обходят эту границу.

Если вы когда-нибудь ловили ощущение “я добавил аннотацию, а база ведёт себя так, будто её нет”, вы не одиноки. @Transactional — не команда компилятору и не “режим” метода. Это инфраструктурное поведение, которое Spring добавляет снаружи, и добавляет его не всегда и не везде.

Главная мысль: Spring не “встраивает транзакцию внутрь метода”. Вместо этого он делает обёртку вокруг вызова метода. Представьте, что ваш сервис — это кабинет, а транзакция — пропуск на вход. Если вы заходите через главный вход, охранник выдаёт пропуск. Если вы пролезли через окно (self-invocation), охранник вас даже не увидит — и пропуска не будет.

2. Spring-прокси: схема вызовов

Пока мы пишем @Service, @Repository и @Transactional, можно легко представить, что Spring “магически” меняет поведение нашего класса. На самом деле Spring делает более земную вещь: вместо того чтобы отдавать всем ваш настоящий объект сервиса, он отдаёт объект-обёртку. Эта обёртка называется proxy (прокси) и ведёт себя как «секретарь у руководителя»: к руководителю можно попасть только через секретаря.

Для нас practical default из этой схемы очень приземлённый: внешний код входит в public-метод сервиса, а всё, что происходит внутри него — helper-методы, репозитории, проверки — уже живёт внутри открытой транзакции.

Схематично нормальный вызов выглядит так:

flowchart TD
    C["Controller / другой bean"] --> P["Spring Proxy"]
    P -->|before| TX["Открыть транзакцию"]
    P --> S["Реальный CatalogService"]
    S --> R["Repository"]
    R --> DB["(PostgreSQL)"]
    S --> P
    P -->|after| END["Commit / завершение"]

Прокси делает “вокруг” вызова три важных вещи: перед вызовом метода он открывает транзакцию, потом вызывает реальный метод, потом закрывает транзакцию. Для нас сейчас достаточно именно этой картинки — без погружения в AOP-внутренности.

В терминах нашего проекта shop-data-jpa (Java 25 + Spring Framework 7 + Spring Boot 4), логика такая: контроллер (если он есть как thin adapter) или другой сервис вызывает публичный метод CatalogService. Но реально он попадает не в “настоящий” CatalogService, а в прокси, и уже прокси решает, включать транзакцию или нет.

Мини-пример (в духе “вызов пришёл снаружи — прокси работает”):

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

@Service
public class CatalogService {

    @Transactional
    public void renameCategory(Long categoryId, String newName) {
        // Важно: этот метод должен быть вызван СНАРУЖИ через Spring-прокси.
        // Тогда прокси откроет транзакцию ДО вызова и сделает commit/rollback ПОСЛЕ.
        // Если вызвать этот метод напрямую на объекте (через new), транзакции не будет.
    }
}

Ключевое условие здесь не в аннотации, а в том, что вызов должен прийти извне, через Spring-контейнер. Прокси не телепат: он видит только те вызовы, которые проходят через него.

3. Self-invocation и обход прокси

Из этого правила self-invocation получается почти автоматически. Это ситуация, когда метод класса вызывает другой метод того же самого класса через this. И вот тут начинается магия… точнее, прекращается.

Представим, что мы сделали в CatalogService два метода: один публичный “сценарный”, второй — тоже публичный, и мы наивно захотели, чтобы второй был “в транзакции”. Это выглядит логично, но работает не так, как ожидает новичок.

Плохой пример: аннотация на “внутреннем” методе

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

@Service
public class CatalogService {

    public void createDemoCategory() {
        // Вызов внутри того же класса = self-invocation.
        // Прокси здесь не участвует, потому что это обычный вызов метода на this.
        saveCategory("books", "Books");
    }

    @Transactional
    public void saveCategory(String code, String name) {
        // Ожидаем транзакцию, но при вызове из createDemoCategory() её не будет,
        // потому что @Transactional "включает" транзакцию только при входе через прокси.
    }
}

Почему “не будет”? Потому что createDemoCategory() вызывает saveCategory() на том же объекте. А значит, вызов проходит не через прокси, а напрямую: “внутри кабинета руководитель позвал себя по внутреннему телефону”, секретарь тут не участвует.

Чтобы почувствовать это как систему, полезно представить два разных пути:

sequenceDiagram
    participant Ctrl as "Controller"
    participant Proxy as "CatalogService Proxy"
    participant Svc as "CatalogService (real)"

    Ctrl->>Proxy: "createDemoCategory()"
    Proxy->>Svc: "createDemoCategory()"
    Svc->>Svc: "this.saveCategory() (self-invocation)"
    Note over Svc: "Прокси НЕ участвует → @Transactional на saveCategory не применяется"

Да, репозиторий внизу всё равно может создать транзакцию на save(...) (у Spring Data JPA есть свои transactional defaults), но граница будет не там, где вы её ожидаете. И это главная проблема: вы думаете “у меня транзакция на saveCategory”, а по факту её нет — и когда вы завтра добавите второй шаг (ещё один репозиторий, ещё одну запись), вы получите набор маленьких разрозненных транзакций вместо одного unit of work.

Хороший пример: транзакция на внешнем entry-point

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

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

@Service
public class CatalogService {

    @Transactional
    public void createDemoCategory() {
        // Вызов приходит СНАРУЖИ (например, из контроллера/другого бина) -> через прокси.
        // Прокси открывает транзакцию здесь, на границе use case.
        saveCategory("books", "Books");
    }

    private void saveCategory(String code, String name) {
        // helper: вызывается уже внутри открытой транзакции
        // (не пытаемся делать из helper-метода отдельную "транзакционную границу").
    }
}

Здесь идея простая: createDemoCategory() вызывается извне через прокси, прокси открывает транзакцию, а дальше внутри можно вызывать сколько угодно helper-методов — они участвуют в уже открытой транзакции. Мы не пытаемся “включать транзакцию” на внутреннем методе — мы включаем её на границе use case.

Если вам хочется сформулировать это как правило, то оно звучит так: аннотируйте транзакцией то, что является entry-point бизнес-операции, а не то, что является вспомогательным шагом внутри неё.

4. Ограничения прокси: private, final, new

Когда студент впервые узнаёт про прокси, он обычно начинает подозревать, что Spring — это такой театр, где половина актёров в масках. И, честно говоря, это не так уж далеко от правды. Но из этой “масочной” природы прокси следуют несколько очень практичных ограничений, которые лучше увидеть сейчас, чем потом в продакшене под звуки падающих тестов.

private-метод — не точка транзакционной границы

Если метод private, прокси физически не может его “перехватить” как отдельную операцию: приватный метод не переопределяется при наследовании, и прокси не может вставить “до/после” логику вокруг него как вокруг публичного API.

Вот пример, который выглядит “умно”, но по смыслу бесполезен:

import org.springframework.transaction.annotation.Transactional;

public class InventoryService {

    public void updateStock(Long productId) {
        // Снаружи виден только updateStock().
        // Если здесь не будет транзакции на entry-point, дальше будет как получится.
        doUpdate(productId);
    }

    @Transactional
    private void doUpdate(Long productId) {
        // Не будет работать как самостоятельная транзакционная граница:
        // private-метод прокси не перехватит.
    }
}

Правильный ход для нас в этом курсе: @Transactional — на публичном методе сервиса, а private оставляем для helper-логики внутри уже открытой транзакции.

final-метод и final-класс — тоже плохие друзья прокси

Эту тему можно объяснять долго, но нам достаточно одной практической мысли: прокси часто создаётся как “подкласс”, который переопределяет методы и добавляет поведение. А final запрещает переопределение.

В Java-проектах это встречается не так часто, но всё же: не делайте транзакционные сервисы final-классами и не делайте транзакционные методы final. Если очень хочется “закрыть API”, лучше обсудить это на уровне архитектуры и пакетов, а не пытаться победить Spring правилами наследования.

“Я создал сервис через new — почему транзакций нет?”

Это одна из самых частых ловушек на уровне мышления. @Transactional работает только на Spring-бине, то есть объекте, который живёт в контейнере и обёрнут прокси (если требуется). Если вы где-то сделали new CatalogService(...), вы создали обычный объект Java, без участия Spring, без прокси, без транзакционной инфраструктуры.

Очень грустный (но реалистичный) антипример:

public class DemoRunner {

    public void run(CategoryRepository categoryRepository,
                    ProductRepository productRepository) {
        // Объект создан руками -> вне Spring-контейнера -> никакого прокси и никакого @Transactional
        CatalogService service = new CatalogService(categoryRepository, productRepository);
        service.renameCategory(1L, "New"); // @Transactional не применится
    }
}

Если вам нужен код, который запускается при старте приложения, он тоже должен быть Spring-компонентом (например, @Component) и должен получать сервис через DI. Тогда вызов пойдёт через прокси.

5. Контроллер и транзакция

Теперь приземлим то же правило на слои. В layered architecture transaction boundary принадлежит use case, а use case живёт в сервисе. Поэтому контроллер не должен становиться хозяином транзакции только потому, что он первым получает HTTP-запрос.

Идея, которую мы фиксируем сегодня: контроллер делегирует, сервис выполняет use case, репозиторий даёт доступ к данным. Транзакционная граница — это часть use case, значит она естественно живёт в сервисе.

Как выглядит “тонкий” контроллер (правильный default)

Даже если web-слой в нашем shop-data-jpa остаётся минимальным smoke-адаптером, он всё равно должен быть тонким:

import java.math.BigDecimal;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/products")
public class ProductController {

    private final CatalogService catalogService;

    public ProductController(CatalogService catalogService) {
        // DI: сервис приходит из Spring-контейнера (а значит, может быть проксирован)
        this.catalogService = catalogService;
    }

    @PostMapping
    public Long create(@RequestParam Long categoryId,
                       @RequestParam String sku,
                       @RequestParam String name,
                       @RequestParam BigDecimal price) {
        // Контроллер только принимает вход и делегирует в use case.
        // Транзакция должна жить в сервисе, а не здесь.
        return catalogService.createProduct(categoryId, sku, name, price);
    }
}

Здесь контроллер не открывает транзакцию. Он не знает, сколько шагов внутри createProduct. Он просто делегирует в сервис, а сервис уже решает, что является unit of work.

@Transactional на контроллере — плохой default

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

Во-первых, транзакция начнётся слишком рано и закончится слишком поздно: вы будете держать транзакцию открытой, пока контроллер парсит параметры, строит ответ, возможно делает лишние преобразования. Во-вторых, это размывает ответственность: бизнес-операция живёт в сервисе, а транзакция — в контроллере. Читаешь код и не понимаешь: где реально “начало use case”?

Антипример (делать так не надо):

import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;

@RestController
public class BadProductController {

    private final ProductRepository productRepository;

    public BadProductController(ProductRepository productRepository) {
        // Репозиторий тоже Spring-бин, но контроллер не должен быть местом "unit of work".
        this.productRepository = productRepository;
    }

    @Transactional
    @PostMapping("/bad/products/{id}/deactivate")
    public void deactivate(@PathVariable Long id) {
        // Контроллер напрямую управляет данными -> смешение web-слоя и data-слоя
        Product p = productRepository.findById(id).orElseThrow();
        p.setStatus(ProductStatus.INACTIVE);
        productRepository.save(p);
    }
}

Да, это “работает”. Но это и есть тот самый учебный путь к “контроллер знает слишком много”. Мы не хотим, чтобы web-слой управлял data-слоем напрямую. Даже в учебном проекте.

6. Проверка транзакции: логи и диагностика

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

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

Включаем логирование транзакций в application-dev.yml

Держите конфиг минимальным и включайте его только в dev-профиле:

logging:
  level:
    # Учебная диагностика: показывает начало/конец транзакций и участие в существующих
    org.springframework.transaction: TRACE

После этого при вызове транзакционного метода вы увидите логи вида “создаём транзакцию / участвуем / завершаем”. Точные формулировки могут отличаться, но общий смысл будет таким.

Маленькая “проверка руками” внутри метода (чисто для обучения)

Иногда хочется прямо из кода убедиться: “я внутри транзакции или нет?”. Для учебной диагностики можно использовать TransactionSynchronizationManager.

import org.springframework.transaction.support.TransactionSynchronizationManager;

public class TxDebug {

    public static void printTxState() {
        // Проверка именно "реальной" транзакции в текущем потоке выполнения
        boolean active = TransactionSynchronizationManager.isActualTransactionActive();

        // Важно: это лабораторный принт для обучения, не постоянная практика для продакшена
        System.out.println("tx active = " + active); // tx active = true/false
    }
}

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

Если вы примените этот приём к примеру с self-invocation, вы сможете увидеть разницу: внешний публичный метод с @Transactional даст true, а “внутренний” вызов аннотированного метода через this сам по себе транзакцию не включит.

Этого уже достаточно, чтобы собирать нормальный сервисный слой: один внешний public-метод сервиса становится точкой входа, а дальше внутри него живут все нужные шаги unit of work.

7. Типичные ошибки при работе с @Transactional

Ошибка №1: ожидать, что @Transactional — это «магическая настройка метода», которая действует даже при вызове внутри того же класса.
Такое ожидание появляется почти у всех: “если аннотация есть — значит транзакция должна быть”. Но в Spring транзакция добавляется прокси-обёрткой, а внутренний вызов this.someMethod() прокси не видит. Поэтому аннотация на “внутреннем” методе часто не даёт того поведения, которое вы предполагаете.

Ошибка №2: ставить @Transactional на private-методы и надеяться, что Spring их «применит».
Даже если IDE подсвечивает аннотацию красиво, это не означает, что она станет транзакционной границей. Приватный метод — это внутренняя деталь класса. Если вы хотите границу операции, делайте её на публичном сервисном entry-point, а приватные методы оставляйте helper-логике.

Ошибка №3: открывать транзакцию в контроллере «потому что это начало запроса».
Контроллер — входной слой, а не владелец бизнес-операции. Если транзакция начинается в контроллере, вы смешиваете web-ответственность и data-ответственность, а граница use case становится неявной. В учебном проекте это закрепляет плохую привычку, которая потом очень мешает в реальной кодовой базе.

Ошибка №4: создавать сервис руками через new и удивляться, что аннотации не работают.
Аннотации вроде @Transactional и @Service работают только тогда, когда объект создан и управляется Spring-контейнером. Если вы обходите контейнер, вы обходите и прокси. Это особенно часто случается в “быстрых демо” и в тестах, где хочется “не поднимать контекст”.

Ошибка №5: пытаться лечить self-invocation «хитрыми трюками», вместо того чтобы правильно поставить entry-point.
Иногда в интернете советуют “внедри сам себя в себя”, “достань прокси из контекста”, “вызови метод через AopContext”. Это всё выглядит как чёрная магия и в учебном проекте только ухудшает понимание. Правильный базовый ход проще: транзакция на публичном entry-point метода сервиса, а внутренние шаги — обычные методы без попытки включить инфраструктуру по отдельности.

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