Assist_Design/docs/DOMAIN-LAYER-DESIGN.md

16 KiB

Domain Layer Design

Customer Portal - Domain-Driven Type System


Table of Contents

  1. Overview
  2. Architecture Philosophy
  3. Domain Structure
  4. Provider Pattern
  5. Type System
  6. Adding New Domains
  7. Best Practices

Overview

The domain layer provides a framework-agnostic, provider-agnostic foundation for the entire application. It defines business entities, validation rules, and transformation logic that remains consistent regardless of which external systems provide the data.

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)

Architecture Philosophy

Single Source of Truth

All types are defined once in the domain layer and consumed by both frontend and backend:

@customer-portal/domain
         ↓
    ┌────┴────┐
    │         │
Portal       BFF
(uses)     (uses)

Provider Abstraction

External systems are abstracted behind clean interfaces:

Application Code
       ↓
   Domain Types (provider-agnostic)
       ↓
   Domain Mapper (transformation)
       ↓
   Raw Provider Types
       ↓
External API

Schema-Driven Validation

Runtime validation at boundaries ensures type safety:

// 1. Define contract (compile-time types)
export interface Invoice { ... }

// 2. Define schema (runtime validation)
export const invoiceSchema = z.object({ ... });

// 3. Validate at boundaries
const invoice = invoiceSchema.parse(rawData);

Domain Structure

Package Organization

packages/domain/
├── billing/                     # Invoices and payments
│   ├── 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()
│           └── index.ts
│
├── subscriptions/               # Service subscriptions
│   ├── contract.ts              # Subscription, SubscriptionStatus
│   ├── schema.ts
│   ├── index.ts
│   └── providers/
│       └── whmcs/
│
├── payments/                    # Payment methods
│   ├── contract.ts              # PaymentMethod, PaymentGateway
│   ├── schema.ts
│   ├── index.ts
│   └── providers/
│       └── whmcs/
│
├── orders/                      # Order management
│   ├── contract.ts              # Order, OrderItem
│   ├── schema.ts
│   ├── index.ts
│   └── providers/
│       ├── salesforce/          # Read orders
│       └── whmcs/               # Create orders
│
├── catalog/                     # Product catalog
│   ├── contract.ts              # CatalogProduct
│   ├── schema.ts
│   ├── index.ts
│   └── providers/
│       └── salesforce/
│
├── customer/                    # Customer profile
│   ├── contract.ts              # CustomerProfile, Address
│   ├── schema.ts
│   ├── index.ts
│   └── providers/
│       ├── salesforce/
│       └── whmcs/
│
├── sim/                         # SIM management
│   ├── contract.ts              # SimDetails, SimUsage
│   ├── schema.ts
│   ├── index.ts
│   └── providers/
│       └── freebit/
│
├── common/                      # Shared types
│   ├── types.ts                 # Address, Money, BaseEntity
│   ├── identifiers.ts           # UserId, OrderId (branded types)
│   ├── api.ts                   # ApiResponse, PaginatedResponse
│   ├── schema.ts                # Common schemas
│   └── index.ts
│
└── toolkit/                     # Utilities
    ├── formatting/
    │   └── currency.ts          # formatCurrency()
    ├── validation/
    │   └── helpers.ts           # Validation utilities
    └── typing/
        └── patterns.ts          # AsyncState, Result<T, E>

Provider Pattern

How It Works

Each domain can have multiple providers (external systems) that supply data. Providers are responsible for:

  1. Defining raw types (what the API returns)
  2. Mapping raw data to domain types
  3. Validating at both boundaries

Example: Billing Domain

Contract (Domain Type):

// packages/domain/billing/contract.ts

export const INVOICE_STATUS = {
  UNPAID: 'Unpaid',
  PAID: 'Paid',
  CANCELLED: 'Cancelled',
  OVERDUE: 'Overdue'
} as const;

export type InvoiceStatus = typeof INVOICE_STATUS[keyof typeof INVOICE_STATUS];

export interface Invoice {
  id: number;
  userId: number;
  status: InvoiceStatus;
  amount: Money;
  dueDate: Date;
  invoiceNumber: string;
  createdAt: Date;
  items: InvoiceItem[];
}

export interface InvoiceItem {
  id: number;
  description: string;
  amount: Money;
  taxed: boolean;
}

Schema (Runtime Validation):

// packages/domain/billing/schema.ts

import { z } from 'zod';
import { moneySchema } from '../common/schema';

export const invoiceStatusSchema = z.enum([
  'Unpaid', 'Paid', 'Cancelled', 'Overdue'
]);

export const invoiceItemSchema = z.object({
  id: z.number(),
  description: z.string(),
  amount: moneySchema,
  taxed: z.boolean()
});

export const invoiceSchema = z.object({
  id: z.number(),
  userId: z.number(),
  status: invoiceStatusSchema,
  amount: moneySchema,
  dueDate: z.coerce.date(),
  invoiceNumber: z.string(),
  createdAt: z.coerce.date(),
  items: z.array(invoiceItemSchema)
});

Provider Raw Types:

// packages/domain/billing/providers/whmcs/raw.types.ts

import { z } from 'zod';

export const whmcsInvoiceRawSchema = z.object({
  invoiceid: z.string(),
  userid: z.string(),
  status: z.string(),
  total: z.string(),
  duedate: z.string(),
  invoicenum: z.string(),
  date: z.string(),
  currencycode: z.string().optional(),
  items: z.object({
    item: z.array(z.object({
      id: z.string(),
      description: z.string(),
      amount: z.string(),
      taxed: z.union([z.string(), z.number(), z.boolean()])
    }))
  }).optional()
});

export type WhmcsInvoiceRaw = z.infer<typeof whmcsInvoiceRawSchema>;

Provider Mapper:

// packages/domain/billing/providers/whmcs/mapper.ts

import type { Invoice, InvoiceItem } from '../../contract';
import { invoiceSchema } from '../../schema';
import { whmcsInvoiceRawSchema, type WhmcsInvoiceRaw } from './raw.types';
import { INVOICE_STATUS } from '../../contract';

export function transformWhmcsInvoice(
  raw: unknown,
  context: { 
    defaultCurrencyCode: string; 
    defaultCurrencySymbol: string;
  }
): Invoice {
  // 1. Validate raw data
  const whmcs = whmcsInvoiceRawSchema.parse(raw);
  
  // 2. Transform to domain model
  const result: Invoice = {
    id: parseInt(whmcs.invoiceid),
    userId: parseInt(whmcs.userid),
    status: mapWhmcsInvoiceStatus(whmcs.status),
    amount: {
      value: parseFloat(whmcs.total),
      currency: whmcs.currencycode || context.defaultCurrencyCode,
      symbol: context.defaultCurrencySymbol
    },
    dueDate: new Date(whmcs.duedate),
    invoiceNumber: whmcs.invoicenum,
    createdAt: new Date(whmcs.date),
    items: whmcs.items?.item?.map(transformWhmcsInvoiceItem) || []
  };
  
  // 3. Validate domain model
  return invoiceSchema.parse(result);
}

function mapWhmcsInvoiceStatus(status: string): InvoiceStatus {
  const statusMap: Record<string, InvoiceStatus> = {
    'Unpaid': INVOICE_STATUS.UNPAID,
    'Paid': INVOICE_STATUS.PAID,
    'Cancelled': INVOICE_STATUS.CANCELLED,
    'Overdue': INVOICE_STATUS.OVERDUE
  };
  
  return statusMap[status] || INVOICE_STATUS.UNPAID;
}

function transformWhmcsInvoiceItem(raw: any): InvoiceItem {
  return {
    id: parseInt(raw.id),
    description: raw.description,
    amount: {
      value: parseFloat(raw.amount),
      currency: '', // Inherited from invoice
      symbol: ''
    },
    taxed: Boolean(raw.taxed)
  };
}

Public Exports:

// packages/domain/billing/index.ts

export * from './contract';
export * from './schema';

// Provider namespace for imports
export * as Providers from './providers';

Type System

Common Types

Money:

export interface Money {
  value: number;
  currency: string;
  symbol?: string;
}

export const moneySchema = z.object({
  value: z.number(),
  currency: z.string(),
  symbol: z.string().optional()
});

Address:

export interface Address {
  street: string;
  city: string;
  state?: string;
  postalCode: string;
  country: string;
}

export const addressSchema = z.object({
  street: z.string().min(1),
  city: z.string().min(1),
  state: z.string().optional(),
  postalCode: z.string().min(1),
  country: z.string().min(2).max(2) // ISO 3166-1 alpha-2
});

API Response:

export interface ApiResponse<T> {
  data: T;
  success: boolean;
  message?: string;
}

export interface PaginatedResponse<T> extends ApiResponse<T[]> {
  pagination: {
    page: number;
    limit: number;
    total: number;
    totalPages: number;
  };
}

Branded Types (Type Safety)

// packages/domain/common/identifiers.ts

export type UserId = number & { readonly __brand: 'UserId' };
export type OrderId = string & { readonly __brand: 'OrderId' };
export type InvoiceId = number & { readonly __brand: 'InvoiceId' };

// Helper functions
export const UserId = (id: number): UserId => id as UserId;
export const OrderId = (id: string): OrderId => id as OrderId;
export const InvoiceId = (id: number): InvoiceId => id as InvoiceId;

Adding New Domains

Step-by-Step Guide

1. Create Domain Folder:

mkdir -p packages/domain/new-domain/providers/provider-name

2. Define Contract:

// packages/domain/new-domain/contract.ts

export const ENTITY_STATUS = {
  ACTIVE: 'Active',
  INACTIVE: 'Inactive'
} as const;

export type EntityStatus = typeof ENTITY_STATUS[keyof typeof ENTITY_STATUS];

export interface Entity {
  id: number;
  name: string;
  status: EntityStatus;
  createdAt: Date;
}

3. Define Schema:

// packages/domain/new-domain/schema.ts

import { z } from 'zod';

export const entityStatusSchema = z.enum(['Active', 'Inactive']);

export const entitySchema = z.object({
  id: z.number(),
  name: z.string().min(1),
  status: entityStatusSchema,
  createdAt: z.coerce.date()
});

4. Define Provider Raw Types:

// packages/domain/new-domain/providers/provider-name/raw.types.ts

import { z } from 'zod';

export const providerEntityRawSchema = z.object({
  // Raw API fields
  entity_id: z.string(),
  entity_name: z.string(),
  status: z.string(),
  created_at: z.string()
});

export type ProviderEntityRaw = z.infer<typeof providerEntityRawSchema>;

5. Define Provider Mapper:

// packages/domain/new-domain/providers/provider-name/mapper.ts

import type { Entity } from '../../contract';
import { entitySchema } from '../../schema';
import { providerEntityRawSchema } from './raw.types';
import { ENTITY_STATUS } from '../../contract';

export function transformProviderEntity(raw: unknown): Entity {
  // 1. Validate raw
  const provider = providerEntityRawSchema.parse(raw);
  
  // 2. Transform
  const result: Entity = {
    id: parseInt(provider.entity_id),
    name: provider.entity_name,
    status: provider.status === 'active' ? ENTITY_STATUS.ACTIVE : ENTITY_STATUS.INACTIVE,
    createdAt: new Date(provider.created_at)
  };
  
  // 3. Validate domain
  return entitySchema.parse(result);
}

6. Export Public API:

// packages/domain/new-domain/index.ts

export * from './contract';
export * from './schema';
export * as Providers from './providers';

7. Use in Application:

// BFF Integration Service
import { Entity } from '@customer-portal/domain/new-domain';
import { Providers } from '@customer-portal/domain/new-domain';

@Injectable()
export class ProviderEntityService {
  async getEntity(id: number): Promise<Entity> {
    const raw = await this.providerClient.getEntity(id);
    return Providers.ProviderName.transformProviderEntity(raw);
  }
}

// Portal Component
import { Entity, entitySchema } from '@customer-portal/domain/new-domain';

function EntityDisplay({ entity }: { entity: Entity }) {
  return <div>{entity.name} - {entity.status}</div>;
}

Best Practices

DO

  1. Define types once in domain - Single source of truth
  2. Use Zod schemas for validation - Runtime safety
  3. Transform in domain mappers - Single transformation point
  4. Keep domain pure - No framework dependencies
  5. Use branded types - Stronger type safety
  6. Validate at boundaries - Raw input, domain output
  7. Export via index.ts - Clean public API

DON'T

  1. Duplicate types - Don't redefine in apps
  2. Skip validation - Always validate raw and domain
  3. Add framework deps - Keep domain pure TypeScript
  4. Transform twice - Use domain mapper once
  5. Expose raw types - Keep providers internal
  6. Hard-code status strings - Use const enums
  7. Mix business logic - Domain is types + validation only

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/common';

// 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 } from '@customer-portal/domain/billing';
import { Providers } from '@customer-portal/domain/billing';

// Use provider mapper
const invoice = Providers.Whmcs.transformWhmcsInvoice(whmcsData, context);

Benefits

1. Type Safety

  • Compile-time: TypeScript catches type errors
  • Runtime: Zod validates data at boundaries
  • Branded types: Stronger guarantees (UserId vs number)

2. Single Source of Truth

  • One definition: Types defined once, used everywhere
  • Consistency: Frontend and backend use same types
  • Refactoring: Change once, update everywhere

3. Provider Abstraction

  • Flexibility: Easy to swap providers
  • Isolation: Provider details don't leak
  • Testability: Easy to mock providers

4. Maintainability

  • Clear structure: Predictable organization
  • Co-location: Related code together
  • Separation: Domain vs infrastructure

5. Scalability

  • Add providers: New folder, no refactoring
  • Add domains: Consistent pattern
  • Team collaboration: Clear boundaries


Last Updated: October 2025
Status: Active - Production System