Adapters: choose any provider without changing the core
Every subsystem that could plausibly have more than one real-world implementation — where data lives, what powers the CMS, who processes a payment, how analytics events are collected, who fulfills an order, who ships a package — is defined as a small, typed interface in its own package. The rest of the app (catalog, cart, checkout, admin UI, the AI tool catalog) talks only to that interface. A merchant picks concrete implementations once, at startup, via environment variables; swapping one out is meant to never require touching catalog, cart, or CMS code.
This deployment’s live adapter wiring
Computed fresh from this process’s own environment variables on every request (lib/adapter-info.ts’s getAdapterInfo()) — not a static claim. All three demo stores on this deployment (print-shop, northline, broadleaf) share this exact wiring, since it’s process-wide, not per-store: a real merchant configures these env vars once for their own deployment. (Persistence and the 14 subsystems built on top of it are the exceptions — see the per-demo table right below.)
| Subsystem | Active adapter | Why |
|---|---|---|
| CMS | Sanity | SANITY_PROJECT_ID is set -- createSanityAdapter() (packages/adapter-sanity) |
| Payments | Sandbox (demo mode) | STRIPE_SECRET_KEY is not set -- createSandboxPaymentAdapter() (packages/payments) is used instead of Stripe: a real, fully-working PaymentAdapter that completes checkout with no external provider, so every order here is genuinely a demo/sandbox order, never a real charge. Set STRIPE_SECRET_KEY to switch this instance to real Stripe Checkout. |
| Analytics | PostHog | POSTHOG_API_KEY is set -- createPostHogAdapter() (packages/analytics) |
| Fulfillment | Manual (self-fulfillment) | PRINTFUL_API_TOKEN is not set -- Printful is not registered; PRINTIFY_API_TOKEN/PRINTIFY_SHOP_ID are not both set -- Printify is not registered -- alongside createManualFulfillmentAdapter() (packages/fulfillment), always registered as the permanent self-fulfillment fallback; a SKU only actually routes to a registered provider once fulfillmentRouting.setProviderForSku is called for it, every SKU still defaults to "manual" otherwise |
| Shipping | Manual (PirateShip) | SHIPPO_API_TOKEN is not set -- only createManualShippingAdapter() (packages/shipping) is registered, a genuinely honest documented-manual-workflow default (PirateShip has no public API, confirmed by research), not an error state |
| Image CDN | Passthrough (no transform) | CLOUDINARY_CLOUD_NAME is not set -- createPassthroughImageAdapter() (packages/media) serves every product image's raw URL unchanged, a genuinely honest default, not an error state |
Persistence, per demo store
Unlike every subsystem above, catalog persistence and the 14 subsystems built on top of it (inventory, the named Catalog entity, cart, orders, customer profiles, promotions, reviews, storefront views, bundles, recommendations, advertising, service areas, the internal BI event log, and fulfillment routing) are each resolved separately per demo store (per-demo-backend-diversity and full-commerce-persistence-audit epics) — each store can genuinely run a different real database, not one process-wide choice. This table proves it live, computed fresh per demo on every request; every one of these rows besides “Persistence (catalog)” is Postgres-only in this pass (real Mongo/Convex/SQLite equivalents are out of scope, see lib/services.ts’s own doc comments), so each shows either “Postgres” or the in-memory reference default.
| Subsystem | The Print Shop | Northline Home Tech | Broadleaf & Co. |
|---|---|---|---|
| Persistence (catalog) | Postgres | Postgres | Convex |
| Inventory | Postgres | Postgres | In-memory (reference default) |
| Catalog (named entity) | Postgres | Postgres | In-memory (reference default) |
| Cart | Postgres | Postgres | In-memory (reference default) |
| Orders (checkout) | Postgres | Postgres | In-memory (reference default) |
| Customer profiles (account) | Postgres | Postgres | In-memory (reference default) |
| Promotions | Postgres | Postgres | In-memory (reference default) |
| Reviews | Postgres | Postgres | In-memory (reference default) |
| Storefront views | Postgres | Postgres | In-memory (reference default) |
| Bundles | Postgres | Postgres | In-memory (reference default) |
| Recommendations | Postgres | Postgres | In-memory (reference default) |
| Advertising (campaigns) | Postgres | Postgres | In-memory (reference default) |
| Service areas | Postgres | Postgres | In-memory (reference default) |
| Internal BI event log | Postgres | Postgres | In-memory (reference default) |
| Fulfillment routing | Postgres | Postgres | In-memory (reference default) |
The flagship example: payments
packages/payments/src/types.ts defines the whole contract every payment provider implements — three methods, nothing more:
interface PaymentAdapter {
createPaymentSession(input: CreatePaymentSessionInput): Promise<PaymentSession>;
confirmPayment(sessionId: string): Promise<PaymentConfirmation>;
handleWebhookEvent(rawBody: string | Buffer, signature: string): Promise<void>;
}Two real implementations exist today. createStripeAdapter() (packages/payments/src/stripe-adapter.ts) wraps real Stripe Checkout Sessions and verified Stripe webhooks. createSandboxPaymentAdapter() (packages/payments/src/sandbox-adapter.ts) is a second, fully-working adapter with no external provider and no API key at all — it redirects the shopper to this app’s own /demo/<slug>/checkout/sandbox page, and once they click “Pay,” a server action calls that adapter’s own confirmSandboxPayment(), which publishes the exact same payments.payment.succeeded event a verified Stripe webhook would. From checkout-orders’s point of view, the two adapters are indistinguishable — same event, same order-status transition, zero special-casing.
lib/services.ts picks between them with one if: real Stripe when STRIPE_SECRET_KEY is set, the sandbox adapter otherwise — the same shape the table above just showed you live, for real, for this deployment’s Payments row.
The same pattern, repeated across every subsystem
Payments isn’t a special case — it’s just the easiest one to demo end-to-end. Every other row in the table above has the same shape: one interface, a zero-infra reference default, and a real alternate package that swaps in via an env var.
Persistence (catalog)
DATABASE_URL set switches the whole catalog/cart/CMS persistence layer to Postgres -- same repository interfaces, different storage engine.
CMS
SANITY_PROJECT_ID set swaps every page/component read and write onto real Sanity content, with no change to how pages are rendered.
Payments
The flagship example above -- STRIPE_SECRET_KEY set is the only difference between a real charge and a fully-working, zero-infra demo checkout.
Fulfillment
PRINTFUL_API_TOKEN / PRINTIFY_API_TOKEN + PRINTIFY_SHOP_ID additively register real print-on-demand providers alongside the manual default.
Shipping
SHIPPO_API_TOKEN set additionally registers a real Shippo-backed rate/label adapter alongside the manual default.
Registering an alternate adapter package isn’t automatically the same as every order routing to it — fulfillment and shipping, for example, still default every SKU to the manual workflow until an operator explicitly routes it elsewhere. The point isn’t that every provider is live in this one environment; it’s that the interface these packages implement is the actual integration surface, proven by more than one real implementation existing for it.