Skip to content
weLabsweLabsStorefront Playbook
Live demoQuickstart

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).

  • Read CONTEXT.md and the Capability interfaces in packages/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 false and 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.
  1. Create the package. Add packages/adapter-acme/ with the same shape as the Fake Adapter: raw TypeScript, one exports entry, 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.json in the package’s tsconfig.json. The base config is fully strict (noUncheckedIndexedAccess, verbatimModuleSyntax).

  2. 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 whole CommerceAdapter interface, including getCurrency(), getStoreTimezone() and an identity sub-adapter.

    // Illustrative
    import 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 upsells
    crossSells: 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 platform
    identity: 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.

  3. 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.

  4. Implement Identity. IdentityAdapter is tokens in, tokens out: login, refresh, logout, register, the password and email-verification flows, social sign-in and me. It knows nothing about Auth.js; the app’s session layer in lib/auth/ stays untouched (ADR 0003). If the platform cannot sign Shoppers in, still provide an identity sub-adapter that reports failures, and declare identity: false so the auth UI hides.

  5. Map payments to a Processor. The storefront chooses its payment UI by processor alone, 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.
    • placeOrder turns the opaque Payment Token into whatever the platform’s checkout expects. Refusals become PaymentDeclinedError; a total that moved after approval becomes PaymentAmountChangedError.
    • A bank challenge returns a shopperAction; confirmPayment asks the platform, not the browser, whether the order is paid.
    • findPlacedOrder must 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 (and expressCheckout: 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.

  6. Run the contract suite from the package. Add src/contract.test.ts that registers the suite against your adapter. It must pass for every Capability you declare; undeclared ones are skipped, never failed.

    // Illustrative
    import { 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 test hermetic: a live-backend run is opt-in behind an environment variable, as the WooCommerce adapter’s is behind WOO_CONTRACT=1. When a Processor step needs a person in a browser, throw ShopperActionNotAutomatable or ApprovalNotAutomatable from the hook: the suite skips the rest of that case instead of faking it.

  7. 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 a COMMERCE_SOURCE value.

    // Illustrative: one more case in create()
    case "acme":
    return new AcmeCommerceAdapter(undefined, auth, locale);

    Then add @welabs/retail-adapter-acme to apps/storefront/package.json dependencies and to transpilePackages in apps/storefront/next.config.ts. ESLint already allows the import in the root and nowhere else.

  8. Check the storefront end to end. Run pnpm typecheck, pnpm lint and pnpm test, and keep pnpm test:e2e green (it runs in Demo Mode, so it proves you broke nothing shared). Then run the storefront with COMMERCE_SOURCE=acme against your disposable store and walk browse, cart, checkout and sign-in by hand.

  9. Record what you decided. Add the new backend’s terms to CONTEXT.md if any are new, and write an ADR for anything hard to reverse, such as how identity or payment works on the platform.

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
Storefront Playbook · Built by weLabsFeaturesFAQTalk to us