← Back to the section

When a marketplace needs to show a buyer a price, take a product offline for the season, or make sure a seller isn't touching someone else's cards — the Catalog Service handles all of it. Let's see how it works under the hood: from the database to the REST endpoints and the access rules.

What the Catalog Service is and what it doesn't do

The Catalog Service is the service that manages product cards on the seller's side. It answers one central question: "Does this product exist, and what price is it sold at?"

What falls within its responsibility:

  • a seller creates a product card with a price and a description;
  • moves it from draft to "published," or hides it;
  • the Order Service requests the price by productId when a customer places an order.

What is not part of it — and this matters:

  • the customer storefront (listings, search, categories, photos, reviews) — that's the Customer BFF;
  • stock levels — that's the Inventory Service;
  • file uploads — that's the Media Service;
  • authentication — that's Keycloak.

A clear boundary removes the temptation to "just park it here for now" — and keeps Catalog small and easy to understand.

The product card lifecycle

A product card is never "just sitting in the database" — it always has a status. There are three of them:

  • DRAFT — a draft. The seller has just created the card. The Order Service can't see it, and the storefront doesn't show it.
  • PUBLISHED — published. Order sees the price, and the storefront shows it to buyers.
  • HIDDEN — temporarily hidden. For example, the product ran out but the card isn't deleted — it'll be back soon.

The transitions between statuses are strictly defined:

       CreateProduct
∅  ──────────────────→  DRAFT
                           │
                   PublishProduct
                           │
                           ↓
                       PUBLISHED  ←──────┐
                           │             │ PublishProduct
                    HideProduct          │
                           │             │
                           ↓             │
                        HIDDEN  ─────────┘

Deleting a product isn't allowed at launch — that's a deliberate restriction. Later you can add an ARCHIVED status without reworking anything else.

Why this matters: when a seller hides a product, the Order Service starts getting a 404 on GET /products/{id} and can't add it to an order. That's how the storefront and cart automatically stop offering something that isn't available.

The database schema

A single table is enough to start:

diagram

A few details worth explaining:

  • seller_id — the seller's UUID from the JWT token. This is exactly what's used to check whose product it is.
  • product_status — a type in Postgres (ENUM), not text. The database itself won't let an invalid status be written.
  • currency — for now only RUB. It's text rather than an ENUM, so new currencies can be added by a migration without changing the code.
  • id — generated on the server; the client doesn't set it. This protects against spoofing and collisions.

A Read Model (separate tables for reads) isn't needed here — everything is read from a single table by index.

REST API: what's exposed and to whom

Catalog exposes several endpoints:

MethodPathWho calls itWhat it does
POST /productssellercreate a draft
POST /products/{id}/publishsellerpublish
POST /products/{id}/hidesellerhide
GET /products/{id}public / Orderfetch the card
GET /products/mysellerlist one's own products

GET /products/{id} is a special case. Without authorization it returns the card only if the status is PUBLISHED. Drafts and hidden cards get a 404. This matters: the Order Service must not take a price from a draft.

Through GET /products/my, a seller sees all their products in any status — so they can manage them from their dashboard.

Access control: who can do what

The roles in the system:

  • seller — the seller; manages only their own cards.
  • admin — a platform operator; can touch any card.
  • system — the Order Service with a service token; reads the price only.

The key check is ABAC (attribute-based access control): before publishing or hiding, the handler compares the seller_id from the database with the sub from the request's JWT token. If they don't match, it returns a 404 (not a 403). Why 404? So as not to reveal to another seller the very fact that the card exists.

if (!product.getSellerId().equals(requesterSellerId)) {
    throw new OwnProductRequiredException(); // → 404
}

It's a small detail with a big meaning: an attacker brute-forcing UUIDs won't learn which cards other sellers have.

Business rules and errors

When creating a product and moving between statuses, the service checks several rules:

RuleWhat's checkedError
Price is required and greater than zeroon CreateProductINVALID_PRICE (400)
Currency must be RUB onlyon CreateProductINVALID_CURRENCY (400)
Only the owner changes the statuspublish / hideOWN_PRODUCT_REQUIRED (404)
Transition allowed by the schemacan't hide a draftINVALID_STATE_TRANSITION (409)
Public GET returns PUBLISHED only404

Errors are returned in RFC 9457 format (application/problem+json) — a standard most clients and API gateways understand.

How the Order Service gets the price

This is the busiest scenario: every time an order is placed, Order calls Catalog for the price.

The response requirement: p95 ≤ 50 ms. A single SELECT by primary key is fast enough without a cache. If load grows, a Redis cache is placed in front of Catalog at the Order level, not inside Catalog — that way it's easier to invalidate when a price changes.

The product-hiding scenario:

  1. The seller calls HideProduct → the status changes to HIDDEN.
  2. Order requests GET /products/{id} → gets a 404.
  3. Order can't place an order with this product.

No events, no queues, no subscriptions — plain synchronous REST. That's enough for this scale.

What's in the stack and why

  • Spring Boot 3 / Java 21 — the standard choice for JVM services.
  • PostgreSQL — the primary database; ENUM for statuses gives validation at the database level.
  • jOOQ — for database queries. It generates classes from the schema, so errors are caught at compile time.
  • Flyway — schema migrations; they run without restarting the service.
  • Spring Security / OAuth2 Resource Server — JWT validation via the Keycloak JWK.
  • Resilience4j — timeouts and retries when calling external dependencies.
  • Micrometer + OpenTelemetry — metrics and tracing for observability.

There's no Kafka here — deliberately. An event-driven model (publishing ProductPublished, ProductHidden) would require an Outbox, idempotent consumers, and a schema registry. That's needed when several services react to catalog changes; for now, synchronous REST is enough.

Common design mistakes

Putting the storefront into Catalog. Buyer-facing listings, category search, review aggregation — that's a different domain with different load requirements. Mixing it with card management means creating coupling that's painful to untangle later.

Not checking seller_id in every command. One missed check, and seller A can hide seller B's product. ABAC has to be in every command handler, not only in the role-level authorization.

Returning 403 instead of 404 for someone else's product. A 403 says "you know the product exists, but you're not allowed." A 404 says "no such thing exists." The correct answer is 404.

Letting the client pass the id. The UUID must be generated on the server. Otherwise a client could guess an existing id and overwrite someone else's card (if the seller_id check is also missing).

Returning DRAFT in the public GET /products/{id}. The Order Service must get a 404 for a draft — otherwise it could place an order against an unfinished card with no price, or with a test one.

In short

  • Catalog manages the seller's product cards; the storefront, stock levels, and files are not its concern.
  • Statuses: DRAFT → PUBLISHED ↔ HIDDEN; transitions are strictly defined; Order sees a hidden product as a 404.
  • GET /products/{id} without authorization returns only PUBLISHED — so Order doesn't take a price from a draft.
  • ABAC: the seller_id from the database is compared with the sub from the JWT; no match means 404, not 403.
  • A single products table with a Postgres ENUM for statuses; a Read Model isn't needed at this scale.
  • No Kafka on purpose: synchronous REST is enough until Catalog needs to notify several consumers about changes.
  • Catalog Service: step-by-step generation from business description to code — what a full development session looks like, with prompts and responses.
  • Order Service — Use Case specification — the neighboring service that calls Catalog for the price.