14 KiB
Validation Duplication Report: BFF vs Portal
Date: October 8, 2025
Scope: Cross-layer validation type duplication analysis
Executive Summary
After fixing frontend form duplication with domain schemas, I found additional type duplication between the portal and domain layers. The portal is defining its own request types instead of importing validated types from the domain layer.
Status: 🔴 Duplication Found
Impact: Medium - Types work but lack validation, could diverge over time
Effort to Fix: Low (1-2 hours)
Duplication Found
Issue #1: SIM Action Request Types Duplicated
Location: apps/portal/src/features/subscriptions/services/sim-actions.service.ts
Portal defines (lines 3-15):
export interface TopUpRequest {
quotaMb: number;
}
export interface ChangePlanRequest {
newPlanCode: string;
assignGlobalIp: boolean;
scheduledAt?: string;
}
export interface CancelRequest {
scheduledAt: string;
}
Domain already has (packages/domain/sim/schema.ts):
export const simTopUpRequestSchema = z.object({
quotaMb: z
.number()
.int()
.min(100, "Quota must be at least 100MB")
.max(51200, "Quota must be 50GB or less"),
});
export const simPlanChangeRequestSchema = z.object({
newPlanCode: z.string().min(1, "New plan code is required"),
assignGlobalIp: z.boolean().optional(),
scheduledAt: z
.string()
.regex(/^\d{8}$/, "Scheduled date must be in YYYYMMDD format")
.optional(),
});
export const simCancelRequestSchema = z.object({
scheduledAt: z
.string()
.regex(/^\d{8}$/, "Scheduled date must be in YYYYMMDD format")
.optional(),
});
// Exported types
export type SimTopUpRequest = z.infer<typeof simTopUpRequestSchema>;
export type SimPlanChangeRequest = z.infer<typeof simPlanChangeRequestSchema>;
export type SimCancelRequest = z.infer<typeof simCancelRequestSchema>;
Problems:
- ❌ Portal types lack validation (no min/max for quotaMb, no format validation for scheduledAt)
- ❌ Portal types use different names (TopUpRequest vs SimTopUpRequest)
- ❌ Field types differ (assignGlobalIp is required in portal but optional in domain)
- ❌ No single source of truth - types could diverge over time
- ❌ Portal doesn't benefit from Zod validation rules
Impact:
- Medium priority - The portal service works, but requests aren't validated client-side
- Data could be sent in wrong format (e.g., scheduledAt as "2024-10-08" instead of "20241008")
- No runtime type checking before API calls
Issue #2: SIM Configuration Schema Missing
Location: apps/portal/src/features/catalog/hooks/useSimConfigure.ts (lines 7-14)
Portal attempts to import:
import {
simConfigureFormSchema, // ❌ Does not exist
simConfigureFormToRequest, // ❌ Does not exist
type SimConfigureFormData, // ❌ Does not exist
type SimType, // ✅ Exists in domain/sim
type ActivationType, // ❌ Does not exist in billing
type MnpData, // ❌ Does not exist in billing
} from "@customer-portal/domain/billing"; // ❌ Wrong domain
Problems:
- ❌ Imports from wrong domain (billing instead of sim/orders)
- ❌ Schemas don't exist yet in any domain
- ❌ This is likely causing TypeScript errors
Impact:
- High priority - Broken imports
- SIM configuration flow may not be working correctly
- Need to create proper schemas in domain layer
BFF Layer Analysis
✅ BFF Correctly Uses Domain Schemas
The BFF layer is following the correct pattern:
Example 1: Auth Controller
// apps/bff/src/modules/auth/presentation/http/auth.controller.ts
import {
signupRequestSchema,
passwordResetRequestSchema,
passwordResetSchema,
setPasswordRequestSchema,
linkWhmcsRequestSchema,
// ... all from domain
} from "@customer-portal/domain/auth";
@UsePipes(new ZodValidationPipe(signupRequestSchema))
async signup(@Body() body: SignupRequest) {
// ...
}
Example 2: Orders Controller
// apps/bff/src/modules/orders/orders.controller.ts
import {
createOrderRequestSchema,
type CreateOrderRequest,
} from "@customer-portal/domain/orders";
@UsePipes(new ZodValidationPipe(createOrderRequestSchema))
async create(@Body() body: CreateOrderRequest) {
// ...
}
Example 3: Subscriptions Controller
// apps/bff/src/modules/subscriptions/subscriptions.controller.ts
import {
simTopupRequestSchema,
simChangePlanRequestSchema,
simCancelRequestSchema,
simFeaturesRequestSchema,
type SimTopupRequest,
type SimChangePlanRequest,
type SimCancelRequest,
type SimFeaturesRequest,
} from "@customer-portal/domain/sim";
@UsePipes(new ZodValidationPipe(simTopupRequestSchema))
async topUp(@Body() body: SimTopupRequest) {
// ...
}
Why BFF is correct:
- ✅ Imports validation schemas from domain
- ✅ Uses ZodValidationPipe for runtime validation
- ✅ Uses domain types for type safety
- ✅ No duplicate type definitions
- ✅ Single source of truth
Portal Layer Issues
❌ Portal Sometimes Bypasses Domain Types
The portal defines its own types for API calls instead of importing from domain:
Problem Pattern:
// Portal defines its own simple types
export interface TopUpRequest {
quotaMb: number;
}
// But domain has validated types
export type SimTopUpRequest = z.infer<typeof simTopUpRequestSchema>;
// Portal should be using domain types
import type { SimTopUpRequest } from "@customer-portal/domain/sim";
Why this is problematic:
- No client-side validation before API calls
- Types could diverge from backend expectations
- Harder to maintain consistency
- Duplicates type definitions
- Loses validation rules (min/max, regex patterns, etc.)
Comparison: What's Working vs. What's Not
✅ Good Examples (Portal correctly uses domain)
// apps/portal/src/features/orders/services/orders.service.ts
import type { CreateOrderRequest } from "@customer-portal/domain/orders";
async function createOrder(payload: CreateOrderRequest) {
// Uses domain type directly
}
// apps/portal/src/features/auth/stores/auth.store.ts
import type {
LoginRequest,
SignupRequest,
LinkWhmcsRequest
} from "@customer-portal/domain/auth";
// Uses domain types for all auth operations
❌ Bad Examples (Portal defines own types)
// apps/portal/src/features/subscriptions/services/sim-actions.service.ts
// ❌ BAD - Duplicates domain types
export interface TopUpRequest {
quotaMb: number;
}
// ✅ GOOD - Should be:
import type { SimTopUpRequest } from "@customer-portal/domain/sim";
Recommended Fixes
Fix #1: Replace Portal SIM Types with Domain Types
File: apps/portal/src/features/subscriptions/services/sim-actions.service.ts
Current (lines 1-16):
import { apiClient, getDataOrDefault } from "@/lib/api";
export interface TopUpRequest {
quotaMb: number;
}
export interface ChangePlanRequest {
newPlanCode: string;
assignGlobalIp: boolean;
scheduledAt?: string;
}
export interface CancelRequest {
scheduledAt: string;
}
Should be:
import { apiClient, getDataOrDefault } from "@/lib/api";
import type {
SimTopUpRequest,
SimPlanChangeRequest,
SimCancelRequest
} from "@customer-portal/domain/sim";
// Remove duplicate type definitions
// Types are now imported from domain
Update service methods:
export const simActionsService = {
async topUp(subscriptionId: string, request: SimTopUpRequest): Promise<void> {
await apiClient.POST("/api/subscriptions/{subscriptionId}/sim/top-up", {
params: { path: { subscriptionId } },
body: request,
});
},
async changePlan(subscriptionId: string, request: SimPlanChangeRequest): Promise<void> {
await apiClient.POST("/api/subscriptions/{subscriptionId}/sim/change-plan", {
params: { path: { subscriptionId } },
body: request,
});
},
async cancel(subscriptionId: string, request: SimCancelRequest): Promise<void> {
await apiClient.POST("/api/subscriptions/{subscriptionId}/sim/cancel", {
params: { path: { subscriptionId } },
body: request,
});
},
};
Benefits:
- ✅ Single source of truth
- ✅ Types match backend exactly
- ✅ Can add client-side validation using domain schemas
- ✅ Less code to maintain
Fix #2: Create Missing SIM Configuration Schemas
Files to create/update:
packages/domain/sim/schema.ts- Add configuration schemasapps/portal/src/features/catalog/hooks/useSimConfigure.ts- Fix imports
Add to domain (packages/domain/sim/schema.ts):
// SIM Configuration Form Schema (for frontend)
export const simConfigureFormSchema = z.object({
simType: z.enum(["eSIM", "Physical SIM"]),
eid: z.string().min(15).optional(),
selectedAddons: z.array(z.string()),
activationType: z.enum(["Immediate", "Scheduled"]),
scheduledActivationDate: z.string().regex(/^\d{8}$/).optional(),
wantsMnp: z.boolean(),
mnpData: z.object({
reservationNumber: z.string(),
expiryDate: z.string().regex(/^\d{8}$/),
phoneNumber: z.string(),
mvnoAccountNumber: z.string().optional(),
portingLastName: z.string().optional(),
portingFirstName: z.string().optional(),
portingLastNameKatakana: z.string().optional(),
portingFirstNameKatakana: z.string().optional(),
portingGender: z.string().optional(),
portingDateOfBirth: z.string().regex(/^\d{8}$/).optional(),
}).optional(),
});
export type SimConfigureFormData = z.infer<typeof simConfigureFormSchema>;
export type ActivationType = "Immediate" | "Scheduled";
export type MnpData = z.infer<typeof simConfigureFormSchema>["mnpData"];
// Transform function to convert form data to API request
export function simConfigureFormToRequest(
formData: SimConfigureFormData
): SimOrderActivationRequest {
// Transform logic here
return {
// ...
};
}
Update portal hook (apps/portal/src/features/catalog/hooks/useSimConfigure.ts):
import {
simConfigureFormSchema,
simConfigureFormToRequest,
type SimConfigureFormData,
type ActivationType,
type MnpData,
} from "@customer-portal/domain/sim"; // ✅ Correct domain
import type { SimType } from "@customer-portal/domain/sim"; // ✅ Already exists
Implementation Plan
Phase 1: Fix Portal SIM Action Types (30 minutes)
Priority: Medium
Impact: Improves type safety, enables client validation
Tasks:
- Update
apps/portal/src/features/subscriptions/services/sim-actions.service.ts - Remove duplicate type definitions
- Import types from
@customer-portal/domain/sim - Update all usages in components
- Verify TypeScript compilation
Phase 2: Create SIM Configuration Schemas (1-2 hours)
Priority: High (fixes broken imports)
Impact: Enables SIM configuration flow
Tasks:
- Create
simConfigureFormSchemainpackages/domain/sim/schema.ts - Export types:
SimConfigureFormData,ActivationType,MnpData - Create
simConfigureFormToRequest()transform function - Update exports in
packages/domain/sim/index.ts - Fix imports in
apps/portal/src/features/catalog/hooks/useSimConfigure.ts - Test SIM configuration flow
Phase 3: Add Client-Side Validation (Optional, 1 hour)
Priority: Low
Impact: Better UX with early validation
Tasks:
- Add Zod validation in portal services before API calls
- Show validation errors to users before submitting
- Use same validation rules as backend
Example:
async topUp(subscriptionId: string, request: SimTopUpRequest) {
// Validate before API call
const validated = simTopUpRequestSchema.parse(request);
await apiClient.POST("/api/subscriptions/{subscriptionId}/sim/top-up", {
params: { path: { subscriptionId } },
body: validated,
});
}
Testing Strategy
After Fix #1 (Portal SIM Types)
- Import and use domain types in portal service
- Verify TypeScript compilation succeeds
- Test SIM top-up, plan change, and cancel flows
- Verify API receives correctly formatted data
After Fix #2 (SIM Configuration)
- Verify all imports resolve correctly
- Test SIM configuration form
- Verify form validation works
- Test API submission with form data
Benefits of Fixes
Current State Issues
- ❌ Duplicate type definitions
- ❌ No client-side validation
- ❌ Types could diverge from backend
- ❌ Broken imports in SIM configuration
- ❌ No validation for date formats, min/max values
After Fixes
- ✅ Single source of truth for all types
- ✅ Type safety across layers
- ✅ Option to add client-side validation
- ✅ All imports working correctly
- ✅ Validation rules enforced (formats, ranges, etc.)
- ✅ Less code to maintain
- ✅ Consistency guaranteed
Summary
Current Architecture
Domain Layer (packages/domain/sim)
├── Schemas with validation ✅
├── Types inferred from schemas ✅
└── Exported for reuse ✅
BFF Layer (apps/bff)
├── Imports domain schemas ✅
├── Uses ZodValidationPipe ✅
└── No duplication ✅
Portal Layer (apps/portal)
├── Sometimes imports domain types ⚠️
├── Sometimes defines own types ❌
└── Missing schemas (SIM config) ❌
Target Architecture
Domain Layer
└── Single source of truth for all schemas and types
BFF Layer
└── Uses domain schemas via ZodValidationPipe
Portal Layer
└── Uses domain types for all API calls
└── Optional: Add client validation using domain schemas
Related Documents
Next Steps: Review and approve fixes, then implement in priority order.