631 lines
16 KiB
Markdown
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
|
|
|