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 «не перетнулися». Якщо змішати ці світи в голові, потім дуже складно зрозуміти, чому все працює «не так».
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ