2025-10-03 14:26:55 +09:00
|
|
|
# Domain-First Structure with Providers
|
|
|
|
|
|
|
|
|
|
**Date**: October 3, 2025
|
|
|
|
|
**Status**: ✅ Implementing
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## 🎯 Architecture Philosophy
|
|
|
|
|
|
|
|
|
|
**Core Principle**: Domain-first organization where each business domain owns its:
|
2025-12-23 15:43:36 +09:00
|
|
|
|
2025-10-03 14:26:55 +09:00
|
|
|
- **contract.ts** - Normalized types (provider-agnostic)
|
|
|
|
|
- **schema.ts** - Runtime validation (Zod)
|
|
|
|
|
- **providers/** - Provider-specific adapters (raw types + mappers)
|
|
|
|
|
|
|
|
|
|
**Why This Works**:
|
2025-12-23 15:43:36 +09:00
|
|
|
|
2025-10-03 14:26:55 +09:00
|
|
|
- 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
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## 📦 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
|
|
|
|
|
│
|
|
|
|
|
├── catalog/
|
|
|
|
|
│ ├── contract.ts # CatalogProduct (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)**
|
2025-12-23 15:43:36 +09:00
|
|
|
|
2025-10-03 14:26:55 +09:00
|
|
|
```typescript
|
|
|
|
|
// Import normalized domain types
|
|
|
|
|
import { Invoice, invoiceSchema, INVOICE_STATUS } from "@customer-portal/domain/billing";
|
|
|
|
|
import { Subscription } from "@customer-portal/domain/subscriptions";
|
2025-10-07 17:38:39 +09:00
|
|
|
import { Address } from "@customer-portal/domain/customer";
|
2025-10-03 14:26:55 +09:00
|
|
|
|
|
|
|
|
// Use domain types
|
|
|
|
|
const invoice: Invoice = {
|
|
|
|
|
id: 123,
|
|
|
|
|
status: INVOICE_STATUS.PAID,
|
|
|
|
|
// ...
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Validate
|
|
|
|
|
const validated = invoiceSchema.parse(rawData);
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### **Integration Code (Needs Provider Specifics)**
|
2025-12-23 15:43:36 +09:00
|
|
|
|
2025-10-03 14:26:55 +09:00
|
|
|
```typescript
|
|
|
|
|
// Import domain + provider
|
|
|
|
|
import { Invoice, invoiceSchema } from "@customer-portal/domain/billing";
|
2025-12-23 15:43:36 +09:00
|
|
|
import {
|
2025-10-03 14:26:55 +09:00
|
|
|
transformWhmcsInvoice,
|
2025-12-23 15:43:36 +09:00
|
|
|
type WhmcsInvoiceRaw,
|
2025-10-03 14:26:55 +09:00
|
|
|
} from "@customer-portal/domain/billing/providers/whmcs/mapper";
|
|
|
|
|
import { whmcsInvoiceRawSchema } from "@customer-portal/domain/billing/providers/whmcs/raw.types";
|
|
|
|
|
|
|
|
|
|
// Transform raw API data
|
|
|
|
|
const whmcsData: WhmcsInvoiceRaw = await whmcsApi.getInvoice(id);
|
|
|
|
|
const invoice: Invoice = transformWhmcsInvoice(whmcsData);
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## 🏗️ Domain File Templates
|
|
|
|
|
|
|
|
|
|
### **contract.ts**
|
2025-12-23 15:43:36 +09:00
|
|
|
|
2025-10-03 14:26:55 +09:00
|
|
|
```typescript
|
|
|
|
|
/**
|
|
|
|
|
* {Domain} - Contract
|
2025-12-23 15:43:36 +09:00
|
|
|
*
|
2025-10-03 14:26:55 +09:00
|
|
|
* 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**
|
2025-12-23 15:43:36 +09:00
|
|
|
|
2025-10-03 14:26:55 +09:00
|
|
|
```typescript
|
|
|
|
|
/**
|
|
|
|
|
* {Domain} - Schemas
|
2025-12-23 15:43:36 +09:00
|
|
|
*
|
2025-10-03 14:26:55 +09:00
|
|
|
* 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**
|
2025-12-23 15:43:36 +09:00
|
|
|
|
2025-10-03 14:26:55 +09:00
|
|
|
```typescript
|
|
|
|
|
/**
|
|
|
|
|
* {Provider} {Domain} Provider - Raw Types
|
2025-12-23 15:43:36 +09:00
|
|
|
*
|
2025-10-03 14:26:55 +09:00
|
|
|
* 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**
|
2025-12-23 15:43:36 +09:00
|
|
|
|
2025-10-03 14:26:55 +09:00
|
|
|
```typescript
|
|
|
|
|
/**
|
|
|
|
|
* {Provider} {Domain} Provider - Mapper
|
2025-12-23 15:43:36 +09:00
|
|
|
*
|
2025-10-03 14:26:55 +09:00
|
|
|
* 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);
|
2025-12-23 15:43:36 +09:00
|
|
|
|
2025-10-03 14:26:55 +09:00
|
|
|
// 2. Transform to domain model
|
|
|
|
|
const result: {Domain} = {
|
|
|
|
|
id: validated.someId,
|
|
|
|
|
status: mapStatus(validated.rawStatus),
|
|
|
|
|
// ... map all fields
|
|
|
|
|
};
|
2025-12-23 15:43:36 +09:00
|
|
|
|
2025-10-03 14:26:55 +09:00
|
|
|
// 3. Validate domain model
|
|
|
|
|
return {domain}Schema.parse(result);
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## 🎓 Key Patterns
|
|
|
|
|
|
|
|
|
|
### **1. Co-location**
|
2025-12-23 15:43:36 +09:00
|
|
|
|
2025-10-03 14:26:55 +09:00
|
|
|
Everything about a domain lives together:
|
2025-12-23 15:43:36 +09:00
|
|
|
|
2025-10-03 14:26:55 +09:00
|
|
|
```
|
|
|
|
|
billing/
|
|
|
|
|
├── contract.ts # What billing IS
|
|
|
|
|
├── schema.ts # How to validate it
|
|
|
|
|
└── providers/ # Where it comes FROM
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### **2. Provider Isolation**
|
2025-12-23 15:43:36 +09:00
|
|
|
|
2025-10-03 14:26:55 +09:00
|
|
|
Raw types and mappers stay in `providers/`:
|
2025-12-23 15:43:36 +09:00
|
|
|
|
2025-10-03 14:26:55 +09:00
|
|
|
```typescript
|
|
|
|
|
// ✅ GOOD - Isolated
|
|
|
|
|
import { transformWhmcsInvoice } from "@customer-portal/domain/billing/providers/whmcs/mapper";
|
|
|
|
|
|
|
|
|
|
// ❌ BAD - Would leak WHMCS details into app code
|
|
|
|
|
import { WhmcsInvoiceRaw } from "@somewhere/global";
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### **3. Schema-Driven**
|
2025-12-23 15:43:36 +09:00
|
|
|
|
2025-10-03 14:26:55 +09:00
|
|
|
Domain schemas define the contract:
|
2025-12-23 15:43:36 +09:00
|
|
|
|
2025-10-03 14:26:55 +09:00
|
|
|
```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**
|
2025-12-23 15:43:36 +09:00
|
|
|
|
2025-10-03 14:26:55 +09:00
|
|
|
App code never knows about providers:
|
2025-12-23 15:43:36 +09:00
|
|
|
|
2025-10-03 14:26:55 +09:00
|
|
|
```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:**
|
2025-12-23 15:43:36 +09:00
|
|
|
|
2025-10-03 14:26:55 +09:00
|
|
|
```bash
|
|
|
|
|
mkdir -p packages/domain/billing/providers/stripe
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
**2. Add raw types:**
|
2025-12-23 15:43:36 +09:00
|
|
|
|
2025-10-03 14:26:55 +09:00
|
|
|
```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:**
|
2025-12-23 15:43:36 +09:00
|
|
|
|
2025-10-03 14:26:55 +09:00
|
|
|
```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:**
|
2025-12-23 15:43:36 +09:00
|
|
|
|
2025-10-03 14:26:55 +09:00
|
|
|
```typescript
|
|
|
|
|
// No changes to domain contract needed!
|
|
|
|
|
import { transformStripeInvoice } from "@customer-portal/domain/billing/providers/stripe/mapper";
|
|
|
|
|
|
|
|
|
|
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.
|