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
productIdwhen 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:
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), nottext. The database itself won't let an invalid status be written.currency— for now onlyRUB. It'stextrather than anENUM, 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:
| Method | Path | Who calls it | What it does |
|---|---|---|---|
POST /products | — | seller | create a draft |
POST /products/{id}/publish | — | seller | publish |
POST /products/{id}/hide | — | seller | hide |
GET /products/{id} | — | public / Order | fetch the card |
GET /products/my | — | seller | list 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:
| Rule | What's checked | Error |
|---|---|---|
| Price is required and greater than zero | on CreateProduct | INVALID_PRICE (400) |
Currency must be RUB only | on CreateProduct | INVALID_CURRENCY (400) |
| Only the owner changes the status | publish / hide | OWN_PRODUCT_REQUIRED (404) |
| Transition allowed by the schema | can't hide a draft | INVALID_STATE_TRANSITION (409) |
Public GET returns PUBLISHED only | — | 404 |
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:
- The seller calls
HideProduct→ the status changes toHIDDEN. - Order requests
GET /products/{id}→ gets a 404. - 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;
ENUMfor 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 onlyPUBLISHED— so Order doesn't take a price from a draft.- ABAC: the
seller_idfrom the database is compared with thesubfrom the JWT; no match means 404, not 403. - A single
productstable 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.
What to read next
- 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.