Assist_Design/docs/DOMAIN-LAYER-DESIGN.md

631 lines
16 KiB
Markdown

# 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<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)**:
```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<typeof whmcsInvoiceRawSchema>;
```
**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<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**:
```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<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)
```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<typeof providerEntityRawSchema>;
```
**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<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)
```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