# Domain-First Structure with Providers **Date**: October 3, 2025 **Status**: ✅ Implementing --- ## 🎯 Architecture Philosophy **Core Principle**: Domain-first organization where each business domain owns its: - **contract.ts** - Normalized types (provider-agnostic) - **schema.ts** - Runtime validation (Zod) - **providers/** - Provider-specific adapters (raw types + mappers) **Why This Works**: - Domain-centric matches business thinking - Provider isolation prevents leaking implementation details - Adding new providers = adding new folders (no refactoring) - Single package (`@customer-portal/domain`) for all types --- ## 📦 Package Structure ``` packages/domain/ ├── billing/ │ ├── contract.ts # Invoice, InvoiceItem, InvoiceStatus │ ├── schema.ts # invoiceSchema, INVOICE_STATUS const │ ├── index.ts # Public exports │ └── providers/ │ └── whmcs/ │ ├── raw.types.ts # WhmcsInvoiceRaw (API response) │ └── mapper.ts # transformWhmcsInvoice() │ ├── subscriptions/ │ ├── contract.ts # Subscription, SubscriptionStatus │ ├── schema.ts │ ├── index.ts │ └── providers/ │ └── whmcs/ │ ├── raw.types.ts │ └── mapper.ts │ ├── payments/ │ ├── contract.ts # PaymentMethod, PaymentGateway │ ├── schema.ts │ ├── index.ts │ └── providers/ │ └── whmcs/ │ ├── raw.types.ts │ └── mapper.ts │ ├── sim/ │ ├── contract.ts # SimDetails, SimUsage │ ├── schema.ts │ ├── index.ts │ └── providers/ │ └── freebit/ │ ├── raw.types.ts │ └── mapper.ts │ ├── orders/ │ ├── contract.ts # Order, OrderItem │ ├── schema.ts │ ├── index.ts │ └── providers/ │ ├── salesforce/ # Read orders │ │ ├── raw.types.ts │ │ └── mapper.ts │ └── whmcs/ # Create orders │ ├── raw.types.ts │ └── mapper.ts │ ├── services/ │ ├── contract.ts # Service catalog products (UI view model) │ ├── schema.ts │ ├── index.ts │ └── providers/ │ └── salesforce/ │ ├── raw.types.ts # SalesforceProduct2 │ └── mapper.ts │ ├── common/ │ ├── types.ts # Address, Money, BaseEntity │ ├── identifiers.ts # UserId, OrderId (branded types) │ ├── api.ts # ApiResponse, PaginatedResponse │ ├── schema.ts # Common schemas │ └── index.ts │ └── toolkit/ ├── formatting/ │ └── currency.ts # formatCurrency() ├── validation/ │ └── helpers.ts # Validation utilities ├── typing/ │ └── patterns.ts # AsyncState, etc. └── index.ts ``` --- ## 📝 Import Patterns ### **Application Code (Domain Only)** ```typescript // Import normalized domain types import { Invoice, invoiceSchema, INVOICE_STATUS } from "@customer-portal/domain/billing"; import { Subscription } from "@customer-portal/domain/subscriptions"; import { Address } from "@customer-portal/domain/customer"; // Use domain types const invoice: Invoice = { id: 123, status: INVOICE_STATUS.PAID, // ... }; // Validate const validated = invoiceSchema.parse(rawData); ``` ### **Integration Code (Needs Provider Specifics)** ```typescript // Import domain + provider import { Invoice, invoiceSchema } from "@customer-portal/domain/billing"; import { transformWhmcsInvoice, type WhmcsInvoiceRaw, } from "@customer-portal/domain/billing/providers/whmcs/mapper"; import { whmcsInvoiceRawSchema } from "@customer-portal/domain/billing/providers/whmcs/raw.types"; // Transform raw API data const whmcsData: WhmcsInvoiceRaw = await whmcsApi.getInvoice(id); const invoice: Invoice = transformWhmcsInvoice(whmcsData); ``` --- ## 🏗️ Domain File Templates ### **contract.ts** ```typescript /** * {Domain} - Contract * * Normalized types for {domain} that all providers must map to. */ // Status enum (if applicable) export const {DOMAIN}_STATUS = { ACTIVE: "Active", // ... } as const; export type {Domain}Status = (typeof {DOMAIN}_STATUS)[keyof typeof {DOMAIN}_STATUS]; // Main entity export interface {Domain} { id: number; status: {Domain}Status; // ... normalized fields } ``` ### **schema.ts** ```typescript /** * {Domain} - Schemas * * Zod validation for {domain} types. */ import { z } from "zod"; export const {domain}StatusSchema = z.enum([...]); export const {domain}Schema = z.object({ id: z.number(), status: {domain}StatusSchema, // ... field validation }); ``` ### **providers/{provider}/raw.types.ts** ```typescript /** * {Provider} {Domain} Provider - Raw Types * * Actual API response structure from {Provider}. */ import { z } from "zod"; export const {provider}{Domain}RawSchema = z.object({ // Raw API fields (different naming, types, structure) }); export type {Provider}{Domain}Raw = z.infer; ``` ### **providers/{provider}/mapper.ts** ```typescript /** * {Provider} {Domain} Provider - Mapper * * Transforms {Provider} raw data into normalized domain types. */ import type { {Domain} } from "../../contract"; import { {domain}Schema } from "../../schema"; import { type {Provider}{Domain}Raw, {provider}{Domain}RawSchema } from "./raw.types"; export function transform{Provider}{Domain}(raw: unknown): {Domain} { // 1. Validate raw data const validated = {provider}{Domain}RawSchema.parse(raw); // 2. Transform to domain model const result: {Domain} = { id: validated.someId, status: mapStatus(validated.rawStatus), // ... map all fields }; // 3. Validate domain model return {domain}Schema.parse(result); } ``` --- ## 🎓 Key Patterns ### **1. Co-location** Everything about a domain lives together: ``` billing/ ├── contract.ts # What billing IS ├── schema.ts # How to validate it └── providers/ # Where it comes FROM ``` ### **2. Provider Isolation** Raw types and mappers stay in `providers/`: ```typescript // ✅ GOOD - Isolated import { transformWhmcsInvoice } from "@customer-portal/domain/billing/providers/whmcs/mapper"; // ❌ BAD - Would leak WHMCS details into app code import { WhmcsInvoiceRaw } from "@somewhere/global"; ``` ### **3. Schema-Driven** Domain schemas define the contract: ```typescript // Contract (types) export interface Invoice { ... } // Schema (validation) export const invoiceSchema = z.object({ ... }); // Provider mapper validates against schema return invoiceSchema.parse(transformedData); ``` ### **4. Provider Agnostic** App code never knows about providers: ```typescript // ✅ App only knows domain function displayInvoice(invoice: Invoice) { // Doesn't care if it came from WHMCS, Salesforce, or Stripe } // ✅ Service layer handles providers async function getInvoice(id: number): Promise { const raw = await whmcsClient.getInvoice(id); return transformWhmcsInvoice(raw); // Provider-specific } ``` --- ## 🔄 Adding a New Provider Example: Adding Stripe as an invoice provider **1. Create provider folder:** ```bash mkdir -p packages/domain/billing/providers/stripe ``` **2. Add raw types:** ```typescript // billing/providers/stripe/raw.types.ts export const stripeInvoiceRawSchema = z.object({ id: z.string(), status: z.enum(["draft", "open", "paid", "void"]), // ... Stripe's structure }); ``` **3. Add mapper:** ```typescript // billing/providers/stripe/mapper.ts export function transformStripeInvoice(raw: unknown): Invoice { const stripe = stripeInvoiceRawSchema.parse(raw); return invoiceSchema.parse({ id: parseInt(stripe.id), status: mapStripeStatus(stripe.status), // ... transform to domain model }); } ``` **4. Use in service:** ```typescript // No changes to domain contract needed! import { transformStripeInvoice } from "@customer-portal/domain/billing/providers/stripe/mapper"; const invoice = transformStripeInvoice(stripeData); ``` --- ## ✨ Benefits 1. **Domain-Centric** - Matches business thinking 2. **Provider Isolation** - No leaking of implementation details 3. **Co-location** - Everything about billing is in `billing/` 4. **Scalable** - New provider = new folder 5. **Single Package** - One `@customer-portal/domain` 6. **Type-Safe** - Schema validation at boundaries 7. **Provider-Agnostic** - App code doesn't know providers exist --- ## 📚 Related Documentation - [TYPE-CLEANUP-GUIDE.md](./TYPE-CLEANUP-GUIDE.md) - Migration guide - [ARCHITECTURE.md](./ARCHITECTURE.md) - Overall system architecture - [TYPE-CLEANUP-SUMMARY.md](./TYPE-CLEANUP-SUMMARY.md) - Implementation summary --- **Status**: Implementation in progress. See TODO list for remaining work.