Developers
Adding an adapter
Put another commerce backend behind the Port — a new adapter package, its capabilities, the contract suite, identity, payment mapping and one case in the Composition Root.
Supporting another commerce platform means writing one adapter package and adding one case to the Composition Root. No
page, component or Server Action changes, because they only speak the canonical model (ADR 0001, ADR 0002). This guide walks
through the work in the order that keeps the suite green. The code samples use a made-up platform called “Acme” and are
illustrative; the real adapters to read alongside them are packages/adapter-fake (small, complete) and
packages/adapter-woocommerce (a real backend).
Before you start
Section titled “Before you start”- Read
CONTEXT.mdand the Capability interfaces inpackages/commerce-core/src(commerce.ts,DiscoveryAdapter.ts,IdentityAdapter.ts,ThemeAdapter.ts,ContentAdapter.ts,GeographyAdapter.ts). - Decide which Capabilities the platform really has. Absent is a first-class answer: declare
falseand the storefront degrades. - Have a disposable store on the platform for the live contract run. Never point a contract run at a store with real customers.
-
Create the package. Add
packages/adapter-acme/with the same shape as the Fake Adapter: raw TypeScript, oneexportsentry, and a single workspace dependency on@welabs/retail-commerce-core. The adapter must not import from the app, from another adapter, or from deep paths of any package.{"name": "@welabs/retail-adapter-acme","version": "0.1.0","private": true,"type": "module","exports": { ".": "./src/index.ts" },"scripts": { "typecheck": "tsc --noEmit", "test": "vitest run" },"dependencies": { "@welabs/retail-commerce-core": "workspace:*" },"devDependencies": { "typescript": "^5", "vitest": "^3.2.4" }}Extend
../../tsconfig.base.jsonin the package’stsconfig.json. The base config is fully strict (noUncheckedIndexedAccess,verbatimModuleSyntax). -
Implement
CommerceAdapter. Compose one class per Capability, as the WooCommerce adapter does, or write one class, as the Fake Adapter does. Either way, the exported adapter implements the wholeCommerceAdapterinterface, includinggetCurrency(),getStoreTimezone()and anidentitysub-adapter.// Illustrativeimport type {AuthContext,CommerceAdapter,CommerceCapabilities,Currency,Product,} from "@welabs/retail-commerce-core";import { AcmeIdentityAdapter } from "./AcmeIdentityAdapter";import { AcmeClient, loadAcmeConfig, type AcmeConfig } from "./client";export class AcmeCommerceAdapter implements CommerceAdapter {readonly capabilities: CommerceCapabilities;readonly identity: AcmeIdentityAdapter;private readonly client: AcmeClient;constructor(config: AcmeConfig = loadAcmeConfig(), auth?: AuthContext, locale?: string) {this.client = new AcmeClient({ ...config, locale }, auth);this.identity = new AcmeIdentityAdapter(config);this.capabilities = {liveSearch: true,upsells: false, // the platform has no curated upsellscrossSells: false,productPreview: false,cart: true,embeddedPayment: true,expressCheckout: false,orders: true,accounts: true,customerStats: false,addressBook: false,reviews: false,reviewMedia: false,reviewThreads: false,wishlist: false,stockNotifications: false,geography: true,locations: true,theme: false,content: false, // no blog on this platformidentity: true,multilingualContent: Boolean(locale && config.translations),};}async getProduct(handle: string): Promise<Product | null> {const raw = await this.client.product(handle);return raw ? toProduct(raw) : null; // map to canonical, prices in minor units}async getCurrency(): Promise<Currency> { /* … */ }async getStoreTimezone(): Promise<string | null> { /* … */ }// …every other Port operation, including the absent ones}An absent Capability’s operations still exist, because the interface requires them. Make them answer the “nothing” value the interface documents (an empty list,
null, or a refusal) rather than throwing at import time. -
Map to the canonical model at the edge. Every payload becomes canonical types inside the adapter. Money becomes integer minor units in the store’s currency (
Money.amount, never a float); a platform that answers in major units is converted here. Dates stay ISO 8601. Platform ids and vocabulary never leave the package. -
Implement Identity.
IdentityAdapteris tokens in, tokens out:login,refresh,logout,register, the password and email-verification flows, social sign-in andme. It knows nothing about Auth.js; the app’s session layer inlib/auth/stays untouched (ADR 0003). If the platform cannot sign Shoppers in, still provide an identity sub-adapter that reports failures, and declareidentity: falseso the auth UI hides. -
Map payments to a Processor. The storefront chooses its payment UI by
processoralone, never by your gateway id.- Each embedded method carries
processor("stripe","paypal"or"demo"),interaction("form"or"button") and the Public Config the Processor’s browser SDK needs. Secrets never cross the Port. placeOrderturns the opaque Payment Token into whatever the platform’s checkout expects. Refusals becomePaymentDeclinedError; a total that moved after approval becomesPaymentAmountChangedError.- A bank challenge returns a
shopperAction;confirmPaymentasks the platform, not the browser, whether the order is paid. findPlacedOrdermust answer the order the platform recorded for that cart, never a search for a lookalike.- A gateway whose Processor the storefront has no UI for must not be listed.
If the platform can’t embed payment, declare
embeddedPayment: false(andexpressCheckout: false) and list offline methods only. There is no redirect mode (ADR 0022). Inside the WooCommerce adapter, this mapping is the Gateway Dialect table; your adapter may organise it however suits the platform. - Each embedded method carries
-
Run the contract suite from the package. Add
src/contract.test.tsthat registers the suite against your adapter. It must pass for every Capability you declare; undeclared ones are skipped, never failed.// Illustrativeimport { describeAdapterContract } from "@welabs/retail-commerce-core/contract-suite";import { AcmeCommerceAdapter } from "./index";const enabled = process.env.ACME_CONTRACT === "1";if (enabled) {describeAdapterContract({name: "AcmeCommerceAdapter",makeAdapter: () => new AcmeCommerceAdapter(),// Optional hooks enable deeper sections when your test can supply them:// issuePaymentToken, completeShopperAction, approvePreparedPayment,// makeAddressBookAdapter, makeForeignOrder, freeShippingDestinations, …});}Keep
pnpm testhermetic: a live-backend run is opt-in behind an environment variable, as the WooCommerce adapter’s is behindWOO_CONTRACT=1. When a Processor step needs a person in a browser, throwShopperActionNotAutomatableorApprovalNotAutomatablefrom the hook: the suite skips the rest of that case instead of faking it. -
Add one case to the Composition Root. In
apps/storefront/lib/commerce/adapters/index.ts, the only module allowed to name an adapter, add the case and aCOMMERCE_SOURCEvalue.// Illustrative: one more case in create()case "acme":return new AcmeCommerceAdapter(undefined, auth, locale);Then add
@welabs/retail-adapter-acmetoapps/storefront/package.jsondependencies and totranspilePackagesinapps/storefront/next.config.ts. ESLint already allows the import in the root and nowhere else. -
Check the storefront end to end. Run
pnpm typecheck,pnpm lintandpnpm test, and keeppnpm test:e2egreen (it runs in Demo Mode, so it proves you broke nothing shared). Then run the storefront withCOMMERCE_SOURCE=acmeagainst your disposable store and walk browse, cart, checkout and sign-in by hand. -
Record what you decided. Add the new backend’s terms to
CONTEXT.mdif any are new, and write an ADR for anything hard to reverse, such as how identity or payment works on the platform.
Checklist
Section titled “Checklist”| Item | Done when |
|---|---|
| Capabilities | Every flag is an explicit boolean, honest for this backend, and true only where the contract section passes |
| Money | No float crosses the Port; every Money is integer minor units with an ISO 4217 code |
| Locale | Reads honour the locale constructor argument, and multilingualContent is true only if the platform really translates |
| Time zone | getStoreTimezone() answers an IANA name, a fixed offset, or null |
| Identity | Tokens in, tokens out; nothing about cookies or sessions |
| Payments | Processor and interaction per method; tokens opaque; findPlacedOrder answers a recorded fact |
| Tests | The contract suite passes for every declared Capability; the smoke suite stays green |
| Boundaries | Only the Composition Root names the package; no deep imports anywhere |