12 KiB
12 KiB
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:
// 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.
📦 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)
// 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,
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
/**
* {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";
// ❌ 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";
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
📋 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 rules and ESLint enforcement
- Domain Packages - Package internal structure
- Domain Types - Unified type system
- BFF Integration Patterns - How BFF uses domain mappers
- Architecture Decisions - Why these patterns exist