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.
The shape
Section titled “The shape” ┌──────────────────────────── 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 pluginThe vocabulary
Section titled “The vocabulary”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 |
Why a capability-split Port
Section titled “Why a capability-split Port”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.
Why one model
Section titled “Why one model”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.
The Composition Root
Section titled “The Composition Root”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
AuthContextcarries 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.
Request flow: a product page
Section titled “Request flow: a product page”-
The route
app/[locale]/(customer)/product/[slug]/page.tsxis a Server Component. ItsgenerateMetadataasksgetLocalizedAdapter()forgetProduct(slug)and hands aPageSeotopageMetadata(). -
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.
-
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. -
The adapter maps every answer into canonical types. Prices are integer minor units; capabilities decide which rails are even requested (no
upsellscapability, no upsell rail). -
The active Template Family’s Product template renders the page (
resolveTemplate()from@welabs/retail-templates).<PageJsonLd>prints the structured data from the samePageSeo. Only small islands, such as the gallery, add to cart and the express buttons, run on the client.
Request flow: checkout
Section titled “Request flow: checkout”-
The cart’s handle lives in an httpOnly cookie (
cart_token). It holds whatever the adapter issued asCart.id; on WooCommerce that is the Store APICart-Token. -
The checkout form calls the Server Action
placeOrderAction(features/checkout/actions.ts). It reads the cart token and callsplaceOrderon the adapter with the billing and shipping details, the payment method id and, for an embedded method, the Processor’s Payment Token. -
The WooCommerce adapter posts to the Store API checkout with the
Cart-Token, the HMAC-signed shopper address headers, and thepayment_dataits 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. -
The adapter answers a canonical
PlacedOrderResult. A refusal becomesPaymentDeclinedError; a changed total becomesPaymentAmountChangedError; a bank challenge comes back as ashopperAction, which the page runs in the Processor’s UI before callingconfirmPaymentAction. -
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.
What the Companion plugin is, from here
Section titled “What the Companion plugin is, from here”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.
Recorded off-Port exceptions
Section titled “Recorded off-Port exceptions”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). |