9.1 KiB
9.1 KiB
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
📦 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)
// 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)
// Import domain + provider
import { Invoice, invoiceSchema } from "@customer-portal/domain/billing";
import {
transformWhmcsInvoice,
type WhmcsInvoiceRaw
} 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
/**
* {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
/**
* {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
/**
* {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
/**
* {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/:
// ✅ 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
Domain schemas define the contract:
// 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:
// ✅ 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:
mkdir -p packages/domain/billing/providers/stripe
2. Add raw types:
// 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:
// 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:
// No changes to domain contract needed!
import { transformStripeInvoice } from "@customer-portal/domain/billing/providers/stripe/mapper";
const invoice = transformStripeInvoice(stripeData);
✨ Benefits
- Domain-Centric - Matches business thinking
- Provider Isolation - No leaking of implementation details
- Co-location - Everything about billing is in
billing/ - Scalable - New provider = new folder
- Single Package - One
@customer-portal/domain - Type-Safe - Schema validation at boundaries
- Provider-Agnostic - App code doesn't know providers exist
📚 Related Documentation
- TYPE-CLEANUP-GUIDE.md - Migration guide
- ARCHITECTURE.md - Overall system architecture
- TYPE-CLEANUP-SUMMARY.md - Implementation summary
Status: Implementation in progress. See TODO list for remaining work.