JavaRush /Курси /Spring Data JPA /Композиція сервісів у mini-shop

Композиція сервісів у mini-shop

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

1. Цілісність даних і композиція сервісів

Коли проєкт маленький, дуже легко написати все в одному сервісі й радіти: «працює ж!». Але mini-shop у нас уже розділений за фічами (catalog, inventory, ordering), і це нормально: бізнес-світ теж не плоский. Проблема починається тоді, коли ці сервіси починають викликати один одного, а кожен намагається підстрахуватися транзакцією по-своєму. У результаті ваша бізнес-операція перестає бути цілісною.

Композиція сервісів у шарі даних — це не про естетику і не про архітектуру заради архітектури. Це про дуже приземлену річ: щоб після placeOrder(productId, quantity) не залишалося «незавершених замовлень», «незавершених резервів», «неповністю оновлених залишків» і всього того болю, який потім лагодять нічними скриптами й тихою ненавистю до свого минулого «я». Нам потрібно, щоб одна операція мала одну долю: або все вдалося, або все відкотилося.

Щоб не загрузнути в теорії, триматимемо в голові один конкретний сценарій: placeOrder(productId, quantity). Він охоплює одразу три зони: каталог — перевірити товар, залишки — зарезервувати, замовлення — створити замовлення і позиції. Якщо все це «роз’їдеться» по різних транзакціях, цілісність даних роз’їдеться разом із ними.

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

2. Власник транзакції placeOrder()

Найкорисніший патерн на нашому рівні звучить просто: зовнішній сервіс — власник транзакції, внутрішні сервіси — учасники. Тобто точка входу в сценарій використання (OrderService.placeOrder) відкриває транзакцію, а далі всі виклики вниз (catalog/inventory) відбуваються всередині цієї ж транзакції.

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

Схематично правильна композиція виглядає так: один сценарій — одна транзакція.

sequenceDiagram
    participant C as CatalogService
    participant I as InventoryService
    participant O as OrderService
    participant DB as Database

    Note over O: "TX-1 починається (@Transactional)"
    O->>C: requireActiveProduct(productId)
    C->>DB: SELECT product...
    O->>I: reserve(productId, qty)
    I->>DB: SELECT stock_item...
    I->>DB: UPDATE stock_item...
    O->>DB: INSERT customer_order...
    O->>DB: INSERT order_item...
    Note over O: "TX-1 фіксація"

А тепер маленький, але реалістичний за змістом фрагмент коду placeOrder():

import org.springframework.transaction.annotation.Transactional;

@Transactional // Зовнішній сценарій використання: саме тут задаємо межі фіксації та відкату
public Long placeOrder(Long productId, int quantity) {
    catalogService.requireActiveProduct(productId);   // 1) перевіряємо, що товар можна продавати
    inventoryService.reserve(productId, quantity);    // 2) резервуємо залишок (у тій же транзакції)

    CustomerOrder order = CustomerOrder.singleItem(productId, quantity); // 3) формуємо доменну модель
    orderRepository.save(order);                      // 4) явна точка збереження результату операції
    return order.getId();                             // 5) повертаємо ідентифікатор створеного замовлення
}

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

3. Внутрішні сервіси і Propagation.REQUIRED

У placeOrder() внутрішні сервіси не повинні вигадувати для даних окрему долю. CatalogService.requireActiveProduct(...) і InventoryService.reserve(...) — це кроки однієї операції, тому для них природним є Propagation.REQUIRED: коли зовнішня транзакція вже відкрита, вони просто беруть участь у ній.

Ось такий стиль для внутрішнього кроку резервування якраз і потрібен:

import org.springframework.transaction.annotation.Transactional;

@Transactional // propagation = REQUIRED за замовчуванням: приєднуємося до зовнішньої транзакції, якщо вона вже є
public void reserve(Long productId, int quantity) {
    StockItem stock = stockRepository.findByProductId(productId).orElseThrow(); // читаємо поточний залишок
    stock.reserve(quantity);               // бізнес-правило в доменній моделі: перевіряє та змінює стан
    stockRepository.save(stock);           // явна точка збереження (у межах спільного стилю)
}

Якщо reserve() викликали з placeOrder(), він вбудується в загальну транзакцію. Якщо його викликали окремо як самостійний сценарій використання, тоді він відкриє свою. У цьому і є практичний сенс REQUIRED: внутрішній сервіс лишається повторно використовуваним, але не стає новим власником фіксації та відкату.

4. REQUIRES_NEW і часткові фіксації

REQUIRES_NEW у placeOrder() ламає картину майже відразу. Якщо винести reserve() в окрему транзакцію, резерв може зафіксуватися раніше за замовлення. Тоді зовнішній сценарій впаде й відкотиться, а склад уже буде змінено.

Уявімо поганий сценарій. Ви вирішили, що резерв залишків — надзвичайно важлива річ, і зробили так:

import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

@Transactional(propagation = Propagation.REQUIRES_NEW) // УВАГА: окрема транзакція, незалежна від зовнішнього сценарію
public void reserve(Long productId, int quantity) {
    StockItem stock = stockRepository.findByProductId(productId).orElseThrow(); // читаємо залишок
    stock.reserve(quantity); // змінюємо стан
    stockRepository.save(stock); // фіксація цієї зміни відбудеться окремо від placeOrder()
}

Тепер reserve() завжди запускає свою транзакцію. І ось що може статися: резерв успішно зафіксувався, а потім збереження замовлення впало, наприклад через помилку даних або constraint у базі. Зовнішня операція відкочується — але резерв уже не відкотити, він живе своїм життям.

Схема «як ви випадково перетворюєте одну операцію на кілька незалежних»:

sequenceDiagram
    participant O as "OrderService (TX-A)"
    participant I as "InventoryService (TX-B)"
    participant DB as Database

    Note over O: "TX-A починається"
    O->>I: reserve() (REQUIRES_NEW)
    Note over I: "TX-B починається (незалежна)"
    I->>DB: UPDATE stock_item...
    Note over I: "TX-B фіксація"
    O->>DB: INSERT customer_order...
    Note over O: "помилка"
    Note over O: "TX-A відкат"
    Note over DB: "stock_item вже змінено => неузгоджений стан"

Для основних кроків placeOrder() цього вже достатньо, щоб відмовитися від REQUIRES_NEW. Тут потрібен спільний commit/rollback. Окрема транзакційна доля доречна лише для побічного запису, який справді має пережити провал замовлення, наприклад окремого failure-log.

5. Межі сценарію використання і @Transactional

У placeOrder() легко почати розмивати межу сценарію через допоміжні методи: окремо порахувати суму, окремо зібрати CustomerOrder, окремо щось перевірити й усюди на всяк випадок повісити @Transactional. Не потрібно. Чисті обчислення і складання доменної моделі спокійно живуть усередині вже відкритої операції без власної транзакції.

Якщо метод не лізе в БД і не змінює її стан, він не є власником commit/rollback. У композиції сервісів це особливо важливо: транзакція має висіти на placeOrder(), а не розповзатися по кожному внутрішньому шматку.

6. Винятки в композиції сервісів

У композиції сервісів виняток — це не локальна дрібниця, а сигнал про долю всієї операції. Якщо reserve() не спрацював, placeOrder() уже не можна вважати успішним. Тому шаблон try/catch, лог і «йдемо далі» тут майже завжди ламає дані.

Ось як виглядає поганий варіант:

import org.springframework.transaction.annotation.Transactional;

@Transactional // Транзакція є, але логіка нижче ламає сенс операції
public Long placeOrder(Long productId, int quantity) {
    try {
        inventoryService.reserve(productId, quantity);
    } catch (OutOfStockException e) {
        // Поганий патерн: помилка була, а ми вдаємо, що все гаразд
        log.warn("Резервування не вдалося, але ми продовжуємо...", e);
    }

    CustomerOrder order = CustomerOrder.singleItem(productId, quantity);
    orderRepository.save(order); // замовлення створюється навіть без резерву => неузгоджені дані
    return order.getId();
}

Якщо така помилка змінює сенс сценарію, її не ковтають: максимум додають лог і перекидають далі, щоб відкотити весь placeOrder(). Інакше ви самі зафіксуєте стан, у якому замовлення вже є, а товару під нього фактично не зарезервовано.

7. Єдиний стиль точок запису

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

Це не універсальна формула на кшталт «без save() запису не буде». Тут ми свідомо не йдемо в dirty checking, бо зараз важливіша загальна доля placeOrder() між сервісами, а не внутрішня механіка persistence context.

8. Типові помилки під час композиції сервісів і транзакцій

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

Помилка №2: ставити REQUIRES_NEW на основний крок на кшталт reserve() «для надійності».
REQUIRES_NEW не підсилює цілісність, а змінює правила: тепер у кроку свій окремий commit. Для логування невдалої спроби це може бути доречно. Для основної доменної зміни всередині placeOrder() — зазвичай прямий шлях до часткових фіксацій.

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

Помилка №4: сподіватися, що анотації на репозиторіях самі зберуть бізнес-операцію.
У репозиторіїв є свої налаштування за замовчуванням, але вони не роблять репозиторій власником placeOrder(). Якщо транзакційну межу не позначено на сервісі, виклики до даних легко розповзаються в набір незалежних дій. Репозиторій має залишатися інструментом доступу, а не режисером сценарію.

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