12 KiB

Domain-First Structure with Providers

Last Updated: January 2026 Status: Implemented


Quick Reference

Aspect Location
Domain package packages/domain/
Schemas packages/domain/<module>/schema.ts
Provider mappers packages/domain/<module>/providers/<provider>/mapper.ts
Raw types packages/domain/<module>/providers/<provider>/raw.types.ts

Import patterns:

// App/Portal code - domain types only
import { Invoice, invoiceSchema } from "@customer-portal/domain/billing";

// BFF integration code - includes provider mappers
import { transformWhmcsInvoice } from "@customer-portal/domain/billing/providers";

Key principle: Transform once in domain mappers, use domain types everywhere else.


🎯 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

Import rules: See docs/development/domain/import-hygiene.md.


📦 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)

// 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)

// Import domain + provider
import { Invoice, invoiceSchema } from "@customer-portal/domain/billing";
import {
  transformWhmcsInvoice,
  whmcsInvoiceRawSchema,
  type WhmcsInvoiceRaw,
} from "@customer-portal/domain/billing/providers";

// Transform raw API data
const whmcsData: WhmcsInvoiceRaw = await whmcsApi.getInvoice(id);
const invoice: Invoice = transformWhmcsInvoice(whmcsData);

🏗️ Domain File Templates

contract.ts

/**
 * {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

/**
 * {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

/**
 * {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<typeof {provider}{Domain}RawSchema>;

providers/{provider}/mapper.ts

/**
 * {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/:

// ✅ GOOD - Isolated
import { transformWhmcsInvoice } from "@customer-portal/domain/billing/providers";

// ❌ BAD - Would leak WHMCS details into app code
import { WhmcsInvoiceRaw } from "@somewhere/global";

3. Schema-Driven

Domain schemas define the contract:

// 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:

// ✅ 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<Invoice> {
  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:

mkdir -p packages/domain/billing/providers/stripe

2. Add raw types:

// 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:

// 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:

// No changes to domain contract needed!
import { transformStripeInvoice } from "@customer-portal/domain/billing/providers";

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

📋 Domain Module Inventory

Module Key Types Key Schemas Provider(s) Purpose
auth AuthenticatedUser, Session authenticatedUserSchema Authentication types
address Address, JapanAddress addressSchema japanpost Address management
billing Invoice, InvoiceItem invoiceSchema whmcs Invoice management
checkout CheckoutSession, CartItem checkoutSchema Checkout flow
common Money, ApiResponse moneySchema Shared utilities
customer Customer, CustomerProfile customerSchema salesforce Customer data
dashboard DashboardStats dashboardSchema Dashboard data
get-started GetStartedFlow, EligibilityCheck getStartedSchema Signup flow
notifications Notification notificationSchema Notifications
opportunity Opportunity opportunitySchema salesforce Opportunity lifecycle
orders Order, OrderDetails, OrderItem orderSchema salesforce, whmcs Order management
payments PaymentMethod, PaymentGateway paymentMethodSchema whmcs Payment methods
services CatalogProduct, ServiceCategory catalogProductSchema salesforce Product catalog
sim SimDetails, SimUsage simDetailsSchema freebit SIM management
subscriptions Subscription, SubscriptionStatus subscriptionSchema whmcs, salesforce Active services
support Case, CaseMessage caseSchema salesforce Support cases
toolkit Formatting, AsyncState Utilities