JavaRush /Курси /Hibernate deep-dive /Оформлення замовлення і транзакції

Оформлення замовлення і транзакції

Hibernate deep-dive
Рівень 17 , Лекція 4
Відкрита

1. Транзакція: від результату до межі

Тепер можна зібрати все в один повний сценарій placeOrder. Скорочений order/inventory сценарій уже показав, де має жити transaction boundary: підтвердження замовлення й резервування залишків мають бути пов’язані однією долею. Тепер розширюємо цю ж лінію до повноцінного оформлення замовлення, де до резервування додаються створення PurchaseOrder і OrderItem.

У Commerce Persistence Lab оформлення замовлення — це бізнес-операція, у якій зазвичай беруть участь різні частини домену: клієнт (Customer), залишки (InventoryItem), саме замовлення (PurchaseOrder) і рядки замовлення (OrderItem). Отже, ми повинні проєктувати код так, щоб єдиний результат операції був захищений однією спільною транзакційною долею.

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

Нижче — проста таблиця, яка допомагає тримати фокус:

Сценарій Що змінюємо в БД Що вважаємо «успіхом» Що вважаємо «провалом»
Оформлення замовлення
purchase_order
,
order_item
,
inventory_item
усі зміни зафіксовано разом нічого не зафіксовано (або зафіксовано лише те, що ми свідомо дозволили)

І саме ця таблиця має бути у вас у голові ще до того, як рука потягнеться поставити @Transactional.

2. Склад операції placeOrder

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

У спрощеному вигляді place-order сценарій у Commerce Persistence Lab виглядає так: ми читаємо клієнта, читаємо товар (або товари), читаємо й перевіряємо залишки, оновлюємо InventoryItem, створюємо PurchaseOrder, створюємо OrderItem, записуємо все це в БД. Поруч зазвичай живуть обчислення суми, знімки імені/SKU/ціни, ініціалізація адреси доставки (як Address-snapshot), але сьогодні нам важливіша не повнота бізнес-логіки, а цілісність.

Схематично можна представити одну операцію так:

flowchart TD
    %% Один сценарій = один смисловий ланцюжок кроків
    A["Вхід у сценарій: placeOrder()"] --> B[Завантажити Customer]
    B --> C[Завантажити Product]
    C --> D[Завантажити InventoryItem]
    D --> E[Перевірити доступність qty]
    E --> F[Оновити InventoryItem: available/reserved]
    F --> G[Створити PurchaseOrder]
    G --> H["Створити OrderItem(и)"]
    H --> I[Зберегти order]
    I --> J[Підтвердження транзакції]

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

3. Антипатерн: дві транзакції

Дуже типова помилка — зробити гарний зовнішній метод placeOrderBad(), який виглядає як одна операція, але всередині викликає два (або три) сервіси, і кожен живе своїм життям. Здалеку це схоже на оркестр, а зблизька — на хор людей, які співають різні пісні, але всі впевнені, що вони в одній тональності.

Подивімося на приклад — він спеціально не доведений до робочого стану, бо нам зараз важливіша форма думки, ніж ідеально зібрана конфігурація залежностей:

import org.springframework.stereotype.Service;

@Service
public class CheckoutFacade {

    public Long placeOrderBad(Long customerId, Long productId, int qty) {
        // Крок 1: створюємо замовлення (часто всередині буде своя транзакція)
        Long orderId = orderDraftService.createDraft(customerId, productId, qty);

        // Крок 2: окрема операція із залишками (часто теж усередині своя транзакція)
        inventoryReservationService.reserve(productId, qty);

        // Повертаємо id, але реальна цілісність тут уже не гарантована
        return orderId;
    }
}

Ззовні це виглядає логічно: «створив чернетку замовлення, зарезервував залишки, повернув id». Але проблема починається, коли ви ставите запитання: а що буде, якщо другий крок упаде? Наприклад, reserve() кине виняток, бо залишків не вистачило. Якщо createDraft() уже встиг зафіксувати транзакцію, ви отримаєте замовлення в БД, але без реального резерву. Це не просто «трохи некрасиво», це модель, яка починає жити в суперечностях.

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

Візуально поганий дизайн можна показати так:

sequenceDiagram
    %% Поганий сценарій: коміт однієї частини і збій іншої -> «неповний стан» у БД
    participant U as "Зовнішній шар (контролер/фасад)"
    participant O as OrderDraftService
    participant I as InventoryReservationService
    participant DB as PostgreSQL

    U->>O: createDraft()
    O->>DB: "Коміт (замовлення збережено)"
    U->>I: reserve()
    I->>DB: "Збій (виняток)"
    Note over DB: У БД залишилося замовлення без резерву

Так, можна сказати «зробимо @Transactional на фасаді». І це вже буде крок у правильному напрямку — але тільки якщо ви справді зберете всю операцію під однією межею, а не залишите шматки «про всяк випадок» з REQUIRES_NEW або хитрою self-invocation-магією.

4. Патерн: одна транзакція

Тепер зберемо нормальний варіант. Він починається з дуже простої ідеї: в операції є єдина доля — отже, у неї є єдина транзакційна межа. Зазвичай це один публічний метод сервісу, який є точкою входу в сценарій.

Скелет виглядає так:

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

@Service
public class CheckoutService {

    @Transactional // Одна транзакція на весь сценарій
    public Long placeOrder(Long customerId, Long productId, int qty) {
        // 1) завантажили дані
        // 2) перевірили інваріанти (наприклад, чи вистачає залишків)
        // 3) змінили managed-сутності (dirty checking сам зробить UPDATE)
        // 4) створили нові сутності (їх потрібно зробити managed через save/persist)
        // 5) вийшли з методу -> commit/rollback
        return 42L;
    }
}

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

Давайте накидаємо реалістичний, але короткий варіант логіки, який добре поєднується з тим, що ви вже знаєте про managed-state і dirty checking:

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

@Service
public class CheckoutService {

    @Transactional // Вся операція оформляється атомарно
    public Long placeOrder(Long customerId, Long productId, int qty) {
        // Завантажуємо дані -> сутності потрапляють у persistence context (стають managed)
        Customer customer = customerRepository.findById(customerId).orElseThrow();
        Product product = productRepository.findById(productId).orElseThrow();

        // Завантажуємо залишки і змінюємо їх через доменну операцію
        InventoryItem item = inventoryRepository.findByProductId(productId).orElseThrow();
        item.reserve(qty); // Змінюємо managed-сутність -> Hibernate побачить зміни на flush

        // Створюємо нове замовлення і позиції (поки transient-об’єкти)
        PurchaseOrder order = PurchaseOrder.forCustomer(customer);
        order.addItem(OrderItem.from(product, qty));

        // Новий агрегат потрібно зробити managed, щоб Hibernate виконав INSERT-и
        return orderRepository.save(order).getId();
    }
}

Тут мовчки припускається нормальний aggregate mapping від PurchaseOrder до OrderItem — наприклад, cascade PERSIST або еквівалентний каскад на створення позицій. Інакше цей самий приклад миттєво перетвориться на кейс про mapping, а зараз нам важлива одна транзакційна доля всього графа.

Зверніть увагу на дві речі. По-перше, item.reserve(qty) — це доменна операція на managed-сутності: ми не викликаємо inventoryRepository.save(item) «для певності», бо Hibernate й так побачить зміни під час dirty checking. По-друге, orderRepository.save(order) тут доречний, бо PurchaseOrder — нова сутність, її потрібно зробити managed (через persist, який у Spring Data зазвичай ховається всередині save()).

Самі helper-методи в сутностях — це не прикраса, а спосіб зробити сервіс читабельним і зменшити ризик «зламати модель руками». Наприклад, InventoryItem.reserve(qty):

public void reserve(int qty) {
    // Захищаємо інваріанти доменної моделі
    if (qty <= 0) throw new IllegalArgumentException("qty має бути додатним");
    if (availableQty < qty) throw new IllegalStateException("недостатньо товару на складі");

    // Власне зміна стану (dirty checking зробить UPDATE на flush)
    availableQty -= qty;
    reservedQty += qty;
}

І аналогічно PurchaseOrder.addItem(...) допомагає тримати двобічний зв’язок консистентним: helper-метод одразу синхронізує обидві сторони й не залишає «напівзв’язок»:

public void addItem(OrderItem item) {
    // Колекція замовлення — одна сторона зв’язку
    items.add(item);

    // Друга сторона зв’язку: без цього рядка легко отримати «напівзв’язок»
    item.setOrder(this); // важливий рядок, який рятує від напівзв’язків
}

Сенс усього цього такий: сервіс керує сценарієм, сутності керують своєю внутрішньою консистентністю, а Hibernate керує синхронізацією з БД.

5. Hibernate всередині транзакції

Після того як ви написали «гарний» @Transactional-метод, виникає наступне типове запитання: «А що там реально відбувається в БД? Коли саме підуть INSERT і UPDATE?» І це якраз той момент, де курс Hibernate deep-dive починає виправдовувати свою назву: ми не віримо на слово, ми розуміємо механіку.

Всередині placeOrder() відбувається приблизно таке. Коли ви робите findById(), сутності (Customer, Product, InventoryItem) стають managed і потрапляють у persistence context. Коли ви викликаєте item.reserve(qty), змінюються поля managed-сутності, і Hibernate позначає її як «брудну» — точніше, виявить це під час dirty checking на flush. Коли ви створюєте PurchaseOrder і OrderItem, це поки просто нові Java-об’єкти (transient), але після orderRepository.save(order) вони стануть managed — через persist або merge, залежно від того, чи це новий об’єкт.

Коли метод закінчується, Spring намагається завершити транзакцію. У цей момент Hibernate виконує flush: формує SQL, надсилає його в БД, і лише потім відбувається commit JDBC-транзакції. Якщо десь по дорозі кинули виняток, транзакція відкочується, а зміни в БД не будуть зафіксовані.

Типовий SQL-профіль (спрощено) матиме приблизно такий вигляд:

-- читання
select * from customers where id = ?;
select * from products where id = ?;
select * from inventory_items where product_id = ?;

-- запис залишків (dirty checking -> UPDATE)
update inventory_items
set available_qty = ?, reserved_qty = ?
where id = ?;

-- створення замовлення і позицій
insert into purchase_orders (...) values (...);
insert into order_items (...) values (...);

І тут важлива деталь із попередніх днів: якщо ви всередині транзакції робите додаткові запити, які перетинаються зі зміненими сутностями, Hibernate може виконати flush раніше завершення методу (flush-before-query), щоб запити «бачили» узгоджену картину. У нашому прикладі цього може й не бути, але ваша голова має пам’ятати, що flush — окрема фаза, і SQL інколи з’являється раніше, ніж ви думали.

Окремо проговорю нюанс, який морально ламає новачків: rollback не відкочує Java-об’єкти в пам’яті. Якщо ви всередині placeOrder() змінили item і потім кинули виняток, item у пам’яті залишається зі зміненими полями — просто ці зміни не потраплять у БД. Це нормально. Це ще одна причина, чому після невдалої операції не треба намагатися «дожити» на тих самих об’єктах і далі користуватися ними так, ніби нічого не сталося: краще дати операції завершитися і почати нову одиницю роботи.

6. Транзакція має бути короткою

Є спокуса зробити транзакцію «на все»: і дані завантажити, і знижки порахувати, і лист надіслати, і з зовнішнім сервісом поспілкуватися, і заодно чайник увімкнути. Але транзакція — штука ревнива: чим довше ви її тримаєте, тим більше ресурсів БД ви утримуєте і тим менш передбачуваною стає поведінка (включно з банальними таймаутами та навантажувальними ефектами).

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

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

Смішний, але правдивий тест: якщо всередині транзакційного методу вам захотілося написати Thread.sleep(...), отже, десь в архітектурі плаче один DBA. Навіть якщо DBA у вас ще немає — він плаче авансом.

Сценарії читання — окремо

Дуже часто архітектурна каша починається з невинної думки: «раз ми оформляємо замовлення, давайте тут же прочитаємо його статус/деталі й повернемо назовні entity». У результаті сценарій запису перетворюється на гібрид «створив, зберіг, повернув managed-об’єкт, а десь там його ще серіалізували». І от ви вже знову поруч із LazyInitializationException, OSIV та іншими чудовими речами, які ми свідомо вимкнули в проєкті.

Правильний стиль простіший: метод оформлення замовлення повертає мінімально достатній результат, зазвичай orderId. А окремий сценарій читання статусу чи деталей працює в @Transactional(readOnly=true) або взагалі повертає projection/DTO, коли повноцінна entity для відповіді не потрібна. Навіть якщо зараз ви повертаєте всього лише String status, поділ за змістом усе одно робить код життєздатним.

Ось приклад чесного читання статусу:

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

@Service
public class OrderQueryService {

    @Transactional(readOnly = true) // Явно фіксуємо: це лише читання
    public String loadOrderStatus(Long orderId) {
        // Завантажуємо замовлення всередині транзакції, щоб не впертися в lazy поза контекстом
        PurchaseOrder order = orderRepository.findById(orderId).orElseThrow();

        // Повертаємо назовні просте значення, а не entity
        return order.getStatus().name();
    }
}

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

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

Helper-методи і self-invocation

І ще важливо пам’ятати: Spring застосовує @Transactional через proxy, а self-invocation може обійти цей proxy. У контексті сценарію оформлення замовлення це зазвичай проявляється так: розробник розбиває метод на кілька private методів і вішає на них @Transactional, очікуючи, що «кожен шматок буде транзакційним». А потім шматки раптом виявляються нетранзакційними, і починається шаманство.

Для сценарію оформлення замовлення хороший стиль такий: розбивайте код на приватні методи, але сприймайте їх як звичайні helper-методи, а не як незалежні транзакційні точки.

Наприклад, так можна (і треба) робити — бо транзакція все одно одна, на зовнішньому методі:

@Transactional // Транзакція оголошена на зовнішній точці входу
public Long placeOrder(Long customerId, Long productId, int qty) {
    // Приватні методи — це просто декомпозиція, а не «нові транзакції»
    InventoryItem item = loadInventory(productId);
    item.reserve(qty);

    PurchaseOrder order = createOrder(customerId, productId, qty);

    // Зберігаємо новий агрегат, щоб він став managed і потрапив у flush
    return orderRepository.save(order).getId();
}

А loadInventory(...) і createOrder(...) нехай будуть звичайними методами без анотацій. Ви виграєте читабельність, але не будете будувати хибних очікувань, ніби в кожного helper-методу своя транзакційна доля.

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

7. Типові помилки: межа транзакції

Майже всі помилки в транзакціях починаються не з Hibernate і навіть не зі Spring, а з туманного розуміння: що я взагалі намагаюся гарантувати? Тому нижче — набір граблів, на які найчастіше наступають, коли реалізують сценарій оформлення замовлення. Я опишу їх як життєві історії: так запам’ятовується краще, ніж «пункт 1, пункт 2».

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

Помилка № 2: робити readOnly=true на методі, який змінює дані, бо «так швидше».
Це одна з найпідступніших пасток: метод називається наче placeOrder(), всередині ви змінюєте InventoryItem, створюєте PurchaseOrder, а анотація каже readOnly=true. Такий код стає двозначним з погляду читання: ви читаєте його як «не змінюємо», а він насправді змінює. Навіть якщо воно «випадково працює», супровід перетворюється на детектив.

Помилка № 3: механічно викликати save() для managed-сутності після кожної зміни.
У сценарії оформлення замовлення InventoryItem зазвичай завантажується через репозиторій, стає managed, і достатньо викликати item.reserve(qty). Якщо після цього ви робите inventoryRepository.save(item), ви часто просто закріплюєте нерозуміння dirty checking. Плюс ви ускладнюєте читання коду: здається, що без save() нічого б не збереглося, хоча це не так.

Помилка № 4: повертати назовні PurchaseOrder як entity і продовжувати працювати з ним «після транзакції».
Це виглядає зручно («я поверну замовлення і далі в контролері щось зроблю»), але в реальності ви повертаєте об’єкт, який після виходу з методу стане detached. Будь-яка спроба ліниво довантажити колекції або зв’язки поза транзакцією може впасти, а ще гірше — «випадково» спрацювати в іншому оточенні й породити непередбачуваний SQL. Для сценарію запису набагато чесніше повернути orderId і все, а читання винести окремо.

Помилка № 5: розтягувати транзакцію на зайву роботу й «чекати» всередині неї.
Якщо метод оформлення замовлення всередині транзакції робить щось довге (виклики зовнішніх сервісів, важкі обчислення, очікування користувача), ви перетворюєте БД на заручницю свого методу. Це погіршує передбачуваність і масштабованість навіть у навчальному проєкті. Транзакція має бути короткою і цілеспрямованою: завантажили, перевірили, записали, вийшли.

1
Опитування
Транзакції Spring, рівень 17, лекція 4
Недоступний
Транзакції Spring
Hibernate і межі сесії
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ