Средний по сложности артефакт между Order Service (Уровень 3) и Notification Service (Уровень 1). Уровень 2: подключаем usecase-pattern, вводим UseCase + UseCaseHandler с CQRS-маркерами, но не вводим агрегаты, доменные события, саги, hexagonal-разделение — это Уровень 3. Агрегатов нет → спека в одном файле; §Доменные события и §Процессы — «не применимо».

Catalog делает минимум: продавец заводит карточку товара, публикует, скрывает; Order Service берёт цену по productId. Никаких категорий, поиска, фото, остатков — это уровень витрины (Customer BFF), не Catalog.


1. Bounded Context

Контекст: Catalog (на Уровне 2 термин уже уместен — контекст владеет концептом Product и его жизненным циклом). Субдомен: Supporting. Владелец: команда «Каталог». Агрегатов нет (UseCase Pattern без DDD).

Внутри границы: создание/публикация/скрытие карточек; контроль перехода DRAFT → PUBLISHED ↔ HIDDEN; базовые атрибуты (title, description, price, seller_id, status); sync REST для Order («дай цену по productId»); кабинет продавца «мои продукты».

Вне границы: витрина покупателя (листинг/поиск/категории/фото/отзывы — Customer BFF / storefront); остатки (Inventory); файлы (Media); full-text поиск (Elasticsearch); аутентификация (Keycloak).


2. Интеграции (Context Map)

diagram
РеброНаправлениеКаналСвязьПередаётся
Seller BFFinboundsynccustomer-supplierCreateProduct/PublishProduct/HideProduct, поиск своих
Order Serviceinboundsynccustomer-supplierGET /products/{id} — цена для расчёта заказа
Customer BFF (витрина)inboundsynccustomer-supplierGET /products/{id} — карточка
Keycloakoutboundsyncconformistвалидация JWT (JWK)

Catalog не публикует и не подписывается на события — Kafka не нужен (сознательное ограничение Уровня 2; события вытащили бы Outbox/idempotent-консьюмеры/schema-registry — это Уровень 3).

Контракты: Catalog REST → OpenAPI (contracts/catalog-api.openapi.yaml, владелец Catalog).


3. Ubiquitous Language

ТерминВ кодеОпределение
ПродуктProductКарточка товара конкретного продавца. Один iPhone у двух продавцов = два разных Product.
ПродавецSellerУ продукта ровно один owner-seller.
СтатусStatusDRAFT, PUBLISHED, HIDDEN; переход — отдельный UseCase.
UseCase / HandlerUseCaseCommand/UseCaseQuery + UseCaseHandlerбизнес-операция как record + Spring-bean @Transactional (логика в handler'е).

Намеренно нет агрегатов, value objects, доменных событий, категорий — это Уровень 3 / вне scope.


4. Доменная модель

Уровень 2 — модель = одна таблица products + UseCase-модели ввода/вывода (request/response). Доменных классов вне UseCase нет; на Уровне 3 тут появились бы VO Money(amount, currency) и агрегат Product с инвариантами — на Уровне 2 BigDecimal + String достаточно.

SellerId/ProductId — record-обёртки над UUID (минимальная типизация без полноценного DDD). Возвращаемый Product — generated ProductsPojo от jOOQ (handcrafted-класса нет). Схема (ER, типы) — §Техническая реализация.


5. Жизненный цикл продукта

diagram
СтатусОписание
DRAFTсоздан, не виден Order и витрине
PUBLISHEDопубликован: Order видит цену, витрина показывает
HIDDENвременно скрыт (сезон/ремонт); можно снова опубликовать

Удаление/архив не делаем на старте; при необходимости — отдельный ArchiveProduct + статус ARCHIVED без рефакторинга. На Уровне 3 переход был бы методом агрегата (product.publish()); на Уровне 2 — проверка в handler'е. Логика та же, разница — где она живёт.


6. Роли и доступ

РольКто
sellerпродавец (управляет только своими продуктами)
adminоператор (любые продукты)
systemOrder Service (s2s, service-account)
(без auth)публичный GET /products/{id} для PUBLISHED
Операцияselleradminsystem / публичноABAC
CreateProductот своего имени
PublishProduct / HideProductтолько свои (BR-C4)
ListMyProductsтолько свои
GetProduct✅ (только PUBLISHED)seller/admin — любой свой

ABAC в handler'е: requesterSellerId из JWT (sub) сравнивается с product.sellerId; не совпало → OWN_PRODUCT_REQUIRED (404 — не светим существование чужого).


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

  • BR-C1предусловие CreateProduct. Цена обязательна, > 0 (валидация в compact-конструкторе record). → INVALID_PRICE.
  • BR-C2инвариант. Валюта только RUB (кейс «только Россия и рубли»). → INVALID_CURRENCY.
  • BR-C3инвариант. ID генерируется на сервере (не принимаем от клиента).
  • BR-C4предусловие. Только владелец публикует/скрывает (admin перебивает). → OWN_PRODUCT_REQUIRED.
  • BR-C5предусловие. Допустимые переходы: Publish из DRAFT/HIDDEN; Hide из PUBLISHED. → INVALID_STATE_TRANSITION.
  • BR-C6инвариант. GET /products/{id} отдаёт только PUBLISHED (на DRAFT/HIDDEN — 404, чтобы Order не использовал черновик); кабинет продавца видит все свои через ListMyProducts.

8. Команды

Все — UseCaseCommand<Product> (возвращают актуальный Product); валидация в record-конструкторе, логика — в handler'е, контроллер только маппинг + @PreAuthorize.

CreateProduct

  • Актор: seller, admin · Переход: ∅ → DRAFT
  • Вход: title, description, price, currency · Предусловия: BR-C1, BR-C2
  • Логика: создать DRAFT от имени requesterSellerId, серверный UUID.
  • Ошибки: INVALID_PRICE, INVALID_CURRENCY

PublishProduct

  • Актор: seller, admin · Переход: DRAFT/HIDDENPUBLISHED
  • Предусловия: BR-C4, BR-C5
  • Ошибки: OWN_PRODUCT_REQUIRED, INVALID_STATE_TRANSITION

HideProduct

  • Актор: seller, admin · Переход: PUBLISHEDHIDDEN
  • Предусловия: BR-C4, BR-C5
  • Ошибки: OWN_PRODUCT_REQUIRED, INVALID_STATE_TRANSITION

9. Доменные события

Не применимо на Уровне 2. Catalog событий не публикует и не потребляет (см. §Интеграции).


10. Запросы

GetProduct

  • Актор: публично / Order / seller / admin · Параметры: productId
  • Возвращает: Product (для публичного — только PUBLISHED, иначе 404; seller/admin — любой свой) · Логика: SELECT по PK; BR-C6.

ListMyProducts

  • Актор: seller · Параметры: requesterSellerId, status?, page, size
  • Возвращает: страницу продуктов продавца (любые статусы) · Логика: SELECT по seller_id (Read Model на Уровне 2 нет).

11. Use Cases

UC-C1 Завести и опубликовать. CreateProduct (DRAFT) → продавец проверяет превью → PublishProduct (ABAC + BR-C5) → PUBLISHED.

UC-C2 Order берёт цену. Order → GET /products/{id}{id, title, price, currency} если PUBLISHED, иначе 404.

UC-C3 Временно скрыть. HideProductHIDDEN; Order возвращает 404, витрина перестаёт показывать.

UC-C4 Чужой продавец. Seller-A → PublishProduct чужого → OWN_PRODUCT_REQUIRED (404, скрываем существование).


12. Процессы

Не применимо на Уровне 2. Распределённых транзакций / Saga нет.


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

Catalog UI не показывает — работает за Seller BFF (кабинет: «мои продукты», создание, publish/hide) и Customer BFF (витрина: показ карточки). Листинг публичных товаров — не Catalog, а agg/cache на стороне BFF.

Код ошибкиТекст пользователю
INVALID_PRICEЦена должна быть больше нуля.
INVALID_STATE_TRANSITIONЭто действие недоступно для текущего статуса товара.
OWN_PRODUCT_REQUIREDТовар не найден.

14. Критерии приёмки (Given / When / Then)

  • Given seller аутентифицирован · When POST /products · Then создан DRAFT с серверным UUID.
  • Given свой DRAFT/HIDDEN · When publish · Then PUBLISHED; на уже PUBLISHEDINVALID_STATE_TRANSITION (409).
  • Given свой PUBLISHED · When hide · Then HIDDEN.
  • Given чужой продукт · When любая команда · Then OWN_PRODUCT_REQUIRED (404).
  • Given price ≤ 0/null · When create · Then INVALID_PRICE (400).
  • Given PUBLISHED/DRAFT · When публичный GET /products/{id} · Then 200 / 404.
  • Given seller · When GET /products/my · Then только свои, с пагинацией.

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

АспектТребование
ПроизводительностьGET /products/{id} p95 ≤ 50ms (Order дёргает в цикле); publish p95 ≤ 100ms; ~100 RPS read, 5 RPS write
БезопасностьJWT (OAuth2 Resource Server, Keycloak JWK); ABAC по seller_id; PII нет; TLS обязателен
Наблюдаемостьметрики create/transition/latency; логи productId/sellerId/requestId; трейс на каждый UseCase
ЭксплуатацияLiquibase без рестарта; горизонтальное масштабирование без шардирования (jOOQ stateless)

16. Техническая реализация

java-21 · spring-boot-3 · spring-web · spring-security (OAuth2 Resource Server) · postgresql-16 + jooq + flyway · ru.vikulinva:usecase-pattern-starter (UseCase/UseCaseCommand/UseCaseQuery + UseCaseHandler + UseCaseDispatcher) · resilience4j · Micrometer/Prometheus · OpenTelemetry · JUnit5 + Testcontainers. Single-module Spring Boot (без hexagonal core/adapter — это Уровень 3).

Persistence — только jOOQ, только сгенерированные классы (BS-17, на любом уровне зрелости): ProductsPojo, generated enum product_status (Postgres ENUM). UseCase — record (валидация в compact-конструкторе, без @Valid поверх транспорта); handler — @Component + @Transactional.

Схема БД

diagram

Одна таблица. Read Model нет (всё из products). Ошибки — RFC 9457 ProblemDetails: PRODUCT_NOT_FOUND (404), OWN_PRODUCT_REQUIRED (404), INVALID_STATE_TRANSITION (409), INVALID_PRICE/INVALID_CURRENCY (400).

Что не делаем (Уровень 3): агрегат Product с publish()/hide(); доменные события ProductPublished/ProductHidden; Outbox; Saga; hexagonal; категории/поиск/фото/отзывы/остатки.