413 lines
12 KiB
Markdown
Raw Normal View History

# Domain-First Structure with Providers
**Last Updated**: January 2026
**Status**: ✅ Implemented
---
## Quick Reference
| Aspect | Location |
| -------------------- | ------------------------------------------------------------ |
| **Domain package** | `packages/domain/` |
| **Schemas** | `packages/domain/<module>/schema.ts` |
| **Provider mappers** | `packages/domain/<module>/providers/<provider>/mapper.ts` |
| **Raw types** | `packages/domain/<module>/providers/<provider>/raw.types.ts` |
**Import patterns**:
```typescript
// App/Portal code - domain types only
import { Invoice, invoiceSchema } from "@customer-portal/domain/billing";
// BFF integration code - includes provider mappers
import { transformWhmcsInvoice } from "@customer-portal/domain/billing/providers";
```
**Key principle**: Transform once in domain mappers, use domain types everywhere else.
---
## 🎯 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
---
## 📋 Domain Module Inventory
| Module | Key Types | Key Schemas | Provider(s) | Purpose |
| --------------- | ------------------------------------ | ------------------------- | ----------------- | --------------------- |
| `auth` | `AuthenticatedUser`, `Session` | `authenticatedUserSchema` | — | Authentication types |
| `address` | `Address`, `JapanAddress` | `addressSchema` | japanpost | Address management |
| `billing` | `Invoice`, `InvoiceItem` | `invoiceSchema` | whmcs | Invoice management |
| `checkout` | `CheckoutSession`, `CartItem` | `checkoutSchema` | — | Checkout flow |
| `common` | `Money`, `ApiResponse` | `moneySchema` | — | Shared utilities |
| `customer` | `Customer`, `CustomerProfile` | `customerSchema` | salesforce | Customer data |
| `dashboard` | `DashboardStats` | `dashboardSchema` | — | Dashboard data |
| `get-started` | `GetStartedFlow`, `EligibilityCheck` | `getStartedSchema` | — | Signup flow |
| `notifications` | `Notification` | `notificationSchema` | — | Notifications |
| `opportunity` | `Opportunity` | `opportunitySchema` | salesforce | Opportunity lifecycle |
| `orders` | `Order`, `OrderDetails`, `OrderItem` | `orderSchema` | salesforce, whmcs | Order management |
| `payments` | `PaymentMethod`, `PaymentGateway` | `paymentMethodSchema` | whmcs | Payment methods |
| `services` | `CatalogProduct`, `ServiceCategory` | `catalogProductSchema` | salesforce | Product catalog |
| `sim` | `SimDetails`, `SimUsage` | `simDetailsSchema` | freebit | SIM management |
| `subscriptions` | `Subscription`, `SubscriptionStatus` | `subscriptionSchema` | whmcs, salesforce | Active services |
| `support` | `Case`, `CaseMessage` | `caseSchema` | salesforce | Support cases |
| `toolkit` | `Formatting`, `AsyncState` | — | — | Utilities |
---
## 📚 Related Documentation
- [Import Hygiene](./import-hygiene.md) - Import rules and ESLint enforcement
- [Domain Packages](./packages.md) - Package internal structure
- [Domain Types](./types.md) - Unified type system
- [BFF Integration Patterns](../bff/integration-patterns.md) - How BFF uses domain mappers
- [Architecture Decisions](../../decisions/README.md) - Why these patterns exist