Когда в маркетплейсе нужно показать покупателю цену, заблокировать товар на сезон или проверить, что продавец не трогает чужие карточки — всё это делает Catalog Service. Разберём, как он устроен изнутри: от базы данных до REST-ручек и правил доступа.
Что такое Catalog Service и что он не делает
Catalog Service — сервис, который управляет карточками товаров на стороне продавца. Он отвечает на один главный вопрос: «Существует ли товар, и по какой цене его продают?»
Что входит в его ответственность:
- продавец заводит карточку товара с ценой и описанием;
- переводит её из черновика в «опубликовано» или скрывает;
- Order Service запрашивает цену по
productIdпри оформлении заказа.
Что не входит — и это важно:
- витрина покупателя (листинг, поиск, категории, фото, отзывы) — это Customer BFF;
- остатки — это Inventory Service;
- загрузка файлов — это Media Service;
- аутентификация — это Keycloak.
Чёткая граница избавляет от соблазна «пока положу сюда» — и Catalog остаётся маленьким и понятным.
Жизненный цикл карточки товара
Карточка товара не бывает «просто в базе» — у неё всегда есть статус. Три возможных:
DRAFT— черновик. Продавец только что создал карточку. Order Service её не видит, витрина не показывает.PUBLISHED— опубликована. Order видит цену, витрина показывает покупателям.HIDDEN— временно скрыта. Например, товар кончился, но карточку не удаляют — скоро вернут.
Переходы между статусами строго определены:
CreateProduct
∅ ──────────────────→ DRAFT
│
PublishProduct
│
↓
PUBLISHED ←──────┐
│ │ PublishProduct
HideProduct │
│ │
↓ │
HIDDEN ─────────┘
Удалить товар на старте не дают — это сознательное ограничение. Позже можно добавить статус ARCHIVED без переработки остального.
Почему это важно: когда продавец скрывает товар, Order Service начинает получать 404 на GET /products/{id} и не может добавить его в заказ. Так витрина и корзина автоматически перестают предлагать недоступное.
Схема базы данных
Одна таблица — достаточно для старта:
Несколько деталей, которые стоит объяснить:
seller_id— UUID продавца из JWT-токена. Именно по нему проверяют, чей товар.product_status— тип в Postgres (ENUM), а неtext. База сама не даст записать неправильный статус.currency— пока толькоRUB. НеENUM, аtext, чтобы добавить новые валюты миграцией без изменения кода.id— генерируется на сервере, клиент не задаёт. Это защита от подмены и коллизий.
Read Model (отдельные таблицы для чтения) здесь не нужен — всё читается из одной таблицы по индексу.
REST API: что открыто и кому
Catalog открывает несколько ручек:
| Метод | Путь | Кто вызывает | Что делает |
|---|---|---|---|
POST /products | — | продавец | создать черновик |
POST /products/{id}/publish | — | продавец | опубликовать |
POST /products/{id}/hide | — | продавец | скрыть |
GET /products/{id} | — | публично / Order | получить карточку |
GET /products/my | — | продавец | список своих товаров |
GET /products/{id} — особый случай. Без авторизации возвращает карточку только если статус PUBLISHED. Черновики и скрытые — 404. Это важно: Order Service не должен брать цену из черновика.
Продавец через GET /products/my видит все свои товары в любом статусе — чтобы управлять ними из личного кабинета.
Разграничение доступа: кто что может
Роли в системе:
seller— продавец; управляет только своими карточками.admin— оператор платформы; может трогать любые карточки.system— Order Service с сервисным токеном; только читает цену.
Ключевая проверка — ABAC (контроль доступа по атрибутам): перед публикацией или скрытием handler сравнивает seller_id из базы с sub из JWT-токена запроса. Если не совпадает — возвращает 404 (не 403). Почему 404? Чтобы не раскрывать чужому продавцу сам факт существования карточки.
if (!product.getSellerId().equals(requesterSellerId)) {
throw new OwnProductRequiredException(); // → 404
}
Это маленькая деталь с большим смыслом: злоумышленник, перебирая UUID, не узнает, какие карточки существуют у других продавцов.
Бизнес-правила и ошибки
При создании и переходе между статусами сервис проверяет несколько правил:
| Правило | Что проверяется | Ошибка |
|---|---|---|
| Цена обязательна и больше нуля | при CreateProduct | INVALID_PRICE (400) |
Валюта только RUB | при CreateProduct | INVALID_CURRENCY (400) |
| Только владелец меняет статус | publish / hide | OWN_PRODUCT_REQUIRED (404) |
| Переход допустим по схеме | нельзя скрыть черновик | INVALID_STATE_TRANSITION (409) |
Публичный GET — только PUBLISHED | — | 404 |
Ошибки возвращаются в формате RFC 9457 (application/problem+json) — стандарт, который понимают большинство клиентов и API-шлюзов.
Как Order Service берёт цену
Это самый нагруженный сценарий: при каждом оформлении заказа Order дёргает Catalog за ценой.
Требования к ответу: p95 ≤ 50 мс. Один SELECT по первичному ключу — достаточно быстро без кэша. Если нагрузка вырастет, перед Catalog ставят Redis-кэш на уровне Order, а не внутри Catalog — так проще инвалидировать при смене цены.
Сценарий скрытия товара:
- Продавец вызывает
HideProduct→ статус меняется наHIDDEN. - Order запрашивает
GET /products/{id}→ получает 404. - Order не может оформить заказ с этим товаром.
Никаких событий, очередей, подписок — простой синхронный REST. Для этого масштаба достаточно.
Что в стеке и почему
- Spring Boot 3 / Java 21 — стандартный выбор для сервисов на JVM.
- PostgreSQL — основная база;
ENUMдля статусов даёт проверку на уровне БД. - jOOQ — для запросов к базе. Генерирует классы из схемы, ошибки ловятся на компиляции.
- Flyway — миграции схемы; запускаются без рестарта сервиса.
- Spring Security / OAuth2 Resource Server — валидация JWT через JWK Keycloak.
- Resilience4j — таймауты и повторы при обращении к внешним зависимостям.
- Micrometer + OpenTelemetry — метрики и трассировка для наблюдаемости.
Kafka здесь нет — сознательно. Событийная модель (публикация ProductPublished, ProductHidden) потребует Outbox, идемпотентных потребителей и schema registry. Это нужно когда несколько сервисов реагируют на изменение каталога; пока достаточно синхронного REST.
Типичные ошибки при проектировании
Класть витрину в Catalog. Листинг для покупателей, поиск по категориям, агрегация отзывов — другой домен с другими требованиями по нагрузке. Смешивать их с управлением карточками — значит создавать связанность, которую потом больно распутывать.
Не проверять seller_id в каждой команде. Одна пропущенная проверка — и продавец A может скрыть товар продавца B. ABAC должен быть в каждом обработчике команды, не только в авторизации на уровне роли.
Возвращать 403 вместо 404 при чужом товаре. 403 говорит «ты знаешь, что товар есть, но тебе нельзя». 404 говорит «такого не существует». Правильный ответ — 404.
Разрешать клиенту передавать id. UUID должен генерироваться на сервере. Иначе клиент может подобрать существующий id и перезаписать чужую карточку (если пропущена ещё и проверка seller_id).
Возвращать DRAFT в публичном GET /products/{id}. Order Service должен получать 404 на черновик — иначе может выставить заказ на незавершённую карточку без цены или с тестовой.
Коротко
- Catalog управляет карточками товаров продавца; витрина, остатки, файлы — не его забота.
- Статусы:
DRAFT → PUBLISHED ↔ HIDDEN; переходы строго определены; скрытый товар Order видит как 404. GET /products/{id}без авторизации отдаёт толькоPUBLISHED— чтобы Order не брал цену из черновика.- ABAC:
seller_idиз базы сравнивается сsubиз JWT; не совпало — 404, не 403. - Одна таблица
productsс Postgres ENUM для статусов; Read Model не нужен на этом масштабе. - Kafka нет намеренно: синхронный REST достаточен, пока Catalog не нужно уведомлять несколько потребителей об изменениях.
Что почитать дальше
- Catalog Service: пошаговая генерация от бизнес-описания до кода — как выглядит полный сеанс разработки с промптами и ответами.
- Order Service — Use Case спецификация — смежный сервис, который вызывает Catalog за ценой.