Abstract The Provider
A hard swap locks you to the new vendor. An agnostic layer with a default lets you swap again without a rewrite. The default means you ship now; the abstraction means you're not stuck.
When to abstract
- The user asks to replace a vendor ("is there an open-source alternative to Shopify?").
- The feature could plausibly need a second provider later (commerce, payments, email, auth, LLM, search).
- You're choosing between a paid and an open-source option and the user prefers optionality.
- The integration is non-trivial — a hard swap would mean a future hard swap too.
Don't abstract when:
- The integration is one function call — abstraction is more code than the call.
- The provider is genuinely irreplaceable (a specific data source only one vendor offers).
- YAGNI is clear — one provider, no realistic second, and the abstraction adds complexity.
The shape
<feature>/provider.ts # the interface (methods, types)
<feature>/providers/
medusa.ts # default implementation
shopify.ts # the old one, kept or removed
<feature>/config.ts # picks the provider, default = "medusa"
Rules:
- The interface is the stable surface. Callers depend on the interface, never on a provider.
- A default is chosen, not a wiring puzzle.
COMMERCE_PROVIDER=medusa(default) — ship now, no config required. - Each provider implements the same interface. No provider-specific methods leak into callers.
- The old provider is either kept (for migration) or removed (if fully replaced) — don't leave it half-wired.
Example (commerce)
// commerce/provider.ts
export interface CommerceProvider {
listProducts(): Promise<Product[]>;
createCheckout(items: CartItem[]): Promise<CheckoutUrl>;
}
// commerce/providers/medusa.ts (default)
export const medusa: CommerceProvider = { /* ... */ };
// commerce/config.ts
const name = process.env.COMMERCE_PROVIDER ?? "medusa";
export const commerce = providers[name];
Callers import commerce from config.ts — they never import medusa directly.
Why this beats a hard swap
- Ship now, swap later. The default gets you working today; a future swap is a new provider file + a config flip, not a rewrite.
- Open-source by default, paid as escape hatch. You can default to Medusa and add Shopify later if a paid feature is needed.
- Test surface is the interface. Mock the interface in tests; don't mock each provider.
- The "swap" decision is reversible. A hard swap is a one-way door; an abstraction is a two-way door.
Anti-patterns
- Hard swap "to keep it simple". Simpler now; the next swap is a full rewrite.
- Provider methods leak into callers.
if (provider === "medusa") ...in a page — the abstraction is broken. - No default. Forces config to ship; the default is what makes it shippable today.
- Abstracting a one-call integration. An interface for
getUnixTime()is more code than the call. - Keeping the old provider half-wired. Pick: migrate-then-remove, or keep-both-supported. Don't leave it dangling.
Pair with
dependency-hygiene— the new provider's pins should be an opt-in extra if it's heavy.match-conventions— mirror the existing provider's interface shape if one already exists.validate-gate— the interface is the test surface; mock it, don't mock the provider.
Include the go-live handoff for the default provider
After the abstraction lands, the default provider still has to be stood up. Hand off the provider-specific go-live steps so the user can take it to production without re-discovering them:
"For Medusa go-live you'll need to: stand up a Medusa backend (
npx create-medusa-app@latest), create …"
The abstraction is the wiring; the go-live is the runtime. The handoff closes the loop — without it, the user has a clean interface and no running provider. This belongs in the readiness-report's "next steps" or a follow-up message, not buried in a commit message.