Средний по сложности артефакт между 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)
| Ребро | Направление | Канал | Связь | Передаётся |
|---|---|---|---|---|
| Seller BFF | inbound | sync | customer-supplier | CreateProduct/PublishProduct/HideProduct, поиск своих |
| Order Service | inbound | sync | customer-supplier | GET /products/{id} — цена для расчёта заказа |
| Customer BFF (витрина) | inbound | sync | customer-supplier | GET /products/{id} — карточка |
| Keycloak | outbound | sync | conformist | валидация 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. |
| Статус | Status | DRAFT, PUBLISHED, HIDDEN; переход — отдельный UseCase. |
| UseCase / Handler | UseCaseCommand/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. Жизненный цикл продукта
| Статус | Описание |
|---|---|
DRAFT | создан, не виден Order и витрине |
PUBLISHED | опубликован: Order видит цену, витрина показывает |
HIDDEN | временно скрыт (сезон/ремонт); можно снова опубликовать |
Удаление/архив не делаем на старте; при необходимости — отдельный ArchiveProduct + статус ARCHIVED без рефакторинга. На Уровне 3 переход был бы методом агрегата (product.publish()); на Уровне 2 — проверка в handler'е. Логика та же, разница — где она живёт.
6. Роли и доступ
| Роль | Кто |
|---|---|
seller | продавец (управляет только своими продуктами) |
admin | оператор (любые продукты) |
system | Order Service (s2s, service-account) |
| (без auth) | публичный GET /products/{id} для PUBLISHED |
| Операция | seller | admin | system / публично | 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/HIDDEN→PUBLISHED - Предусловия:
BR-C4,BR-C5 - Ошибки:
OWN_PRODUCT_REQUIRED,INVALID_STATE_TRANSITION
HideProduct
- Актор: seller, admin · Переход:
PUBLISHED→HIDDEN - Предусловия:
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 Временно скрыть. HideProduct → HIDDEN; 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 · ThenPUBLISHED; на ужеPUBLISHED→INVALID_STATE_TRANSITION(409). - Given свой
PUBLISHED· When hide · ThenHIDDEN. - Given чужой продукт · When любая команда · Then
OWN_PRODUCT_REQUIRED(404). - Given
price ≤ 0/null · When create · ThenINVALID_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.
Схема БД
Одна таблица. 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; категории/поиск/фото/отзывы/остатки.