Skip to content
weLabsweLabsStorefront Playbook
Live demoQuickstart

Developers

Architecture

Ports and adapters in the storefront — the Port, Capabilities, adapters, the Composition Root and Demo Mode — and how a product page and a checkout flow through them.

The storefront is built as ports and adapters. Pages, components and Server Actions speak one storefront-owned vocabulary, the canonical model. They reach any commerce backend through one contract, the Port. Each backend is an adapter behind that contract, and only one module, the Composition Root, knows which adapter is running. The same pages therefore run against WooCommerce and against an in-memory store with no backend at all.

┌──────────────────────────── apps/storefront ────────────────────────────┐
│ Server Components · Server Actions · Route Handlers · client islands │
│ │ canonical types + getAdapter() from "@/lib/commerce" │
│ ▼ │
│ lib/server/* (thin wrappers: thread auth + Locale, no new vocabulary) │
│ │ │
│ ▼ │
│ lib/commerce/adapters/index.ts ◄── the Composition Root │
│ │ reads COMMERCE_SOURCE, returns one CommerceAdapter │
└──────────┼────────────────────────────────────────────────────────────────┘
│ the Port: CommerceAdapter (Capability interfaces + capabilities)
┌────────┴─────────────────────────┐
▼ ▼
adapter-woocommerce adapter-fake
│ Store API (wc/store/v1) in-memory state, seeded demo catalog
│ WC REST v3 (wc/v3) = Demo Mode and the contract suite's
│ WP REST (wp/v2) reference implementation
│ Companion (retail-store-companion/v1)
▼
WordPress + WooCommerce + the Companion plugin

These terms come from CONTEXT.md, the glossary every layer uses. The glossary page has the shopper-facing ones.

Term What it is Where it lives
Canonical Model The storefront-owned commerce vocabulary: Product, Category, Money, Cart, Order, and so on. No backend shape crosses into it, and the app has no second “UI type” model. packages/commerce-core/src
Port The contract the storefront consumes commerce through: the Capability interfaces composed into CommerceAdapter. The single seam between presentation and any backend. commerce.ts (CommerceAdapter)
Capability One segment of the Port: Discovery, Cart, Checkout, Order, Account, Identity, Reviews, Wishlist, StockNotifications, Geography, Location, Theme, Content. A backend implements what it has and declares the rest absent in capabilities. CommerceCapabilities
Adapter A backend-specific implementation of the Port. All knowledge of a platform’s APIs lives inside it. packages/adapter-*
Composition Root The one module allowed to name an adapter. It reads configuration and hands the app a fully composed Port. apps/storefront/lib/commerce/adapters/index.ts
Contract Suite The Vitest suite that defines Port behaviour. Every adapter runs it against itself. @welabs/retail-commerce-core/contract-suite
Fake Adapter The in-memory adapter seeded with demo data. Reference implementation for the contract suite and the engine of Demo Mode. packages/adapter-fake
Demo Mode The storefront with no backend configured. The Composition Root picks the Fake Adapter; the store is fully browsable and purchasable in memory. COMMERCE_SOURCE unset, fake or mock

ADR 0001 kept the Port split by Capability instead of collapsing it into one Provider interface. The surface is large; flattened, it would be a god-interface of 60 or more methods that every backend must stub. Split, a future backend implements only the Capabilities its platform has and declares the rest absent, and the contract suite runs only the sections an adapter declares.

ADR 0002 deleted the old lib/data accessor layer and its bridge between “canonical” and “UI” types. Components and Server Actions consume canonical types directly. The cost is accepted on purpose: a Port change ripples straight to the components that use it. Looking for “the data layer”? Read the Composition Root and the Port; its absence is the decision.

Everything outside the root imports canonical types and getAdapter from @/lib/commerce. The root itself is short. This excerpt is abbreviated from lib/commerce/adapters/index.ts:

function create(auth?: AuthContext, locale?: string): CommerceAdapter {
const source = process.env.COMMERCE_SOURCE;
switch (source == null || source === "" ? "fake" : source) {
case "woocommerce":
// The Shopper's address rides cart and checkout requests, signed
// (ADR 0023), so the Backend's per-address limits see Shoppers.
return new WooCommerceAdapter({ ...loadWooConfig(), shopperAddress }, auth, locale);
case "mock": // legacy alias for demo mode
case "fake": {
const world = demo();
return new FakeCommerceAdapter(auth, world.identity, world.state, locale);
}
default:
throw new Error(`Unknown COMMERCE_SOURCE: ${source}`);
}
}

Three behaviours matter when you call getAdapter(auth?, locale?):

  • Anonymous adapters are memoised per Locale. An adapter carries the request Locale and, for WooCommerce, Locale-tagged caches. One shared instance would leak one language into another’s requests (ADR 0005).
  • Authenticated adapters are built per request. When an AuthContext carries a customer id or token, the root builds a fresh adapter, so per-customer identity is never shared across requests.
  • Demo Mode has one world per process. The Fake Adapter’s identity and state are pinned to globalThis, because Next.js compiles Route Handlers and Server Actions into separate bundles that must still agree on who is signed in and what is in which cart. Restarting the server resets the demo store.

Page code normally calls getLocalizedAdapter() from lib/server/adapter.ts, which passes the active Locale for you.

  1. The route app/[locale]/(customer)/product/[slug]/page.tsx is a Server Component. Its generateMetadata asks getLocalizedAdapter() for getProduct(slug) and hands a PageSeo to pageMetadata().

  2. The page loads its data in parallel: the product page data (product, rails, reviews, review media policy), the back-in-stock settings, the store currency, the express checkout offer for the product page, and whether an earlier placement is unresolved.

  3. On WooCommerce the adapter turns those reads into Store API requests (/wc/store/v1/products?slug=…) and Companion requests (reviews, product page settings). The Companion has already added its fields to the Store API product: videos, trust badges, the Payment and Security card, SEO fields, sale end date, upsell and cross-sell ids. In Demo Mode the Fake Adapter answers from its seed.

  4. The adapter maps every answer into canonical types. Prices are integer minor units; capabilities decide which rails are even requested (no upsells capability, no upsell rail).

  5. The active Template Family’s Product template renders the page (resolveTemplate() from @welabs/retail-templates). <PageJsonLd> prints the structured data from the same PageSeo. Only small islands, such as the gallery, add to cart and the express buttons, run on the client.

  1. The cart’s handle lives in an httpOnly cookie (cart_token). It holds whatever the adapter issued as Cart.id; on WooCommerce that is the Store API Cart-Token.

  2. The checkout form calls the Server Action placeOrderAction (features/checkout/actions.ts). It reads the cart token and calls placeOrder on the adapter with the billing and shipping details, the payment method id and, for an embedded method, the Processor’s Payment Token.

  3. The WooCommerce adapter posts to the Store API checkout with the Cart-Token, the HMAC-signed shopper address headers, and the payment_data its Gateway Dialect builds from the token. The Companion’s guards run inside that request: one checkout per cart, a per-Shopper rate limit, the PayPal and wallet amount guards, and a generic decline message.

  4. The adapter answers a canonical PlacedOrderResult. A refusal becomes PaymentDeclinedError; a changed total becomes PaymentAmountChangedError; a bank challenge comes back as a shopperAction, which the page runs in the Processor’s UI before calling confirmPaymentAction.

  5. A paid or accepted order sends the Shopper to /order-confirmation/{id}. The order key travels in the URL; the billing email travels in a short-lived httpOnly cookie scoped to that route, because it is personal data.

The payments architecture page covers every branch of this flow.

Above the WooCommerce adapter, the Companion plugin does not exist. CONTEXT.md defines it as part of the WooCommerce backend: it adds the endpoints the adapter needs (identity among them) and extends WooCommerce’s own responses. Nothing in a component names it. See Companion plugin for its own architecture.

A few reads and writes deliberately bypass the Port. Each is recorded, and none speaks a parallel vocabulary.

Exception Where Why it is off-Port
Home and shop marketing blocks: home slider, shop slider, promo tiles, CTA banner, sale banner, Today’s Deals config, testimonials lib/server/slider.ts, lib/server/marketing.ts Not commerce concepts. Read from the Companion’s public routes, cached for 5 minutes, and degraded to “nothing to show” on any error.
Newsletter sign-up, avatar upload, compare-list sync, contact form lib/auth/backend.ts and the app/api/* route handlers that use it Non-commerce companion features. A recorded exception outside the Port.
Static informational pages (About, store coordinates) lib/config/pages/ read through getPageContent() Per-deployment typed config, not backend data.
Session mechanics (encrypted session cookie, TTLs, remember-me, single-flight refresh) lib/auth/ Identity rides the Port, the session never does (ADR 0003).
Storefront Playbook · Built by weLabsFeaturesFAQTalk to us