A jewelry platform’s boundary lines should follow load and change cadence, not domain nouns like “catalog” or “orders.” Pricing recalculates on a metal-rate feed’s own clock and gets read on every product view; catalog search needs a different index shape than the write path that owns the product record; orders, checkout, payment and inventory need to stay inside one ACID transaction until there’s a measured reason to break it apart. The setup below targets Docker Engine 29.6.2, Docker Compose 5.3.1, PostgreSQL 17.10 and Node.js 24.18.0 (Active LTS).
Which jewelry commerce concerns actually deserve their own service
A jewelry catalog carries a combinatorial variant space: metal, purity, stone, size, and every combination is a distinct SKU with its own stock level and price. Layered on top is a price that must be recomputed continuously against a live metal rate, and an order that has to stay transactionally consistent through checkout, payment capture and inventory decrement. These are three genuinely independent concerns, but independence alone doesn’t justify three services.
The only sound reason to pull a concern out of the monolith is that its load profile and its change cadence are independent of the transactional core, not that it sounds like a separate domain on a whiteboard. A concern that scales differently, deploys on a different schedule and gets read or written at a different rate than orders is a candidate. A concern that just has a tidy name is not.
flowchart TD
A[New concern in the platform] --> B{Independent load profile?}
B -- No --> F[Keep in monolith]
B -- Yes --> C{Independent change cadence?}
C -- No --> F
C -- Yes --> D{Splitting breaks an ACID transaction?}
D -- Yes --> F
D -- No --> E[Extract as its own service]
Pricing changes on a completely different clock than the rest of the platform, driven by a metal-rate feed rather than by a merchant editing the catalog, and it’s read far more often than the catalog itself is written to. That mismatch is the day-one cut: extract pricing first.
Catalog search is read-heavy and needs a different index shape, faceted across metal, purity, stone and size, than the relational write path that owns the canonical product record. That’s the second cut, following the same rule as pricing: independent load, independent change cadence.
Orders, checkout, payment and inventory stay together because splitting any pair of them replaces one local ACID transaction with a saga and the compensating actions needed to undo a partially completed one — a cost covered in the next section. A five-person team doesn’t need that cost yet.
For a five-person team, the honest total after this guide is three deployables: the monolith, a pricing service and a catalog-search service. Not five or six services copied from a generic microservices checklist.
Why the live metal-rate pricing engine is the first service to extract
Pricing recalculates whenever the metal-rate feed updates, a schedule that has nothing to do with when a merchant edits the catalog or a customer places an order. That’s the independent change cadence the database-per-service pattern (opens in a new tab) assumes for any service boundary: the data changes for its own reasons, on its own clock.
It also has an independent load profile. Every product page view recomputes a quote, so pricing gets read at a rate far higher than the catalog gets written to or the orders table gets inserted into. That read volume, not aesthetics, is what justifies giving it its own scaling story.
Pricing has no write path into orders or inventory: it only produces a number. That makes it the lowest-risk service to extract first, because a mistake in the pricing service can’t corrupt an order or double-decrement stock the way a mistake in a transactional-core split could.
The pitfall this section exists to prevent: storing a reference to “today’s price” on the order — a foreign key to a live price row — instead of the price and the metal-rate snapshot that produced it. Recompute later from a live feed and the number on the confirmation email stops matching what the customer paid. Support gets the ticket.
The fix is to have checkout capture and persist the price and the rate inputs behind it as an immutable fact on the order, at the moment of payment capture. Once that price is written, the pricing service is never queried again for that order; its historical price lives on the order row, not behind a link.
Extract with Martin Fowler’s strangler fig pattern (opens in a new tab): stand the pricing service up alongside the monolith, route new price-quote calls to it, and only delete the monolith’s own pricing code once every caller has moved. No rewrite step, no cutover weekend.
{
"sku": "RING-14K-YG-VS1-SZ7",
"metal": "gold",
"purity": "14k",
"weightGrams": 3.2
}{
"sku": "RING-14K-YG-VS1-SZ7",
"price": {
"amount": 428.51,
"currency": "USD"
},
"metalRate": {
"metal": "gold",
"purity": "14k",
"ratePerGram": 62.14,
"asOf": "2026-07-28T13:02:00Z"
}
}CREATE TABLE orders (
id UUID PRIMARY KEY,
sku TEXT NOT NULL,
price_id UUID REFERENCES pricing.quotes(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);CREATE TABLE orders (
id UUID PRIMARY KEY,
sku TEXT NOT NULL,
captured_price_amount NUMERIC(12,2) NOT NULL,
captured_price_currency TEXT NOT NULL,
metal_rate_per_gram NUMERIC(12,4) NOT NULL,
metal_rate_as_of TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);You should now see: a field on your order model — captured_price_amount and metal_rate_as_of above — that stores the price and rate as they stood at payment capture, not a pointer to a live price row. If that field is missing, that’s the source of the confirmation-email mismatch.
Why catalog search is the second cut, not a bolt-on search box
Catalog search is read-heavy in a different shape than pricing: shoppers filter by metal, purity, stone and size simultaneously, a faceted query pattern a relational write path isn’t indexed for. The service that owns the canonical product record and the service that serves search queries want different storage engines, which is itself a reason to split them.
The catalog service stays the single owner of product identity, the canonical SKU. Search keeps a local, eventually-consistent copy built from catalog events rather than a live query against the catalog’s tables — event-carried state transfer (opens in a new tab), the same pattern pricing uses to know a SKU’s attributes.
A catalog write, a new variant or a price-affecting attribute change, publishes a ProductUpdated event. The search service subscribes and rebuilds its own read model asynchronously, on its own schedule, independent of the catalog’s write transaction.
The measurable consequence is a staleness window between a catalog update and its visibility in search. Measure it instead of assuming it’s zero: the business has a tolerance for how long a sold-out ring can still show up in search results, and that number should come from a test, not a guess.
This follows the same rule as pricing: search’s index is reachable only through search’s own API. No service, including the catalog’s own storefront pages, queries search’s tables with direct SQL.
{
"eventId": "5e7e6c1a-9c3b-4d2f-9a6e-2b7a1f0c8e11",
"eventType": "ProductUpdated",
"sku": "RING-14K-YG-VS1-SZ7",
"variant": {
"metal": "gold",
"purity": "14k",
"stone": "diamond",
"size": "7"
},
"changedFields": ["stone", "priceAffectingWeight"],
"occurredAt": "2026-07-28T13:05:11Z"
}#!/usr/bin/env bash
set -euo pipefail
SKU="RING-14K-YG-VS1-SZ7"
CATALOG_URL="http://localhost:3001/v1/products/${SKU}"
SEARCH_URL="http://localhost:3002/v1/search?sku=${SKU}"
echo "Update ${SKU} in the catalog now, then press Enter."
read -r
START=$(date +%s)
while true; do
CATALOG_VALUE=$(curl -fsS "$CATALOG_URL" | jq -r '.updatedAt')
SEARCH_VALUE=$(curl -fsS "$SEARCH_URL" | jq -r '.[0].updatedAt // empty')
if [ "$SEARCH_VALUE" = "$CATALOG_VALUE" ]; then
END=$(date +%s)
echo "Search caught up after $((END - START))s"
break
fi
sleep 1
doneYou should now see: a measured number of seconds between a catalog write and the same change appearing in search, to compare against how long the business can tolerate a stale facet or a sold-out item still showing as available.
Why orders, checkout and payment stay in the monolith the longest
Splitting orders, inventory and payment across separate services replaces one local ACID transaction with a saga (opens in a new tab): a sequence of local transactions, each committed by a different service, that needs compensating actions to undo whatever already succeeded when a later step fails.
A saga is coordinated one of two ways: choreography, where services react to each other’s published events with no central coordinator, or orchestration, where a coordinator service tells each participant what to do next. Both mean real engineering work — retry logic, idempotency keys, compensating handlers — and neither pays for itself until the monolith is actually the bottleneck.
sequenceDiagram
participant O as Order service
participant I as Inventory service
participant P as Payment service
O->>I: Reserve stock
I-->>O: Reserved
O->>P: Charge card
P-->>O: Payment declined
O->>I: Compensate: release stock
I-->>O: Released
O->>O: Mark order failed
The failure this section exists to prevent: a five-person team spending two sprints building a saga orchestrator for orders, payment and inventory before the pricing extraction has even shipped, because an “orders service” looks like the obvious first microservice in every generic architecture diagram. It’s the wrong first move for a team this size.
The correct trigger for splitting the transactional core is evidence: a specific scaling limit the monolith has hit, a deployment conflict between teams working in the same codebase, or a team-ownership conflict where two groups need to ship on different schedules. Not that a checklist article lists “orders service” as a default.
Until that evidence exists, orders, checkout, payment and inventory keep one database and one deploy, even after pricing and catalog search have moved out. That’s not a temporary compromise; it’s the correct architecture until the team outgrows it.
How services share product identity without sharing a database
Database-per-service (opens in a new tab) is the rule everything else in this section depends on: each service’s data is private and reachable only through that service’s own API, never through a shared table, a view, or another service running SQL directly against it.
When one service needs data it doesn’t own, there are three standard mechanisms. API composition: the caller fetches from each service and joins the results in application code. Event-carried state transfer: a service subscribes to another’s events and keeps its own local read copy. CQRS-style materialized views: a read model built and kept current from a stream of subscribed events. Pick per read, not once for the whole platform.
The catalog service owns the canonical SKU as the identity every other service references. Pricing and search keep their own local copies of just enough catalog data, the SKU and its variant attributes, to do their job, and keep those copies current by subscribing to catalog events rather than querying the catalog’s tables.
Writing to the local database and publishing to the message broker as two independent operations creates the dual-write problem: a crash between the two either loses the event entirely or fires an event for a change that then rolls back. Either way, a downstream service’s read model drifts from the truth.
The transactional outbox pattern (opens in a new tab) fixes this: write the outgoing event to an OUTBOX table in the same local transaction as the state change, then relay that row to the broker with a separate process that retries safely without re-running the state change.
flowchart LR
subgraph Postgres["PostgreSQL 17.10 instance"]
CS[(catalog schema)]
PS[(pricing schema)]
SS[(catalog_search schema)]
end
Catalog[Catalog service] -->|writes + outbox row| CS
Catalog -->|relay process| Bus[(Message broker)]
Bus -->|ProductUpdated| Pricing[Pricing service]
Bus -->|ProductUpdated| Search[Catalog-search service]
Pricing --> PS
Search --> SS
For a five-person team, schema-per-service inside one shared PostgreSQL 17.10 instance is the honest, cheap version of database-per-service. Separate physical database servers aren’t required to get real isolation: a separate schema with its own role and no cross-schema foreign key gets you the same guarantee at a fraction of the operational cost.
CREATE TABLE catalog.outbox (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
aggregate_id TEXT NOT NULL,
event_type TEXT NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
relayed_at TIMESTAMPTZ
);
BEGIN;
UPDATE catalog.products
SET stone = 'sapphire'
WHERE sku = 'RING-14K-YG-VS1-SZ7';
INSERT INTO catalog.outbox (aggregate_id, event_type, payload)
VALUES (
'RING-14K-YG-VS1-SZ7',
'ProductUpdated',
'{"sku": "RING-14K-YG-VS1-SZ7", "changedFields": ["stone"]}'
);
COMMIT;import { Pool } from "pg";
const pool = new Pool({ connectionString: process.env.CATALOG_DATABASE_URL });
async function relayOnce(publish: (eventType: string, payload: unknown) => Promise<void>) {
const { rows } = await pool.query(
`SELECT id, event_type, payload FROM catalog.outbox
WHERE relayed_at IS NULL ORDER BY created_at ASC LIMIT 100`
);
for (const row of rows) {
await publish(row.event_type, row.payload);
await pool.query(
`UPDATE catalog.outbox SET relayed_at = now() WHERE id = $1`,
[row.id]
);
}
}
setInterval(() => {
relayOnce(publishToBroker).catch((err) => console.error("relay failed", err));
}, 1000);async function getCurrentPrice(sku: string) {
const product = await catalogClient.getProduct(sku);
const quote = await pricingClient.getQuote({
sku,
metal: product.metal,
purity: product.purity,
weightGrams: product.weightGrams,
});
return quote.price;
}async function getCurrentPrice(sku: string) {
const row = await pricingDb.query(
`SELECT amount, currency FROM pricing.current_price_view WHERE sku = $1`,
[sku]
);
return row.rows[0];
}You should now see: for one concrete cross-service read in your own design, which of the three mechanisms it actually uses, and whether the write side has a dual-write gap an outbox would close.
Centralising auth at one gateway instead of five services
The API Gateway pattern (opens in a new tab) gives every client one entry point that routes to, and can aggregate responses from, the pricing, catalog-search and monolith backends. It’s also the place cross-cutting concerns, authentication chief among them, get centralized instead of copied into each service.
Without a single edge, every service reimplements “is this caller authenticated” on its own, and those implementations drift. A clock-skew tolerance or a signature check that’s slightly looser in one new service accepts a token every other service would reject, and nobody notices until it’s exploited.
JSON Web Token (opens in a new tab), RFC 7519, is the IETF standard for a compact, signed set of claims. Any downstream service verifies it locally, by checking the signature, with no callback to the issuer and no shared session store to keep available.
The practical rule: validate identity once, at the gateway, or with a single version-pinned JWT-verification library every service shares, and pass the short-lived signed token downstream. Services trust the signature, not a bespoke session check they wrote themselves.
1. Client sends credentials once, to the gateway's /auth/login.
2. Gateway verifies credentials and issues a short-lived signed JWT.
3. Client attaches the JWT to every subsequent request.
4. Pricing, catalog-search and the monolith each verify the same
JWT signature locally, using one shared verification library.
None of them calls back to the gateway or a session store.You should now see: whether every service in your stack verifies tokens through one shared library or edge, or whether one of them rolled its own check — and if so, which service needs to change first.
Running the monolith, pricing and catalog search together with Docker Compose
This setup targets Docker Engine 29.6.2 (release notes (opens in a new tab)), Docker Compose 5.3.1 (releases (opens in a new tab)), PostgreSQL 17.10 (versioning policy (opens in a new tab)) and Node.js 24.18.0 (Active LTS (opens in a new tab)). Pin these in your own base images rather than trusting latest, since all four have moved fast over the last year.
A modern Compose file starts directly with a name: and services: block and skips the top-level version: key entirely. That key is obsolete (opens in a new tab): Compose validates against the current Compose Specification whether or not it’s present, and including it just prints a warning on every docker compose up.
Compose creates one default bridge network per project and registers each service’s name with an internal DNS server, documented in Compose’s networking guide (opens in a new tab). The monolith reaches Postgres and pricing by service name — postgres, pricing — never by a captured container IP, which is assigned dynamically and won’t survive a restart.
One PostgreSQL 17.10 container hosts three schemas, monolith, pricing and catalog_search, instead of three separate database servers: the cheap, honest version of database-per-service for a team this size. Each service connects with credentials scoped to its own schema only; there’s no cross-schema foreign key and no service queries another’s schema directly.
flowchart TD
Client[Storefront client] --> Monolith
Monolith -->|service name: pricing| Pricing
Monolith -->|service name: postgres| PG[(postgres)]
Pricing -->|service name: postgres| PG
CatalogSearch[catalog-search] -->|service name: postgres| PG
subgraph Network["Default Compose bridge network"]
Monolith
Pricing
CatalogSearch
PG
end
name: jewelry-platform
services:
postgres:
image: postgres:17.10
environment:
POSTGRES_USER: admin
POSTGRES_PASSWORD: changeme-in-production
POSTGRES_DB: platform
volumes:
- ./init:/docker-entrypoint-initdb.d
- pgdata:/var/lib/postgresql/data
ports:
- "5432:5432"
monolith:
build: ./monolith
environment:
DATABASE_URL: postgresql://monolith_svc:changeme-in-production@postgres:5432/platform?schema=monolith
PRICING_URL: http://pricing:4001
depends_on:
- postgres
- pricing
ports:
- "3000:3000"
pricing:
build: ./pricing
environment:
DATABASE_URL: postgresql://pricing_svc:changeme-in-production@postgres:5432/platform?schema=pricing
depends_on:
- postgres
ports:
- "4001:4001"
catalog-search:
build: ./catalog-search
environment:
DATABASE_URL: postgresql://search_svc:changeme-in-production@postgres:5432/platform?schema=catalog_search
depends_on:
- postgres
ports:
- "4002:4002"
volumes:
pgdata:CREATE SCHEMA monolith;
CREATE SCHEMA pricing;
CREATE SCHEMA catalog_search;
CREATE ROLE monolith_svc WITH LOGIN PASSWORD 'changeme-in-production';
CREATE ROLE pricing_svc WITH LOGIN PASSWORD 'changeme-in-production';
CREATE ROLE search_svc WITH LOGIN PASSWORD 'changeme-in-production';
GRANT ALL PRIVILEGES ON SCHEMA monolith TO monolith_svc;
GRANT ALL PRIVILEGES ON SCHEMA pricing TO pricing_svc;
GRANT ALL PRIVILEGES ON SCHEMA catalog_search TO search_svc;
ALTER ROLE monolith_svc SET search_path TO monolith;
ALTER ROLE pricing_svc SET search_path TO pricing;
ALTER ROLE search_svc SET search_path TO catalog_search;docker compose upExpected output:
You should now see: all four containers running, and the monolith’s own log lines showing it reached postgres and pricing by service name rather than by IP. Capture the actual log lines from your own run before treating this as verified.
The first three extraction steps for a five-person team
Strangler fig (opens in a new tab) is the frame for all three steps: build the new service alongside the legacy code path and shift traffic to it incrementally. There’s no rewrite step anywhere in this plan.
Step 1: extract pricing behind the monolith’s existing pricing code path. Give it its own outbox-relayed events, cut the monolith over to call it for every new price quote, and only delete the monolith’s own pricing logic once every caller has moved.
Step 2: extract catalog search as a subscriber to the catalog’s product-change events. Run it alongside the monolith’s existing search until you’ve confirmed parity, measure the staleness window from the catalog search section, then repoint the storefront’s search calls.
Step 3: introduce the API gateway and JWT issuance once two independent services exist behind it. That’s the point where reimplementing auth per service would otherwise start happening, so it’s the right moment to centralize it — not before, when there’s only the monolith to authenticate against.
You should now see: a three-step extraction order — pricing, then catalog search, then gateway and auth — you can start this sprint, with an explicit stop condition for the transactional core: don’t touch orders, checkout, payment or inventory until the monolith has hit a measured limit.
Limitations
This sequence assumes a single-region deployment. It doesn’t cover multi-region data residency, GDPR-driven data locality, or which message-broker product to run — those are separate decisions the team still has to make.
It also doesn’t cover whether or when to eventually split orders, checkout and payment. That split is deferred deliberately, pending evidence the monolith doesn’t have yet, and folding it into this guide would turn a three-step plan into a speculative one.