4.7 KiB
ADR-004: Domain Provider Isolation
Date: 2025-01-15 Status: Accepted
Context
The Customer Portal uses multiple external providers:
- WHMCS for billing (invoices, payment methods, subscriptions)
- Salesforce for CRM (accounts, orders, cases, products)
- Freebit for SIM management
- Japan Post for address lookup
Each provider has its own API response structures. Application code should not need to know these details.
Decision
Isolate provider-specific code in providers/ subdirectories within each domain module. Only BFF integration code imports from providers; Portal and application code use only normalized domain types.
packages/domain/billing/
├── contract.ts # ✅ Import everywhere
├── schema.ts # ✅ Import everywhere
├── index.ts # ✅ Import everywhere
└── providers/ # ⚠️ BFF-only imports
└── whmcs/
├── raw.types.ts # WHMCS-specific structures
└── mapper.ts # WHMCS → domain transformation
Rationale
Why Provider Isolation?
-
Portal stays provider-agnostic: Frontend code never knows if data came from WHMCS, Salesforce, or elsewhere
-
Easy to add/swap providers: Adding Stripe as a billing provider = add
providers/stripe/folder. No changes to domain contract or application code. -
Clear import boundaries: ESLint enforces that Portal cannot import from
providers/ -
Single responsibility: Raw types and mappers live together, making it clear what's provider-specific
Import Rules (ESLint Enforced)
// ✅ Allowed everywhere (Portal + BFF)
import { Invoice, invoiceSchema } from "@customer-portal/domain/billing";
// ✅ Allowed in BFF only
import { transformWhmcsInvoice } from "@customer-portal/domain/billing/providers";
// ❌ Forbidden in Portal
import { WhmcsInvoiceRaw } from "@customer-portal/domain/billing/providers/whmcs/raw.types";
Alternatives Considered
| Approach | Pros | Cons |
|---|---|---|
| Provider types in separate package | Clear separation | Multiple packages to maintain, harder to co-locate |
| Provider types in BFF | BFF-only by default | Types not reusable, duplicated if needed elsewhere |
| Provider isolation in domain | Co-located, clear boundaries, ESLint enforceable | Requires import discipline |
Consequences
Positive
- Portal remains provider-agnostic
- Adding new providers is straightforward (new folder, no contract changes)
- Clear, enforceable import boundaries
- Co-located raw types + mappers
Negative
- Requires ESLint rules to enforce boundaries
- Developers must understand import restrictions
Implementation
Directory Structure
packages/domain/billing/
├── contract.ts # Normalized types (Invoice, InvoiceStatus)
├── schema.ts # Zod schemas (invoiceSchema)
├── index.ts # Public exports
└── providers/
├── index.ts # Re-exports all provider mappers
└── whmcs/
├── raw.types.ts # WhmcsInvoiceRaw (API response shape)
└── mapper.ts # transformWhmcsInvoice()
Adding a New Provider
# 1. Create provider folder
mkdir -p packages/domain/billing/providers/stripe
# 2. Add raw types
# packages/domain/billing/providers/stripe/raw.types.ts
export const stripeInvoiceRawSchema = z.object({ ... });
export type StripeInvoiceRaw = z.infer<typeof stripeInvoiceRawSchema>;
# 3. Add mapper
# packages/domain/billing/providers/stripe/mapper.ts
export function transformStripeInvoice(raw: unknown): Invoice { ... }
# 4. Export from providers/index.ts
export * from "./stripe/mapper.js";
# 5. Use in BFF integration service - no other changes needed!
ESLint Configuration
// eslint.config.mjs
{
rules: {
"no-restricted-imports": [
"error",
{
patterns: [
{
group: ["@customer-portal/domain/*/providers/*"],
message: "Portal cannot import provider-specific types. Use domain types instead.",
},
],
},
],
},
}