barsa 0f8435e6bd Update Documentation and Refactor Service Structure
- Revised README and documentation links to reflect updated paths and improve clarity on service offerings.
- Refactored service components to enhance organization and maintainability, including updates to the Internet and SIM offerings.
- Improved user navigation and experience in service-related views by streamlining component structures and enhancing data handling.
- Updated internal documentation to align with recent changes in service architecture and eligibility processes.
2025-12-25 15:48:57 +09:00

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
│
├── 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,
  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

  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


Status: Implementation in progress. See TODO list for remaining work.