Assist_Design/VALIDATION_DUPLICATION_REPORT.md

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:

  1. Portal types lack validation (no min/max for quotaMb, no format validation for scheduledAt)
  2. Portal types use different names (TopUpRequest vs SimTopUpRequest)
  3. Field types differ (assignGlobalIp is required in portal but optional in domain)
  4. No single source of truth - types could diverge over time
  5. 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:

  1. Imports from wrong domain (billing instead of sim/orders)
  2. Schemas don't exist yet in any domain
  3. 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:

  1. No client-side validation before API calls
  2. Types could diverge from backend expectations
  3. Harder to maintain consistency
  4. Duplicates type definitions
  5. 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";

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:

  1. packages/domain/sim/schema.ts - Add configuration schemas
  2. apps/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 simConfigureFormSchema in packages/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)

  1. Import and use domain types in portal service
  2. Verify TypeScript compilation succeeds
  3. Test SIM top-up, plan change, and cancel flows
  4. Verify API receives correctly formatted data

After Fix #2 (SIM Configuration)

  1. Verify all imports resolve correctly
  2. Test SIM configuration form
  3. Verify form validation works
  4. 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


Next Steps: Review and approve fixes, then implement in priority order.