Order Service — Use Case спецификация (Tier C)
Полная Use Case спецификация Order Service из кейса маркетплейса. Tier C, UCP Level 3 (DDD): агрегат Order, доменные события, Saga, Outbox, ABAC.
Содержание
- Bounded Context
- Ubiquitous Language
- Domain Model
- Жизненный цикл и состояния
- Роли и права
- Бизнес-правила (BR)
- Commands
- Domain Events
- Queries / Read Model
- Use Cases
- UI-спецификация
- Saga / Process Manager
- Каталог ошибок
- Интеграции
- Критерии приёмки
- Нефункциональные требования
- Стек технологий
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
2. Ubiquitous Language
| Термин (рус) | В коде | Определение | Пример |
|---|---|---|---|
| Заказ | Order | Финансово-обязывающий документ: что куплено, кем, у кого, на сколько. Корень агрегата. | заказ #A-2026-001 на 12 800 ₽ |
| Позиция заказа | OrderItem | Товар × количество × цена на момент покупки. Сущность внутри агрегата. | iPhone 15 × 1 шт × 89 990 ₽ |
| Сумма заказа | OrderTotal | Value object: сумма позиций − скидки + доставка. Считается, не хранится отдельно. | 89 990 − 5 000 + 350 = 85 340 ₽ |
| Резерв | Reservation | Внешняя зависимость от Inventory: остаток заблокирован под заказ до оплаты. | resvId 7c8a9b… |
| Идентификатор покупателя | CustomerId | UUID, приходит из JWT в заголовке. | 0fd3-…-2c1e |
| Идентификатор продавца | SellerId | UUID, отождествляется с владельцем товаров. | 8e1f-…-9a04 |
| Статус заказа | OrderStatus | Value object (enum) — текущая фаза жизненного цикла. | PAID, SHIPPED |
| Доменное событие | DomainEvent | Факт, произошедший в агрегате; публикуется через Outbox. | OrderPaid, OrderShipped |
| Спор | Dispute | Состояние заказа, требующее решения оператора. Sub-state в OrderStatus. | спор открыт после DELIVERED |
| Промокод | PromoCode | Внешняя ссылка на правило скидки в Catalog. Не хранится в Order; хранится применённая Discount. | BLACKFRIDAY24 → 10% |
| Скидка | Discount | Value object: фиксированная или процентная скидка, применённая к позиции или ко всему заказу. | 10% или −500 ₽ |
| Идемпотентный ключ | IdempotencyKey | UUID, передаваемый клиентом в заголовке Idempotency-Key. | защита от повторного нажатия «оплатить» |
Не путать
- Корзина ≠ Заказ. Корзина живёт в Customer BFF (Redis-сессия) и не имеет резерва. Заказ — после оформления, с резервом.
- Платёж ≠ Заказ. Один заказ может оплачиваться несколькими попытками
Payment. Order видит последний исход через события. - Отмена ≠ Возврат. Отмена — до отправки (
PAIDилиPENDING_PAYMENT→CANCELLED/EXPIRED); возврат — после получения (DELIVERED→REFUNDED). - Резерв ≠ Списание. Резерв — временная блокировка до оплаты; списание — окончательное снятие после
PAID.
3. Domain Model
3.1 Агрегаты
Агрегат Order — корень. Защищает инварианты: согласованность суммы, валидность переходов состояний, отсутствие дублирования позиций по (productId, sellerId).
| Атрибут | Тип | Описание |
|---|---|---|
| id | OrderId (UUID) | первичный ключ |
| customerId | CustomerId (UUID) | FK на покупателя (по ID) |
| status | OrderStatus (enum) | текущая фаза жизненного цикла |
| items | List<OrderItem> | позиции заказа, ≥ 1 для перехода в PENDING_PAYMENT |
| discount | Discount? | применённая скидка (от промокода или акции) |
| shippingFee | Money | стоимость доставки |
| total | Money (вычисляется) | сумма позиций − скидка + доставка |
| reservationId | ReservationId? | id внешнего резерва в Inventory; null до PENDING_PAYMENT |
| paymentId | PaymentId? | id последней успешной попытки платежа |
| paidAt / shippedAt / deliveredAt / closedAt | Instant? | временные метки переходов |
| createdAt / updatedAt | Instant | служебные |
| events | List<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по значению.Money—BigDecimal 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
3.4 Доменные события
Список — в §8 Domain Events. Публикуются из агрегата через registerEvent(...) и доставляются репозиторием в Outbox в той же транзакции с save.
3.5 Схема базы данных
Индексы:
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. Из них переходов нет.
Матрица переходов
| Из | Команда / триггер | В | Условие |
|---|---|---|---|
DRAFT | ConfirmOrder (UseCaseCommand) | PENDING_PAYMENT | резерв подтверждён ItemReserved; есть ≥ 1 позиции; см. BR-002 |
PENDING_PAYMENT | PaymentSucceeded (event) | PAID | сумма платежа = total; см. BR-011 |
PENDING_PAYMENT | PaymentFailed (event) | DRAFT | резерв снимается, можно попробовать снова |
PENDING_PAYMENT | таймаут 15 мин | EXPIRED | шедулер ExpireUnpaidOrders; резерв снимается |
PAID | MarkShipped | SHIPPED | вызывает продавец; см. BR-005 |
SHIPPED | MarkDelivered | DELIVERED | вызывает курьер/ПВЗ; срабатывает таймер 14 дней |
DELIVERED | таймаут 14 дней | COMPLETED | спор не открыт; запускается Settlement |
DELIVERED | OpenDispute | DISPUTE | вызывает покупатель; см. BR-007 |
DISPUTE | ResolveDisputeForBuyer | REFUNDED | оператор решил в пользу покупателя |
DISPUTE | ResolveDisputeForSeller | COMPLETED | оператор решил в пользу продавца |
PAID / SHIPPED | CancelOrder | CANCELLED → REFUNDED | покупатель инициирует возврат; см. BR-006 |
DRAFT / PENDING_PAYMENT | CancelOrder | CANCELLED | без возврата (деньги ещё не списаны) |
Диаграмма состояний
Time-based переходы
| Триггер | Из | В | Реализация |
|---|---|---|---|
| оплата не пришла за 15 мин | PENDING_PAYMENT | EXPIRED | @Scheduled job ExpireUnpaidOrdersJob, SELECT … FOR UPDATE SKIP LOCKED |
| окно спора истекло | DELIVERED | COMPLETED | @Scheduled job CloseDeliveredOrdersJob, ежедневно |
5. Роли и права доступа
Роли (из JWT)
| Роль | Откуда | Описание |
|---|---|---|
customer | Customer BFF (Authorization Code + PKCE) | покупатель |
seller | Seller BFF | продавец |
admin | Admin BFF (SSO + MFA) | оператор маркетплейса |
system | service-to-service (client credentials / mTLS) | внутренние сервисы (Payment, Inventory) |
Матрица команд
| Команда / Query | customer | seller | admin | system | ABAC |
|---|---|---|---|---|---|
CreateOrder | ✅ | — | ✅ (от имени) | — | customerId == jwt.sub (кроме admin) |
AddItem / RemoveItem | ✅ | — | ✅ | — | владение заказом |
ApplyPromo | ✅ | — | — | — | владение заказом |
ConfirmOrder | ✅ | — | ✅ | — | владение заказом |
CancelOrder | ✅ | — | ✅ | — | владение заказом |
MarkShipped | — | ✅ | ✅ | — | заказ содержит товар продавца |
MarkDelivered | — | — | ✅ | ✅ | system 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(один продавец). - Действия:
- Проверить
idempotency_keys— если ключ уже есть, вернуть прежнийorderId. - (sync REST → Catalog) Запросить актуальные цены и существование товаров.
- Создать новый
Orderв статусеDRAFT, рассчитатьtotal(BR-001). - Записать
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). - Действия:
- Загрузить агрегат, валидировать инварианты.
- (одна транзакция)
order.confirm()→ переход вPENDING_PAYMENT(sub-state «ожидание резерва»), регистрация событияOrderConfirmed, save + Outbox. - Outbox-relay публикует
OrderConfirmedв Kafka → подписан Inventory. - 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: запустить SagaProcessRefund(см. §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— запускается SagaProcessRefund.
Внутренние обработчики (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.v1 | Inventory (резервирует), Notification (welcome SMS) |
OrderReservationFailed | после HandleReservationFailed | внешнее | marketplace.orders.v1 | Notification (сообщение покупателю) |
OrderPaid | после HandlePaymentSucceeded | внешнее | marketplace.orders.v1 | Notification (чек), Inventory (commit резерва), Settlement (учёт) |
OrderShipped | после MarkShipped | внешнее | marketplace.orders.v1 | Notification (трек-номер) |
OrderDelivered | после MarkDelivered | внешнее | marketplace.orders.v1 | Notification (запрос отзыва), внутренний таймер 14 дней |
OrderCompleted | после CloseDeliveredOrdersJob | внешнее | marketplace.orders.v1 | Settlement (выручка) |
OrderCancelled | после CancelOrder | внешнее | marketplace.orders.v1 | Inventory (снять резерв), Notification |
OrderExpired | после ExpireUnpaidOrdersJob | внешнее | marketplace.orders.v1 | Inventory (снять резерв) |
DisputeOpened | после OpenDispute | внешнее | marketplace.orders.v1 | Notification (продавцу), Admin BFF (в очередь споров) |
DisputeResolved | после ResolveDispute | внешнее | marketplace.orders.v1 | Notification |
OrderRefunded | после Saga ProcessRefund | внешнее | marketplace.orders.v1 | Settlement (компенсация), Notification |
Структура события
Все события наследуют DomainEvent и имеют:
id: UUID— id события, генерируется при создании;occurredAt: Instant— время возникновения;aggregateType: "Order";aggregateId: String—orderId.
Плюс типобезопасный 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 |
|---|---|---|---|---|---|
GetOrderByIdQuery | GET /api/v1/orders/{id} | id | OrderJsonBean | customer / seller / admin | владение или admin |
SearchMyOrdersQuery | GET /api/v1/orders?role=customer&status=…&page=… | status?, dateFrom?, dateTo?, page, size | Page<OrderSummaryJson> | customer | customerId == jwt.sub |
SearchSellerOrdersQuery | GET /api/v1/orders?role=seller&status=… | status?, dateFrom?, dateTo?, page, size | Page<OrderSummaryJson> | seller | sellerId == jwt.sub (через JOIN с items) |
SearchAllOrdersQuery | GET /api/v1/orders?role=admin&… | расширенные фильтры | Page<OrderSummaryJson> | admin | — |
GetOrderTimelineQuery | GET /api/v1/orders/{id}/timeline | id | List<TimelineEntryJson> | customer / seller / admin | владение или admin |
Все handler-ы помечены @Transactional(readOnly = true); SearchSellerOrdersQuery дополнительно @Cacheable("seller-orders") с TTL 30 секунд.
Read Model
Таблица order_summaries
Денормализованная плоская таблица для списков заказов.
| Колонка | Тип | Назначение |
|---|---|---|
| order_id | UUID PK | |
| customer_id | UUID | для индекса (customer_id, status) |
| primary_seller_id | UUID | первый seller в заказе (для индекса (seller_id, status)) |
| status | order_status | enum |
| total_amount | numeric | для сортировки/фильтрации по сумме |
| currency | varchar(3) | RUB |
| items_count | int | для UI |
| first_product_title | varchar | превью в списке |
| created_at, updated_at | timestamp |
Индексы:
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 «история заказа».
| Колонка | Тип | Назначение |
|---|---|---|
| id | UUID PK | |
| order_id | UUID FK | |
| event_type | varchar | OrderCreated, OrderPaid, … |
| occurred_at | timestamp | |
| actor_type | varchar | customer / seller / system / admin |
| actor_id | UUID? | |
| metadata | jsonb | дополнительные поля события |
Индекс 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).
Триггер: покупатель нажимает «Оформить заказ» из корзины.
Основной поток:
- Customer BFF вызывает
POST /api/v1/ordersс заголовкомIdempotency-Key. CreateOrderUseCaseсоздаётOrderв статусеDRAFT, валидирует цены через Catalog (BR-001,BR-014).- Order возвращает
{ orderId, total }в BFF; покупатель видит экран подтверждения. - Покупатель нажимает «Подтвердить и оплатить» → BFF вызывает
POST /api/v1/orders/{id}/confirm. ConfirmOrderUseCaseпроверяет инварианты (BR-002,BR-013), переводит вPENDING_PAYMENT, публикуетOrderConfirmedчерез Outbox.- Inventory подписан на
OrderConfirmed→ резервирует остаток → публикуетItemReserved. Order ловит событие, сохраняетreservationId(см. §12 Saga). - BFF параллельно (после шага 5) вызывает
POST /paymentsв Payment Service → переход на платёжную форму. - Покупатель оплачивает; Payment публикует
PaymentSucceeded. - Order ловит
PaymentSucceeded(handlerHandlePaymentSucceeded), переходитPENDING_PAYMENT → PAID, публикуетOrderPaid. - Notification (подписан) шлёт чек покупателю и уведомление продавцу.
- Продавец видит заказ в кабинете, собирает посылку, нажимает «Отправлено» →
MarkShipped→OrderShipped. - Курьер вручает →
MarkDelivered(system role) →OrderDelivered. Запускается таймер 14 дней. - Через 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).
Поток:
POST /api/v1/orders/{id}/cancel.CancelOrderUseCaseпроверяет ABAC и статус.- Запускает Saga
ProcessRefund(см. §12). - Saga отменяет резерв в Inventory и инициирует возврат денег в Payment.
- После
RefundIssuedот Payment — заказ→ REFUNDED. - Notification сообщает покупателю об успехе возврата.
UC-3: Возврат после получения (открытие спора)
Актор: Покупатель.
Предусловие: заказ в DELIVERED, прошло ≤ 14 дней.
Поток:
POST /api/v1/orders/{id}/disputesс фото и описанием.OpenDisputeUseCaseпроверяет окно (BR-007), переводитDELIVERED → DISPUTE.DisputeOpenedпубликуется → Notification уведомляет продавца, у него 3 дня на ответ.- Альтернатива А — продавец согласился: оператор закрывает спор в пользу покупателя →
ResolveDisputeForBuyer→ SagaProcessRefund→REFUNDED. - Альтернатива Б — продавец оспорил: оператор смотрит детали, выносит решение. В пользу покупателя → как А; в пользу продавца →
ResolveDisputeForSeller→COMPLETED, выручка продавцу.
UC-4: Продавец отмечает отправку
Актор: Продавец.
Предусловие: заказ в PAID, продавец имеет ≥ 1 позицию в заказе.
Поток:
- Продавец логинится в Seller BFF, видит список своих заказов в
PAID. - Выбирает заказ, вводит
shipmentRef(трек-номер у логиста), нажимает «Отправлено». POST /api/v1/orders/{id}/ship { shipmentRef }через Seller BFF.MarkShippedUseCaseпереводит вSHIPPED, публикуетOrderShipped.- Notification уведомляет покупателя с трек-номером.
UC-5: Покупатель смотрит свои заказы
Актор: Покупатель.
Поток:
GET /api/v1/orders?role=customer&status=PAID,SHIPPED,DELIVERED&page=0&size=20.SearchMyOrdersQueryчерез Read Model (order_summaries), фильтр поcustomer_id == jwt.sub.- Возвращает страницу
OrderSummaryJsonдля UI-списка.
UC-6: Продавец смотрит свои заказы
Актор: Продавец.
Поток:
GET /api/v1/orders?role=seller&status=PAID&page=0.SearchSellerOrdersQueryфильтруетprimary_seller_id == jwt.sub.- Кэш на 30 секунд (
@Cacheable("seller-orders")).
UC-7: Оператор закрывает спор
Актор: admin.
Поток:
- Admin BFF показывает очередь споров (
status = DISPUTE). - Оператор открывает заказ, читает таймлайн (
GetOrderTimelineQuery), смотрит фото и переписку. - Принимает решение:
POST /api/v1/orders/{id}/disputes/resolve { decision: BUYER | SELLER }. ResolveDisputeUseCaseобрабатывает решение, переходит вREFUNDEDилиCOMPLETED.
11. UI-спецификация
Order Service сам по себе UI не имеет — экраны живут в Customer BFF, Seller BFF и Admin BFF. Этот раздел фиксирует, какие экраны и тексты завязаны на статусы и ошибки Order Service. Дизайн-макеты — в Figma за пределами репозитория.
Экраны, завязанные на Order
| Экран | Канал | Команды / запросы Order |
|---|---|---|
| Корзина | Customer BFF | (нет — корзина живёт в BFF, не в Order) |
| Подтверждение заказа | Customer BFF | CreateOrderUseCase → отображает total, адрес, позиции |
| Оплата | Customer BFF | ConfirmOrderUseCase → редирект на форму платёжного шлюза |
| Заказ оформлен | Customer BFF | GetOrderByIdQuery → показ статуса (PAID/PENDING_PAYMENT) |
| Список моих заказов | Customer BFF | SearchMyOrdersQuery |
| Деталь заказа | Customer BFF | GetOrderByIdQuery + GetOrderTimelineQuery |
| Открыть спор | Customer BFF | OpenDisputeUseCase |
| Список заказов продавца | Seller BFF | SearchSellerOrdersQuery |
| Кнопка «Отправлено» | Seller BFF | MarkShippedUseCase |
| Очередь споров | Admin BFF | SearchAllOrdersQuery (фильтр status=DISPUTE) |
| Карточка спора | Admin BFF | GetOrderByIdQuery + 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.
Шаги:
Компенсации:
| Шаг | При ошибке | Компенсация |
|---|---|---|
5. reserve | ReservationFailed | Order → DRAFT, эмиссия OrderReservationFailed; ничего не нужно откатывать. |
11. payment | PaymentFailed | Order → 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.
Шаги:
Компенсации и обработка ошибок:
| Ошибка | Реакция |
|---|---|
ReservationReleased не приходит за 5 минут | retry от Outbox-relay (повторная публикация ReleaseReservationRequested); если 3 раза не пришёл — алёрт оператору, RefundSaga → FAILED, ручной разбор. |
RefundFailed | RefundSaga → 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, либо своя
@Scheduledjob, читающая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_FOUND | 404 | заказ не существует или нет доступа | GetOrderByIdQuery, * | BR-008 |
ORDER_INVALID_STATE | 409 | команда не применима к текущему статусу | MarkShipped, MarkDelivered, CancelOrder, OpenDispute, ConfirmOrder | переходы §4 |
EMPTY_ORDER | 400 | попытка ConfirmOrder без позиций | ConfirmOrder | BR-002 |
ORDER_BELOW_MINIMUM | 400 | total < 100 RUB при ConfirmOrder | ConfirmOrder | BR-013 |
MULTI_SELLER_NOT_SUPPORTED | 400 | попытка добавить товар другого продавца | CreateOrder, AddItem | BR-014 |
OUT_OF_STOCK | 409 | резерв не удался (по событию ReservationFailed) | ConfirmOrder (асинхронно) | BR-002 |
PRODUCT_NOT_FOUND | 404 | Catalog не нашёл товар | CreateOrder, AddItem | — |
PROMO_INVALID | 400 | промокод не существует / истёк / израсходован | ApplyPromo | BR-003 |
PROMO_NOT_APPLICABLE | 400 | промокод не подходит товару / категории / минимальной сумме | ApplyPromo | — |
PROMO_ALREADY_APPLIED | 409 | заказ уже имеет применённый промокод | ApplyPromo | BR-003 |
PAYMENT_FAILED | 422 | платёж отклонён шлюзом (приходит как событие, отображается в UI при следующем чтении) | HandlePaymentFailed | BR-011 |
PAYMENT_TIMEOUT | 504 | шлюз не ответил вовремя | HandlePayment* | — |
REFUND_TOO_LATE | 422 | прошло > 14 дней с DELIVERED | OpenDispute | BR-007 |
DISPUTE_ALREADY_OPEN | 409 | спор по этому заказу уже открыт | OpenDispute | — |
FORBIDDEN | 403 | ABAC: нет прав на этот заказ | любая команда/query | BR-008 |
UNAUTHORIZED | 401 | JWT отсутствует или невалиден | любая | — |
IDEMPOTENCY_KEY_CONFLICT | 409 | тот же Idempotency-Key использован для другого тела запроса | CreateOrder | BR-010 |
INTERNAL_ERROR | 500 | unexpected (баг или сбой инфраструктуры); инвариант не сошёлся | любая | 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) → аналогично, deferredPAYMENT_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, scopeseller.
Аутентификация
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}+GetOrderTimelinePOST /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
HandlePaymentSucceededHandler—Order: PENDING_PAYMENT → PAID, эмиссияOrderPaid. Idempotent.HandlePaymentFailedHandler—Order: PENDING_PAYMENT → DRAFT, снимаетreservationId, эмиссияOrderPaymentFailed→ Inventory снимет резерв.HandleRefundIssuedHandler— завершает SagaProcessRefund,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, схема единая.
События
| Событие | Подписчики | Что делают |
|---|---|---|
OrderPaid | Notification, Settlement, Inventory | Notification: чек покупателю + уведомление продавцу. Settlement: фиксация суммы к расчёту. Inventory: commit резерва (списание окончательное). |
OrderShipped | Notification | трек-номер покупателю |
OrderDelivered | Notification | запрос отзыва |
OrderCompleted | Settlement | начисление выручки продавцу в текущий период |
OrderCancelled | Inventory, Notification | Inventory: снять резерв (если был). Notification: подтверждение отмены. |
OrderExpired | Inventory | снять резерв |
OrderRefunded | Settlement, Notification | Settlement: компенсация (с баланса продавца если деньги уже выплачены). Notification: возврат подтверждён. |
DisputeOpened | Notification, Admin BFF | продавцу — уведомление с дедлайном, оператору — таск в очередь |
DisputeResolved | Notification | финальное уведомление |
Контракт
Все события следуют единой схеме (см. §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-001 | unit (Order aggregate) | пересчёт total при addItem/removePromo |
| BR-002 | integration (Saga) | ConfirmOrder ждёт ItemReserved; ReservationFailed возвращает в DRAFT |
| BR-003 | unit | повторный applyPromo падает |
| BR-007 | unit + integration | OpenDispute на DELIVERED + 15 дней → REFUND_TOO_LATE |
| BR-008 | integration (ABAC) | покупатель не видит чужой заказ; продавец не видит чужой |
| BR-010 | integration | повторный Idempotency-Key возвращает прежний orderId |
| BR-011 | integration | повторный PaymentSucceeded не ломает состояние |
| BR-013 | unit | total < 100 RUB блокирует ConfirmOrder |
Покрытие тестами
- Unit (
Orderaggregate,Money,Quantity) — 100% веток инвариантов. - Integration (
*UseCaseHandler+ Testcontainerspostgres+kafka) — все commands и event handlers. - E2E (
Customer BFFmock → Order →Inventory/Paymentmocks) — 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. Нефункциональные требования
Производительность
| Операция | Цель | Метрика | Алёрт |
|---|---|---|---|
CreateOrderUseCase | p95 < 1.5s | usecase_duration_seconds{usecase="CreateOrder"} | p95 > 2s 5 минут |
ConfirmOrderUseCase | p95 < 800ms | usecase_duration_seconds{usecase="ConfirmOrder"} | p95 > 1.5s |
GetOrderByIdQuery | p95 < 100ms (Read Model) | usecase_duration_seconds{usecase="GetOrderById"} | p95 > 300ms |
SearchMyOrdersQuery | p95 < 200ms | то же | p95 > 500ms |
| Outbox-relay лаг | < 1s | outbox_lag_seconds | > 5s 5 минут |
| Read Model лаг | < 1s от Outbox | read_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_seconds | custom metric | "Order – Outbox" |
processed_events_total{kind=duplicate} | Idempotent Consumer | "Order – Idempotency" |
orders_status_count | scheduled scrape | "Order – State distribution" |
kafka_consumer_lag_* | Kafka exporter | "Order – Kafka" |
| Distributed tracing | OpenTelemetry → 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 месяцев |
|---|---|---|---|
Заказов в orders | 15 млн | 90 млн | 180 млн |
Записей order_items (×3 в среднем) | 45 млн | 270 млн | 540 млн |
| Outbox строк | 50 млн | 300 млн | 600 млн (с partition rotation) |
| Read Model | следует за write | ||
| Kafka offset retention | 7 дней | 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-starter—UseCase/UseCaseHandler/UseCaseDispatcher+ auto-config + Micrometer метрики на каждый use case.- CQRS-маркеры
UseCaseCommand/UseCaseQuery(из той же библиотеки).
DDD
ru.vikulinva:ddd-building-blocks—AggregateRoot/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 — метрики.
- OpenTelemetry → Tempo — трассировка.
- 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/.