1. Транзакційні налаштування за замовчуванням репозиторію
Якби Spring Data JPA був людиною, він би сказав: «Я вже дещо зробив за вас, але не факт, що саме те, чого ви очікували». Транзакційні налаштування репозиторію за замовчуванням — це як заводські налаштування телефона: вони рятують від зовсім прикрих сценаріїв, але якщо ви не знаєте, де вмикається режим «не турбувати», можна раптово отримати дзвінок о третій ночі — у вигляді несподіваної транзакційної поведінки.
Propagation уже показав, що доля сценарію використання зазвичай вирішується на рівні сервісу. Але всередині цієї межі все одно живуть конкретні виклики репозиторію: інколи — у вже відкритій транзакції, інколи — окремо. Тому наступне практичне питання дуже приземлене: що repository proxy взагалі робить за замовчуванням і де закінчуються ці налаштування.
З практичного погляду нам важливо зрозуміти лише одну річ: не всі методи репозиторію однаково «транзакційні» за замовчуванням. І це особливо помітно, коли ви порівнюєте «успадковані CRUD-методи» (findById, save, deleteById…) і «declared query methods» (ваші findByStatus(...), @Query(...), @Modifying…).
Щоб не плутатися, зручно уявляти репозиторій як проксі-об’єкт, через який Spring пропускає виклики:
flowchart LR
S[Сервіс] --> R[Проксі репозиторію]
R --> T[Перехоплювач транзакцій]
T --> E[Виконання: CRUD-реалізація або виконання запиту]
E --> DB[(PostgreSQL)]
Сервіс викликає метод репозиторію. Репозиторій — це bean, але насправді там сидить проксі, який перед виконанням вирішує: «Чи треба відкрити транзакцію? З readOnly чи без нього? Чи беремо участь у вже відкритій?» .
2. CRUD-методи: налаштування за замовчуванням від Spring Data JPA
Успадковані CRUD-методи — це ті, які ви отримуєте «в комплекті» лише тому, що ваш інтерфейс розширює JpaRepository. Ви їх не писали, але користуєтеся ними щодня: findById, findAll, existsById, save, deleteById і так далі. Найважливіше тут: для цих методів Spring Data JPA вже задав транзакційні налаштування, і саме тому вони поводяться доволі передбачувано.
Типова модель (у навчальному спрощенні, без заглиблення в джерельний код) виглядає так: читання зазвичай відбувається в транзакції з readOnly = true, а запис — у звичайній транзакції. Тобто CRUD-методи вже мають вбудований @Transactional, і якщо ви викликаєте їх поза сервісною транзакцією, репозиторій усе одно може відкрити свою.
Для наочності подивімося на дві групи CRUD-методів у вигляді таблиці:
| Категорія | Приклади методів JpaRepository | Ідея транзакційного налаштування за замовчуванням |
|---|---|---|
| Читання | findById, findAll, existsById, count | транзакція для читання, зазвичай readOnly = true |
| Запис | save, deleteById, delete, saveAll | звичайна транзакція на запис (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) — і вони потенційно виконуватимуться у різних транзакційних умовах, якщо ви не керуєте цим на рівні сервісу або не задаєте політику на репозиторії.
Для нас зараз головний висновок не філософський, а прикладний: якщо ви хочете, щоб читання через query methods було передбачуваним, задайте йому передбачувану транзакційну рамку. Найпопулярніший варіант — @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);
}
Цей варіант подобається тим, що він задає тон репозиторію: «за замовчуванням це доступ лише для читання». При цьому CRUD-запис (save, delete) усе одно залишається операцією на запис, бо в нього є власна семантика і він не повинен випадково стати 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? А чому воно відрізняється?».
І ще одна важлива думка: транзакційні налаштування репозиторію за замовчуванням — це фон. Вони допомагають, але не замінюють архітектурне рішення про межу сценарію використання, яке ви зазвичай приймаєте в сервісі. Репозиторій — це інструмент доступу, а не власник бізнес-операції.
6. @Modifying: write-методи репозиторію
Коли query method перестає бути читанням і починає змінювати дані (update/delete), ви буквально перетинаєте межу: тепер це сценарій запису, для якого мають діяти правила запису. І тут з’являється типова помилка-самостріл: розробник позначив репозиторій як @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 (текстовий блок зручніше читати, особливо якщо запит розростеться)
@Query("""
update Product p
set p.status = ?2
where p.id = ?1
""")
int updateStatus(Long productId, ProductStatus status);
}
Тут одразу видно три важливі сигнали, які добре читаються навіть у втомлених очах після пʼяти годин дебагу:
По-перше, репозиторій загалом read-only за замовчуванням, тобто всі звичайні читання і derived queries будуть у безпечному режимі. По-друге, конкретний метод явно позначено як modifying, тобто це не «ще один select». По-третє, метод явно транзакційний у write-режимі, тобто він не намагається вдавати читання.
Окремо корисно памʼятати, що write query method майже завжди має виконуватися всередині транзакції. Так, якщо ви викликаєте цей метод із сервісного @Transactional, він братиме участь у зовнішній транзакції. Але явна анотація на методі репозиторію робить поведінку передбачуваною навіть тоді, коли хтось випадково викличе метод не звідти, а такі «випадково» в командах зазвичай трапляються дуже регулярно.
readOnly як підказка: що це насправді означає для репозиторію
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-методи мають свої налаштування за замовчуванням, а declared query methods легко опиняються без явної transactional політики. Лікується це дисципліною: або транзакція на сервісі як власнику сценарію використання, або явні анотації на query methods.
Помилка №2: залишати derived queries і @Query без @Transactional(readOnly = true) і сподіватися на «якось само».
Навіть якщо «якось само» не зламалося сьогодні, ви залишили міну на майбутнє: поведінка запиту залежатиме від того, хто і як його викликав. У навчальному проєкті це особливо шкідливо, тому що ви втрачаєте причинно-наслідковий зв’язок між кодом і поведінкою.
Помилка №3: позначати репозиторій як readOnly = true, а потім додавати @Modifying метод без окремого @Transactional.
Виглядає як дрібниця, але за змістом це конфлікт: ви кажете «я читаю» і тут же «я оновлюю». Правильний стиль — method-level @Transactional для modifying query, щоб він явно виходив із read-only режиму.
Помилка №4: сприймати readOnly = true як «залізну заборону запису».
readOnly — це hint, а не замок на дверях. Якщо ви хочете заборонити запис на рівні архітектури, ви робите це структурою коду: розділяєте сценарії читання і запису, не змішуєте їх в одному методі, а не сподіваєтеся, що анотація «не дасть» зробити save().
Помилка №5: намагатися розв’язати сервісні проблеми анотаціями на репозиторії.
Репозиторій — це шар доступу до даних, а не режисер бізнес-операції. Транзакційні налаштування репозиторію корисно знати, але вони не замінюють того, що межа сценарію використання зазвичай живе в сервісі. Якщо ви починаєте крутити анотації на репозиторії, щоб компенсувати хаос у сервісах, ви зазвичай просто переносите хаос на нижчий рівень.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ