JavaRush /Курсы /Spring Data JPA /Transactional defaults Spring Data JPA

Transactional defaults Spring Data JPA

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

1. Transactional defaults репозитория

Если бы Spring Data JPA был человеком, он бы сказал: «Я уже кое-что сделал за тебя, но не факт, что именно то, что ты ожидал». Transactional defaults репозитория — как заводские настройки у телефона: они спасают от совсем уж грустных сценариев, но если вы не знаете, где включается режим «не беспокоить», можно внезапно поймать звонок в три ночи — в виде неожиданного поведения транзакций.

Propagation уже показал, что судьба use case обычно решается на уровне сервиса. Но внутри этой границы всё равно живут конкретные вызовы репозитория: иногда внутри уже открытой транзакции, иногда отдельно. Поэтому следующий практический вопрос очень приземлённый: что repository proxy вообще делает по умолчанию и где эти дефолты заканчиваются.

С практической точки зрения нам важно понять всего одну вещь: не все методы репозитория одинаково «транзакционные» по умолчанию. И это особенно заметно, когда вы сравниваете «унаследованные CRUD-методы» (findById, save, deleteById…) и «declared query methods» (ваши findByStatus(...), @Query(...), @Modifying…).

Чтобы не путаться, удобно представлять репозиторий как прокси-объект, через который Spring пропускает вызовы:

flowchart LR
    S[Service] --> R[Repository proxy]
    R --> T[Transaction interceptor]
    T --> E[Execution: CRUD impl or Query execution]
    E --> DB[(PostgreSQL)]

Сервис вызывает метод репозитория. Репозиторий — это bean, но на самом деле там сидит прокси, который перед выполнением решает: «Нужно ли открыть транзакцию? С readOnly или без? Или участвуем в уже открытой?».

2. CRUD-методы: defaults от Spring Data JPA

Унаследованные CRUD-методы — это те, которые вы получаете «в комплекте» просто потому, что ваш интерфейс расширяет JpaRepository. Вы их не писали, но вы ими пользуетесь каждый день: findById, findAll, existsById, save, deleteById и так далее. Самое важное здесь: для этих методов Spring Data JPA уже задал транзакционные настройки, и именно поэтому они ведут себя довольно предсказуемо.

Типовая модель (в учебном упрощении, без углубления в исходники) выглядит так: чтение обычно идёт в транзакции с readOnly = true, а запись — в обычной транзакции. То есть у CRUD-методов уже есть «встроенный» @Transactional, и если вы вызываете их вне сервисной транзакции, репозиторий всё равно может открыть свою.

Для ощущения «на кончиках пальцев» посмотрим на две группы CRUD-методов в виде таблицы:

Категория Примеры методов JpaRepository Идея transactional default
Чтение findById, findAll, existsById, count транзакция для чтения, обычно readOnly = true
Запись save, deleteById, delete, saveAll обычная write-транзакция (readOnly = false)

В нашем проекте shop-data-jpa это означает, что даже если вы сделали «быструю проверку» из какого-нибудь временного кода (что само по себе спорно), CRUD-методы часто всё равно отработают в более-менее корректной транзакционной рамке.

Мини-пример репозитория в каталоге:

import org.springframework.data.jpa.repository.JpaRepository;

// Репозиторий без своих методов: CRUD придёт "из коробки" от JpaRepository
public interface ProductRepository extends JpaRepository<Product, Long> {
    // Здесь пусто, но findById/save/deleteById уже доступны и имеют дефолтную транзакционность
}

В этом интерфейсе нет ни одного метода — но findById() и save() уже доступны, и у них уже есть транзакционная семантика «по умолчанию» со стороны Spring Data JPA.

И вот здесь начинается самая частая ловушка новичка: «Если CRUD-методы уже транзакционные, значит и мои методы тоже будут такими же». Нет. И дальше мы как раз разберём, почему.

3. Переопределяем настройки CRUD-методов

Иногда бывает так, что «заводских настроек» CRUD-метода вам мало. Например, вы хотите поставить таймаут (чтобы не ждать «вечный запрос»), или вы хотите явно показать намерение команды: «да, этот метод всё ещё read-only, но у него есть особые требования». В Spring Data JPA для этого есть простой трюк: redeclare — переобъявить унаследованный метод в вашем интерфейсе репозитория и повесить на него нужные аннотации.

Это выглядит немного странно для новичка: «Я же не реализую метод, зачем я его снова объявляю?». Ответ простой: вы объявляете не реализацию, а дополнительные метаданные, которые Spring прочитает на уровне прокси и применит.

Пример: захотели ограничить по времени findAll() (условно, для админского экрана, который не должен висеть вечность):

import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.transaction.annotation.Transactional;

public interface CategoryRepository extends JpaRepository<Category, Long> {

    // Redeclare: мы не пишем реализацию, но задаём транзакционные метаданные
    @Override
    @Transactional(timeout = 5) // Таймаут в секундах: если запрос "завис", не ждём бесконечно
    List<Category> findAll();
}

Да, это всё ещё findAll(). Да, вы его не реализуете. Но теперь Spring видит: “для этого вызова действует таймаут 5 секунд”.

Важно не превращать redeclare в «аннотационный спам». Если вы начнёте переобъявлять всё подряд, репозиторий станет похож на новогоднюю ёлку, на которой игрушек больше, чем хвои. Redeclare — это инструмент точечной настройки, когда у вас действительно есть причина.

Ещё один момент: redeclare помогает именно с унаследованными CRUD-методами, потому что у них есть базовая реализация и базовые настройки. Но как только вы начинаете писать собственные query methods, картина меняется — и именно там начинаются сюрпризы.

4. Declared query methods: отдельные правила

Declared query methods — это все методы, которые вы добавили сами: derived queries вроде findByStatus(...), JPQL через @Query, и, отдельно стоящая группа, modifying-запросы через @Modifying. Для новичка они выглядят как «такие же методы репозитория», но внутри Spring Data они исполняются иначе — и поэтому по умолчанию не обязаны получать те же transactional defaults, что CRUD.

Это важно: когда вы смотрите на интерфейс репозитория, ваш мозг видит просто набор методов. Но Spring видит два разных мира: мир встроенных CRUD и мир объявленных query methods. И если у CRUD транзакционность уже настроена из базовой реализации, то у query methods этого «подарка по умолчанию» может не быть.

Давайте посмотрим на пример, который очень легко написать в каталоге:

import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;

public interface ProductRepository extends JpaRepository<Product, Long> {

    // Derived query: Spring Data сам построит запрос по имени метода
    List<Product> findByStatus(ProductStatus status);
}

findByStatus(...) — это derived query method. Он «выглядит» как метод чтения, но его транзакционность не обязана быть явно настроенной, пока вы сами не зададите правила.

И вот тут возникает неприятная асимметрия: ваш код в сервисе может делать подряд два чтения — одно через findAll() (CRUD), другое через findByStatus() (declared method) — и они потенциально будут выполняться в разных transactional условиях, если вы не управляете этим на сервисном уровне или не задаёте политику на репозитории.

Для нас сейчас главный вывод не философский, а прикладной: если вы хотите, чтобы чтение через query methods было предсказуемым, задайте ему предсказуемую transactional рамку. Самый популярный вариант — @Transactional(readOnly = true).

5. Read-only режим для чтения репозитория

Когда вы проектируете репозиторий, вы почти всегда хотите, чтобы чтение было «чтением», то есть без случайной записи и без лишней тяжёлой инфраструктурной возни. И вы хотите, чтобы это намерение было видно прямо в коде. Поэтому очень распространённый паттерн в Spring Data JPA — повесить @Transactional(readOnly = true) на репозиторий (или на конкретные методы чтения), чтобы query methods не жили «как получится».

Есть два практичных стиля. Первый — поставить @Transactional(readOnly = true) на весь интерфейс репозитория, и тогда все query methods чтения попадают в понятный режим:

import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.transaction.annotation.Transactional;

@Transactional(readOnly = true) // Политика по умолчанию: операции репозитория считаем чтением
public interface ProductRepository extends JpaRepository<Product, Long> {

    // Чтение: остаётся в read-only транзакции (если нет внешней транзакции с другими настройками)
    List<Product> findByStatus(ProductStatus status);
}

Этот вариант нравится тем, что он задаёт «тон» репозитория: «по умолчанию это read-only доступ». При этом CRUD-запись (save, delete) всё равно остаётся write-операцией, потому что у неё есть собственная семантика и она не должна случайно стать read-only только из-за того, что вы пометили интерфейс.

Второй стиль — аннотировать конкретный метод, особенно если репозиторий смешанный или вы не хотите задавать глобальную политику:

import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.transaction.annotation.Transactional;

public interface StockItemRepository extends JpaRepository<StockItem, Long> {

    // Точечная настройка: этот метод гарантированно выполняется как read-only
    @Transactional(readOnly = true)
    List<StockItem> findByAvailableQuantityGreaterThan(int minQty);
}

Какой стиль лучше? В учебном проекте чаще проще первый: «репозиторий читающий по умолчанию». Но важнее не стиль, а единообразие. Если в одном репозитории вы иногда ставите @Transactional(readOnly = true), иногда нет, а иногда оно на сервисе, то через месяц вы сами будете читать этот код как детектив: “Так, а здесь транзакция была? А readOnly? А почему оно отличается?”.

И вот здесь важная мысль дня: transactional defaults репозитория — это фон. Они помогают, но не заменяют архитектурное решение о границе use case, которое вы обычно принимаете в сервисе. Репозиторий — это «инструмент доступа», а не «владелец бизнес-операции».

6. @Modifying: write-методы репозитория

Когда query method перестаёт быть чтением и начинает менять данные (update/delete), вы буквально пересекаете границу: теперь это write-сценарий, у которого должны быть write-правила. И тут появляется частая «самострел»-ошибка: разработчик пометил репозиторий как @Transactional(readOnly = true), а затем добавил @Modifying метод… и получил «почему оно странно себя ведёт?».

Правильная дисциплина простая: modifying query method должен быть явно write-транзакционным. То есть вы ставите @Modifying и поверх него — @Transactional без readOnly = true (или с readOnly = false, если хотите подчеркнуть намерение).

Пример для нашего каталога: массово (или точечно) поменять статус товара через JPQL update:

import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.transaction.annotation.Transactional;

@Transactional(readOnly = true) // По умолчанию репозиторий "читающий"
public interface ProductRepository extends JpaRepository<Product, Long> {

    // Modifying-запрос: это уже не select, он меняет данные
    @Modifying

    // ВАЖНО: выходим из read-only режима для конкретного метода
    @Transactional

    // JPQL update (Text block удобнее читать, особенно если запрос разрастётся)
    @Query("""
           update Product p
           set p.status = ?2
           where p.id = ?1
           """)
    int updateStatus(Long productId, ProductStatus status);
}

Здесь видно сразу три важных сигнала, которые хорошо читаются даже «уставшими глазами» после пяти часов дебага:

Во-первых, репозиторий в целом read-only по умолчанию, то есть все обычные чтения и derived queries будут в safe-режиме. Во-вторых, конкретный метод явно помечен как modifying, то есть это не «ещё один select». В-третьих, метод явно транзакционный в write-режиме, то есть он не пытается притворяться чтением.

Отдельно полезно помнить, что write query method почти всегда должен выполняться внутри транзакции. Да, если вы зовёте этот метод из сервисного @Transactional, он будет участвовать во внешней транзакции. Но явная аннотация на методе репозитория делает поведение предсказуемым даже тогда, когда кто-то случайно вызовет метод не оттуда (а такие «случайно» в командах обычно происходят очень даже регулярно).

readOnly как hint: что он реально значит для репозитория

readOnly = true звучит как «запрещено писать», но в реальности это скорее «я обещаю, что не буду писать, а ты, инфраструктура, можешь оптимизировать чтение». То есть это не охранник с дубинкой, а табличка «не мусорить» — соблюдать её легко, но физически она вас не остановит. Именно поэтому важно не превращать readOnly в магическое заклинание, которое «гарантирует безопасность».

С практической точки зрения для нас важны две вещи. Первая — readOnly делает ваш код читабельнее: вы сразу видите намерение. Вторая — readOnly может повлиять на то, как ORM и транзакционный менеджер относятся к операции (например, к необходимости синхронизации изменений). Но это не значит, что вы можете положиться на readOnly как на железный запрет записи.

Очень плохая идея — делать так:

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

@Service
public class CatalogQueryService {

    @Transactional(readOnly = true) // Метод декларируется как "чтение"
    public void oops(ProductRepository productRepository, Product product) {
        // Нарушение контракта: внутри read-only сценария мы делаем запись
        productRepository.save(product); // "Ну я же случайно..."
    }
}

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

В контексте репозиториев правило очень простое и жизненное: если метод читает, пусть он выглядит как чтение; если он пишет, пусть он выглядит как запись. readOnly — один из инструментов, который помогает удерживать эту честность.

7. Типичные ошибки при транзакциях репозитория

Ошибка №1: считать, что все методы репозитория «одинаково транзакционные».
Это происходит почти у всех новичков: интерфейс репозитория выглядит как ровный список методов, и кажется, что Spring обрабатывает их одинаково. На практике CRUD-методы имеют свои defaults, а declared query methods легко оказываются без явной transactional политики. Лечится это дисциплиной: либо транзакция на сервисе как owner use case, либо явные аннотации на query methods.

Ошибка №2: оставлять derived queries и @Query без @Transactional(readOnly = true) и надеяться на «оно само».
Даже если «само» не сломалось сегодня, вы оставили мину на будущее: поведение запроса будет зависеть от того, кто и как его вызвал. В учебном проекте это особенно вредно, потому что вы теряете причинно-следственную связь между кодом и поведением.

Ошибка №3: помечать репозиторий как readOnly = true, а затем добавлять @Modifying метод без override.
Выглядит как мелочь, но по смыслу это конфликт: вы говорите «я читаю» и тут же «я обновляю». Правильный стиль — method-level @Transactional для modifying query, чтобы он явно выходил из read-only режима.

Ошибка №4: воспринимать readOnly = true как «железный запрет записи».
readOnly — это hint, а не замок на двери. Если вы хотите запретить запись на уровне архитектуры, вы делаете это структурой кода: разделяете read и write use cases, не смешиваете их в одном методе, а не надеетесь, что аннотация «не даст» сделать save().

Ошибка №5: пытаться решить сервисные проблемы аннотациями на репозитории.
Репозиторий — это слой доступа к данным, а не режиссёр бизнес-операции. Transactional defaults репозитория полезно знать, но они не заменяют того, что граница use case обычно живёт в сервисе. Если вы начинаете «крутить» аннотации на репозитории, чтобы компенсировать хаос на сервисах, вы обычно просто переносите хаос на более низкий уровень.

1
Задача
Spring Data JPA, 19 уровень, 2 лекция
Недоступна
Переобъявление унаследованного `findAll()` с собственной настройкой
Переобъявление унаследованного `findAll()` с собственной настройкой
1
Задача
Spring Data JPA, 19 уровень, 2 лекция
Недоступна
Read-only query methods и write-метод с `@Modifying`
Read-only query methods и write-метод с `@Modifying`
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ