Заказ в маркетплейсе — это не просто запись в базе. За ним стоит цепочка: выбрать товары, зарезервировать остатки, получить оплату, передать продавцу, доставить, и только потом закрыть. А если что-то пошло не так — спор или возврат. Весь этот процесс обслуживает один сервис — Order Service.
Разберём, как он устроен изнутри: какие сущности выделяются, как они общаются и почему спор — это отдельный объект, а не просто флаг у заказа.
Что делает Order Service
Его задача — провести заказ от первого «добавить в корзину» до финального «завершён». При этом сам сервис не хранит каталог товаров, не списывает деньги и не двигает остатки — это делают соседние сервисы. Order Service координирует их и хранит состояние заказа.
С ним работают три категории пользователей:
- Покупатель — создаёт заказ, оплачивает, может отменить или открыть спор.
- Продавец — видит заказы по своим товарам, отмечает отправку.
- Оператор — разбирает споры, может отменить любой заказ.
Жизненный цикл заказа
Заказ проходит через несколько статусов. Это не произвольные пометки, а строгая машина состояний — перейти из любого статуса в любой нельзя.
DRAFT → PENDING_PAYMENT → PAID → SHIPPED → DELIVERED → COMPLETED
↓
EXPIRED (не оплатили за 15 минут)
Дополнительные пути:
- Из
PAIDилиDRAFT— отмена вCANCELLED/REFUNDED. - Из
DELIVERED— спор вDISPUTED, потомCOMPLETEDилиREFUNDED.
DRAFT — покупатель набрал товары, заказ ещё не подтверждён. Можно менять позиции.
PENDING_PAYMENT — заказ подтверждён, остатки зарезервированы в Inventory. У покупателя есть 15 минут на оплату. Если не успел — заказ переходит в EXPIRED.
PAID — оплата прошла. Теперь шар на стороне продавца.
SHIPPED → DELIVERED — продавец отметил отправку, затем логистика подтверждает вручение.
COMPLETED — заказ закрыт. Происходит автоматически через 14 дней после доставки, если покупатель не открыл спор.
Почему спор и возврат — отдельные сущности
Первое желание — добавить поле disputeReason в таблицу orders. Но тогда у спора нет собственного жизненного цикла: нельзя отслеживать, ответил ли продавец, когда истекает срок, что решил оператор.
В Order Service спор и возврат выделены в отдельные агрегаты — самостоятельные объекты с собственными состояниями.
Агрегат Dispute (спор)
Покупатель может открыть спор в течение 14 дней после доставки. Спор живёт отдельно, но ссылается на заказ по ID.
OPEN → AWAITING_SELLER → UNDER_REVIEW → RESOLVED_FOR_BUYER
↘ RESOLVED_FOR_SELLER
Продавец получает 3 дня на ответ. Если промолчал — спор переходит в UNDER_REVIEW автоматически. Оператор изучает и выносит решение.
Важное правило: спор ≠ возврат. Если оператор решил в пользу продавца — никакого возврата нет, заказ завершается.
Агрегат Refund (возврат)
Возврат денег — тоже процесс, а не мгновенная операция. Нужно сначала снять резерв в Inventory, потом вернуть деньги через Payment. Каждый шаг может упасть, нужны повторы и компенсации.
REQUESTED → RESERVATION_RELEASED → REFUNDING → COMPLETED
↘ ↘ FAILED
Refund создаётся в двух случаях: покупатель отменил оплаченный заказ, или спор решился в его пользу. В обоих случаях Order ждёт события RefundCompleted и только после него переходит в REFUNDED.
Как агрегаты координируются
Три агрегата — три границы транзакций. Они не вызывают методы друг друга напрямую, а общаются через доменные события.
Пример: оплата заказа.
Покупатель → подтверждает заказ
Order: DRAFT → PENDING_PAYMENT, публикует OrderConfirmed
Inventory: получает OrderConfirmed → резервирует остатки → публикует ItemReserved
Покупатель → платит
Payment → публикует PaymentSucceeded
Order: получает PaymentSucceeded → PENDING_PAYMENT → PAID
Пример: спор с возвратом.
Покупатель открывает спор → Dispute создаётся
Order: DELIVERED → DISPUTED
...оператор решает в пользу покупателя...
Dispute публикует DisputeResolved(buyer)
Order создаёт Refund
Refund идёт в Inventory (снять резерв) и Payment (вернуть деньги)
Refund публикует RefundCompleted
Order: DISPUTED → REFUNDED
Каждый переход — результат события. Агрегаты не знают деталей реализации друг друга, только форму события.
Как события не теряются: Outbox
Представьте: Order записал PAID в базу, а потом упал до отправки события в Kafka. Inventory так и не узнал о заказе. Деньги списаны, резерва нет.
Для этого используют паттерн Outbox: событие записывается в ту же транзакцию, что и изменение состояния агрегата. Отдельный процесс (relay) читает таблицу outbox и отправляет события в Kafka. Если relay упал — перезапустится и отправит снова. Получатели обрабатывают события идемпотентно (запоминают уже обработанные).
Что заказ знает о ценах
Цены фиксируются в момент оформления заказа. Если продавец поднял цену через час — ваш заказ уже содержит старую цену в поле unitPrice каждой позиции. Это правило BR-O04: цена позиции в заказе никогда не меняется после создания.
Что хранится в базе
Основные таблицы:
orders— заголовок заказа: статус, итоговая сумма, ссылки на резерв и платёж.order_items— позиции: товар, продавец, количество, цена на момент покупки.disputes— споры, логическая ссылка на заказ поorder_id(без внешнего ключа — разные агрегаты).refunds— возвраты, аналогично.outbox— события, ждущие отправки в Kafka.
Связи между агрегатами — только через order_id без FK. Это принципиально: каждый агрегат можно масштабировать, мигрировать и деплоить независимо.
Коротко
- Order Service координирует заказ, но не хранит каталог, остатки и деньги — это соседние сервисы.
- Заказ проходит строгую машину состояний:
DRAFT → PENDING_PAYMENT → PAID → SHIPPED → DELIVERED → COMPLETED. - Спор и возврат — отдельные агрегаты со своим жизненным циклом, потому что у них своя логика, таймауты и правила.
- Агрегаты координируются через доменные события — никаких прямых вызовов между ними.
- Outbox гарантирует: изменение состояния и публикация события атомарны.
- Цена фиксируется при создании заказа и не меняется.
- Спор ≠ возврат: спор может решиться в пользу продавца без выплаты покупателю.
Что почитать дальше
- Как устроена Use Case спецификация — формат, из которого строится Order Service.
- DDD: агрегаты и доменные события — теория за трёхагрегатной моделью.
- Outbox и Saga в распределённых системах — как события не теряются при сбоях.