# Domain Layer Design **Customer Portal - Domain-Driven Type System** --- ## Table of Contents 1. [Overview](#overview) 2. [Architecture Philosophy](#architecture-philosophy) 3. [Domain Structure](#domain-structure) 4. [Provider Pattern](#provider-pattern) 5. [Type System](#type-system) 6. [Adding New Domains](#adding-new-domains) 7. [Best Practices](#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: ```typescript // 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 ``` --- ## 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)**: ```typescript // 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)**: ```typescript // 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**: ```typescript // 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; ``` **Provider Mapper**: ```typescript // 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 = { '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**: ```typescript // 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**: ```typescript 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**: ```typescript 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**: ```typescript export interface ApiResponse { data: T; success: boolean; message?: string; } export interface PaginatedResponse extends ApiResponse { pagination: { page: number; limit: number; total: number; totalPages: number; }; } ``` ### Branded Types (Type Safety) ```typescript // 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**: ```bash mkdir -p packages/domain/new-domain/providers/provider-name ``` **2. Define Contract**: ```typescript // 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**: ```typescript // 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**: ```typescript // 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; ``` **5. Define Provider Mapper**: ```typescript // 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**: ```typescript // packages/domain/new-domain/index.ts export * from './contract'; export * from './schema'; export * as Providers from './providers'; ``` **7. Use in Application**: ```typescript // 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 { 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
{entity.name} - {entity.status}
; } ``` --- ## 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) ```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/common'; // 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 } 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 --- ## Related Documentation - [System Architecture](./SYSTEM-ARCHITECTURE.md) - Overall system design - [Integration & Data Flow](./INTEGRATION-DATAFLOW.md) - How providers are used --- **Last Updated**: October 2025 **Status**: Active - Production System