← назад к разделу

Когда в маркетплейсе нужно показать покупателю цену, заблокировать товар на сезон или проверить, что продавец не трогает чужие карточки — всё это делает 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} и не может добавить его в заказ. Так витрина и корзина автоматически перестают предлагать недоступное.

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

Одна таблица — достаточно для старта:

diagram

Несколько деталей, которые стоит объяснить:

  • 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, не узнает, какие карточки существуют у других продавцов.

Бизнес-правила и ошибки

При создании и переходе между статусами сервис проверяет несколько правил:

ПравилоЧто проверяетсяОшибка
Цена обязательна и больше нуляпри CreateProductINVALID_PRICE (400)
Валюта только RUBпри CreateProductINVALID_CURRENCY (400)
Только владелец меняет статусpublish / hideOWN_PRODUCT_REQUIRED (404)
Переход допустим по схеменельзя скрыть черновикINVALID_STATE_TRANSITION (409)
Публичный GET — только PUBLISHED404

Ошибки возвращаются в формате RFC 9457 (application/problem+json) — стандарт, который понимают большинство клиентов и API-шлюзов.

Как Order Service берёт цену

Это самый нагруженный сценарий: при каждом оформлении заказа Order дёргает Catalog за ценой.

Требования к ответу: p95 ≤ 50 мс. Один SELECT по первичному ключу — достаточно быстро без кэша. Если нагрузка вырастет, перед Catalog ставят Redis-кэш на уровне Order, а не внутри Catalog — так проще инвалидировать при смене цены.

Сценарий скрытия товара:

  1. Продавец вызывает HideProduct → статус меняется на HIDDEN.
  2. Order запрашивает GET /products/{id} → получает 404.
  3. 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 за ценой.