5.6 KiB
5.6 KiB
Schema-First Migration Guide
Status: 🚧 In Progress
Date: October 2025
Objective: Standardize all domain types to be derived from Zod schemas
🎯 Migration Strategy
Chosen Approach: Schema-First
All TypeScript types will be derived from Zod schemas using z.infer<typeof schema>.
Benefits:
- ✅ Single source of truth (schema defines structure)
- ✅ Impossible for types to drift from validation
- ✅ Less maintenance (update schema, type auto-updates)
- ✅ Runtime + compile-time safety guaranteed
📋 File Structure Pattern
Before (Mixed Approach):
// contract.ts
export interface Invoice {
id: number;
status: InvoiceStatus;
// ...
}
// schema.ts
export const invoiceSchema = z.object({
id: z.number(),
status: invoiceStatusSchema,
// ...
});
After (Schema-First):
// schema.ts
export const invoiceSchema = z.object({
id: z.number().int().positive(),
status: invoiceStatusSchema,
// ...
});
// Derive type from schema
export type Invoice = z.infer<typeof invoiceSchema>;
// contract.ts
// Only for:
// 1. Constants (e.g., INVOICE_STATUS)
// 2. Complex business types without schemas
// 3. Union types
// 4. Provider-specific types
export const INVOICE_STATUS = {
PAID: "Paid",
UNPAID: "Unpaid",
// ...
} as const;
export type InvoiceStatus = (typeof INVOICE_STATUS)[keyof typeof INVOICE_STATUS];
// Re-export inferred type
export type { Invoice } from './schema';
🔄 Migration Checklist
Phase 1: Core Domains
- billing - invoices, billing summary
- orders - order creation, order queries
- customer - customer profile, address
- auth - login, signup, password reset
Phase 2: Secondary Domains
- subscriptions - service subscriptions
- payments - payment methods, gateways
- sim - SIM details, usage, management
- catalog - product catalog
Phase 3: Supporting Domains
- common - shared schemas
- mappings - ID mappings
- dashboard - dashboard types
📝 Migration Steps (Per Domain)
For each domain, follow these steps:
1. Audit Current State
- Identify all types in
contract.ts - Identify all schemas in
schema.ts - Find which types already have corresponding schemas
2. Convert Schema to Type
// Before
export interface Invoice { ... }
// After (in schema.ts)
export const invoiceSchema = z.object({ ... });
export type Invoice = z.infer<typeof invoiceSchema>;
3. Update contract.ts
- Remove duplicate interfaces
- Keep only constants and business logic types
- Re-export types from schema.ts
4. Update index.ts
- Export both schemas and types
- Maintain backward compatibility
5. Verify Imports
- Check no breaking changes for consumers
- Types still importable from domain package
🛠️ Special Cases
Case 1: Constants + Inferred Types
// schema.ts
export const invoiceStatusSchema = z.enum(["Paid", "Unpaid", "Overdue"]);
export type InvoiceStatus = z.infer<typeof invoiceStatusSchema>;
// contract.ts
export const INVOICE_STATUS = {
PAID: "Paid",
UNPAID: "Unpaid",
OVERDUE: "Overdue",
} as const;
// Keep both - they serve different purposes
Case 2: Schemas Referencing Contract Types
// ❌ Bad - circular dependency
import type { SimTopUpRequest } from "./contract";
export const simTopUpRequestSchema: z.ZodType<SimTopUpRequest> = z.object({ ... });
// ✅ Good - derive from schema
export const simTopUpRequestSchema = z.object({
quotaMb: z.number().int().min(100).max(51200),
});
export type SimTopUpRequest = z.infer<typeof simTopUpRequestSchema>;
Case 3: Complex Business Types
// contract.ts - Keep types that have no validation schema
export interface OrderBusinessValidation extends CreateOrderRequest {
userId: string;
opportunityId?: string;
}
// These are internal domain types, not API contracts
Case 4: Provider-Specific Types
// providers/whmcs/raw.types.ts
// Keep as-is - these are provider-specific, not validated
export interface WhmcsInvoiceRaw {
id: string;
status: string;
// Raw WHMCS response shape
}
✅ Success Criteria
After migration, each domain should have:
-
schema.ts
- All Zod schemas
- Exported types derived from schemas (
z.infer)
-
contract.ts
- Constants (e.g.,
INVOICE_STATUS) - Business logic types (not validated)
- Re-exports of types from schema.ts
- Constants (e.g.,
-
index.ts
- Exports all schemas
- Exports all types
- Maintains backward compatibility
-
No Type Drift
- Every validated type has a corresponding schema
- All types are derived from schemas
🧪 Testing Strategy
After Each Domain Migration:
-
Type Check
pnpm run type-check -
Build Domain Package
cd packages/domain && pnpm build -
Test Imports in Apps
// Verify exports still work import { Invoice, invoiceSchema } from '@customer-portal/domain/billing'; -
Run Schema Tests (if added)
pnpm test packages/domain
📚 References
- Zod Documentation: https://zod.dev/
- Type Inference: https://zod.dev/?id=type-inference
- Schema Composition: https://zod.dev/?id=objects
🚀 Next Steps
- Start with billing domain (most straightforward)
- Apply pattern to orders domain
- Replicate across all domains
- Update documentation
- Create linting rules to enforce pattern