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(), за ними й відновили склад.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ