JavaRush /Курси /Spring Data JPA /Фінальна збірка OrderServi...

Фінальна збірка OrderService

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

1. Фінальна збірка та читабельний сервіс

Інваріанти вже названо, порядок кроків зрозумілий, а commit/rollback rules більше не виглядають магією. Залишилося зібрати все це в один OrderService, який читається згори донизу як нормальний use case, а не як музей випадкових save(...).

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

Такий рефакторинг потрібен не для краси заради краси. Він потрібен, щоб у операції було одне зрозуміле місце входу, а деталі кроків не перетворювали сервіс на «суп із if-ів, save-ів і випадкових змінних».

2. Каркас OrderService: залежності та відповідальність

Репозиторії, як і раніше, роблять свою роботу: читають і записують дані. Сервіс збирає use case, у якому сходяться CustomerOrder, OrderItem, Product і StockItem. Тому в OrderService нам потрібні лише три залежності — по одній на кожен вид даних, з якими працює сценарій.

package com.example.shopdatajpa.ordering.service;

import com.example.shopdatajpa.catalog.repository.ProductRepository;
import com.example.shopdatajpa.inventory.repository.StockItemRepository;
import com.example.shopdatajpa.ordering.repository.CustomerOrderRepository;
import org.springframework.stereotype.Service;

@Service
public class OrderService {

    // Репозиторій замовлень: створює, читає та оновлює самі замовлення і пов’язані дані
    private final CustomerOrderRepository orderRepository;
    // Репозиторій товарів: потрібен, щоб отримати ціну та пов’язати OrderItem із Product
    private final ProductRepository productRepository;
    // Репозиторій залишків: потрібен для списання та повернення кількості
    private final StockItemRepository stockItemRepository;

    // Звичайне впровадження через конструктор: залежності видно явно й вони не ховаються
    public OrderService(CustomerOrderRepository orderRepository,
                        ProductRepository productRepository,
                        StockItemRepository stockItemRepository) {
        this.orderRepository = orderRepository;
        this.productRepository = productRepository;
        this.stockItemRepository = stockItemRepository;
    }
}

Цього каркаса достатньо: далі вся цінність не в DI як такому, а в тому, як усередині сервісу зібрано сам placeOrder().

3. placeOrder() згори донизу

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

І окремо: межа транзакції має бути на одному публічному методі сервісу. Допоміжні методи можуть бути приватними, але @Transactional живе на вході в сценарій, а не «на шматочках усередині».

import com.example.shopdatajpa.common.persistence.DeliveryAddress;
import com.example.shopdatajpa.ordering.service.dto.OrderLineInput;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Transactional
public Long placeOrder(String email, DeliveryAddress address, List<OrderLineInput> items) {
    // Спершу валідуємо вхід: до будь-яких змін даних у БД
    validatePlaceOrderRequest(email, address, items);

    // Створюємо "заголовок" замовлення ще без позицій
    var order = createOrder(email, address);

    // Додаємо позиції замовлення по одній — кожна сама дбає про перевірки й залишки
    for (OrderLineInput line : items) {
        addLine(order, line);
    }

    // Фінально зберігаємо замовлення разом із позиціями через зв’язки та каскади, якщо вони налаштовані
    return orderRepository.save(order).getId();
}

І важливий фільтр на весь метод: orderRepository.save(order) — це ще не окрема перемога. Операція вважається успішною лише якщо метод дійшов до кінця і транзакція закомітилася.

4. Допоміжні методи: кроки сценарію

Слово helper іноді звучить так, ніби це щось другорядне. Але в сервісній логіці допоміжні методи — це не дрібниця, а спосіб дати імʼя важливому кроку. Коли крок отримує імʼя, у вас з’являється можливість обговорювати його як бізнес-частину: «а чи має перевірка залишків бути тут?», «а що, коли email порожній?», «а порядок дій правильний?».

При цьому важливо не піти в іншу крайність: не потрібно робити 30 методів по одному рядку кожен. Ми тримаємо баланс: один метод — один смисловий крок операції.

Валідація входу: «рання відмова» без напівмертвих замовлень

Першу частину перевірок — нормальний email, адреса, непорожній список, додатні кількості — ми робимо до будь-яких змін даних. Це зменшує шанс, що ви почнете збирати замовлення, а потім зрозумієте, що вхід узагалі сміттєвий.

import com.example.shopdatajpa.common.persistence.DeliveryAddress;
import com.example.shopdatajpa.ordering.service.dto.OrderLineInput;

import java.util.List;

private void validatePlaceOrderRequest(String email,
                                       DeliveryAddress address,
                                       List<OrderLineInput> items) {
    // Мінімальна перевірка: чи можна взагалі починати сценарій
    if (email == null || email.isBlank()) {
        throw new IllegalArgumentException("Email обовʼязковий");
    }
    if (address == null) {
        throw new IllegalArgumentException("Адреса доставки обовʼязкова");
    }
    // Замовлення без позицій — майже завжди помилка клієнта або форми
    if (items == null || items.isEmpty()) {
        throw new IllegalArgumentException("Замовлення має містити щонайменше одну позицію");
    }
}

Цього мінімуму достатньо: якщо базові вхідні дані зламані, ми навіть не починаємо будувати замовлення.

Створення замовлення: один метод — один «знімок наміру»

Створення замовлення — це не просто new CustomerOrder(). Ми одразу фіксуємо мінімально коректний початковий стан: статус, нульову суму, email, адресу та номер замовлення, якщо ви вже зберігаєте orderNumber.

import com.example.shopdatajpa.common.persistence.DeliveryAddress;
import com.example.shopdatajpa.ordering.entity.CustomerOrder;
import com.example.shopdatajpa.ordering.entity.OrderStatus;

import java.math.BigDecimal;
import java.util.UUID;

private CustomerOrder createOrder(String email, DeliveryAddress address) {
    CustomerOrder order = new CustomerOrder();
    order.setOrderNumber(UUID.randomUUID().toString());
    order.setCustomerEmail(email);
    order.setDeliveryAddress(address);
    order.setStatus(OrderStatus.NEW);
    order.setTotalAmount(BigDecimal.ZERO);
    return order;
}

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

Додавання позиції: усередині — деталі, зовні — один крок

Тепер ключовий метод: addLine(order, line). Саме він і має містити всю механіку однієї позиції замовлення: завантаження товару й складу, перевірку залишку, складання OrderItem, оновлення суми замовлення та зменшення залишку.

Але знову робитимемо це не однією «простинею», а невеликими кроками.

private void addLine(CustomerOrder order, OrderLineInput line) {
    // Кількість із входу; далі її використовуємо в перевірках, обчисленні суми та списанні
    int qty = line.getQuantity();

    // Завантажуємо товар і відповідний складський залишок
    var product = loadProduct(line.getProductId());
    var stockItem = loadStockItem(product.getId());

    // Перед змінами переконуємося, що списання можливе
    ensureEnoughStock(stockItem, qty);

    // Будуємо позицію замовлення як "знімок" ціни на момент покупки
    var item = buildOrderItem(product, qty);

    // Додаємо позицію в замовлення: тут важливо утримати зв’язки й перерахувати суму
    order.addItem(item);

    // Списуємо залишок і зберігаємо зміну по складу
    decreaseStock(stockItem, qty);
    stockItemRepository.save(stockItem);
}

Зверніть увагу на один тонкий момент: order.addItem(item) — це не просто «додати до списку». Це місце, де ми хочемо утримати зв’язок OrderItem -> CustomerOrder і перерахувати суму. Тому ми винесемо це в допоміжний метод на сутності — трохи пізніше.

5. Допоміжний метод у CustomerOrder

Перенесення частини логіки в сутність часто лякає новачків: «а раптом я зроблю DDD, і все стане складно». Ми не будуємо складну доменну модель. Ми робимо просту річ: додаємо метод, який допомагає утримати узгодженість даних. Це майже як «не забути зачинити двері», тільки у світі Java-об’єктів.

Ідея така: коли ви додаєте OrderItem у замовлення, мають відбутися три пов’язані дії. Позиція має потрапити до колекції items. Позиція має отримати посилання на замовлення через item.setOrder(this). І сума totalAmount має збільшитися на lineTotal. Якщо ви робите це вручну в сервісі в трьох місцях, рано чи пізно ви забудете один із кроків.

import com.example.shopdatajpa.ordering.entity.OrderItem;

import java.math.BigDecimal;

public void addItem(OrderItem item) {
    // 1) Додаємо до колекції, щоб замовлення "володіло" позицією
    items.add(item);

    // 2) Утримуємо зворотне посилання: це важливо для JPA-зв’язків і цілісності об’єкта
    item.setOrder(this);

    // 3) Перераховуємо суму замовлення як частину цього самого кроку
    BigDecimal nextTotal = totalAmount.add(item.getLineTotal());
    totalAmount = nextTotal;
}

Це маленький, але дуже «дорослий» шматок коду. Він робить структуру вашого сервісу простішою, а модель — більш самопогодженою. І так: щоб це працювало, items і totalAmount мають бути ініціалізовані (наприклад, items = new ArrayList<>(), totalAmount = BigDecimal.ZERO) — це базова гігієна сутності.

6. Позиція замовлення: завантаження, гроші, залишки

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

Завантаження Product: помилка має бути зрозумілою

Якщо товар не знайдено, ми не можемо продовжувати. І краще кинути виняток із нормальним текстом, ніж просто orElseThrow() без повідомлення. Потім ви самі себе проклянете в логах.

import com.example.shopdatajpa.catalog.entity.Product;

private Product loadProduct(Long productId) {
    return productRepository.findById(productId)
            // У повідомленні залишаємо id, щоб потім було що шукати в логах
            .orElseThrow(() -> new IllegalArgumentException("Товар не знайдено: id=" + productId));
}

Ми використовуємо IllegalArgumentException, тому що це схоже на «вхідні дані некоректні» для поточної операції.

Завантаження StockItem: окрема сутність — окрема перевірка

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

import com.example.shopdatajpa.inventory.entity.StockItem;

private StockItem loadStockItem(Long productId) {
    return stockItemRepository.findByProductId(productId)
            // Тут проблема радше в стані системи, а не у вхідних даних користувача
            .orElseThrow(() -> new IllegalStateException("Залишок не знайдено для товару id=" + productId));
}

Тут уже IllegalStateException: вхід начебто нормальний, але стан системи не дозволяє оформити замовлення.

Перевірка залишку: проста й жорстка

Перевірка залишку має бути максимально простою й прямою. Чим менше «розумності», тим менше шансів продати мінус 5 пачок печива.

private void ensureEnoughStock(StockItem stockItem, int quantity) {
    // Локальна перевірка кількості: захищає крок навіть якщо загальна валідація зміниться
    if (quantity <= 0) {
        throw new IllegalArgumentException("Кількість має бути більшою за нуль");
    }
    // Головна перевірка: не можна списати більше, ніж доступно
    if (stockItem.getAvailableQuantity() < quantity) {
        throw new IllegalStateException("Недостатньо товару на складі");
    }
}

Цей метод розв’язує рівно одне завдання: усередині одного placeOrder() не дати нам прийняти замовлення, яке вже за поточним станом складу неможливе. Але не варто приписувати йому зайву магію: якщо дві різні транзакції одночасно сперечаються за один і той самий StockItem, це вже питання конкуренції кількох операцій, а не просто акуратної валідації кроку.

Так, ми знову перевірили quantity > 0, хоча вже робили це раніше. Це нормально: локальний захист кроку не заважає загальному захисту входу, а іноді навіть рятує під час майбутніх змін коду.

Збірка OrderItem: знімок ціни та сума позиції

OrderItem — це не «посилання на товар + кількість». Це маленький фінансовий документ. Він має пам’ятати ціну на момент покупки. Інакше ви зміните ціну товару завтра — і раптом усі старі замовлення «перерахуються» заднім числом. Клієнт буде радий, бухгалтер — ні.

import com.example.shopdatajpa.catalog.entity.Product;
import com.example.shopdatajpa.ordering.entity.OrderItem;

import java.math.BigDecimal;

private OrderItem buildOrderItem(Product product, int quantity) {
    // Ціна фіксується на момент замовлення
    BigDecimal unitPrice = product.getPrice();
    // Сума позиції рахується тут же, щоб не приймати її "ззовні" і не помилитися
    BigDecimal lineTotal = unitPrice.multiply(BigDecimal.valueOf(quantity));

    OrderItem item = new OrderItem();
    item.setProduct(product);
    item.setQuantity(quantity);
    item.setUnitPrice(unitPrice);
    item.setLineTotal(lineTotal);
    return item;
}

Цей метод короткий, але в ньому одразу видно: unitPrice беремо з товару, lineTotal рахуємо тут, а не приймаємо ззовні.

Зміна залишку: один метод — одна дія

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

private void decreaseStock(StockItem stockItem, int quantity) {
    // Важливо не переплутати знак: тут саме списання
    int next = stockItem.getAvailableQuantity() - quantity;
    stockItem.setAvailableQuantity(next);
}

Ми не робимо тут додаткових перевірок, тому що їх уже зробила ensureEnoughStock(...). Сервіс тепер схожий на ланцюжок перевірок і дій, а не на хаотичне редагування полів.

7. cancelOrder(): бізнес-відміна

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

@Transactional
public void cancelOrder(Long orderId) {
    CustomerOrder order = loadOrder(orderId);

    if (order.getStatus() == OrderStatus.CANCELLED) {
        throw new IllegalStateException("Замовлення вже скасовано");
    }

    restoreStockForOrder(order);

    order.setStatus(OrderStatus.CANCELLED);
    orderRepository.save(order);
}

І тут корисно тримати ту саму дисципліну імен: завантаження замовлення і повернення залишків зʼїжджають у допоміжні методи, щоб верхній сценарій залишався читабельним. Тут важливий сам принцип: відміна спирається на збережені OrderItem, а не на новий вхідний список товарів. Тому вона і виглядає як окрема сервісна операція, а не як ще одна версія rollback.

8. Карта методів OrderService

Коли ви розбиваєте код на допоміжні методи, корисно мати внутрішню карту: де що лежить і хто за що відповідає. Інакше можна випадково перетворити сервіс на «набір випадкових приватних методів», де теж нічого не зрозуміло — просто хаос став красиво нарізаним.

Нижче — приклад того, як можна подумки розкласти OrderService на групи методів. Це не «правило Spring», а просто зручна дисципліна читання.

Група Приклади методів Ідея
Вхідні сценарії placeOrder(...), cancelOrder(...) Короткі, читаються як історія
Валідація validatePlaceOrderRequest(...), ensureEnoughStock(...) Кажуть «можна / не можна»
Створення / збирання createOrder(...), buildOrderItem(...) Збирають коректні об’єкти
Завантаження loadProduct(...), loadStockItem(...), loadOrder(...) Перетворюють id на сутність або падають
Зміна даних decreaseStock(...), restoreStockForOrder(...) Застосовують зрозумілу зміну стану

Якщо у вас placeOrder() раптом починає викликати buildSomethingWeird(), який усередині ще й лізе в репозиторій, і ще й змінює статус, це сигнал, що межі методів «попливли».

9. Схема потоку placeOrder()

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

flowchart TD
    A["placeOrder()"] --> B[валідація входу]
    B --> C[createOrder]
    C --> D{для кожної позиції}
    D --> E[loadProduct]
    E --> F[loadStockItem]
    F --> G[ensureEnoughStock]
    G --> H[buildOrderItem]
    H --> I[order.addItem]
    I --> J[decreaseStock]
    J --> K[save StockItem]
    D -->|після всіх позицій| L[save CustomerOrder]
    L --> M[метод завершився без помилки]

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

10. Типові помилки під час фінальної збірки OrderService

У цьому розділі ми акуратно пройдемося по найчастіших пастках. Вони особливо підступні тим, що проєкт може «ніби працювати», але в даних поступово з’являється дивне життя. Краще один раз побачити ці помилки на власні очі й більше ніколи не повторювати, ніж потім розбирати, чому в нас замовлення CANCELLED, а залишок не повернувся.

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

Помилка №2: спроба поставити @Transactional на приватні допоміжні методи і чекати, що вони “створять свою транзакцію”.
Якщо ви вже стикалися з цим учора — не дивуйтеся, ця помилка повертається як серіал на 12 сезонів. Транзакція має починатися в публічному методі сервісу, який реально викликається ззовні Spring-біна. Приватні методи — це просто шматочки того самого сценарію, а не «другі входи» в інфраструктуру.

Помилка №3: totalAmount оновлюється в кількох місцях за різними правилами.
Наприклад, один раз ви робите order.setTotalAmount(...) прямо в сервісі, другий раз — усередині addItem(), а третій — узагалі забули оновити. У результаті сума замовлення стає «сюрпризом», зазвичай неприємним. Гарне лікування — обрати один спосіб: або сума рахується через order.addItem(...), або ви рахуєте її окремо, але строго в одному місці і за одним алгоритмом.

Помилка №4: cancelOrder() зроблено як “просто поставити статус CANCELLED”.
Це най«психологічно зрозуміліша» помилка: «ну відміна ж — це статус». Але в нашому домені відміна має ще й повернути залишок. Якщо ви забули повернення, дані починають суперечити бізнесу: замовлення скасовано, а товар усе одно “зник” зі складу. Правильна відміна завжди спирається на збережені OrderItem і повертає quantity назад у StockItem.

Помилка №5: повернення залишків у відміні робиться “за вхідними даними”, а не за фактичними позиціями замовлення.
Іноді хочеться написати cancelOrder(orderId, itemsFromRequest). Це створює діру в цілісності: вам можуть надіслати “не ті” позиції, ви повернете “не ту” кількість, і база стане театром абсурду. Відміна має працювати від факту: завантажили замовлення, взяли order.getItems(), за ними й відновили склад.

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