BlueShop Recommendation Domain — Implementation Plan (v4, semantic and measurement)
Status: PHASES 0-4 SHIPPED, DEPLOYED, AND LIVE-VALIDATED. Last updated: 2026-07-11. Research basis: 6 deep exploration reports (catalog, customer/order/cart behavior, stock/analytics/infra, storefront frontend, specs/BDD/e2e conventions, 2026 off-the-shelf recommender landscape). Phase 5 Gorse remains gated on sufficient real CTR evidence.
1. Executive summary
The recommendation bounded context is deployed as recommendation-service and serves product recommendations to the storefront at four placements: home page, cart page, order confirmation, and PDP similar products. It maintains event-fed projections from Kafka topics (order.orders, stock.stock-events, catalog.products) and computes recommendations with curated relations, count-based co-purchase, decayed popularity, client-provided seeds, and multilingual semantic similarity. One non-negotiable invariant remains: never recommend a product that is OUT_OF_STOCK or not PUBLISHED.
Build-vs-buy verdict (researched, §4): hybrid. The bounded context, domain rules, and serving API are ours regardless of engine. The scoring engine sits behind a domain port (RecommendationEngine):
- v1 (shipped): deterministic SQL/count-based strategies — zero new infra, Gherkin-provable, explainable.
- Phase 3 (deployed): semantic layer — pinned multilingual product embeddings (in-JVM ONNX inference, CPU) + pgvector cosine kNN → SIMILAR_CONTENT. It serves the cold-start customer (browsed but never ordered) and PDP similar-product placement (§5.9); live coverage is 3,860/3,860 eligible products.
- Phase 4 (deployed): no-PII
RecommendationServed/RecommendationClickedCloudEvents via an RLS-protected outbox torecommendation.events, then item-grain ClickHouse facts and CTR by environment, traffic class, placement, strategy, and rank.LIVE,SYNTHETIC, and retainedLEGACYtraffic remain separate; dashboard 22 defaults to theLIVEbaseline at/dashboards/recommendation-performance-ctr. - Phase 5 (gated on CTR data): Gorse (Apache-2.0, active 2026, CPU-only, runs on our existing Postgres + idle Redis) for learned matrix-factorization personalization.
2. Current-state findings (what exists — verified in code)
| Finding | Evidence |
|---|---|
| Curated cross-sell/upsell exists end-to-end | catalog.product_cross_sell/product_upsell tables, `PUT /v1/products/{id}/cross-sells |
| A catalog-admin recommendation endpoint exists — different context | RecommendationsController (GET /v1/recommendations, hasRole('catalog-admin')), strategies HIGH_MARGIN/SLOW_MOVER/BEST_SELLER/COMPLEMENTARY, ≤500 products in-memory. Campaign backoffice tool. Untouched by this plan (consolidation option recorded in ADR-024). |
| Storefront recommendation strips are deployed | Dedicated RecommendationsStore + <app-recommended-products> render HOME, CART, ORDER_CONFIRMATION, and PDP_SIMILAR. Authenticated HOME passes RecentlyViewedStore product ids as seeds; anonymous HOME remains public and auth-independent. Recommendation set ids and click rank/strategy are measured without PII. |
| Recently-viewed lives in catalog-service, not customer | catalog.customer_recently_viewed_products (max 30, prune-on-write, RLS self-only), GET/POST /v1/customers/{id}/recently-viewed, recorded on PDP view for authenticated customers only. |
| Stock-service is fully implemented (STRUCTURE.md is stale) | Real topic stock.stock-events (not blueshop.stock.events), StockAvailabilityChanged {productId, variantId, availableToSell, status, occurredAt} event-carried state (ADR-020); GET /v1/availability?productIds= permitAll ≤100 ids. MockInventoryClient no longer exists — cart uses HttpInventoryClient (fail-open display / fail-closed checkout). |
| Proven availability-projection pattern to copy | catalog StockAvailabilityEventConsumer → catalog.product_stock_availability (idempotent on occurred_at), feeds ES inStock. |
| Order events carry full line items | order.orders: OrderCreatedEvent + OrderChangedEvent (every lifecycle transition), CloudEvents envelope, keyed customerId, items[] {productId, variantId, quantity, unitPrice, unitCostPrice…}. Replayable from earliest. |
| ClickHouse already computes basket affinity | analytics.basket_product_affinity (support/confidence/lift, ANL-016). Used as offline validation oracle only — ADR-023 keeps analytics a terminal read model, not a runtime dependency. |
| Bulk product hydration exists | Recommendation hydration reuses the existing public GET /v1/products/bulk?productIds=... endpoint. The recommendation client uses comma-separated ids and 20-item refresh batches to stay under WebClient response-buffer limits. |
| Redis deployed but unused by any service | Two small deployments (50m/64Mi req), zero consumers today. Available as serving cache / future Gorse cache store. |
| Elasticsearch has no spare headroom | Single node, 1 replica, limits 1 CPU/2Gi, sized for catalog search only. Phase 3 must be measured + cached + feature-flagged. |
| Infra cost of one service is templated | ApplicationSet entry + overlay + Vault role + Kafka OIDC client/ACLs + Harbor + CI job. Standard footprint 2×(100m/512Mi req → 500m/1Gi lim); analytics precedent allows replicas: 1. No GPU, arm64 node. |
3. Data sources → how each is exploited
| # | Source | Transport | Exploitation |
|---|---|---|---|
| 1 | Order history (orders.order_items) |
Kafka order.orders (replay from earliest = instant backfill) |
Fact ingestion → co-purchase pair counts, popularity, per-customer purchase profile (§5.2–5.4). The backbone signal. |
| 2 | Stock availability | Kafka stock.stock-events |
Eligibility projection; hard filter REC-001. OUT_OF_STOCK removes a product from all responses within the propagation window (§5.6). |
| 3 | Product catalog (status, category, brand, price) | Kafka catalog.products + CatalogProductClient re-fetch on thin ProductUpdated |
Eligibility projection (PUBLISHED-only, REC-002) + category/brand/price features for personalization and diversity (§5.4, §5.5). |
| 4 | Curated cross-sell/upsell (catalog-manager input) | Kafka CrossSellUpdated/UpsellUpdated (full replacement lists) |
curated_relations projection → CURATED_RELATION strategy: merchandiser intent ranks above statistics when present (§5.5). This finally makes the catalog-manager's upsell/cross-sell input systemically exploited beyond the PDP. |
| 5 | Recently viewed (catalog, RLS self-only) | Client-side seeds — storefront already holds the list (RecentlyViewedStore), passes ids in the request |
Extra seeds for HOME personalization for logged-in customers. No cross-service RLS bypass, no new API. |
| 6 | Cart contents | Client-side seeds (cart page knows its items) | Seeds for CART placement + REC-003 exclusion set. |
| 7 | Category tree (3 levels) | via #3 (categoryId + path from re-fetch) | Category-proximity scoring in PERSONAL_HISTORY; diversity guard (§5.5). |
| 8 | Product text/attributes (name, description ×4 locales, category path, brand, attributes) | via #3 re-fetch → embedded in-service (Phase 3) | Semantic layer (§5.9): multilingual sentence embeddings → vector kNN → SIMILAR_CONTENT. Serves cold-start customers (seeds = views/cart, zero orders needed) and cold-start products. ES More-Like-This kept as a cheap term-based complement/fallback. |
| 9 | ClickHouse analytics.basket_product_affinity |
offline only | Validation oracle: our pair counts must reconcile with analytics' independent computation from the same events (§9, data-quality gate). |
Not used v1 (documented why): cart.events topic (funnel analytics grain, no removal events — cart context comes fresher from the client); customer loyalty/RFM (Phase 5 personalization features); review ratings (candidate quality boost — noted as backlog REC idea).
4. Build-vs-buy analysis (2026 state of the art, researched)
| Option | Verdict | Key facts |
|---|---|---|
| Own count-based strategies (co-occurrence + popularity + rules) | ✅ v1 | The industry-standard baseline (original Amazon item-item CF). Zero new infra, deterministic ⇒ BDD-provable, explainable reason per item. Right-sized for our data volume. |
| Gorse (gorse.io, Go, Apache-2.0) | ✅ Phase 5, gated | Only mature, actively maintained (v0.5.10 2026-06, pushed 2026-07), CPU-only, self-hostable recommender service. REST feed/serve (users/items/feedback → /recommend/{user}); data store = our Postgres, cache = our idle Redis; Helm chart; gorse-in-one ~1 CPU/1–2Gi; arm64 OK. Risks: single lead maintainer, config-breaking minors ⇒ pin version. |
| Own embeddings (in-JVM ONNX) + pgvector | ✅ Phase 3, implemented | Immutable-checksum multilingual 384-d model runs inside recommendation-service via ONNX Runtime/DJL on a dedicated bounded scheduler. PostgreSQL pgvector stores vectors and filters cosine similarity at >= 0.55. Details §5.9. |
| ES More-Like-This | ✅ complement | Free Basic tier, TF-IDF-style term similarity, zero new components — cheap first content signal and fallback if embeddings are delayed. ELSER excluded (Platinum + ≥4Gi ML node). |
| Recombee | ❌ | SaaS-only. |
| Apache PredictionIO / Mahout | ❌ | Attic 2021 / pivoted to quantum computing. |
| NVIDIA Merlin | ❌ | GPU-required, key components deprecated. |
| Metarank | ⚠️ watch | Good LTR-over-ES concept; maintenance stalled (last push 2025-09). Re-evaluate if it revives. |
| RecBole / LensKit / Surprise / implicit | ❌ | Research libraries, not services; heavy Python footprint; we'd still build the service. |
| pgvector | ✅ deployed vector store (§5.9) | PostgreSQL-license extension 0.8.2 is installed in CNPG; recommendation owns a 384-d vector table and HNSW index, keeping vector load off shared Elasticsearch. |
Why not Gorse first? (a) Acceptance criteria are Gherkin — deterministic heuristics are provable, a trained CF model is not. (b) The hard rules (OOS, PUBLISHED, exclusions, PII) must live in our domain layer anyway. (c) Zero extra pods now; Gorse costs ~1 CPU. (d) CF needs interaction volume we don't have yet; at demo scale it would underperform the heuristics. The port makes the swap cheap and A/B-testable when Phase-4 CTR data justifies it.
5. Algorithm & data pipeline design — the "how"
Direct answers: No trained-by-us model, ever — training is Gorse's job in Phase 5 if the CTR gate opens. No GPU (none exists). v1 is memory-based (count-based) collaborative filtering + decayed popularity + rule-based personalization — pure deterministic SQL over event-fed projections. Embeddings ARE in the plan and committed: Phase 3 adds a semantic layer (pretrained multilingual sentence-embedding model, ONNX inference in-JVM on CPU — inference only, no training) that powers SIMILAR_CONTENT and is the designed answer for the customer who has never ordered (§5.7, §5.9).
5.0 Three-layer pipeline
Kafka (order.orders, stock.stock-events, catalog.products)
│ streaming, idempotent (ADR-021 inbox, message_id = envelope id)
▼
L1 INGESTION → immutable-ish FACTS + live projections
order_product_facts | product_eligibility | curated_relations
│ scheduled projector (15 min, config) + admin/BDD-triggerable recompute
▼
L2 DERIVATION → precomputed SCORES (deterministic, fully rebuildable from facts)
product_pair_affinity | product_popularity | customer_product_profile
│ request-time, indexed lookups only, <10 ms target
▼
L3 SERVING → strategy chain → eligibility filter → exclusions → RecommendationSet
Design principle: facts are the source of truth inside the context; every derived table is recomputable with RLS-safe DELETE + rebuild from facts, and facts are rebuildable from Kafka (earliest). This makes decay/damping parameters tunable without migration pain and gives a trivial disaster-recovery story (same property analytics relies on).
5.1 L1 — Ingestion (streaming consumers)
| Topic / event | Action | Idempotency |
|---|---|---|
order.orders (Kafka type = OrderCreatedEvent or OrderChangedEvent; both carry the same OrderCreatedEvent payload — verified remi) |
On any non-cancelled status: insert one row per items[] into order_product_facts(order_id, product_id, customer_id, quantity, unit_price, ordered_at, status) PK(order_id, product_id); upsert customer_product_purchases. items[] has productId/variantId/quantity/unitPrice/margin snapshots/display names — no categoryId, no brand (enrich at L2 from catalog projection) |
Inbox (envelope id, raw fallback orderId:status) + PK ON CONFLICT DO NOTHING |
order.orders payload status=CANCELLED |
Mark the order's facts status='CANCELLED' (excluded from all derivations at L2) |
Inbox + idempotent UPDATE |
stock.stock-events · StockAvailabilityChanged (payload: productId, variantId?, availableToSell:int, status ∈ {IN_STOCK, LOW_STOCK, OUT_OF_STOCK}, occurredAt — there are no out_of_stock/back_in_stock event names) |
Upsert product_eligibility.availability + available_to_sell from the payload directly, guarded by occurred_at. LOW_STOCK is still eligible; only OUT_OF_STOCK is ineligible (REC-001). (Ignore InsufficientStockDetected on the same topic — not an eligibility change.) |
Time-guarded upsert |
catalog.products · ProductPublished/Unpublished/Archived/Deleted/Restored/Discontinued |
Upsert/flip product_eligibility.published; Deleted ⇒ delete row |
Inbox |
catalog.products · ProductCreated/Updated/PriceChanged (thin — ProductCreated carries only productId/sku/name(German-only)/categoryId/brandId; ProductUpdated carries changedFields + nullable German name, not the new values; no four-locale names, no category path, no brand name, no isFeatured) |
Upsert id + re-fetch GET /v1/products/{id} via CatalogProductClient for localized names, categoryId, brand, isFeatured, cross/upsell ids. Resilience4j; on fetch failure keep last-known state (REC-011 bounds staleness) |
Inbox |
catalog.products · CrossSellUpdated(crossSellProductIds) / UpsellUpdated(upsellProductIds) — full replacement lists |
Full-replace curated_relations(source_product_id, target_product_id, relation_type, sort_order) |
Inbox (full replacement is naturally idempotent) |
Deployment correction shipped after audit: recommendation-service initially parsed invented targetProductIds/targets fields and therefore projected zero curated relations. Backend MRs !844–!846 fixed event parsing, catalog bulk relation backfill, admin-triggered refresh, and 20-item refresh batches. Live proof: curated_relations=26,528, exactly matching catalog cross-sell + upsell counts, and seeded CART returns CURATED_RELATION first.
Order-status semantics (REC-006, corrected per remi): the storefront checkout actually creates orders at PAID (Order.createPaid), while backoffice/direct factories can create at CREATED — status enum is CREATED, PENDING_PAYMENT, PAID, PICKED, PROCESSING, SHIPPED, DELIVERED, CANCELLED. So the rule is count on first sight of any non-CANCELLED status (covers both real checkout PAID and backoffice CREATED), keyed by orderId so later status-change events for the same order don't double-count (PK ON CONFLICT DO NOTHING on order_product_facts), and remove from derivations when CANCELLED. PENDING_PAYMENT-stuck orders stay low-weight noise; acceptable v1.
5.2 L2 — CO_PURCHASE: item-item collaborative filtering (count-based)
Recomputed from non-cancelled facts:
C(a,b)= number of distinct orders containing both a and b (pairs canonicalizeda<b)C(a)= number of distinct orders containing a;N= total orders
Stored score (asymmetric cosine with bestseller damping):
score(a→b) = C(a,b) / ( C(a) · C(b)^α ) α = 0.5 (config)
- The
C(b)^αdenominator damps the "everything recommends the bestseller" failure mode of raw confidenceC(a,b)/C(a). - Noise floor: pairs kept only if
C(a,b) ≥ minSupport(=2, config; =1 tolerated belowbootstrapOrderCountso the demo dataset produces results — explicit config, not a hack). - We also store raw
co_order_count,confidence,lift = confidence / (C(b)/N)for explainability (reason: "Frequently bought with <seed>" + numbers in debug) and for reconciliation with ClickHouse ANL-016. - Multi-seed aggregation (cart with k items):
score(b) = Σ_seeds w_seed · score(seed→b), seeds equally weighted in CART, recency-weighted in HOME (§5.4). Normalized to 0..1 by the max in the candidate set.
Table: product_pair_affinity(product_a, product_b, co_order_count, score_a_to_b, score_b_to_a, computed_at) PK(product_a, product_b), index on each column for neighbor lookups.
5.3 L2 — POPULARITY: time-decayed best sellers
pop(p) = Σ over non-cancelled orders containing p: exp( -λ · age_days(order) )
λ = ln 2 / 14 (14-day half-life, config)
- Exponential decay ⇒ "trending now", not "all-time bestseller"; an item that stops selling fades with a 14-day half-life.
- Recomputed by the projector (decay makes incremental maintenance pointless; full recompute over facts is milliseconds at our scale).
- Stored per product and per (product, category) so the personalization step can pull "popular within customer's favored categories".
Table: product_popularity(product_id PK, pop_score, category_id, orders_30d, units_30d, last_ordered_at, computed_at).
5.4 L2/L3 — PERSONAL_HISTORY: rule-based personalization (no model)
Server-side profile from customer_product_purchases + client seeds (recently-viewed ids):
- Seed set = last
K=10purchased products, weightw = exp(-λ_p · days_since_purchase)(λ_p = ln2/30), plus recently-viewed seeds at a lower fixed weight (0.5, config) — a view is a weaker signal than a purchase. - Candidates = union of (a) affinity neighbors of every seed (§5.2), (b) top-popularity products in the customer's top-3 categories by purchase weight (§5.3 per-category).
- Score =
Σ_seeds w_seed · score(seed→cand)for (a); category-popularity normalized ×0.6 for (b) so behavioral affinity outranks demographic popularity. - Exclusions: REC-003 (seeds themselves), REC-004 (purchased within 30 days), then eligibility filter.
Deterministic, explainable (reason: "Because you bought <seed name>" / "Popular in <category>"), and — critically — every step is expressible as a Gherkin scenario.
5.5 L3 — Serving: strategy chains, blending, diversity
Per placement (REC-007):
| Placement | Chain (priority order) | Exclusions |
|---|---|---|
| HOME (auth, has purchases) | PERSONAL_HISTORY → CURATED_RELATION(cross-sells/upsells of profile seeds) → POPULARITY | purchased ≤30d (REC-004) |
| HOME (auth, zero orders) | CURATED_RELATION(recently-viewed seeds) → POPULARITY until Phase 3 adds SIMILAR_CONTENT | viewed seeds (REC-003) |
| HOME (anon) | POPULARITY (with featured boost, §5.5a) | — |
| CART | CO_PURCHASE(cart seeds) → CURATED_RELATION(cross-sells of cart items) → SIMILAR_CONTENT(P3) → POPULARITY | cart items (REC-003) |
| ORDER_CONFIRMATION | CO_PURCHASE(ordered seeds) → CURATED_RELATION → POPULARITY | just-ordered items (REC-003) |
Seeding gotcha (mude): the confirmation page today has no purchased product ids —
PlaceOrderResponseonly returnsorderId/orderNumber/status, and the ReviewStep resets cart+checkout before navigating. So Phase 2 must add a durable seed source: capture the cart line-item ids before the reset and carry them into the confirmation route (router state / a short-lived signal), then pass them as CO_PURCHASE seeds. Fallback if unavailable: seed server-side from the customer's latestorder_product_facts(already ingested), else PERSONAL_HISTORY → POPULARITY. Never block the confirmation page on it (REC-010). | PDP_SIMILAR (Phase 3) | SIMILAR_CONTENT → CO_PURCHASE → CURATED_RELATION → POPULARITY | current product |
5.5a Catalog-manager input is a first-class signal (explicit requirement): (1) CURATED_RELATION — the cross-sells/upsells that catalog managers author in backoffice-catalog are present in every seeded chain and outrank statistics when both propose the same slot (fixed score by sort_order, merchandiser intent wins). Today that data is heuristic seed — shipping this feature makes curating it actually pay off, which we surface to the catalog team. (2) Featured boost — products flagged is_featured by catalog managers get a configurable POPULARITY multiplier (×1.2 default) and are the preferred filler for anonymous HOME. product_eligibility snapshots is_featured from catalog re-fetches. Explainability preserved: reason: "Recommended by our team" for curated/featured picks (REC-012).
Blending = priority-fill (not weighted mixing): strategy #1's results (score-normalized 0..1) fill first; remaining slots filled by strategy #2 with scores compressed under the last #1 score; dedupe by productId keeping highest strategy. Simple, order-stable, explainable — every item's strategy field says where it came from. CURATED_RELATION items carry fixed score by sort_order — merchandiser intent beats statistics when both propose the same slot.
Diversity guard (REC-008 refinement): max 3 items per category in a strip of 8 (config) — prevents "8 pairs of socks" carts. Deterministic tiebreak: score desc, then productId asc.
Serving path: candidate lookups are indexed reads on precomputed tables + one eligibility join; target p95 < 30 ms server-side. Caffeine cache 60 s TTL keyed (placement, customerId|anon, hash(seeds), limit) — recommendations tolerate 60 s staleness except availability, which is re-filtered on every request against product_eligibility (so an OOS flip beats the cache, REC-011).
5.6 Consistency windows (explicit, testable)
| Path | Window | Bound by |
|---|---|---|
| OOS → removed from responses | ≤ ~2 s typical (Kafka consume + upsert; no cache on eligibility) | REC-011 e2e asserts ≤ 10 s |
| Unpublished/archived → removed | same as above | REC-002 |
| New order → affects affinity/popularity | ≤ projector interval (15 min default; BDD triggers synchronously) | REC-006 |
| Catalog field drift (name/category) | re-fetch on event; last-known on failure | non-blocking |
5.7 Cold start & empty states
The cold-start ladder, by how much we know about the visitor:
| Visitor knowledge | Answer |
|---|---|
| Nothing (anonymous, no session signals) | POPULARITY + featured boost — the honest baseline; SSR-renderable. |
| Session signals only (cart items, current PDP — anon or auth) | Seeded strategies work without any purchase history: CO_PURCHASE + CURATED_RELATION + SIMILAR_CONTENT(P3) over the seeds the client sends. |
| Auth, browsed but never ordered (the case that motivated §5.9) | Recently-viewed ids (client-held, up to 30) become semantic seeds → SIMILAR_CONTENT kNN + curated relations of viewed items. Until Phase 3 ships: curated relations of viewed items → POPULARITY. |
| Auth with purchase history | Full PERSONAL_HISTORY (§5.4). |
- New product (no pairs, no sales): reachable via CURATED_RELATION (if merchandised), featured boost (if flagged), and SIMILAR_CONTENT (embedding exists from first catalog event — no interaction data needed). This closes product cold-start completely in Phase 3.
- Result < minResults (3, config): response returns what exists; the storefront hides the section entirely (REC-010) — no half-empty strips; recommendations never delay or break a page (independent httpResource).
5.8 Evaluation methodology
- Offline/CI: golden-dataset BDD scenarios (seeded orders → exact expected outputs — possible because v1 is deterministic); reconciliation test: our
co_order_countvs ClickHouseanalytics.basket_product_affinityon identical event sets. - Live data-quality gate (per deploy): coverage % (eligible products reachable by ≥1 strategy), zero OOS/unpublished items sampled from real responses.
- Online (Phase 4):
RecommendationServed/RecommendationClickedevents (outbox →recommendation.events→ analytics → ClickHouse) ⇒ CTR per placement/strategy in Metabase. This is the Phase-5 gate: Gorse gets adopted only if heuristic CTR plateaus at meaningful traffic.
5.9 Semantic layer (Phase 3, committed) — embeddings design
Problem it solves: a customer who browsed but never ordered gets nothing personal from counting-CF; a brand-new product is invisible to statistics. Both need content understanding, not interaction counts.
Model (inference only, never trained by us): a pretrained multilingual sentence-embedding model — candidate: paraphrase-multilingual-MiniLM-L12-v2 (384-d, supports de/fr/it/en, permissive license). Runs inside recommendation-service via ONNX Runtime + DJL on CPU. VERIFIED (sage): DJL 0.36.0 + ONNX Runtime 1.21.1 are Java 25 compatible; the ORT jar and DJL tokenizers both ship linux-aarch64 natives. CRITICAL: those natives are glibc, but the BlueShop backend Dockerfile is Alpine/musl → recommendation-service must use a glibc Temurin Java 25 base image (deviation from the standard backend image — called out in Phase 3.1 / infra). Use the ~118 MB quantized arm64 ONNX model (not the 470 MB full one) to fit the 1Gi pod limit — steady RSS is feasible but tight, monitor. Bake the pinned model + tokenizer bundle into the service image for v1 (no runtime internet); Harbor OCI artifact is an optional later decoupling. Embedding the full catalog (thousands of items) is seconds; no GPU, no Python service, no external API.
What gets embedded (per product, on catalog events): one composed text per product — name(4 locales) + category name + brand + description(primary locale) + salient attributes → one 384-d vector, L2-normalized. (Note per remi: full category path names live only in the internal ES search document, not in ProductResponse; v1 embedding uses the single category name from the re-fetched detail, or projects categories separately if path context proves valuable.) Re-embedded on ProductUpdated/AttributesUpdated re-fetch (same L1 flow, §5.1); embedded at first ProductCreated — which is exactly why product cold-start closes: the vector exists before the first sale.
Storage & query — pgvector preferred, ES fallback (VERIFIED by sage research 2026-07-08, brief worktrees/reco-research/sage-semantic-feasibility.md):
- Preferred:
pgvectorin our ownrecommendationschema (product_embedding(product_id PK, embedding vector(384), embedded_at), HNSW index, cosine<=>). Keeps vector load OFF the tightly-sized shared ES; kNN over thousands of rows is trivial; joins directly withproduct_eligibilityin one query (eligibility filter inside the kNN query — elegant REC-001/002 enforcement). CONFIRMED available: CNPG imageharbor.blueshop.local/ghcr/cloudnative-pg/postgresql:17is built on the standard image family that bundles pgvector. CRITICAL CONSTRAINT:CREATE EXTENSION vectormust be admin/GitOps-managed, NOT service Flyway — pgvector is not a trusted extension and recommendation-service's DB role is non-superuser. So: a GitOps/admin bootstrap enables the extension once; the service's Flyway only creates theproduct_embeddingtable (assuming the type exists). Custom Postgres image only needed if a system/standard tag or CNPG extension-image path is blocked (low/medium effort, no sidecar). - Fallback: ES
dense_vector+ kNN (VERIFIED: ES 8.17.0, free Basic tier supports dense_vector + kNN/BYO vectors). Downside: shared single-node ES has only 512Mi heap — pgvector wins on isolation and eligibility joins.
SIMILAR_CONTENT scoring: for seed set S (viewed/cart/current product), candidate score = max_{s∈S} cos(v_s, v_c) (max, not mean — preserves niche interests instead of averaging them into mush), recency-weighted by seed age like §5.4. Threshold cos ≥ 0.55 (config) to avoid nonsense matches; deterministic given fixed vectors ⇒ still Gherkin-testable with a frozen test model + golden fixtures.
Why this is Phase 3 and not v1: v1 ships value immediately with zero new moving parts; the semantic layer adds a model artifact, an inference runtime, and a vector store — worth doing (committed, it completes cold-start), but not worth blocking first delivery. ES More-Like-This can be wired in days as an interim content signal if Phase 2 feedback demands it before the embedding work lands.
6. Bounded context design (DDD)
Ubiquitous language: Placement · RecommendationQuery (placement + seeds + customer + limit) · Recommendation (productId, score 0..1, reason, strategy) · RecommendationSet (ordered, deduped, filtered) · Strategy (CO_PURCHASE, POPULARITY, PERSONAL_HISTORY, CURATED_RELATION, SIMILAR_CONTENT) · Eligibility (PUBLISHED ∧ ¬OUT_OF_STOCK) · Seed · FallbackChain · Projector.
com.blueshop.recommendation/
├── api/ RecommendationController + request/response DTOs
├── application/
│ ├── service/ DefaultRecommendationApplicationService # chain orchestration + filters + cache
│ ├── port/ RecommendationEngine # THE swap point (Heuristic v1 → Gorse P5)
│ └── client/ CatalogProductClient (interface)
├── domain/
│ ├── model/vo/ Placement, Recommendation, RecommendationSet, Strategy, Seed, Eligibility, AffinityScore
│ ├── service/ EligibilityPolicy (REC-001/002), ExclusionPolicy (REC-003/004),
│ │ FallbackChain (REC-007), DiversityPolicy (REC-008)
│ └── repository/ EligibilityRepository, AffinityRepository, PopularityRepository,
│ CustomerPurchaseRepository, CuratedRelationRepository, OrderFactRepository
└── infrastructure/
├── persistence/ R2DBC entities + repos (schema `recommendation`, RLS service context)
├── event/ OrderEventConsumer, StockAvailabilityConsumer, CatalogProductConsumer,
│ inbox (common-lib IdempotentConsumer), [Phase 4: outbox relay]
├── projection/ AffinityProjector, PopularityProjector (scheduled + admin/BDD trigger endpoint)
├── engine/ HeuristicRecommendationEngine [P3: +ElasticsearchMltClient] [P5: GorseRecommendationEngine]
├── client/ HttpCatalogProductClient (WebClient + Resilience4j)
└── config/ security/
JMolecules annotations, @NullMarked, records for VOs/DTOs, Mono/Flux only, no .block(), ArchUnit enforcing layer boundaries (copy catalog's rules).
Flyway (schema recommendation): V1 baseline + RLS, V2 order_product_facts + customer_product_purchases, V3 product_eligibility + curated_relations, V4 product_pair_affinity + product_popularity, V5 inbox_events, (P4) V6 outbox_events. All tables service-only RLS (no customer-facing rows here; responses expose no PII — REC-009).
Serving API (gateway route /api/recommendation/**):
GET /v1/recommendations?placement=HOME|CART|ORDER_CONFIRMATION
&seedProductIds=<uuid,...> (optional, ≤ 20)
&limit=8 (server-capped at 12, REC-008)
Auth: permitAll; valid JWT ⇒ personalized (customerId = sub); anonymous ⇒ non-personalized (REC-005).
200: { "placement": "CART",
"items": [ { "productId": "…", "score": 0.87,
"strategy": "CO_PURCHASE",
"reason": "Frequently bought together" } ],
"generatedAt": "…" }
400: invalid placement / malformed seeds. Never 5xx for empty data — empty list is a valid answer.
POST /internal/v1/recommendations/recompute (service/admin only; catalog refresh + projector trigger for ops + BDD)
Response is ids + scores only; storefront hydration uses existing catalog bulk endpoint GET /v1/products/bulk?productIds=a,b,c (permitAll, staff-aware filtering).
7. Business rules (REC catalog — authored in docs at Phase 0)
| ID | Rule | Category | Severity |
|---|---|---|---|
| REC-001 | A recommendation never contains a product whose availability is OUT_OF_STOCK | Validation | Blocking |
| REC-002 | Only PUBLISHED products are recommendable | Validation | Blocking |
| REC-003 | Seed/context products are never recommended back (cart items, just-ordered items, current product) | Validation | Blocking |
| REC-004 | Personalized placements exclude products purchased by the customer within the last 30 days | Workflow | Warning |
| REC-005 | Anonymous requests receive non-personalized results; no customer data is read without a valid JWT | Security | Blocking |
| REC-006 | Order facts are ingested effectively-once: redelivered events change no count; cancelled orders are excluded from derivations | Data Quality | Blocking |
| REC-007 | Each placement applies its defined strategy fallback chain; results deduped, priority-filled, score-desc ordered | Calculation | Blocking |
| REC-008 | Limit capped at 12; ≤3 items per category per response; deterministic ordering and tiebreak | Validation | Warning |
| REC-009 | Responses and events expose product ids/scores/reasons only — no PII, no cost/margin data | Security | Blocking |
| REC-010 | Recommendation failure or thin results degrade gracefully: storefront hides the section; pages never block on recommendations | Workflow | Blocking |
| REC-011 | An availability or publication change propagates to serving ≤ the documented window; OUT_OF_STOCK beats any cache | Workflow | Blocking |
| REC-012 | Every recommendation carries a human-readable reason and its strategy | Data Model | Info |
| REC-013 | Co-purchase scoring damps bestseller bias (asymmetric cosine, α documented) and enforces a minimum support | Calculation | Warning |
| REC-014 | Derived scores are fully recomputable from facts; facts are fully replayable from Kafka | Data Quality | Blocking |
| REC-015 | Catalog-manager input is honored: curated cross-sells/upsells outrank statistical strategies for the same slot; featured products receive the documented popularity boost | Calculation | Warning |
| (P3) REC-016 | SIMILAR_CONTENT uses locally-inferred multilingual embeddings; candidates below the similarity threshold are never recommended | Calculation | Warning |
| (P4) REC-017+ | Served/clicked measurement events on recommendation.events (CloudEvents, outbox, no PII) |
Analytics | Info |
8. Systemic impact / blast radius
New, additive:
backend/services/recommendation-service— new Gradle module (settings.gradle.kts, CI build jobbuild-recommendation,ci/required-backend-images.list, aggregatebddTestfilter atbackend/build.gradle.kts:1013-1019, PIT matrix entry per harness conventions MR !825).- api-gateway: route
/api/recommendation/**(config-only). - Infra (GitOps, one MR): overlay
overlays/dev/recommendation/(kustomization, resources, db-credentials, kafka-credentials patches), ApplicationSet entry, Vault DB rolerecommendation-svc+ k8s auth role, Keycloak Kafka OIDC clientrecommendation_service, Kafka ACLs Read/Describe onorder.orders,stock.stock-events,catalog.products+ grouprecommendation-service(+ Writerecommendation.eventsin P4), Harbor repo, ExternalSecrets. Startreplicas: 1(analytics precedent), 100m/512Mi → 500m/1Gi. - Frontend (storefront only):
RECOMMENDATION_API_URLtoken;recommendations.store.ts(signalStore + httpResource; personalized resource returnsundefinedduring SSR — Keycloak is browser-only — and activates post-init, exactly theRecentlyViewedStorepattern; anonymous HOME variant SSR-renders); one reusable<app-recommended-products>strip (reusesProductDetailProductCardComponent); mounted inhome.component.html,cart.component.html,order-confirmation.component.html; Transloco keys ×4 locales; replaces the disabledEngagementStore.recommendationsstub. - One deliberate catalog touch: bulk
GET /v1/products?ids=+ (separate MR)ProductLookupStorerefactor to use it. - Docs/specs/e2e: business rules page, events page + index entry, ADR-024,
recommendation.spec.yaml+ Makefile line, e2e specs + demo batch entry.
Explicitly untouched: stock/order/cart services (pure topic consumption, zero producer changes); catalog campaign RecommendationsController; checkout stepper UX (recommendations at cart entry + confirmation only — no mid-funnel distraction against the guarded stepper).
Risk register:
| Risk | Mitigation |
|---|---|
Thin catalog.products events force re-fetch; catalog down ⇒ stale eligibility |
Last-known-state + Resilience4j; REC-011 window documented; recommendation is never checkout-authoritative |
| Semantic layer footprint (Phase 3) | pgvector-in-own-schema preferred (no shared-ES impact); ES dense_vector only as verified fallback, cached + feature-flagged + SigNoz before/after |
| Postgres connections (max 300 shared) | Standard pool sizing, maxSurge: 0, replicas: 1 initially |
| Projector cost growth | Full recompute is O(orders×lines²/order); trivial now; ClickHouse-assisted derivation is the documented escape hatch if catalog×orders explodes |
| Bestseller feedback loop (recs → sales → recs) | α-damping (REC-013) + diversity guard (REC-008) + CTR monitoring (P4) |
| Kafka consumer error handling | Copy analytics' DefaultErrorHandler pattern (MR !818): transient = infinite 5s retry (offset never skips), poison = bounded retry + ERROR log |
| Demo-scale data too sparse for affinity | bootstrapOrderCount config lowers minSupport at low volume; POPULARITY/CURATED fallbacks always fill |
Backend worktree currently on fix/cart-double-vat |
Branch from origin/main in worktrees/backend-recommendation |
9. Testing strategy (TDD + BDD + quality gates)
| Layer | What | Tools |
|---|---|---|
| Unit (TDD red-green first) | Scoring math (asymmetric cosine, decay, blending, diversity, tiebreaks), policies (eligibility/exclusion/fallback), consumers' decode/mapping | JUnit 5, StepVerifier, property-style cases for determinism |
| Architecture | Layer rules, no blocking calls, naming | ArchUnit (copy catalog rules) |
| Integration | Flyway + RLS, R2DBC repos, projector recompute correctness on golden dataset, inbox idempotency under redelivery, catalog re-fetch fallback | Testcontainers Postgres |
| BDD (the ACs) | Every REC-### rule as Gherkin in src/bddTest/resources/features/recommendation/ (@domain:recommendation @rule:REC-###); golden-dataset scenarios: seed N orders → trigger projector → assert exact recommendation lists (possible because v1 is deterministic) |
Cucumber + Serenity, Testcontainers, runner filters not @wip and not @pending |
| Contract/reconciliation | Pair counts vs ClickHouse ANL-016 on identical events (integration-env check, Phase-1 live proof) | script/live probe |
| Mutation | PIT functional-first (per !825 harness: explicit targetClasses, matrix job, allow_failure) | PIT |
| Coverage | JaCoCo 80/70 | CI gate |
| Frontend | Vitest colocated specs (store states: loading/error/empty/hide, SSR-disabled personalized resource, seed passing); zero ESLint warnings | Vitest |
| E2E | See §11 | Playwright post-deploy |
| Live proof (per deploy) | Consumer lag 0; projections row-counted; endpoint probes: anonymous/authenticated/seeded; OOS flip disappears live; SigNoz traces clean | scripted probes |
10. Delivery plan — phases, MRs, dependencies
Every MR follows the full loop: implement → local green → push → CI green → deploy → live verify. Conventional commits, branch feat/recommendation-*.
Phase 0 — Specification first (completed)
| MR | Repo | Content | Done when |
|---|---|---|---|
| 0.1 | blueshop-docs | business-rules/recommendation.md (REC table + detail sections), events/recommendation-events.md + index entry (P4 topic documented as planned), 08-decisions/024-recommendation-bounded-context.md (context map, build-vs-buy record incl. Gorse gate, algorithm spec §5, OOS invariant) |
docs CI green, pages live on docs.blueshop.local |
| 0.2 | specifications | specs/recommendation.spec.yaml via make generate D=recommendation S=recommendation-service (all ACs pending), Makefile generate-all line |
make check green |
| 0.3 | backend | Gherkin feature skeletons (tagged, @pending) + empty module skeleton so spec-checker resolves paths (can fold into MR 1.1) |
spec-checker green |
Phase 1 — recommendation-service core (completed)
| MR | Content |
|---|---|
| 1.1 (backend) | Shipped via backend MRs !831–!856: module, migrations, RLS, consumers, projectors, domain policies, HeuristicRecommendationEngine, controller, config parity, split schedulers, catalog backfill, curated relation and tombstone repair, and sparse purchase-history semantic fallback. |
| 1.2 (infra) | Shipped: overlay, ApplicationSet, Vault DB roles, Keycloak Kafka client/claim, Kafka ACLs, Harbor, gateway route. |
| Live proof | dev-recommendation healthy; Kafka/R2DBC/Vault UP; recompute HTTP 200; anonymous HOME, seeded CART, curated relations, and tombstone invariants proven live. |
Phase 2 — Storefront placements (completed)
| MR | Content |
|---|---|
| 2.1 (backend) | Existing GET /v1/products/bulk?productIds= reused; recommendation catalog client switched to comma-separated ids and bounded batches. |
| 2.2 (frontend) | Shipped RECOMMENDATION_API_URL, dedicated RecommendationsStore, <app-recommended-products>, HOME/CART/ORDER_CONFIRMATION placements, i18n, Vitest, REC-010 hide behavior, and recently-viewed HOME seeds. |
| 2.3 (frontend) | Recommendation hydration uses bulk endpoint; broader storefront lookup refactor remains a separate optimization, not required for recommendation delivery. |
| Live proof | shop.blueshop.local: anonymous HOME visible; authenticated HOME emits recently-viewed seed ids after PDP views; seeded CART curated output; post-deploy storefront E2E pipelines 16011 and 16042 green. |
Phase 3 — Semantic layer (completed — cold-start and PDP, §5.9)
| MR | Content |
|---|---|
| 3.0 (infra, GitOps) | Admin/GitOps CREATE EXTENSION vector on the recommendation DB (NOT service Flyway — untrusted ext, non-superuser role, sage brief); confirm CNPG postgresql:17 exposes it, else custom image path |
| 3.1 (backend) | glibc Temurin Java 25 base image for recommendation-service (ONNX natives are glibc; backend default is Alpine/musl); DJL 0.36 + ORT 1.21.1 + 118MB quantized model baked into the image (pinned); ProductEmbedder on catalog reconciliation, product_embeddings table, full-catalog backfill, SIMILAR_CONTENT strategy + PDP_SIMILAR + zero-order and sparse purchase-history HOME fallbacks; frozen-model fixtures (REC-016) |
| 3.2 (frontend) | PDP "Similar products" strip (same <app-recommended-products>); zero-order HOME already works via chain change |
| Live proof | 3,860/3,860 eligible products embedded; PDP returns eight non-seed SIMILAR_CONTENT products above 0.55; fresh zero-order customer HOME and PDP browser scenarios pass; explicit 256 MiB G1 heap uses about 928 MiB after startup under a right-sized 1,280 MiB limit (512 MiB request unchanged) |
The interim Elasticsearch More-Like-This adapter was not built; pgvector shipped directly.
Phase 4 — Measurement loop (completed)
RLS outbox + recommendation.events (RecommendationServed/Clicked, no PII) → six-partition Kafka topic with 90-day retention and least-privilege ACLs → analytics consumer + ClickHouse item facts/CTR views → Metabase dashboard 22 → storefront navigation-safe click beacons. The server owns the deployment environment; normal traffic defaults to LIVE; Playwright and demo contexts emit SYNTHETIC; retained unclassified events remain LEGACY. Dashboard baseline cards filter to LIVE, so automation cannot satisfy the Gorse evidence gate. Live proof joined served items and a click by the same anonymous recommendation-set id, and the protected dashboard renders that CTR at https://bi.blueshop.local/dashboards/recommendation-performance-ctr.
Phase 5 — Engine upgrade (gated on Phase-4 CTR evidence)
GorseRecommendationEngine behind the port; gorse-in-one Helm (pinned), our Postgres + Redis; feed = same facts; A/B per placement. ADR-024 records the gate criteria: heuristic CTR plateau at meaningful traffic + catalog/order volume where MF outperforms counting.
Current gate result: closed. The current ClickHouse evidence is development/E2E traffic, not sustained customer traffic or a heuristic CTR plateau. It proves instrumentation, not that matrix factorization will outperform the deterministic baseline.
Dependency graph: 0.1 → 0.2 → (0.3) → 1.1 → 1.2 → {2.1, 2.2} → 2.3; 3, 4 independent after 2; 5 after 4.
11. E2E & demo scenarios
Permanent feature specs now shipped (storefront-recommendations.spec.ts, @type:feature @surface:storefront):
REC-005: anonymous HOME returns visible cards and product navigation works.REC-015: seeded CART recommendations rank curated catalog-manager relations first.REC-003/REC-010: seeded recommendations exclude the seed and return only catalogPUBLISHED, non-archived, non-OUT_OF_STOCKproducts.REC-010: HOME hides the recommendation strip gracefully when the recommendation API returns 503.REC-016: PDP semantic results useSIMILAR_CONTENT, exclude the current product, render localized cards, navigate, and emit the click beacon.REC-016: an isolated authenticated zero-order customer receives HOME recommendations seeded by recently viewed products.REC-001/REC-002: isolated curated targets disappear after authoritative OOS and archive events while eligible fallback results remain.REC-007: an isolated customer completes checkout, sees different order-confirmation recommendations, emits a measured click, then receivesPERSONAL_HISTORYHOME results excluding the purchase.REC-017: an analytics viewer opens the protected recommendation CTR dashboard and sees all four live cards.
Demo batch 9 — recommendation journey:
REC-007: narrated real checkout → order-confirmation strip → measured recommendation click → personalized HOME, with the purchased product absent.REC-017: narrated protected BI route showing live overall CTR, CTR by placement/strategy, CTR by rank, and daily trend.- Dynamic REC-001/REC-002 stock/archive mutation remains a permanent acceptance scenario rather than being repeated in the narrated demo.
12. Decisions — closed by the plan (veto window, not questions)
All previously-open points are now decided with rationale. They ship as stated unless vetoed; each lists what would flip it.
| # | Decision | Rationale | What would flip it |
|---|---|---|---|
| 1 | Placements v1 = HOME + CART + ORDER_CONFIRMATION; nothing mid-checkout-stepper | The stepper is a guarded conversion funnel; distraction there is anti-conversion. Cart page IS the checkout-entry CTA; confirmation is the post-purchase CTA. PDP_SIMILAR joins in Phase 3. | A/B evidence (Phase 4) that a review-step block converts. |
| 2 | UNTRACKED availability ⇒ recommendable (fail-open) | Matches storefront search inStock semantics and cart's display path; strict mode would empty strips whenever stock rows lag catalog. REC-001 targets explicit OUT_OF_STOCK — the states where we know it's unavailable. Checkout remains fail-closed downstream (cart re-verifies). |
Live probes showing untracked products being ordered then rejected at checkout at meaningful rate. |
| 3 | Order facts counted on first non-CANCELLED status, removed on CANCELLED |
remi-verified: storefront checkout emits PAID (not CREATED); backoffice can emit CREATED. Counting the first non-cancelled sighting (PK-deduped by orderId) covers both without double-counting later state changes. Delivered-only would starve affinity at our volume. |
Cancellation rate high enough to distort pairs (watch in Phase 4 data). |
| 4 | REC-004 repurchase-exclusion window = 30 days (config recommendation.exclusion.repurchase-window) |
Sane default for a general catalog; consumables nuance is a per-category refinement not worth v1 complexity. | Category-level CTR data showing suppressed legitimate repurchases. |
| 5 | Parameters: α=0.5, half-life 14 d, minSupport=2 (+bootstrap), ≤3/category, cache 60 s, cos≥0.55, featured boost ×1.2 — all externalized config with these defaults | Standard literature values; determinism + config beats bikeshedding. BDD pins behavior at defaults. | Phase-4 CTR tuning — that's what the config knobs are for. |
| 6 | Phase 3 semantic layer = committed (owner request: cold-start customer must work); Phase 5 Gorse = gated on Phase-4 CTR evidence | Embeddings complete the cold-start ladder (§5.7/§5.9) with inference-only, self-hosted, CPU components. Gorse adds an operated service + training loops — only justified by evidence. | Phase 3: nothing (committed). Phase 5: CTR plateau + volume ⇒ adopt; otherwise skip forever. |
| 7 | Reuse catalog bulk GET /v1/products/bulk?productIds=; no duplicate endpoint |
Recommendation uses the existing bounded public contract with comma-separated IDs and 20-item hydration batches. | — |
| 8 | Vector store = pgvector (ES dense_vector fallback) — RESOLVED | sage verified: CNPG postgresql:17 bundles pgvector (ES 8.17.0 also supports kNN on free Basic). pgvector wins on isolation + eligibility joins. Caveats folded into §5.9: CREATE EXTENSION is admin/GitOps (untrusted ext, non-superuser role); service needs a glibc base image for ONNX natives; use 118MB quantized model. |
Only if enabling the extension is blocked in CNPG ⇒ ES fallback. |
| 9 | Catalog-manager curation = first-class (REC-015): curated relations outrank stats; is_featured boosts popularity |
Owner requirement; merchandiser intent is a deliberate business signal, not noise. Makes backoffice curation finally pay off beyond the PDP. | — |
The only thing awaited from the owner/team: veto or go. No open questions remain.