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 обычно живёт в сервисе. Если вы начинаете «крутить» аннотации на репозитории, чтобы компенсировать хаос на сервисах, вы обычно просто переносите хаос на более низкий уровень.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ