Order Service — Use Case спецификация (Tier C)

Полная Use Case спецификация Order Service из кейса маркетплейса. Tier C, UCP Level 3 (DDD): агрегат Order, доменные события, Saga, Outbox, ABAC.

Эталонный пример реализации order-service (полная раскладка + код) Order Service спецификация

Содержание

  1. Bounded Context
  2. Ubiquitous Language
  3. Domain Model
  4. Жизненный цикл и состояния
  5. Роли и права
  6. Бизнес-правила (BR)
  7. Commands
  8. Domain Events
  9. Queries / Read Model
  10. Use Cases
  11. UI-спецификация
  12. Saga / Process Manager
  13. Каталог ошибок
  14. Интеграции
  15. Критерии приёмки
  16. Нефункциональные требования
  17. Стек технологий

1. Bounded Context (ограниченный контекст)

Контекст: «Оформление заказа» (Order)

Отвечает за

  • Жизненный цикл Order от черновика (DRAFT) до закрытия (COMPLETED/REFUNDED).
  • Резервирование остатка у продавцов на момент оформления.
  • Запуск платежа и реакцию на его исход.
  • Координацию Saga: платёж ↔ инвентарь ↔ доставка ↔ уведомления.
  • Публикацию доменных событий (OrderConfirmed, OrderPaid, OrderShipped, …) в Kafka через Outbox.
  • Обработку отмен и возвратов на стороне заказа (само возмещение — в Payment).

Не отвечает за

  • Каталог товаров и поиск (контекст «Catalog»).
  • Списание и пополнение остатка (контекст «Inventory»).
  • Платёжные шлюзы и комиссии (контекст «Payment»).
  • Доставку через внешних логистов (контекст «Customer BFF»/внешние ребра).
  • Расчёты с продавцами (внутри Payment, см. сводный кейс).
  • Аутентификацию (делегируется IdP — Keycloak).

Соседние контексты

  • Customer BFF — основной inbound: команды покупателя (создать, оплатить, отменить, открыть спор) приходят через REST. Тип связи: Customer-Supplier (Order — supplier).
  • Seller / Admin BFF — inbound для продавца (отметить отправку) и оператора (закрыть спор). Тип связи: Customer-Supplier.
  • Catalog Service — outbound REST sync: проверка существования и цены товара при оформлении черновика. Тип связи: Conformist (Order соответствует контракту Catalog).
  • Inventory Service — двунаправлено через Kafka: Order публикует OrderConfirmed → Inventory резервирует и отвечает ItemReserved/ReservationFailed. Тип связи: Customer-Supplier (Order — customer reservation API).
  • Payment Service — outbound REST sync (запуск платежа) + Kafka inbound (PaymentSucceeded/PaymentFailed). Тип связи: Customer-Supplier.
  • Notification Service — outbound через Kafka (подписан на OrderConfirmed/OrderShipped/OrderRefunded). Тип связи: Open Host Service со стороны Order.

Стейкхолдеры и владелец

  • Владелец: команда «Заказы» (Order team).
  • Зависят от нас: команды Customer BFF, Notification, Settlement (через события).
  • От кого зависим: Catalog (валидация товаров), Payment (исполнение платежа), Inventory (резерв и снятие).

Диаграмма C1 — System Context

diagram

2. Ubiquitous Language

Термин (рус)В кодеОпределениеПример
ЗаказOrderФинансово-обязывающий документ: что куплено, кем, у кого, на сколько. Корень агрегата.заказ #A-2026-001 на 12 800 ₽
Позиция заказаOrderItemТовар × количество × цена на момент покупки. Сущность внутри агрегата.iPhone 15 × 1 шт × 89 990 ₽
Сумма заказаOrderTotalValue object: сумма позиций − скидки + доставка. Считается, не хранится отдельно.89 990 − 5 000 + 350 = 85 340 ₽
РезервReservationВнешняя зависимость от Inventory: остаток заблокирован под заказ до оплаты.resvId 7c8a9b…
Идентификатор покупателяCustomerIdUUID, приходит из JWT в заголовке.0fd3-…-2c1e
Идентификатор продавцаSellerIdUUID, отождествляется с владельцем товаров.8e1f-…-9a04
Статус заказаOrderStatusValue object (enum) — текущая фаза жизненного цикла.PAID, SHIPPED
Доменное событиеDomainEventФакт, произошедший в агрегате; публикуется через Outbox.OrderPaid, OrderShipped
СпорDisputeСостояние заказа, требующее решения оператора. Sub-state в OrderStatus.спор открыт после DELIVERED
ПромокодPromoCodeВнешняя ссылка на правило скидки в Catalog. Не хранится в Order; хранится применённая Discount.BLACKFRIDAY24 → 10%
СкидкаDiscountValue object: фиксированная или процентная скидка, применённая к позиции или ко всему заказу.10% или −500 ₽
Идемпотентный ключIdempotencyKeyUUID, передаваемый клиентом в заголовке Idempotency-Key.защита от повторного нажатия «оплатить»

Не путать

  • Корзина ≠ Заказ. Корзина живёт в Customer BFF (Redis-сессия) и не имеет резерва. Заказ — после оформления, с резервом.
  • Платёж ≠ Заказ. Один заказ может оплачиваться несколькими попытками Payment. Order видит последний исход через события.
  • Отмена ≠ Возврат. Отмена — до отправки (PAID или PENDING_PAYMENTCANCELLED/EXPIRED); возврат — после получения (DELIVEREDREFUNDED).
  • Резерв ≠ Списание. Резерв — временная блокировка до оплаты; списание — окончательное снятие после PAID.

3. Domain Model

3.1 Агрегаты

Агрегат Order — корень. Защищает инварианты: согласованность суммы, валидность переходов состояний, отсутствие дублирования позиций по (productId, sellerId).

АтрибутТипОписание
idOrderId (UUID)первичный ключ
customerIdCustomerId (UUID)FK на покупателя (по ID)
statusOrderStatus (enum)текущая фаза жизненного цикла
itemsList<OrderItem>позиции заказа, ≥ 1 для перехода в PENDING_PAYMENT
discountDiscount?применённая скидка (от промокода или акции)
shippingFeeMoneyстоимость доставки
totalMoney (вычисляется)сумма позиций − скидка + доставка
reservationIdReservationId?id внешнего резерва в Inventory; null до PENDING_PAYMENT
paymentIdPaymentId?id последней успешной попытки платежа
paidAt / shippedAt / deliveredAt / closedAtInstant?временные метки переходов
createdAt / updatedAtInstantслужебные
eventsList<DomainEvent>накопленные события (clear после публикации)

3.2 Сущности

OrderItem — внутренняя сущность. Уникальна в рамках агрегата по (productId, sellerId). Не существует вне Order. Содержит: productId, sellerId, quantity (Quantity VO), unitPrice (Money VO), lineTotal (вычисляемое).

3.3 Value Objects

  • OrderId, OrderItemId, CustomerId, SellerId, ProductId — типизированные обёртки над UUID. equals по значению.
  • MoneyBigDecimal amount + Currency currency (всегда RUB в этой версии). Immutable. Операции add/subtract/multiply возвращают новый Money. Не допускает отрицательных сумм.
  • Quantity — целое число от 1 до 999. Защищает от отрицательных и нулевых количеств.
  • Discount — sealed: PercentageDiscount(BigDecimal pct) либо FixedDiscount(Money amount). Применяется к OrderTotal.
  • OrderStatus — enum: DRAFT, PENDING_PAYMENT, PAID, SHIPPED, DELIVERED, COMPLETED, EXPIRED, CANCELLED, REFUNDED, DISPUTE.
  • Address — адрес доставки: страна, город, улица, индекс, ПВЗ-код (если применимо).

Диаграмма C3 — Domain Model

diagram

3.4 Доменные события

Список — в §8 Domain Events. Публикуются из агрегата через registerEvent(...) и доставляются репозиторием в Outbox в той же транзакции с save.

3.5 Схема базы данных

diagram

Индексы:

  • idx_orders_customer_status (customer_id, status) — UC-5 (заказы покупателя).
  • idx_orders_seller_status (seller_id, status) через JOIN с order_items (или materialized view) — UC-6 (заказы продавца).
  • idx_outbox_unpublished (occurred_at) WHERE published_at IS NULL — Outbox-relay.

4. Жизненный цикл и состояния

Состояния OrderStatus

КодОписание
DRAFTзаказ создан, можно добавлять/убирать позиции; ничего не зарезервировано
PENDING_PAYMENTрезерв в Inventory успешен, ждём оплату; есть таймаут 15 мин
PAIDплатёж подтверждён банком; продавцу прилетело уведомление
SHIPPEDпродавец передал курьеру/в ПВЗ
DELIVEREDвручено покупателю; идёт окно для спора (14 дней)
COMPLETEDокно споров закрыто, выручка идёт в Settlement
EXPIREDтаймаут оплаты — резерв снят, заказ закрыт без денег
CANCELLEDотмена покупателем до отправки (с возвратом денег если оплачено)
DISPUTEпокупатель открыл спор после доставки, ждём решения оператора
REFUNDEDденьги возвращены — после DISPUTE или из CANCELLED после оплаты

Терминальные: COMPLETED, EXPIRED, REFUNDED. Из них переходов нет.

Матрица переходов

ИзКоманда / триггерВУсловие
DRAFTConfirmOrder (UseCaseCommand)PENDING_PAYMENTрезерв подтверждён ItemReserved; есть ≥ 1 позиции; см. BR-002
PENDING_PAYMENTPaymentSucceeded (event)PAIDсумма платежа = total; см. BR-011
PENDING_PAYMENTPaymentFailed (event)DRAFTрезерв снимается, можно попробовать снова
PENDING_PAYMENTтаймаут 15 минEXPIREDшедулер ExpireUnpaidOrders; резерв снимается
PAIDMarkShippedSHIPPEDвызывает продавец; см. BR-005
SHIPPEDMarkDeliveredDELIVEREDвызывает курьер/ПВЗ; срабатывает таймер 14 дней
DELIVEREDтаймаут 14 днейCOMPLETEDспор не открыт; запускается Settlement
DELIVEREDOpenDisputeDISPUTEвызывает покупатель; см. BR-007
DISPUTEResolveDisputeForBuyerREFUNDEDоператор решил в пользу покупателя
DISPUTEResolveDisputeForSellerCOMPLETEDоператор решил в пользу продавца
PAID / SHIPPEDCancelOrderCANCELLEDREFUNDEDпокупатель инициирует возврат; см. BR-006
DRAFT / PENDING_PAYMENTCancelOrderCANCELLEDбез возврата (деньги ещё не списаны)

Диаграмма состояний

diagram

Time-based переходы

ТриггерИзВРеализация
оплата не пришла за 15 минPENDING_PAYMENTEXPIRED@Scheduled job ExpireUnpaidOrdersJob, SELECT … FOR UPDATE SKIP LOCKED
окно спора истеклоDELIVEREDCOMPLETED@Scheduled job CloseDeliveredOrdersJob, ежедневно

5. Роли и права доступа

Роли (из JWT)

РольОткудаОписание
customerCustomer BFF (Authorization Code + PKCE)покупатель
sellerSeller BFFпродавец
adminAdmin BFF (SSO + MFA)оператор маркетплейса
systemservice-to-service (client credentials / mTLS)внутренние сервисы (Payment, Inventory)

Матрица команд

Команда / QuerycustomerselleradminsystemABAC
CreateOrder✅ (от имени)customerId == jwt.sub (кроме admin)
AddItem / RemoveItemвладение заказом
ApplyPromoвладение заказом
ConfirmOrderвладение заказом
CancelOrderвладение заказом
MarkShippedзаказ содержит товар продавца
MarkDeliveredsystem role for courier integration
OpenDisputeвладение заказом
ResolveDispute
GetOrderByIdпокупатель — свой; продавец — содержит его товар; admin — любой
SearchMyOrdersсвои; для admin — любые
PaymentSucceeded (внутренний обработчик)

ABAC-правила (агрегатные)

  • customer владеет заказомorder.customerId == jwt.sub. Проверяется в OrderQueryHandler и в командных handler-ах перед вызовом метода агрегата.
  • seller имеет доступ к заказу ⇔ хотя бы одна OrderItem.sellerId == jwt.sub. Реализуется JOIN с order_items в Read Model.
  • admin имеет полный доступ к любому заказу.
  • system (внутренние сервисы) обращается только к подмножеству команд через service-to-service: MarkDelivered (от логистики), приём событий PaymentSucceeded/PaymentFailed/ItemReserved/ReservationFailed (через Kafka).

PII

В заказе хранится:

  • customerId (UUID, не PII по 152-ФЗ — нужен mapping в User-сервисе для разрешения).
  • адрес доставки (Address) — PII, шифрование at-rest.
  • телефон / email — в заказе не храним, тянем из User-сервиса при необходимости.

См. также 16-order-service-nfr.md (раздел Security/Compliance) и 13-order-service-errors.md (FORBIDDEN, UNAUTHORIZED).

6. Бизнес-правила и инварианты

BR-001: Сумма заказа всегда согласована. total = sum(item.lineTotal) − discount.amount() + shippingFee. Инвариант агрегата Order — пересчитывается при каждом изменении позиций или промокода. Нарушение → INTERNAL_ERROR (баг).

BR-002: Резерв обязателен перед PENDING_PAYMENT. Переход DRAFT → PENDING_PAYMENT возможен только после события ItemReserved от Inventory (или вызова reserve через ACL). Если приходит ReservationFailed — заказ остаётся в DRAFT с пометкой о причине. Нарушение → OUT_OF_STOCK (см. §13).

BR-003: Один промокод — одно применение. Нельзя применить два промокода к одному заказу. Замена промокода — это RemovePromo + ApplyPromo. Нарушение → PROMO_ALREADY_APPLIED.

BR-004: Цена позиции фиксируется на момент оформления. После перехода в PENDING_PAYMENT unitPrice не меняется, даже если в Catalog цена изменилась. Защита покупателя.

BR-005: MarkShipped доступна только продавцу позиции. Если в заказе позиции от двух продавцов — каждый отмечает отправку своих позиций отдельно (расширение в будущей версии; в текущей — один заказ = один продавец). Нарушение → FORBIDDEN.

BR-006: Отмена после SHIPPED идёт через возврат-процесс. CancelOrder в статусе SHIPPED переводит заказ в CANCELLING (sub-state для UI), физический возврат пакета продавцу — вне Order Service; после факта возврата (вручную оператором или через интеграцию с логистикой) — → REFUNDED. Нарушение → ORDER_INVALID_STATE.

BR-007: Спор открывает только покупатель и только в окне 14 дней после DELIVERED. После 14 дней заказ автоматически переходит в COMPLETED. Нарушение → REFUND_TOO_LATE.

BR-008: ABAC по владению. Покупатель видит только свои заказы; продавец — только заказы, содержащие его товары; оператор — все. Реализуется в QueryHandler-ах (см. §9 и §5).

BR-009: Возврат денег невозможен, если они уже выплачены продавцу. Если на момент REFUNDED соответствующий SellerPayout уже исполнен — Order переводит баланс продавца в −amount, продавец остаётся должен платформе. Координация — Saga ProcessRefund (см. §12).

BR-010: Идемпотентность CreateOrder. Заголовок Idempotency-Key обязателен; повторный POST с тем же ключом возвращает прежний orderId, не создаёт дубль. См. REST API: заголовки. Нарушение → нет, идемпотентность гарантируется.

BR-011: Событие PaymentSucceeded обрабатывается ровно один раз. Idempotent Consumer: processed_event_id хранится в таблице processed_events, повторное событие игнорируется. Защита от двойного списания/двойного перехода в PAID.

BR-012: Отрицательная сумма заказа невозможна. discount.amount() ≤ sum(items) + shippingFee. Промокод, дающий скидку больше суммы — обрезается до суммы заказа без минуса. Защита от мошенничества (попытка получить деньги через "отрицательную" сумму).

BR-013: Минимальная сумма заказа — 100 ₽. Для ConfirmOrder total >= 100 RUB. Иначе → ORDER_BELOW_MINIMUM.

BR-014: Один заказ → один продавец (V1). В первой версии Order Service не поддерживается мультиселлерный заказ. При добавлении товара другого продавца → ошибка, либо создание отдельного заказа (UI решает). Нарушение → MULTI_SELLER_NOT_SUPPORTED.

BR-015: События публикуются атомарно с записью. Outbox пишется в той же транзакции, что и сам Order. Outbox-relay читает и публикует в Kafka. Нарушение → невозможно (защищено транзакцией БД).

7. Commands

Каждая команда реализуется как UseCaseCommand<R> из usecase-pattern и обрабатывается соответствующим UseCaseHandler с @Transactional.

CreateOrder

  • Класс: CreateOrderUseCase implements UseCaseCommand<OrderJsonBean>
  • Инициатор: покупатель (через Customer BFF) или оператор (admin).
  • Роль: customer или admin (см. §5).
  • Параметры: customerId (из JWT), items (List<{productId, sellerId, quantity}>), shippingAddress, idempotencyKey.
  • Агрегат: Order (создаётся новый).
  • Проверки: BR-008 (ABAC), BR-010 (идемпотентность), BR-014 (один продавец).
  • Действия:
    1. Проверить idempotency_keys — если ключ уже есть, вернуть прежний orderId.
    2. (sync REST → Catalog) Запросить актуальные цены и существование товаров.
    3. Создать новый Order в статусе DRAFT, рассчитать total (BR-001).
    4. Записать Order + OrderCreated в Outbox в одной транзакции.
  • Результат: OrderJsonBean { orderId, status: DRAFT, total }.
  • Переход: → DRAFT.
  • Ошибки: PRODUCT_NOT_FOUND, MULTI_SELLER_NOT_SUPPORTED.

AddItem / RemoveItem

  • Класс: AddItemUseCase, RemoveItemUseCase (оба UseCaseCommand<EmptyResult>).
  • Инициатор: владелец заказа (customer/admin).
  • Параметры: orderId, productId, quantity.
  • Проверки: BR-008, статус заказа = DRAFT иначе ORDER_INVALID_STATE.
  • Действия: загрузить агрегат → order.addItem(...)/order.removeItem(...) → пересчёт total → save + Outbox.
  • Переход: остаётся в DRAFT.

ApplyPromo / RemovePromo

  • Класс: ApplyPromoUseCase, RemovePromoUseCase.
  • Параметры: orderId, promoCode.
  • Проверки: BR-003 (один промокод), BR-012 (неотрицательный total).
  • Действия: вызов Catalog для валидации промокода → order.applyPromo(discount) → пересчёт.
  • Ошибки: PROMO_INVALID, PROMO_NOT_APPLICABLE, PROMO_ALREADY_APPLIED.

ConfirmOrder

  • Класс: ConfirmOrderUseCase implements UseCaseCommand<OrderJsonBean>.
  • Инициатор: владелец заказа.
  • Параметры: orderId.
  • Проверки: статус = DRAFT, ≥ 1 позиции (BR-002), total >= 100 (BR-013).
  • Действия:
    1. Загрузить агрегат, валидировать инварианты.
    2. (одна транзакция) order.confirm() → переход в PENDING_PAYMENT (sub-state «ожидание резерва»), регистрация события OrderConfirmed, save + Outbox.
    3. Outbox-relay публикует OrderConfirmed в Kafka → подписан Inventory.
    4. Inventory отвечает ItemReserved или ReservationFailed (асинхронный обработчик в этом же сервисе, см. §12).
  • Переход: → PENDING_PAYMENT (или возврат в DRAFT после ReservationFailed).
  • Ошибки: ORDER_INVALID_STATE, ORDER_BELOW_MINIMUM, EMPTY_ORDER.

MarkShipped

  • Класс: MarkShippedUseCase implements UseCaseCommand<EmptyResult>.
  • Инициатор: продавец (через Seller BFF).
  • Параметры: orderId, shipmentRef (трек-номер у логиста).
  • Проверки: статус = PAID, BR-005 (продавец имеет позицию в заказе).
  • Действия: order.ship(shipmentRef)OrderShipped → save + Outbox.
  • Переход: PAID → SHIPPED.

MarkDelivered

  • Класс: MarkDeliveredUseCase implements UseCaseCommand<EmptyResult>.
  • Инициатор: служба доставки (system role) или admin.
  • Действия: order.deliver()OrderDelivered → save + Outbox.
  • Переход: SHIPPED → DELIVERED. Запускает таймер 14 дней через delivered_orders_timeline.

CancelOrder

  • Класс: CancelOrderUseCase implements UseCaseCommand<OrderJsonBean>.
  • Инициатор: владелец заказа или admin.
  • Проверки: статус ∈ {DRAFT, PENDING_PAYMENT, PAID, SHIPPED}. BR-006 (после SHIPPED — через возврат).
  • Действия:
    • В DRAFT / PENDING_PAYMENT: снять резерв (если был) → OrderCancelled.
    • В PAID / SHIPPED: запустить Saga ProcessRefund (см. §12).
  • Переход: → CANCELLED или CANCELLED → REFUNDED (после саги).

OpenDispute

  • Класс: OpenDisputeUseCase implements UseCaseCommand<EmptyResult>.
  • Параметры: orderId, reason, attachments (фото).
  • Проверки: статус = DELIVERED, в окне 14 дней (BR-007).
  • Действия: order.openDispute(reason)DisputeOpened → save + Outbox; уведомления продавцу (3 дня на ответ).
  • Переход: DELIVERED → DISPUTE.

ResolveDisputeForBuyer / ResolveDisputeForSeller

  • Класс: ResolveDisputeUseCase (с параметром decision).
  • Инициатор: только admin.
  • Действия: order.resolveDispute(decision)DisputeResolved (с указанием стороны) → save + Outbox.
  • Переход: DISPUTE → REFUNDED или DISPUTE → COMPLETED. На REFUNDED — запускается Saga ProcessRefund.

Внутренние обработчики (event-driven)

Эти не являются «командами» в смысле API — это event handlers, реализованные как UseCaseCommand для единообразия:

  • HandlePaymentSucceeded — слушает PaymentSucceeded из Kafka, переводит PENDING_PAYMENT → PAID. Idempotent (BR-011).
  • HandlePaymentFailed — слушает PaymentFailed, возвращает в DRAFT, снимает резерв.
  • HandleItemReserved — слушает ItemReserved, фиксирует reservationId в Order. Только перевод статуса в PENDING_PAYMENT (после уже состоявшегося ConfirmOrder).
  • HandleReservationFailed — слушает ReservationFailed, возвращает в DRAFT, эмиссия OrderReservationFailed.
  • ExpireUnpaidOrdersJob@Scheduled, периодически переводит протухшие PENDING_PAYMENT → EXPIRED, снимает резерв.
  • CloseDeliveredOrdersJob@Scheduled ежедневно, переводит DELIVERED → COMPLETED через 14 дней.

8. Domain Events

Все события — final class extends DomainEvent (ddd-building-blocks). Регистрируются в агрегате Order через registerEvent(...) и публикуются через Outbox в той же транзакции.

Внутренние и внешние

  • Внутренние (in-process listeners) обрабатываются Spring через @TransactionalEventListener(AFTER_COMMIT) — например, обновление денормализованных Read Model в той же БД.
  • Внешние публикуются в Kafka через Outbox-relay. Это контракт с другими сервисами.

Каталог

СобытиеТриггерТипТопик KafkaПодписчики
OrderCreatedпосле CreateOrderвнутреннееOrder Read Model (для SearchMyOrders)
OrderConfirmedпосле ConfirmOrderвнешнееmarketplace.orders.v1Inventory (резервирует), Notification (welcome SMS)
OrderReservationFailedпосле HandleReservationFailedвнешнееmarketplace.orders.v1Notification (сообщение покупателю)
OrderPaidпосле HandlePaymentSucceededвнешнееmarketplace.orders.v1Notification (чек), Inventory (commit резерва), Settlement (учёт)
OrderShippedпосле MarkShippedвнешнееmarketplace.orders.v1Notification (трек-номер)
OrderDeliveredпосле MarkDeliveredвнешнееmarketplace.orders.v1Notification (запрос отзыва), внутренний таймер 14 дней
OrderCompletedпосле CloseDeliveredOrdersJobвнешнееmarketplace.orders.v1Settlement (выручка)
OrderCancelledпосле CancelOrderвнешнееmarketplace.orders.v1Inventory (снять резерв), Notification
OrderExpiredпосле ExpireUnpaidOrdersJobвнешнееmarketplace.orders.v1Inventory (снять резерв)
DisputeOpenedпосле OpenDisputeвнешнееmarketplace.orders.v1Notification (продавцу), Admin BFF (в очередь споров)
DisputeResolvedпосле ResolveDisputeвнешнееmarketplace.orders.v1Notification
OrderRefundedпосле Saga ProcessRefundвнешнееmarketplace.orders.v1Settlement (компенсация), Notification

Структура события

Все события наследуют DomainEvent и имеют:

  • id: UUID — id события, генерируется при создании;
  • occurredAt: Instant — время возникновения;
  • aggregateType: "Order";
  • aggregateId: StringorderId.

Плюс типобезопасный payload, специфичный для события.

Пример: OrderConfirmed

public final class OrderConfirmed extends DomainEvent {
    private final List<ItemSnapshot> items;
    private final Money total;
    private final UUID customerId;
    private final UUID sellerId;

    public OrderConfirmed(UUID orderId, UUID customerId, UUID sellerId,
                          List<ItemSnapshot> items, Money total) {
        super("Order", orderId.toString());
        this.items = List.copyOf(items);
        this.total = total;
        this.customerId = customerId;
        this.sellerId = sellerId;
    }
    // getters
}

Пример: OrderPaid

public final class OrderPaid extends DomainEvent {
    private final UUID paymentId;
    private final Money amount;
    private final Instant paidAt;
    // …
}

Контракты для внешних потребителей

Сериализация — JSON, схема версионирована: marketplace.orders.v1. Изменения payload — через minor (добавление optional полей) или новую версию топика (breaking changes). Версия указана в Kafka header x-event-version.

См. интеграции: 14-order-service-integrations/order-service-publishes-orderconfirmed.md, …-orderpaid.md, и т.д. — там точные контракты для каждого события.

Идемпотентность приёма

При приёме событий извне (PaymentSucceeded, ItemReserved) Order Service использует таблицу processed_events (event_id PK, processed_at) и не обрабатывает дубликаты повторно. Реализуется в каждом event handler (BR-011).

9. Queries / Read Model

Чтения отделены от записи через UseCaseQuery. Read Model — собственный набор таблиц, обновляемых по событиям.

Запросы

КлассURLПараметрыВозвращаетРолиABAC
GetOrderByIdQueryGET /api/v1/orders/{id}idOrderJsonBeancustomer / seller / adminвладение или admin
SearchMyOrdersQueryGET /api/v1/orders?role=customer&status=…&page=…status?, dateFrom?, dateTo?, page, sizePage<OrderSummaryJson>customercustomerId == jwt.sub
SearchSellerOrdersQueryGET /api/v1/orders?role=seller&status=…status?, dateFrom?, dateTo?, page, sizePage<OrderSummaryJson>sellersellerId == jwt.sub (через JOIN с items)
SearchAllOrdersQueryGET /api/v1/orders?role=admin&…расширенные фильтрыPage<OrderSummaryJson>admin
GetOrderTimelineQueryGET /api/v1/orders/{id}/timelineidList<TimelineEntryJson>customer / seller / adminвладение или admin

Все handler-ы помечены @Transactional(readOnly = true); SearchSellerOrdersQuery дополнительно @Cacheable("seller-orders") с TTL 30 секунд.

Read Model

Таблица order_summaries

Денормализованная плоская таблица для списков заказов.

КолонкаТипНазначение
order_idUUID PK
customer_idUUIDдля индекса (customer_id, status)
primary_seller_idUUIDпервый seller в заказе (для индекса (seller_id, status))
statusorder_statusenum
total_amountnumericдля сортировки/фильтрации по сумме
currencyvarchar(3)RUB
items_countintдля UI
first_product_titlevarcharпревью в списке
created_at, updated_attimestamp

Индексы:

  • idx_summaries_customer (customer_id, status, created_at DESC)
  • idx_summaries_seller (primary_seller_id, status, created_at DESC)
  • idx_summaries_status_created (status, created_at) — для admin-поиска

Обновляется через @TransactionalEventListener(AFTER_COMMIT) на события OrderCreated, OrderPaid, OrderShipped, OrderDelivered, OrderCompleted, OrderCancelled, OrderRefunded.

Таблица order_timelines

История переходов состояний для UI «история заказа».

КолонкаТипНазначение
idUUID PK
order_idUUID FK
event_typevarcharOrderCreated, OrderPaid, …
occurred_attimestamp
actor_typevarcharcustomer / seller / system / admin
actor_idUUID?
metadatajsonbдополнительные поля события

Индекс idx_timeline_order_time (order_id, occurred_at).

Согласованность

Read Model eventual consistent относительно write-side (Outbox-relay вносит лаг). Типичный лаг — < 1 секунды. UI явно предупреждает: «изменения отображаются с задержкой до 5 секунд».

В critical-path операциях (например, кнопка «оплатить» сразу после создания) — клиент использует read-your-own-writes: Customer BFF после создания заказа сразу делает GetOrderById через write-side (запрос идёт к основному orders таблице, не к order_summaries).

10. Use Cases (сценарии использования)

UC-1: Покупка — счастливый сценарий

Актор: Покупатель (через Customer BFF).

Триггер: покупатель нажимает «Оформить заказ» из корзины.

Основной поток:

  1. Customer BFF вызывает POST /api/v1/orders с заголовком Idempotency-Key.
  2. CreateOrderUseCase создаёт Order в статусе DRAFT, валидирует цены через Catalog (BR-001, BR-014).
  3. Order возвращает { orderId, total } в BFF; покупатель видит экран подтверждения.
  4. Покупатель нажимает «Подтвердить и оплатить» → BFF вызывает POST /api/v1/orders/{id}/confirm.
  5. ConfirmOrderUseCase проверяет инварианты (BR-002, BR-013), переводит в PENDING_PAYMENT, публикует OrderConfirmed через Outbox.
  6. Inventory подписан на OrderConfirmed → резервирует остаток → публикует ItemReserved. Order ловит событие, сохраняет reservationId (см. §12 Saga).
  7. BFF параллельно (после шага 5) вызывает POST /payments в Payment Service → переход на платёжную форму.
  8. Покупатель оплачивает; Payment публикует PaymentSucceeded.
  9. Order ловит PaymentSucceeded (handler HandlePaymentSucceeded), переходит PENDING_PAYMENT → PAID, публикует OrderPaid.
  10. Notification (подписан) шлёт чек покупателю и уведомление продавцу.
  11. Продавец видит заказ в кабинете, собирает посылку, нажимает «Отправлено» → MarkShippedOrderShipped.
  12. Курьер вручает → MarkDelivered (system role) → OrderDelivered. Запускается таймер 14 дней.
  13. Через 14 дней без спора — CloseDeliveredOrdersJob переводит в COMPLETED, Settlement начисляет выручку продавцу.

Альтернативный поток — резерв не удался (BR-002):

  • На шаге 6 Inventory публикует ReservationFailed (один из товаров кончился).
  • Order возвращает заказ в DRAFT, публикует OrderReservationFailed.
  • BFF показывает покупателю «Один из товаров закончился: <название>. Уберите его и попробуйте снова».

Альтернативный поток — оплата не прошла:

  • На шаге 9 приходит PaymentFailed. Order возвращает в DRAFT, снимает резерв.
  • Покупатель может попробовать другую карту: POST /api/v1/orders/{id}/confirm повторно (новая попытка резерва).

Альтернативный поток — покупатель ушёл с формы оплаты:

  • На шаге 9 события не приходит. Через 15 минут срабатывает ExpireUnpaidOrdersJob, переводит в EXPIRED, снимает резерв.

UC-2: Отмена до отправки

Актор: Покупатель.

Предусловие: заказ в PAID (не SHIPPED).

Поток:

  1. POST /api/v1/orders/{id}/cancel.
  2. CancelOrderUseCase проверяет ABAC и статус.
  3. Запускает Saga ProcessRefund (см. §12).
  4. Saga отменяет резерв в Inventory и инициирует возврат денег в Payment.
  5. После RefundIssued от Payment — заказ → REFUNDED.
  6. Notification сообщает покупателю об успехе возврата.

UC-3: Возврат после получения (открытие спора)

Актор: Покупатель.

Предусловие: заказ в DELIVERED, прошло ≤ 14 дней.

Поток:

  1. POST /api/v1/orders/{id}/disputes с фото и описанием.
  2. OpenDisputeUseCase проверяет окно (BR-007), переводит DELIVERED → DISPUTE.
  3. DisputeOpened публикуется → Notification уведомляет продавца, у него 3 дня на ответ.
  4. Альтернатива А — продавец согласился: оператор закрывает спор в пользу покупателя → ResolveDisputeForBuyer → Saga ProcessRefundREFUNDED.
  5. Альтернатива Б — продавец оспорил: оператор смотрит детали, выносит решение. В пользу покупателя → как А; в пользу продавца → ResolveDisputeForSellerCOMPLETED, выручка продавцу.

UC-4: Продавец отмечает отправку

Актор: Продавец.

Предусловие: заказ в PAID, продавец имеет ≥ 1 позицию в заказе.

Поток:

  1. Продавец логинится в Seller BFF, видит список своих заказов в PAID.
  2. Выбирает заказ, вводит shipmentRef (трек-номер у логиста), нажимает «Отправлено».
  3. POST /api/v1/orders/{id}/ship { shipmentRef } через Seller BFF.
  4. MarkShippedUseCase переводит в SHIPPED, публикует OrderShipped.
  5. Notification уведомляет покупателя с трек-номером.

UC-5: Покупатель смотрит свои заказы

Актор: Покупатель.

Поток:

  1. GET /api/v1/orders?role=customer&status=PAID,SHIPPED,DELIVERED&page=0&size=20.
  2. SearchMyOrdersQuery через Read Model (order_summaries), фильтр по customer_id == jwt.sub.
  3. Возвращает страницу OrderSummaryJson для UI-списка.

UC-6: Продавец смотрит свои заказы

Актор: Продавец.

Поток:

  1. GET /api/v1/orders?role=seller&status=PAID&page=0.
  2. SearchSellerOrdersQuery фильтрует primary_seller_id == jwt.sub.
  3. Кэш на 30 секунд (@Cacheable("seller-orders")).

UC-7: Оператор закрывает спор

Актор: admin.

Поток:

  1. Admin BFF показывает очередь споров (status = DISPUTE).
  2. Оператор открывает заказ, читает таймлайн (GetOrderTimelineQuery), смотрит фото и переписку.
  3. Принимает решение: POST /api/v1/orders/{id}/disputes/resolve { decision: BUYER | SELLER }.
  4. ResolveDisputeUseCase обрабатывает решение, переходит в REFUNDED или COMPLETED.

11. UI-спецификация

Order Service сам по себе UI не имеет — экраны живут в Customer BFF, Seller BFF и Admin BFF. Этот раздел фиксирует, какие экраны и тексты завязаны на статусы и ошибки Order Service. Дизайн-макеты — в Figma за пределами репозитория.

Экраны, завязанные на Order

ЭкранКаналКоманды / запросы Order
КорзинаCustomer BFF(нет — корзина живёт в BFF, не в Order)
Подтверждение заказаCustomer BFFCreateOrderUseCase → отображает total, адрес, позиции
ОплатаCustomer BFFConfirmOrderUseCase → редирект на форму платёжного шлюза
Заказ оформленCustomer BFFGetOrderByIdQuery → показ статуса (PAID/PENDING_PAYMENT)
Список моих заказовCustomer BFFSearchMyOrdersQuery
Деталь заказаCustomer BFFGetOrderByIdQuery + GetOrderTimelineQuery
Открыть спорCustomer BFFOpenDisputeUseCase
Список заказов продавцаSeller BFFSearchSellerOrdersQuery
Кнопка «Отправлено»Seller BFFMarkShippedUseCase
Очередь споровAdmin BFFSearchAllOrdersQuery (фильтр status=DISPUTE)
Карточка спораAdmin BFFGetOrderByIdQuery + GetOrderTimelineQuery + ResolveDisputeUseCase

Связь UI ↔ статусы

СтатусБейдж в UIЦвет
DRAFT«Черновик»серый
PENDING_PAYMENT«Ожидает оплаты»жёлтый
PAID«Оплачен»синий
SHIPPED«Отправлен»фиолетовый
DELIVERED«Доставлен»зелёный
COMPLETED«Завершён»зелёный (тёмный)
EXPIRED«Истёк»серый (тёмный)
CANCELLED«Отменён»серый (тёмный)
DISPUTE«Спор»красный
REFUNDED«Возврат»серый-синий

Тексты ошибок (показывает BFF, см. также §13)

КодТекст пользователю
OUT_OF_STOCK«Один из товаров закончился: <название>. Уберите его и попробуйте снова.»
PRODUCT_NOT_FOUND«Этот товар больше не продаётся.»
ORDER_INVALID_STATE«Действие недоступно для этого заказа.»
PAYMENT_FAILED«Оплата не прошла — попробуйте другую карту или способ.»
PAYMENT_TIMEOUT«Не удалось подтвердить оплату — мы вернёмся к вам в течение 5 минут.»
PROMO_INVALID«Промокод недействителен.»
PROMO_NOT_APPLICABLE«Промокод не применим к этой корзине.»
PROMO_ALREADY_APPLIED«К этому заказу уже применён промокод.»
REFUND_TOO_LATE«Срок возврата истёк (14 дней с момента получения).»
ORDER_BELOW_MINIMUM«Минимальная сумма заказа — 100 ₽.»
MULTI_SELLER_NOT_SUPPORTED«В одном заказе товары только одного продавца. Оформите второй заказ.»
FORBIDDEN«Этот заказ недоступен.»
UNAUTHORIZED«Войдите в аккаунт.»

Дизайн-система

Используется общий компонент <OrderStatusBadge status={…} /> с маппингом «статус → цвет/текст» по таблице выше. Текст бейджа никогда не задаётся вручную в JSX — только через статус из API.

12. Saga / Process Manager

Saga 1: Подтверждение заказа (Confirm Order)

Цель: Перевести заказ из DRAFT в PAID через резервирование остатка и оплату.

Реализация: orchestrated saga, состояние хранится в самом агрегате Order (status + reservationId). Координация — серия handler-ов Order Service.

Шаги:

diagram

Компенсации:

ШагПри ошибкеКомпенсация
5. reserveReservationFailedOrder → DRAFT, эмиссия OrderReservationFailed; ничего не нужно откатывать.
11. paymentPaymentFailedOrder → DRAFT, асинхронно публикуется OrderPaymentFailed, Inventory подписан → снимает резерв.
11. paymentтаймаут 15 минExpireUnpaidOrdersJob переводит → EXPIRED, событие OrderExpired снимает резерв.

Идемпотентность: каждый event handler хранит processed_event_id; повторный приём ItemReserved/PaymentSucceeded игнорируется (BR-011).

Saga 2: Возврат денег (Process Refund)

Цель: Вернуть деньги покупателю после CancelOrder (для оплаченного заказа) или после ResolveDisputeForBuyer.

Реализация: orchestrated saga, состояние хранится в таблице refund_sagas (id, order_id, status, started_at, completed_at) со статусами STARTED, INVENTORY_RELEASED, PAYMENT_REFUNDING, COMPLETED, FAILED.

Шаги:

diagram

Компенсации и обработка ошибок:

ОшибкаРеакция
ReservationReleased не приходит за 5 минутretry от Outbox-relay (повторная публикация ReleaseReservationRequested); если 3 раза не пришёл — алёрт оператору, RefundSaga → FAILED, ручной разбор.
RefundFailedRefundSaga → FAILED, Order остаётся в CANCELLING или DISPUTE, оператору в Admin BFF приходит таска.
Деньги уже выплачены продавцу (BR-009)Order → REFUNDED, баланс продавца становится −amount, в следующем расчёте у него удержание.

Saga 3: Закрытие доставленных (Close Delivered)

Не оркестрируемая saga, а scheduled job CloseDeliveredOrdersJob.

  • Запускается раз в день.
  • SELECT … FROM orders WHERE status = 'DELIVERED' AND delivered_at < now() - INTERVAL '14 days' FOR UPDATE SKIP LOCKED.
  • Для каждого: Order → COMPLETED, эмиссия OrderCompleted.
  • Settlement подписан → начисляет выручку продавцу.

Контракты Saga-сообщений

Все Saga-сообщения публикуются в топик marketplace.orders.saga.v1 (отдельный от marketplace.orders.v1, чтобы не путать бизнес-события с операционными). Header x-correlation-id (= orderId) и x-saga-id обязательны.

Стек

  • Outbox-relay — Debezium + Kafka Connect, либо своя @Scheduled job, читающая outbox WHERE published_at IS NULL.
  • Saga state — таблицы refund_sagas, без отдельного фреймворка (нет нужды в Camunda для этого объёма).
  • Idempotent consumer — processed_events. См. распределённые паттерны.

13. Каталог ошибок

Формат — RFC 9457 ProblemDetails (application/problem+json). См. REST API: ошибки.

Код (code)HTTPКогда возникаетВозникает в (commands/UC)Триггерует BR
ORDER_NOT_FOUND404заказ не существует или нет доступаGetOrderByIdQuery, *BR-008
ORDER_INVALID_STATE409команда не применима к текущему статусуMarkShipped, MarkDelivered, CancelOrder, OpenDispute, ConfirmOrderпереходы §4
EMPTY_ORDER400попытка ConfirmOrder без позицийConfirmOrderBR-002
ORDER_BELOW_MINIMUM400total < 100 RUB при ConfirmOrderConfirmOrderBR-013
MULTI_SELLER_NOT_SUPPORTED400попытка добавить товар другого продавцаCreateOrder, AddItemBR-014
OUT_OF_STOCK409резерв не удался (по событию ReservationFailed)ConfirmOrder (асинхронно)BR-002
PRODUCT_NOT_FOUND404Catalog не нашёл товарCreateOrder, AddItem
PROMO_INVALID400промокод не существует / истёк / израсходованApplyPromoBR-003
PROMO_NOT_APPLICABLE400промокод не подходит товару / категории / минимальной суммеApplyPromo
PROMO_ALREADY_APPLIED409заказ уже имеет применённый промокодApplyPromoBR-003
PAYMENT_FAILED422платёж отклонён шлюзом (приходит как событие, отображается в UI при следующем чтении)HandlePaymentFailedBR-011
PAYMENT_TIMEOUT504шлюз не ответил вовремяHandlePayment*
REFUND_TOO_LATE422прошло > 14 дней с DELIVEREDOpenDisputeBR-007
DISPUTE_ALREADY_OPEN409спор по этому заказу уже открытOpenDispute
FORBIDDEN403ABAC: нет прав на этот заказлюбая команда/queryBR-008
UNAUTHORIZED401JWT отсутствует или невалиденлюбая
IDEMPOTENCY_KEY_CONFLICT409тот же Idempotency-Key использован для другого тела запросаCreateOrderBR-010
INTERNAL_ERROR500unexpected (баг или сбой инфраструктуры); инвариант не сошёлсялюбаяBR-001 (бага)

Структура ProblemDetails

{
  "type": "https://vikulin-va.ru/errors/order-invalid-state",
  "title": "Order is not in a valid state for this action",
  "status": 409,
  "code": "ORDER_INVALID_STATE",
  "detail": "Cannot mark as shipped: order is in DRAFT, expected PAID",
  "instance": "/api/v1/orders/0fd3-…/ship",
  "violations": []
}

code — стабильный машинный идентификатор; title — для разработчиков; detail — для отладки. UI берёт текст из таблицы в §11 UI.

Маппинг ошибок Inventory / Payment

Ошибки приходят асинхронно через события — не как HTTP-ответы:

  • ReservationFailed (Kafka) → Order переводит в DRAFT, в Read Model отображается флаг «требуется внимание», при следующем чтении BFF возвращает OUT_OF_STOCK в payload как deferred error.
  • PaymentFailed (Kafka) → аналогично, deferred PAYMENT_FAILED.

Для синхронных вызовов (Catalog при создании) — Order пробрасывает HTTP-ошибку как есть, мапируя в свои коды.

14. Интеграции (Context Mapping)

Список рёбер C2. Каждое ребро — отдельный файл в репозитории, машинно-читаемый для C4-генератора.

Customer BFF → Order Service (REST, sync)

Customer BFF проксирует команды покупателя: создание заказа, подтверждение, оплата, отмена, открытие спора.

Контракт

  • Endpoints:
    • POST /api/v1/orders (CreateOrder)
    • POST /api/v1/orders/{id}/confirm (ConfirmOrder)
    • POST /api/v1/orders/{id}/cancel (CancelOrder)
    • POST /api/v1/orders/{id}/disputes (OpenDispute)
    • GET /api/v1/orders/{id} (GetOrderById)
    • GET /api/v1/orders (SearchMyOrders)
  • OpenAPI: docs/api/order-service.openapi.yaml
  • DDD-паттерн: Customer-Supplier. Order — supplier (владеет моделью). BFF — customer.

Аутентификация

JWT от Keycloak (Authorization Code + PKCE flow в BFF). Order Service валидирует JWT через JWK Set. В JWT обязательны:

  • sub — UUID покупателя (используется как customerId в ABAC).
  • realm_access.roles содержит customer.

Идемпотентность

POST /api/v1/orders требует заголовок Idempotency-Key (UUID). См. BR-010.

SLA

Ответ < 1.5s для синхронных команд (Create, Confirm, Cancel); < 200ms для запросов (через Read Model).

При недоступности

BFF получает 503 → возвращает покупателю «временно недоступно, попробуйте через минуту». Идемпотентность защищает от дублей при retry.

Seller BFF → Order Service (REST, sync)

Seller BFF позволяет продавцу видеть свои заказы и отмечать отправку.

Контракт

  • Endpoints:
    • GET /api/v1/orders?role=seller&status=… (SearchSellerOrders)
    • POST /api/v1/orders/{id}/ship (MarkShipped)
    • GET /api/v1/orders/{id} (GetOrderById)
  • OpenAPI: общий docs/api/order-service.openapi.yaml, scope seller.

Аутентификация

JWT с ролью seller и sub = UUID продавца. ABAC: sellerId в позициях заказа должен совпадать с jwt.sub (или admin override).

SLA

< 200ms для запросов; < 1s для MarkShipped.

Admin BFF → Order Service (REST, sync)

Admin BFF — оператор маркетплейса разрешает споры, видит все заказы, делает ручные корректировки.

Контракт

  • Endpoints:
    • GET /api/v1/orders?role=admin&… (SearchAllOrders)
    • GET /api/v1/orders/{id} + GetOrderTimeline
    • POST /api/v1/orders/{id}/disputes/resolve (ResolveDisputeFor*)
    • расширенные команды (MarkDelivered от имени курьера, CancelOrder от имени пользователя в крайних случаях)

Аутентификация

JWT с ролью admin. Внутри Admin BFF — обязательная MFA. Все вызовы пишутся в audit log на стороне Order Service (таблица order_audit_log).

SLA

< 500ms для запросов; < 2s для разрешения спора.

Order Service → Catalog Service (REST, sync)

Order Service вызывает Catalog для получения актуальной цены и факта существования товара при создании черновика заказа и применении промокода.

Контракт

  • Endpoints (на стороне Catalog):
    • GET /api/v1/products/{id} — получить товар, цену, остаток (read-only).
    • POST /api/v1/promos/{code}/validate — валидация промокода (применим ли к корзине, не истёк, есть применения).
  • OpenAPI: catalog-service/docs/api/catalog.openapi.yaml.
  • DDD-паттерн: Conformist. Order соответствует контракту Catalog без переговоров.

Аутентификация

Service-to-service: Order использует mTLS-сертификат + JWT с ролью system, scope catalog:read.

Resilience

  • Timeout: 1 секунда на запрос товара, 500ms на валидацию промокода.
  • Retry: 2 попытки с экспоненциальным backoff (50ms, 200ms).
  • Circuit Breaker: на Catalog (Resilience4j). Если 50% вызовов за 30 секунд падают — открывается на 60 секунд.
  • Fallback при открытом CB: для GetProduct — возврат 503 SERVICE_DEGRADED пользователю; для валидации промокода — пропуск (промокод не применяется).

SLA

< 200ms p95 от Catalog. Если выше — открывается алёрт, переключаемся на read-replica (внутри Catalog).

Order Service → Payment Service (REST, sync — старт; Kafka inbound — исход)

Order инициирует платёж синхронным REST-вызовом, исход получает асинхронно через Kafka. См. также «Order Service ← Payment Service (Kafka)».

Контракт

  • Endpoint (Payment): POST /api/v1/payments
  • Тело: { orderId, amount, currency: "RUB", customerId, idempotencyKey }
  • Ответ: 201 Created с { paymentId, paymentUrl }. paymentUrl — куда BFF делает редирект пользователя.
  • OpenAPI: payment-service/docs/api/payment.openapi.yaml.
  • DDD-паттерн: Customer-Supplier. Payment — supplier; Order — customer.

Аутентификация

mTLS + JWT system с scope payment:initiate.

Resilience

  • Timeout: 3 секунды (включая первичную регистрацию у банка).
  • Retry: 3 попытки с jitter 100–500ms.
  • Circuit Breaker: Resilience4j; sliding window 60s, threshold 50%.
  • Idempotency-Key: обязателен; повтор с тем же ключом возвращает прежний paymentId.

SLA

< 2s p95. При недоступности — Order возвращает PAYMENT_TIMEOUT (504), покупателю показывается «попробуйте через минуту»; идемпотентность защищает от двойного списания.

Order Service → (Kafka) → Inventory Service: OrderConfirmed

Order Service публикует OrderConfirmed после ConfirmOrderUseCase. Inventory подписан, выполняет резервирование остатка.

Контракт

  • Топик: marketplace.orders.v1
  • Ключ: orderId (для упорядочивания событий по одному заказу).
  • Headers: x-event-version: 1, x-event-type: OrderConfirmed, x-correlation-id: <orderId>.
  • Payload (JSON):
    {
      "id": "<event uuid>",
      "occurredAt": "<iso8601>",
      "aggregateType": "Order",
      "aggregateId": "<orderId>",
      "customerId": "<uuid>",
      "sellerId": "<uuid>",
      "items": [{"productId": "<uuid>", "quantity": <int>, "unitPrice": "<decimal>"}],
      "total": {"amount": "<decimal>", "currency": "RUB"}
    }
    
  • DDD-паттерн: Published Language — Order публикует контракт, Inventory его потребляет.

Доставка

  • Гарантия: at-least-once (Outbox-relay + Idempotent Consumer на стороне Inventory).
  • Порядок: по orderId (внутри партиции Kafka).
  • Версионирование: изменения совместимые → x-event-version без изменений; breaking — новый топик marketplace.orders.v2.

Подписчики

  • Inventory Service — резервирует остаток.
  • Notification Service — отправляет «заказ подтверждён».
  • Read Model order_summaries (внутри Order Service) — обновляется через @TransactionalEventListener(AFTER_COMMIT).

Ответ

Inventory отвечает событием ItemReserved или ReservationFailed (см. order-service-from-inventory-itemreserved.md).

Inventory → (Kafka) → Order Service: ItemReserved / ReservationFailed

Inventory отвечает на OrderConfirmed событием успеха или отказа резерва.

Контракты

ItemReserved
  • Топик: marketplace.inventory.v1
  • Headers: x-event-type: ItemReserved, x-correlation-id: <orderId>.
  • Payload: { orderId, reservationId, reservedAt }.
ReservationFailed
  • Топик: marketplace.inventory.v1
  • Headers: x-event-type: ReservationFailed.
  • Payload: { orderId, reason: "OUT_OF_STOCK" | "PRODUCT_UNAVAILABLE", failedItems: [<productId>] }.

Обработка в Order Service

  • HandleItemReservedHandler — записывает reservationId в агрегат, заказ остаётся в PENDING_PAYMENT. Idempotent (processed_events).
  • HandleReservationFailedHandler — переводит Order → DRAFT, эмиссия OrderReservationFailed, в Read Model появляется флаг для UI.

Гарантии

  • At-least-once с idempotent consumer.
  • Если ItemReserved не приходит за 5 секунд — Order продолжает ждать; через 30 секунд логирует warning, через 5 минут — алёрт. Заказ «застревает» в PENDING_PAYMENT без reservationId. Восстановление — ручное либо через retry от Outbox-relay Inventory.

Payment → (Kafka) → Order Service: PaymentSucceeded / PaymentFailed / RefundIssued

Payment Service публикует исход платёжной операции; Order Service слушает и обновляет статус заказа.

Контракты

PaymentSucceeded
  • Топик: marketplace.payments.v1
  • Headers: x-event-type: PaymentSucceeded.
  • Payload: { orderId, paymentId, amount, currency, gateway, paidAt }.
PaymentFailed
  • Headers: x-event-type: PaymentFailed.
  • Payload: { orderId, paymentId, reason: "DECLINED" | "INSUFFICIENT_FUNDS" | "GATEWAY_TIMEOUT" | "FRAUD_BLOCK", failedAt }.
RefundIssued
  • Headers: x-event-type: RefundIssued.
  • Payload: { orderId, refundId, amount, refundedAt }.
RefundFailed
  • Headers: x-event-type: RefundFailed.
  • Payload: { orderId, refundId, reason, failedAt }.

Обработка в Order Service

  • HandlePaymentSucceededHandlerOrder: PENDING_PAYMENT → PAID, эмиссия OrderPaid. Idempotent.
  • HandlePaymentFailedHandlerOrder: PENDING_PAYMENT → DRAFT, снимает reservationId, эмиссия OrderPaymentFailed → Inventory снимет резерв.
  • HandleRefundIssuedHandler — завершает Saga ProcessRefund, Order: CANCELLING/DISPUTE → REFUNDED, эмиссия OrderRefunded.
  • HandleRefundFailedHandler — Saga → FAILED, оператору в Admin BFF приходит таска для ручного разбора.

Гарантии

  • At-least-once с idempotent consumer (processed_events, BR-011).
  • Порядок гарантирован по orderId (через ключ Kafka).
  • В случае задержки события (более 30 секунд после ожидаемого) — алёрт payment-event-lag.

Order Service → (Kafka) → подписчики: остальные события

Это «корзина» событий Order, на которые подписаны Notification, Settlement, Read Model и другие. Топик — marketplace.orders.v1, схема единая.

События

СобытиеПодписчикиЧто делают
OrderPaidNotification, Settlement, InventoryNotification: чек покупателю + уведомление продавцу. Settlement: фиксация суммы к расчёту. Inventory: commit резерва (списание окончательное).
OrderShippedNotificationтрек-номер покупателю
OrderDeliveredNotificationзапрос отзыва
OrderCompletedSettlementначисление выручки продавцу в текущий период
OrderCancelledInventory, NotificationInventory: снять резерв (если был). Notification: подтверждение отмены.
OrderExpiredInventoryснять резерв
OrderRefundedSettlement, NotificationSettlement: компенсация (с баланса продавца если деньги уже выплачены). Notification: возврат подтверждён.
DisputeOpenedNotification, Admin BFFпродавцу — уведомление с дедлайном, оператору — таск в очередь
DisputeResolvedNotificationфинальное уведомление

Контракт

Все события следуют единой схеме (см. §8 Domain Events):

{
  "id": "<event uuid>",
  "occurredAt": "<iso8601>",
  "aggregateType": "Order",
  "aggregateId": "<orderId>",
  ... // type-specific payload
}

x-event-type header определяет конкретное событие.

Гарантии

  • At-least-once.
  • Idempotency на стороне подписчика (processed_events).
  • Порядок по orderId (через Kafka ключ).
  • Версионирование схемы — через minor (совместимые) и x-event-version.

15. Критерии приёмки (Acceptance Criteria)

Use Case 1 — Покупка (счастливый сценарий)

  • [ ] Покупатель может создать заказ с одним продавцом и до 20 позиций.
  • [ ] При повторном POST /orders с тем же Idempotency-Key возвращается тот же orderId (проверяется в integration-тесте).
  • [ ] Сумма заказа = sum(items) − discount + shippingFee (BR-001).
  • [ ] После ConfirmOrder заказ находится в PENDING_PAYMENT и OrderConfirmed есть в Outbox.
  • [ ] Через ≤ 5 секунд после PaymentSucceeded заказ переходит в PAID и OrderPaid есть в Outbox.
  • [ ] После MarkShipped заказ в SHIPPED.
  • [ ] Через 14 дней после MarkDelivered без OpenDispute заказ переходит в COMPLETED.

Use Case 2 — Отмена до отправки

  • [ ] Покупатель может отменить заказ в PAID.
  • [ ] Saga ProcessRefund отрабатывает за ≤ 30 секунд (e2e-тест с моками Inventory и Payment).
  • [ ] OrderRefunded публикуется только после RefundIssued от Payment.

Use Case 3 — Спор

  • [ ] Покупатель может открыть спор только в DELIVERED и в окне 14 дней (BR-007).
  • [ ] После OpenDispute продавец получает уведомление в течение 1 минуты (e2e на Notification моке).
  • [ ] Решение оператора корректно переводит в REFUNDED или COMPLETED.

Use Case 4 — Продавец отмечает отправку

  • [ ] Продавец видит только заказы со своими товарами (BR-008, ABAC-тест).
  • [ ] MarkShipped запрещена другому продавцу (тест возвращает 403 FORBIDDEN).

Use Case 5–6 — Поиск заказов

  • [ ] SearchMyOrdersQuery фильтрует только по customer_id == jwt.sub.
  • [ ] SearchSellerOrdersQuery использует Read Model и кэш на 30 секунд.
  • [ ] Read Model отстаёт от write-side не более чем на 1 секунду (perf-тест).

Бизнес-правила

BRТип тестаПокрытие
BR-001unit (Order aggregate)пересчёт total при addItem/removePromo
BR-002integration (Saga)ConfirmOrder ждёт ItemReserved; ReservationFailed возвращает в DRAFT
BR-003unitповторный applyPromo падает
BR-007unit + integrationOpenDispute на DELIVERED + 15 днейREFUND_TOO_LATE
BR-008integration (ABAC)покупатель не видит чужой заказ; продавец не видит чужой
BR-010integrationповторный Idempotency-Key возвращает прежний orderId
BR-011integrationповторный PaymentSucceeded не ломает состояние
BR-013unittotal < 100 RUB блокирует ConfirmOrder

Покрытие тестами

  • Unit (Order aggregate, Money, Quantity) — 100% веток инвариантов.
  • Integration (*UseCaseHandler + Testcontainers postgres + kafka) — все commands и event handlers.
  • E2E (Customer BFF mock → Order → Inventory/Payment mocks) — UC-1 .. UC-7 каждый.
  • Performance (Gatling) — 200 RPS на CreateOrder, p95 < 1.5s.

Вход в прод

  • [ ] Feature flag order-v3 для постепенного раскатывания (по % покупателей).
  • [ ] Дашборды Grafana: orders_created_total, orders_paid_total, outbox_lag_seconds, processed_events_duplicates_total.
  • [ ] Алёрты: orders_paid_lag_5m > 30s, outbox_unpublished_count > 100.
  • [ ] Канарей: 5% трафика → 50% → 100% за 7 дней.

16. Нефункциональные требования

Производительность

ОперацияЦельМетрикаАлёрт
CreateOrderUseCasep95 < 1.5susecase_duration_seconds{usecase="CreateOrder"}p95 > 2s 5 минут
ConfirmOrderUseCasep95 < 800msusecase_duration_seconds{usecase="ConfirmOrder"}p95 > 1.5s
GetOrderByIdQueryp95 < 100ms (Read Model)usecase_duration_seconds{usecase="GetOrderById"}p95 > 300ms
SearchMyOrdersQueryp95 < 200msто жеp95 > 500ms
Outbox-relay лаг< 1soutbox_lag_seconds> 5s 5 минут
Read Model лаг< 1s от Outboxread_model_lag_seconds> 10s

Throughput: до 500 RPS на оформление заказов в пик (Чёрная пятница). Горизонтальное масштабирование Order Service до 10 реплик за gateway.

Доступность

  • SLO Order Service: 99.9% availability (≈ 8.76 часов простоя в год).
  • Зависимости:
    • PostgreSQL primary + 2 hot standby (streaming replication, RPO ≤ 5s).
    • Kafka cluster 3+ брокера.
    • Catalog, Payment, Inventory — circuit breaker и graceful degradation.

Согласованность

  • Внутри агрегата — strong consistency через PostgreSQL транзакции.
  • Между агрегатами — eventual consistency через Kafka. Лаг до 5 секунд — допустим, более — алёрт.
  • Read Model — eventual consistency от Outbox. Read-your-own-writes для critical UI операций — через прямой запрос к write-side.

Безопасность

  • Транспорт: TLS 1.3 для внешнего трафика, mTLS между внутренними сервисами.
  • Аутентификация: JWT от Keycloak, валидация JWK Set с кэшем (5 мин).
  • Авторизация: RBAC на gateway (роли customer/seller/admin/system); ABAC внутри Order по владению.
  • Audit log: все команды от admin пишутся в таблицу order_audit_log с who/when/what. Retention — 5 лет.
  • PII:
    • Address (адрес доставки) — шифрование at-rest (PostgreSQL TDE + столбец pgcrypto.AES256).
    • customerId — UUID (не PII сам по себе, не позволяет идентифицировать без User-сервиса).
    • email/phoneНЕ хранятся в Order, тянутся из User-сервиса при необходимости.
  • Удаление: по требованию покупателя (152-ФЗ) — анонимизация старых заказов через 5 лет после COMPLETED/REFUNDED. customer_id не удаляется (целостность ссылок), но связь с реальным человеком — только через User-сервис, где данные удалены.
  • Compliance: 152-ФЗ обязательно. PCI DSS не применяется (платёжные данные — в Payment Service, где шифрование и токенизация).

Наблюдаемость

СигналИсточникДашборд
usecase_* метрикиMicrometer (usecase-pattern-starter)"Order – UseCase performance"
outbox_lag_secondscustom metric"Order – Outbox"
processed_events_total{kind=duplicate}Idempotent Consumer"Order – Idempotency"
orders_status_countscheduled scrape"Order – State distribution"
kafka_consumer_lag_*Kafka exporter"Order – Kafka"
Distributed tracingOpenTelemetry → Tempo"Trace по correlation-id (= orderId)"
ЛогиLoki, JSON структурныйполе orderId в каждом логе

Алёрты (P1): outbox lag > 5 минут; circuit breaker открыт > 5 минут; orders_paid_lag_5m > 30s; ошибка > 5% запросов 5 минут.

Капасити

СущностьНа 1 месяцНа 6 месяцевНа 12 месяцев
Заказов в orders15 млн90 млн180 млн
Записей order_items (×3 в среднем)45 млн270 млн540 млн
Outbox строк50 млн300 млн600 млн (с partition rotation)
Read Modelследует за write
Kafka offset retention7 дней7 дней7 дней

PostgreSQL партиционирование orders по created_at (по месяцам) после 6 млн записей.

Compliance

  • 152-ФЗ (РФ) — согласие, право на удаление, аудит.
  • PCI DSS — не применяется (Order не видит реквизиты карт; всё на стороне Payment Service).
  • Sanctioned regions — на этапе Catalog/User-сервисе; Order не делает дополнительных проверок.

17. Стек технологий

Платформа

  • Java 21 — records, sealed, pattern matching.
  • Spring Boot 3.x — DI, web, observability.
  • Gradle (Kotlin DSL) — сборка.

Use Case Pattern

  • ru.vikulinva:usecase-pattern-starterUseCase/UseCaseHandler/UseCaseDispatcher + auto-config + Micrometer метрики на каждый use case.
  • CQRS-маркеры UseCaseCommand/UseCaseQuery (из той же библиотеки).

DDD

  • ru.vikulinva:ddd-building-blocksAggregateRoot/Entity/ValueObject/DomainEvent/AggregateRepository/Specification.

Хранилище

  • PostgreSQL 16+ — основное хранилище (write-side + Read Model + Outbox).
  • jOOQ 3.19+ — типобезопасный SQL, генерация Pojo из схемы.
  • Flyway — миграции.
  • HikariCP — connection pool.

События

  • Apache Kafka 3.x — транспорт между сервисами, консьюмер-группы, key-based partitioning.
  • Spring Kafka — продюсер и консьюмер.
  • Outbox-relay — Debezium + Kafka Connect (или своя @Scheduled-job в первой версии).

Кэш

  • Redis@Cacheable для SearchSellerOrdersQuery (TTL 30s), JWK Set от Keycloak (TTL 5 мин), идемпотентные ключи Catalog-валидации.

Маппинг

  • MapStruct 1.5+ — JsonBean ↔ доменная модель ↔ jOOQ Pojo. Генерируется на этапе компиляции, без рефлексии.
  • Lombok — boilerplate (опционально, может быть убран).

Resilience

  • Resilience4j — Retry / Circuit Breaker / Timeout / Bulkhead / Fallback на адаптерах вызовов Catalog, Payment, внешних логистов.

Безопасность

  • Spring Security + Spring Security OAuth2 Resource Server — валидация JWT.
  • Keycloak — IdP (внешний, не часть Order Service).
  • mTLS — на внутренних REST-вызовах.

Наблюдаемость

  • Micrometer + Prometheus — метрики.
  • OpenTelemetryTempo — трассировка.
  • Loki — структурные логи (JSON).
  • Grafana — дашборды.

Тесты

  • JUnit 5 — unit и integration.
  • AssertJ — assertions.
  • Mockito — моки в unit-тестах.
  • Testcontainers — integration: PostgreSQL, Kafka, Redis.
  • WireMock — моки внешних REST (Catalog, Payment) в integration.
  • Gatling — нагрузочные тесты.
  • ArchUnit — архитектурные правила (Tier C/L4 — не сейчас, в L4-сервисе).

Инфраструктура

  • Docker — контейнеризация.
  • Kubernetes + Helm chart — деплой.
  • GitHub Actions — CI/CD (build, test, image push, deploy).

Скиллы AI-агента

  • usecase-spec-design — пишет/обновляет эту спеку.
  • ddd-tactical-design — генерирует код агрегата Order, событий, repository.
  • ddd-tactical-review — ревью доменного кода на соответствие правилам.
  • usecase-pattern-design — генерирует UseCase + Handler.
  • usecase-pattern-review — ревью на соответствие методологии.
  • api-design — OpenAPI из раздела Commands.
  • api-review — ревью контракта.

Скиллы лежат в github.com/remodov/usecase-pattern-skills. Подключаются симлинком в .claude/skills/.