Assist_Design/docs/decisions/004-domain-provider-isolation.md

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?

  1. Portal stays provider-agnostic: Frontend code never knows if data came from WHMCS, Salesforce, or elsewhere

  2. Easy to add/swap providers: Adding Stripe as a billing provider = add providers/stripe/ folder. No changes to domain contract or application code.

  3. Clear import boundaries: ESLint enforces that Portal cannot import from providers/

  4. 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.",
          },
        ],
      },
    ],
  },
}