Skip to content

Domain model

The mental model

MerchOS’s domain has two halves that meet in the middle:

  • The supply side (lower half): what suppliers actually carry — every t-shirt color, size, and print method that Gelato or Printful can fulfill, mirrored locally as SupplierVariantRaw rows.
  • The demand side (upper half): what Joy curates for the storefronts — Sku rows that customers can put their logo on.

In the middle: the CuratedStyle + RawStyle + mapping layer that decouples “the supplier’s idea of a product” from “YCM’s curated product.” That decoupling is the single most important architectural choice in MerchOS, and most non-obvious behavior in the system stems from it.

If you only remember one diagram from this chapter, remember this:

┌──────────────────────────┐
│ Company │
│ (one tenant per row) │
└──────┬───────────────────┘
│ 1..N
┌──────────────────────────┐
│ Product │
│ (Company's instance of │
│ a Sku, with pricing, │
│ print files, enabled │
│ colors) │
└──────┬───────────────────┘
│ N..1
┌──────────────────────────┐
│ Sku │ ◄── DEMAND SIDE
│ (YCM's curated product: │
│ Gildan 64000 with axes │
│ + the print options │
│ Joy promoted to master) │
└──────┬───────────────────┘
│ 1..1
┌──────────────────────────┐
│ CuratedStyle │
│ (Joy's curation state: │
│ NEW / ACCEPTED / etc. │
│ for one RawStyle) │
└──────┬───────────────────┘
│ 1..1
┌──────────────────────────┐
│ RawStyle │ ◄── THE PIVOT
│ (canonical product │
│ identity, supplier- │
│ agnostic; one row per │
│ brand|styleCode for │
│ apparel, productUid │
│ for non-apparel) │
└──────┬───────────────────┘
│ 1..N
┌──────────────────────────┐
│ RawStyleSupplierLink │ ◄── MAPS RawStyle → supplier
│ (each RawStyle can be │
│ fulfilled by 1+ │
│ suppliers; the link │
│ carries the supplier's │
│ product key) │
└──────┬───────────────────┘
│ 1..N
┌──────────────────────────┐
│ SupplierVariantRaw │ ◄── SUPPLY SIDE
│ (every actual variant │
│ the supplier carries — │
│ color × size × print │
│ combos with prices) │
└──────────────────────────┘

The four conceptual layers

Layer 1: SupplierVariantRaw (the supplier mirror)

The raw truth of what Gelato + Printful carry. ~177K rows. Each row is a single (productUid, color, size, print) variant with the supplier’s prices, fulfillment regions, and a JSONB attributes blob carrying everything else.

This is append-driven. Supplier-sync crons run nightly per supplier, do a full crawl of the supplier’s catalog API, and upsert by productUid. Rows soft-delete via lastSeenAt < now() when the supplier drops a SKU.

You write to this table only via the sync code path. You never hand-edit a row.

Layer 2: RawStyle (the canonical product)

A RawStyle represents a product in the supplier-agnostic sense — “Gildan 64000 unisex T-shirt.” Multiple suppliers can fulfill the same RawStyle; the linkage lives in RawStyleSupplierLink.

The natural key for apparel is (brand, styleCode). For non-apparel it’s productUid (because non-apparel products like mugs don’t have a shared cross-supplier identity yet — Phase 0129 follow-up #3, POST_DEPLOY_BACKLOG.md, is the eventual operator-driven merge UI for those).

This is the layer Joy works on in the Raw Catalog UI. She sees one row per RawStyle, knows it’s fulfillable by Gelato (and now Printful), and clicks Promote.

Layer 3: CuratedStyle (Joy’s curation state)

For every RawStyle Joy has ever looked at, there’s a CuratedStyle row carrying her decision. States: NEW (she hasn’t decided), ACCEPTED (promote → became a Sku), DEFERRED, REJECTED, ARCHIVED. The state machine is read-only for shoppers; only Joy writes it via the internal admin.

The Master demote feature (Phase 0155, 2026-05-19) flips ACCEPTED back to NEW. The Sku is hard-deleted via CASCADE; the CuratedStyle survives so the RawStyle re-appears in Joy’s queue.

Layer 4: Sku (the curated product)

The promoted product. Has colorChoices, sizeChoices, printOptions (the three independent axes from ADR-0077), all editable by Joy. Each axis row has a SupplierMapping table linking it to the supplier’s code for that color/size/print (Gelato calls white pigment-arctic-white; Printful calls it white — both map to YCM’s SkuColorChoice with displayName: "Arctic White"). The mapping shape is the centerpiece of ADR-0116.

A Sku is global — not company-scoped. Every Company can put any active Sku into their storefront via the Product model.

Plus: the company-tenant overlay

Product is the per-Company instance of a Sku. Carries:

  • Display name (the Company can rename “Gildan 64000 T-shirt” to “Crew Tee” for their storefront).
  • Price (the Company-facing retail price; supplier cost lives upstream).
  • enabledColors (a Company can disable colors they don’t want — e.g. a church might disable yellow).
  • Print files (per-Sku APPROVED PNGs from the designer queue).

Product is where the companyId filter goes on every storefront query. Cross-tenant isolation lives here.

The Sku axis model (and why it’s three tables, not one)

The single most-asked design question: why does a Sku have three separate axis tables (SkuColorChoice, SkuSizeChoice, SkuPrintOption) instead of one matrix-cell model with (color, size, print, supplier_code, price) rows?

The short answer: axes are the right granularity for the operator workflow. Joy adds a color once; that color is available across every size + print. Joy retires a print method (e.g. drops embroidery from a hoodie) once; it disappears from every color + size automatically. A matrix model would force her to add or retire each combination row-by-row.

The fulfillability gap (sparse Cartesian product — some (color × size × print) tuples genuinely don’t exist at the supplier) is solved by SkuVariantCellState as an additive override layer on top of axes. Phase 0153 / ADR-0115 shipped this; the design rationale (axes are right, don’t replace them) lives in that ADR’s §Context.

If you find yourself wanting to “just use a matrix model” because axes feel limiting, read ADR-0077 and ADR-0115 in order before proposing it.

Onboarding state machine (touched by every flow)

Companies move through an explicit state machine on Company.onboardingStatus:

INTAKE_IN_PROGRESS (wizard active; Company row created at step 1)
→ INTAKE_COMPLETE (customer hit final confirm on wizard step 5)
→ SCRAPING
→ SCRAPING_FAILED (manual recovery by YCM)
→ LOGO_DESIGN_PENDING (only if logo add-on purchased — pauses entire flow)
→ PRINT_FILES_PENDING (designer queue produces print-ready files per enabled SKU)
→ LIVE (all print files APPROVED; store open)
→ PAUSED (admin or YCM disabled the store)
→ CHURNED (subscription cancelled; data retained, store closed)

Nine states, not the twelve that earlier drafts had. The original review-loop states (MOCKUPS_GENERATING, PENDING_INTERNAL_REVIEW, PENDING_CLIENT_APPROVAL, CHANGE_REQUESTED) were removed when the sharp compositor (Phase 0017) replaced Placid — mockups now generate synchronously at intake. Approval happens on print files, not mockups. INTAKE_IN_PROGRESS was added by Phase 6 / ADR-0091 to model the wizard’s mid-flow state explicitly.

All transitions go through OnboardingService.transitionTo(). No direct Company.onboardingStatus = X writes from outside this service. The single chokepoint is what makes the state machine trustworthy.

See Onboarding flow for the full chapter walk.

The four non-obvious relationships

These are the relationships that trip people up. Memorize them.

  1. Product.sku is onDelete: NoAction. Not Cascade. Not SetNull. NoAction. Postgres refuses to delete a Sku if any Company has it in their catalog. This is the safety gate that makes the Master demote feature safe.
  2. Sku.curatedStyleId is unique. A Sku is the promoted form of a CuratedStyle. One CuratedStyle → at most one Sku at a time. Demoting a Sku frees its CuratedStyle to be re-promoted later.
  3. RawStyle.brand + RawStyle.styleCode is unique for apparel but RawStyle.productUid is unique for non-apparel. Cross-supplier merging is automatic for apparel (Gildan 64000 from Gelato and Gildan 64000 from Printful collapse into one RawStyle); for non-apparel it requires the operator merge UI (deferred).
  4. AuditEvent.actorUserId has a real FK to User. Audit-event writes from a service path must use a real user id, not a synthetic placeholder. This bit Phase 0155’s first integration test pass — three failures before the fixture seeded a real User row.

The mapping layer (ADR-0116 in plain English)

For each axis (color, size, print) there’s a Mapping table linking the YCM axis row to the supplier’s code for that axis row. So:

  • SkuColorChoice (id, displayName: "Arctic White", hex: "#F5F5F5")
  • SkuColorChoiceSupplierMapping (colorChoiceId, supplierId, supplierColorCode):
    • one row: gelato → pigment-arctic-white
    • one row: printful → white

When the storefront renders a color swatch, it shows “Arctic White.” When the order resolver routes to a supplier, it looks up that supplier’s code via the mapping. Joy never sees the supplier codes directly; she only sees the human-facing displayName.

Pre-Phase-0154, the supplier code was a column directly on SkuColorChoice (gelatoCode). Worked when there was one supplier; broke immediately when Printful arrived. The mapping table is the right shape; the column was a shortcut. Phase 0154 was the corrective rebuild.

What’s next

  • Pricing models — the three families that drive billing + UX divergence.
  • Catalog pipeline — how Raw → CuratedStyle → Sku happens in practice.

Canonical sources

  • prisma/schema.prisma — the authoritative schema. Every field, every relation, every @@unique constraint.
  • ADR-0077 — the axis-model decision.
  • ADR-0080 — the cross-supplier RawStyle identity rules.
  • ADR-0115 — the SkuVariantCellState override layer.
  • ADR-0116 — the mapping-table rebuild.
  • OnboardingStatus enum in prisma/schema.prisma — the eight onboarding states.
  • docs/services/catalog-pipeline.md — the service-level deep read.

Triggers for update

Update this chapter if you:

  • Add a fifth conceptual layer (e.g. a new entity sitting between Sku and Product).
  • Change the natural key for RawStyle.
  • Add or remove an axis on Sku.
  • Add or remove an OnboardingStatus value.
  • Add or change one of the four non-obvious relationships listed above.
  • Replace the mapping-table shape with something else.