JavaRush /Курси /Spring Data JPA /@Transactional на сер...

@Transactional на сервісі

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

1. Що робить @Transactional

Коли початківець уперше бачить @Transactional, її легко сприйняти як «режим серйозності»: ніби без неї код працює якось не дуже, а з нею — уже «по-дорослому». Насправді все простіше й корисніше: @Transactional каже Springʼу: «навколо виконання цього методу має бути транзакція бази даних». Для БД це одна операція: або все фіксуємо (commit), або все відкочуємо (rollback).

Уявіть, що ви не «зберігаєте Product», а оформлюєте покупку в супермаркеті. Вам не потрібен сценарій «хліб оплатив, а молоко — ні, бо каса зависла на другому товарі». Потрібно, щоб чек був або повним, або взагалі відсутнім. Саме про це транзакція — тільки замість хліба й молока у нас INSERT, UPDATE, DELETE.

У застосунку Spring Boot, коли метод сервісу позначено @Transactional, фреймворк бере на себе інфраструктурну частину: відкриває транзакцію на початку, виконує ваш код, а наприкінці або робить commit, або rollback, якщо щось пішло не так.

Невелика схема, щоб не тримати це лише в голові:

flowchart TD
    %% Загальна ідея: сервісний метод виконується "в межах" транзакції
    A[Виклик сервісного методу] --> B[Spring відкриває транзакцію]
    B --> C[Усередині методу: кілька дій із БД]
    C --> D{Метод завершився успішно?}
    D -->|Так| E[COMMIT]
    D -->|Ні, стався виняток| F[ROLLBACK]

Важливо: тут не потрібно вручну писати commit(). Ви не JDBC-герой із давніх легенд. Ви пишете прикладний код, а Spring акуратно робить те, що має робити інфраструктурний шар.

2. @Transactional на сервісі, а не в репозиторії

Одна з головних причин, чому початківці швидко потрапляють у пастки, — вони починають думати: «транзакція = метод репозиторію». Це здається логічним, адже репозиторій безпосередньо працює з БД. Але проблема в тому, що репозиторій виражає крок доступу до даних, а сервіс — прикладну операцію.

Репозиторій — це як «взяти коробку з полиці» або «поставити коробку на полицю». Сервіс — це «зібрати замовлення клієнта». Замовлення складається з послідовності дій: взяти коробку, перевірити строк придатності, додати другу коробку, перерахувати суму й тільки потім заклеїти коробку скотчем. Якщо скотч закінчився на останньому кроці, ми не хочемо залишити «замовлення майже готове» в базі даних.

Окремий нюанс, який важливо розуміти саме в Spring Data JPA: багато методів репозиторію справді виконуються в транзакції — часто дуже короткій, коли навколо немає більшої транзакції. Для простих випадків це зручно, але як архітектурний фундамент — небезпечно. Якщо операція складається з кількох викликів репозиторію, то без транзакції на сервісі ці виклики можуть перетворитися на набір незалежних «мінітранзакцій». А незалежні мінітранзакції — це як спроба розраховуватися на касі частинами: сьогодні оплатили хліб, завтра — молоко, а післязавтра зʼясувалося, що ви взагалі хотіли воду.

Сервісний метод — природна точка, бо там видно весь сценарій, і саме там можна чесно сказати: «усе це — одна дія».

Для закріплення — маленька таблиця-памʼятка. Не як «істина в останній інстанції», а як хороший орієнтир для новачка:

Де розмістити @Transactional Що ви виражаєте Типовий ефект Чому це (не)вдало
На сервісному методі Бізнес-операцію / unit of work Один спільний commit/rollback для всіх кроків Зазвичай це й є правильне значення за замовчуванням
На методах репозиторію Окремі кроки доступу до даних Транзакції «шматками» Може працювати, але легко отримати частково виконаний сценарій
На контролері Веб-запит цілком Транзакція розтягується на веб-рівень Як значення за замовчуванням — погана ідея (у подробиці сьогодні не заглиблюємося)

Ми ще акуратно обговорюватимемо межі шарів, але вже зараз достатньо знати: сервіс — це місце, де сценарій існує цілком. Отже, і транзакція як межа цілісності живе там само.

3. Один метод — одна дія

Коли ви починаєте розставляти @Transactional, зʼявляється спокуса піти двома крайнощами. Перша — «давайте поставимо на всі методи взагалі, щоб точно працювало». Друга — «не буду ставити ніде, бо репозиторій і так щось там робить». Обидві крайнощі зазвичай закінчуються загадковими багами й відчуттям, ніби Spring усе робить непередбачувано.

У здоровому варіанті ви спершу формулюєте сервісний метод як дію з предметної області. Саме тому імена на кшталт process(), handle() і doStuff() — не просто некрасиві, а шкідливі: вони приховують зміст. А зміст і потрібен, щоб вибрати межу транзакції.

У нашому проєкті shop-data-jpa вдалі імена зазвичай виглядають як дієслово + обʼєкт: createProduct, renameCategory, setAvailableQuantity, deactivateProduct. З такою назвою легко домовитися і в команді, і в себе в голові: «ось операція, ось межа».

Мініприклад, де вже видно «одну дію» і природне місце для транзакції:

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

@Service
public class CatalogService {

    @Transactional
    public void renameCategory(Long categoryId, String newName) {
        // Завантажуємо категорію: якщо не знайшли, вважаємо це помилкою сценарію
        Category category = categoryRepository.findById(categoryId).orElseThrow();

        // Змінюємо стан агрегату в памʼяті
        category.setName(newName);

        // Явно зберігаємо (навіть якщо JPA може зробити flush пізніше — новачкові так зрозуміліше)
        categoryRepository.save(category);
    }
}

Код навмисно виглядає простим. У цьому й суть: @Transactional ми ставимо не «лише на складне», а на те, що є операцією зміни даних. Навіть якщо зараз вона коротка, у неї все одно є смислова межа: «перейменувати категорію».

4. Кілька викликів репозиторію без транзакції

Найнеприємніша особливість помилок із транзакціями в тому, що вони часто не падають одразу. Код компілюється, тести «на око» проходять, дані нібито зʼявляються. А потім ви ловите в базі стан, який бізнес узагалі не передбачає: «товар створено, а запису про залишки немає», «категорія оновилася, а товар не переїхав», «частина змін збереглася, частина — ні».

Щоб відчути проблему, візьмімо життєвий сценарій із нашого домену: створити товар і одразу створити для нього запис про залишки (StockItem). Це два окремі save() у два різні репозиторії. І в цьому є ризик: між кроками щось піде не так.

Поганий варіант без спільної межі може виглядати так:

public Long createProductUnsafe(Long categoryId, String sku) {
    // 1) Спочатку читаємо дані (без спільної транзакції це буде окрема маленька історія)
    Category category = categoryRepository.findById(categoryId).orElseThrow();

    // 2) Створюємо товар і зберігаємо
    Product product = new Product();
    product.setCategory(category);
    product.setSku(sku);
    productRepository.save(product);

    // 3) "Щось пішло не так" між кроками — далі сценарій уже не виконується
    throw new IllegalStateException("Ой. Щось пішло не так.");

    // stockItemRepository.save(...) уже не викличеться
}

Це виглядає як штучна помилка — ми спеціально кинули виняток, — але в реальності замість throw може бути що завгодно: помилка валідації, випадковий NullPointerException, логіка, яка вирішила «не можна створювати товар у неактивній категорії» і кинула виняток, або навіть банальне «другий запис не зберігся через обмеження в БД». Сенс один: кроки розʼїхалися.

Тепер правильний варіант: ми говоримо Springʼу «це одна операція» і ставимо @Transactional на сервісний метод:

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

@Service
public class CatalogService {

    @Transactional
    public Long createProduct(Long categoryId, String sku) {
        // Уся операція цілком виконується в одній транзакції
        Category category = categoryRepository.findById(categoryId).orElseThrow();

        // Крок 1: створюємо і зберігаємо Product
        Product product = new Product();
        product.setCategory(category);
        product.setSku(sku);
        productRepository.save(product);

        // Крок 2: створюємо повʼязаний запис про залишки
        StockItem stockItem = new StockItem();
        stockItem.setProduct(product);
        stockItemRepository.save(stockItem);

        // Повертаємо результат сценарію (id товару)
        return product.getId();
    }
}

Зараз це виглядає як «та сама логіка плюс одна анотація», але з інженерного погляду картина інша: коли всередині операції щось падає, ми не залишаємо базу в напівзібраному стані. Або створено і товар, і залишки, або не створено нічого.

Якщо ви вмикали SQL-логування в попередні дні курсу, у такому сценарії зазвичай побачите кілька SQL-команд, які потрапляють в одну транзакцію. Приблизно так:

-- Усередині однієї транзакції (логіка одна — команд кілька)
SELECT ... FROM category WHERE id = ?;
INSERT INTO product (...);
INSERT INTO stock_item (...);
COMMIT;

Саме заради цього ефекту ми й оформлюємо межу на сервісі: база даних має «побачити» те саме цілісне діяння, що й ми в коді.

Але не кожен сервісний метод щось змінює. Щойно межа запису стала зрозумілою, одразу постає симетричне запитання: як позначати чисті читання так, щоб код не вдавав із себе запис і не приховував свій намір.

5. Типові помилки під час використання @Transactional на сервісі

Помилка № 1: ставити @Transactional «про всяк випадок» на все підряд.
Іноді руки сверблять поставити анотацію на весь клас сервісу, на всі методи й узагалі «щоб напевно». Проблема не в тому, що транзакції самі по собі погані, а в тому, що ви втрачаєте зміст. Якщо метод насправді лише читає дані, а ви оформлюєте його як запис, ви плутаєте себе і команду. У результаті код стає менш читабельним: транзакція перестає бути сигналом наміру.

Помилка № 2: сподіватися, що репозиторій «сам усе зробить», і не оформлювати межу бізнес-операції.
Методи репозиторію справді вміють виконувати свою маленьку роботу коректно. Але коли один сервісний сценарій складається з кількох кроків, вам потрібна єдина межа. Без неї ви ризикуєте отримати частково виконану операцію. Для новачка це особливо небезпечно тим, що помилка може проявитися не одразу, а «через тиждень у проді», тобто в найбільш незручний момент.

Помилка № 3: робити один величезний транзакційний метод «на все», бо так простіше.
Якщо ви перетворюєте транзакційний метод на «комбайн», який і створює товар, і оновлює категорію, і масово деактивує товари, і ще щось робить «заодно», транзакція перетворюється на мішок, у якому все перемішано. Такий код складно тестувати, складно читати й легко зламати. Краще тримати принцип: один публічний сервісний метод — одна зрозуміла дія.

Помилка № 4: розставляти @Transactional не там, де є бізнес-сенс, а там, де «технічно зручніше».
Іноді анотацію ставлять на «внутрішній допоміжний» метод, бо «ну він же зберігає». Але сенс транзакції в тому, щоб обʼєднати кілька кроків, а не позначити місце, де є save(). Якщо межа має охоплювати читання, перевірку і запис, то й стояти вона має на методі, який виражає весь сценарій цілком.

Помилка № 5: думати, що @Transactional — це про потоки, блокування в Java і «синхронізацію».
Анотація @Transactional не робить метод synchronized і не перетворює ваш сервіс на «монітор із чергою». Вона керує транзакцією бази даних. Тобто це про цілісність даних і commit/rollback на боці БД, а не про те, щоб два потоки в JVM «не перетнулися». Якщо змішати ці світи в голові, потім дуже складно зрозуміти, чому все працює «не так».

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