368 lines
9.1 KiB
Markdown
Raw Normal View History

# Domain-First Structure with Providers
**Date**: October 3, 2025
**Status**: ✅ Implementing
---
## 🎯 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`](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)**
```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/customer";
// 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, 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**
```typescript
/**
* {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**
```typescript
/**
* {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**
```typescript
/**
* {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**
```typescript
/**
* {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/`:
```typescript
// ✅ 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:
```typescript
// 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:
```typescript
// ✅ 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:**
```bash
mkdir -p packages/domain/billing/providers/stripe
```
**2. Add raw types:**
```typescript
// 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:**
```typescript
// 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:**
```typescript
// 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
---
## 📚 Related Documentation
- [TYPE-CLEANUP-GUIDE.md](./TYPE-CLEANUP-GUIDE.md) - Migration guide
- [ARCHITECTURE.md](./ARCHITECTURE.md) - Overall system architecture
- [TYPE-CLEANUP-SUMMARY.md](./TYPE-CLEANUP-SUMMARY.md) - Implementation summary
---
**Status**: Implementation in progress. See TODO list for remaining work.