← назад к разделу

Заказ в маркетплейсе — это не просто запись в базе. За ним стоит цепочка: выбрать товары, зарезервировать остатки, получить оплату, передать продавцу, доставить, и только потом закрыть. А если что-то пошло не так — спор или возврат. Весь этот процесс обслуживает один сервис — Order Service.

Разберём, как он устроен изнутри: какие сущности выделяются, как они общаются и почему спор — это отдельный объект, а не просто флаг у заказа.

Что делает Order Service

Его задача — провести заказ от первого «добавить в корзину» до финального «завершён». При этом сам сервис не хранит каталог товаров, не списывает деньги и не двигает остатки — это делают соседние сервисы. Order Service координирует их и хранит состояние заказа.

С ним работают три категории пользователей:

  • Покупатель — создаёт заказ, оплачивает, может отменить или открыть спор.
  • Продавец — видит заказы по своим товарам, отмечает отправку.
  • Оператор — разбирает споры, может отменить любой заказ.

Жизненный цикл заказа

Заказ проходит через несколько статусов. Это не произвольные пометки, а строгая машина состояний — перейти из любого статуса в любой нельзя.

DRAFT PENDING_PAYMENT PAID SHIPPED DELIVERED COMPLETED EXPIRED не оплатили за 15 минут

Заказ не висит в ожидании оплаты вечно: через пятнадцать минут он уходит в EXPIRED, и резерв на складе возвращается. Без этого состояния товар остался бы занят навсегда.

Дополнительные пути:

  • Из 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 возврат не прошёл

Возврат денег может не пройти, и это не ошибка программы: у провайдера свои причины. Поэтому FAILED — обычное состояние, с которого начинается разбор, а не исключение.

Refund создаётся в двух случаях: покупатель отменил оплаченный заказ, или спор решился в его пользу. В обоих случаях Order ждёт события RefundCompleted и только после него переходит в REFUNDED.

Как агрегаты координируются

Три агрегата — три границы транзакций. Они не вызывают методы друг друга напрямую, а общаются через доменные события.

Пример: оплата заказа.

покупатель подтверждает заказ Order DRAFT → PENDING_PAYMENT, публикует OrderConfirmed Inventory слышит OrderConfirmed, резервирует, публикует ItemReserved покупатель платит Payment публикует PaymentSucceeded Order слышит PaymentSucceeded: PENDING_PAYMENT → PAID

Никто никого не зовёт напрямую: каждый сервис слышит событие и делает своё. Заказ переходит в PAID не потому, что его попросили, а потому что пришло подтверждение оплаты.

Пример: спор с возвратом.

покупатель открывает спор — создан Dispute Order DELIVERED → DISPUTED оператор решает в пользу покупателя Dispute публикует DisputeResolved(buyer) Order создаёт Refund Refund снимает резерв на складе и возвращает деньги 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 гарантирует: изменение состояния и публикация события атомарны.
  • Цена фиксируется при создании заказа и не меняется.
  • Спор ≠ возврат: спор может решиться в пользу продавца без выплаты покупателю.

Что почитать дальше