JavaRush /Курси /Spring Data JPA /Транзакційні налаштування за замовчуванням у Spring Data ...

Транзакційні налаштування за замовчуванням у Spring Data JPA

Spring Data JPA
Рівень 19 , Лекція 2
Відкрита

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: намагатися розв’язати сервісні проблеми анотаціями на репозиторії.
Репозиторій — це шар доступу до даних, а не режисер бізнес-операції. Транзакційні налаштування репозиторію корисно знати, але вони не замінюють того, що межа сценарію використання зазвичай живе в сервісі. Якщо ви починаєте крутити анотації на репозиторії, щоб компенсувати хаос у сервісах, ви зазвичай просто переносите хаос на нижчий рівень.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ