# BFF Architecture Review - Logic Overlaps & Patterns **Date**: October 8, 2025 **Scope**: Backend For Frontend (BFF) service architecture and validation patterns **Status**: 🟡 Generally Good - Minor Improvements Recommended --- ## Executive Summary The BFF layer demonstrates **good separation of concerns** with proper use of validation pipes and domain schemas. However, there are a few areas where business logic could be better organized and some potential duplication in validation concerns. ### Key Findings ✅ **Strengths**: - Controllers use `ZodValidationPipe` consistently - Domain schemas are imported and reused (no schema duplication in BFF) - Clear service layer separation (orchestrator, validator, builder patterns) - Infrastructure validation properly isolated in BFF services ⚠️ **Concerns**: - Payment validation duplicated across services (2 locations) - SIM business logic mixed with infrastructure concerns - Some business rules could be moved to domain layer --- ## 🔍 Validation Pattern Analysis ### ✅ Correct Patterns Observed #### 1. Controller Input Validation All controllers properly use `ZodValidationPipe` with domain schemas: ```typescript:20:22:apps/bff/src/modules/orders/orders.controller.ts @Post() @UsePipes(new ZodValidationPipe(createOrderRequestSchema)) async create(@Request() req: RequestWithUser, @Body() body: CreateOrderRequest) { ``` ```typescript:125:127:apps/bff/src/modules/auth/presentation/http/auth.controller.ts @UsePipes(new ZodValidationPipe(validateSignupRequestSchema)) async validateSignup(@Body() validateData: ValidateSignupRequest, @Req() req: Request) { ``` ```typescript:151:152:apps/bff/src/modules/subscriptions/subscriptions.controller.ts @UsePipes(new ZodValidationPipe(simTopupRequestSchema)) async topUpSim( ``` **Assessment**: ✅ Excellent pattern, consistent across all controllers --- #### 2. Domain Schema Imports BFF properly imports schemas from domain package: ```typescript:6:11:apps/bff/src/modules/orders/orders.controller.ts import { createOrderRequestSchema, sfOrderIdParamSchema, type CreateOrderRequest, type SfOrderIdParam, } from "@customer-portal/domain/orders"; ``` ```typescript:14:16:apps/bff/src/modules/users/users.controller.ts import { updateCustomerProfileRequestSchema, type UpdateCustomerProfileRequest, } from "@customer-portal/domain/auth"; ``` **Assessment**: ✅ No schema duplication in BFF layer --- #### 3. Infrastructure Validation in Services BFF services handle infrastructure-dependent validation (DB, API calls): ```typescript:84:104:apps/bff/src/modules/orders/services/order-validator.service.ts async validateUserMapping( userId: string ): Promise<{ userId: string; sfAccountId?: string; whmcsClientId: number }> { const mapping = await this.mappings.findByUserId(userId); if (!mapping) { this.logger.warn({ userId }, "User mapping not found"); throw new BadRequestException("User account mapping is required before ordering"); } if (!mapping.whmcsClientId) { this.logger.warn({ userId, mapping }, "WHMCS client ID missing from mapping"); throw new BadRequestException("WHMCS integration is required before ordering"); } return { userId: mapping.userId, sfAccountId: mapping.sfAccountId || undefined, whmcsClientId: mapping.whmcsClientId, }; } ``` **Assessment**: ✅ Correct - infrastructure checks belong in BFF --- ## 🟡 Areas for Improvement ### 1. **Payment Method Validation Duplication** (Priority: MEDIUM) **Problem**: Payment validation logic exists in two places: | Service | File | Lines | |---------|------|-------| | `OrderValidator` | `apps/bff/src/modules/orders/services/order-validator.service.ts` | 109-122 | | `OrderFulfillmentValidator` | `apps/bff/src/modules/orders/services/order-fulfillment-validator.service.ts` | ~130-150 (private method) | **Current Implementation (OrderValidator)**: ```typescript:109:122:apps/bff/src/modules/orders/services/order-validator.service.ts async validatePaymentMethod(userId: string, whmcsClientId: number): Promise { try { const pay = await this.whmcs.getPaymentMethods({ clientid: whmcsClientId }); const paymentMethods = Array.isArray(pay.paymethods) ? pay.paymethods : []; if (paymentMethods.length === 0) { this.logger.warn({ userId }, "No WHMCS payment method on file"); throw new BadRequestException("A payment method is required before ordering"); } } catch (e: unknown) { const err = getErrorMessage(e); this.logger.error({ err }, "Payment method verification failed"); throw new BadRequestException("Unable to verify payment method. Please try again later."); } } ``` **Impact**: - Logic duplicated in two validators - Changes must be made in two places - Potential for inconsistent error messages **Recommendation**: Create a shared payment validation service: ```typescript // apps/bff/src/modules/orders/services/payment-validator.service.ts @Injectable() export class PaymentValidatorService { constructor( private readonly whmcs: WhmcsConnectionOrchestratorService, @Inject(Logger) private readonly logger: Logger ) {} async validatePaymentMethodExists(userId: string, whmcsClientId: number): Promise { try { const pay = await this.whmcs.getPaymentMethods({ clientid: whmcsClientId }); const paymentMethods = Array.isArray(pay.paymethods) ? pay.paymethods : []; if (paymentMethods.length === 0) { this.logger.warn({ userId, whmcsClientId }, "No payment method on file"); throw new BadRequestException("A payment method is required before ordering"); } } catch (e: unknown) { if (e instanceof BadRequestException) throw e; const err = getErrorMessage(e); this.logger.error({ err, userId }, "Payment method verification failed"); throw new BadRequestException("Unable to verify payment method. Please try again later."); } } } // Then both validators inject and use it constructor(private readonly paymentValidator: PaymentValidatorService) {} async validatePaymentMethod(userId: string, whmcsClientId: number): Promise { return this.paymentValidator.validatePaymentMethodExists(userId, whmcsClientId); } ``` --- ### 2. **SIM Business Logic in BFF Service** (Priority: MEDIUM) **Problem**: `SimValidationService` contains business rules that should be in domain layer. **File**: `apps/bff/src/modules/subscriptions/sim-management/services/sim-validation.service.ts` **Current Issues**: #### Issue 2a: Hardcoded Business Rules ```typescript:29:35:apps/bff/src/modules/subscriptions/sim-management/services/sim-validation.service.ts // Check if this is a SIM service const isSimService = subscription.productName.toLowerCase().includes("sim") || subscription.groupName?.toLowerCase().includes("sim"); if (!isSimService) { throw new BadRequestException("This subscription is not a SIM service"); } ``` **Issue**: Business rule (what makes a subscription a "SIM service") is in BFF layer, not domain. **Should be**: ```typescript // packages/domain/subscriptions/validation.ts export function isSimSubscription(subscription: Subscription): boolean { return ( subscription.productName.toLowerCase().includes("sim") || subscription.groupName?.toLowerCase().includes("sim") ); } ``` --- #### Issue 2b: Magic Numbers/Test Data ```typescript:58:74:apps/bff/src/modules/subscriptions/sim-management/services/sim-validation.service.ts // 4. Final fallback - for testing, use the known test SIM number if (!account) { // Use the specific test SIM number that should exist in the test environment account = "02000331144508"; this.logger.warn( `No SIM account identifier found for subscription ${subscriptionId}, using known test SIM number: ${account}`, { userId, subscriptionId, productName: subscription.productName, domain: subscription.domain, customFields: subscription.customFields ? Object.keys(subscription.customFields) : [], note: "Using known test SIM number 02000331144508 - should exist in Freebit test environment", } ); } ``` **Issue**: Hardcoded test data in service layer, should be in config. **Should be**: ```typescript // config/env.validation.ts or constants file export const TEST_SIM_ACCOUNT = process.env.TEST_SIM_ACCOUNT || "02000331144508"; // In service if (!account && this.configService.get('NODE_ENV') === 'test') { account = TEST_SIM_ACCOUNT; this.logger.warn(`Using test SIM account: ${account}`); } ``` --- #### Issue 2c: Custom Field Mapping Logic ```typescript:110:195:apps/bff/src/modules/subscriptions/sim-management/services/sim-validation.service.ts private extractAccountFromCustomFields( customFields: Record, subscriptionId: number ): string { // Common field names for SIM phone numbers in WHMCS const phoneFields = [ "phone", "msisdn", "phonenumber", "phone_number", "mobile", "sim_phone", "Phone Number", "MSISDN", // ... 30+ more variations ]; for (const fieldName of phoneFields) { const rawValue = customFields[fieldName]; if (rawValue !== undefined && rawValue !== null && rawValue !== "") { const accountValue = this.formatCustomFieldValue(rawValue); this.logger.log(`Found SIM account in custom field '${fieldName}': ${accountValue}`); return accountValue; } } return ""; } ``` **Issue**: WHMCS field mapping is provider-specific knowledge, should be in provider layer. **Should be**: ```typescript // packages/domain/subscriptions/providers/whmcs/sim-field-mapper.ts export const WHMCS_SIM_ACCOUNT_FIELDS = [ "phone", "msisdn", "phonenumber", "phone_number", "mobile", "sim_phone", // ... etc ] as const; export function extractSimAccountFromWhmcsFields( customFields: Record ): string | null { for (const fieldName of WHMCS_SIM_ACCOUNT_FIELDS) { const value = customFields[fieldName]; if (value !== undefined && value !== null && value !== "") { return String(value); } } return null; } ``` --- ### 3. **Order Business Rules in BFF Service** (Priority: LOW) **Current Implementation**: ```typescript:180:201:apps/bff/src/modules/orders/services/order-validator.service.ts validateBusinessRules(orderType: string, skus: string[]): void { const error = getOrderTypeValidationError(orderType, skus); if (error) { this.logger.warn({ orderType, skuCount: skus.length, error }, "Order validation failed"); throw new BadRequestException(error); } // Log successful validation this.logger.log( { orderType, skuCount: skus.length }, `Order business rules validated successfully for ${orderType} order` ); } ``` **Assessment**: This is actually **OK** because it delegates to domain function `getOrderTypeValidationError()`. The function correctly imports from domain: ```typescript import { getOrderTypeValidationError } from "@customer-portal/domain/orders/validation"; ``` **Recommendation**: ✅ Keep as-is, this is the correct pattern. --- ## 🟢 Good Architectural Patterns ### 1. **Service Layer Separation** The orders module demonstrates excellent separation: ``` orders/ ├── orders.controller.ts → HTTP layer (validation pipes only) ├── services/ │ ├── order-orchestrator.service.ts → Workflow coordination │ ├── order-validator.service.ts → Validation aggregation │ ├── order-builder.service.ts → Data transformation │ ├── order-fulfillment-orchestrator.service.ts → Fulfillment workflow │ └── order-fulfillment-validator.service.ts → Fulfillment validation ``` **Benefits**: - Single Responsibility Principle - Easy to test each layer independently - Clear separation of concerns --- ### 2. **Orchestrator Pattern** ```typescript:33:80:apps/bff/src/modules/orders/services/order-orchestrator.service.ts async createOrder(userId: string, rawBody: unknown) { this.logger.log({ userId }, "Order creation workflow started"); // 1) Complete validation (format + business rules) const { validatedBody, userMapping, pricebookId } = await this.orderValidator.validateCompleteOrder(userId, rawBody); this.logger.log( { userId, orderType: validatedBody.orderType, skuCount: validatedBody.skus.length, }, "Order validation completed successfully" ); // 2) Build order fields (includes address snapshot) const orderFields = await this.orderBuilder.buildOrderFields( validatedBody, userMapping, pricebookId, validatedBody.userId ); // 3) Create Order in Salesforce via integration service const created = await this.salesforceOrderService.createOrder(orderFields); // 4) Create OrderItems from SKUs await this.orderItemBuilder.createOrderItemsFromSKUs( created.id, validatedBody.skus, pricebookId ); this.logger.log( { orderId: created.id, skuCount: validatedBody.skus.length, }, "Order creation workflow completed successfully" ); return { sfOrderId: created.id, status: "Created", message: "Order created successfully in Salesforce", }; } ``` **Benefits**: - Clear workflow steps - Easy to understand business process - Each step can be modified independently - Excellent logging for debugging --- ### 3. **Distributed Transaction Pattern** ```typescript:83:142:apps/bff/src/modules/orders/services/order-fulfillment-orchestrator.service.ts private async executeFulfillmentWithTransactions( sfOrderId: string, payload: Record, idempotencyKey: string ): Promise { const context: OrderFulfillmentContext = { sfOrderId, idempotencyKey, validation: null, steps: this.initializeSteps( typeof payload.orderType === "string" ? payload.orderType : "Unknown" ), }; this.logger.log("Starting transactional fulfillment orchestration", { sfOrderId, idempotencyKey, }); // Step 1: Validation (no rollback needed) try { context.validation = await this.orderFulfillmentValidator.validateFulfillmentRequest( sfOrderId, idempotencyKey ); if (context.validation.isAlreadyProvisioned) { this.logger.log("Order already provisioned, skipping fulfillment", { sfOrderId }); return context; } } catch (error) { this.logger.error("Fulfillment validation failed", { sfOrderId, error: getErrorMessage(error), }); throw error; } // Step 2: Get order details (no rollback needed) try { const orderDetails = await this.orderOrchestrator.getOrder(sfOrderId); if (!orderDetails) { throw new Error("Order details could not be retrieved."); } context.orderDetails = orderDetails; } catch (error) { this.logger.error("Failed to get order details", { sfOrderId, error: getErrorMessage(error), }); throw error; } // Step 3: Execute the main fulfillment workflow as a distributed transaction let mappingResult: OrderItemMappingResult | undefined; let whmcsCreateResult: { orderId: number } | undefined; let whmcsAcceptResult: WhmcsOrderResult | undefined; const fulfillmentResult = await this.distributedTransactionService.executeDistributedTransaction( // ... transaction steps ); ``` **Benefits**: - Idempotency built-in - Clear rollback strategy - Transactional integrity across services - Proper error handling and logging --- ## 📋 Summary of Recommendations ### Immediate Actions 1. **Extract Payment Validation to Shared Service** - Create `PaymentValidatorService` - Both `OrderValidator` and `OrderFulfillmentValidator` use it - Eliminates duplication, ensures consistency ### Medium Priority 2. **Move SIM Business Logic to Domain** - Extract `isSimSubscription()` to `packages/domain/subscriptions/validation.ts` - Move WHMCS field mappings to `packages/domain/subscriptions/providers/whmcs/` - Move test constants to config 3. **Extract SIM Account Extraction Logic** - Create domain function for SIM account extraction - Keep infrastructure concerns (DB, logging) in BFF - Move business rules (what fields to check, validation logic) to domain ### Low Priority (Optional) 4. **Consider Creating Base Validator Service** ```typescript // apps/bff/src/core/validation/base-validator.service.ts export abstract class BaseValidatorService { constructor( protected readonly logger: Logger, protected readonly mappingsService: MappingsService ) {} protected async validateUserMapping(userId: string) { // Common validation logic used by multiple validators } } ``` --- ## 🎯 BFF Validation Checklist Use this checklist when adding new validation: - [ ] **Format validation**: Use `ZodValidationPipe` in controller - [ ] **Schema source**: Import from `@customer-portal/domain` - [ ] **Business rules**: Check if rule belongs in domain layer - [ ] **Infrastructure checks**: Keep in BFF service (DB, API calls) - [ ] **Shared logic**: Consider extracting to shared service if used > 1 place - [ ] **Error messages**: Use consistent, user-friendly messages - [ ] **Logging**: Log validation failures with context - [ ] **Type safety**: Use inferred types from domain schemas --- ## 📊 Validation Logic Distribution Current distribution of validation logic: | Layer | Responsibility | Examples | Status | |-------|---------------|----------|--------| | **Controller** | Input format validation | `@UsePipes(ZodValidationPipe)` | ✅ Good | | **Domain Schemas** | Data structure & format rules | `createOrderRequestSchema` | ✅ Good | | **Domain Validation** | Business rules (pure logic) | `hasSimServicePlan()` | ✅ Good | | **BFF Services** | Infrastructure validation | `validateUserMapping()`, `validatePaymentMethod()` | 🟡 Minor duplication | | **BFF Services** | Business logic | `isSimService` check in `SimValidationService` | ⚠️ Should move to domain | --- ## 🔍 Validation Flow Example (Orders) ``` 1. HTTP Request ↓ 2. OrdersController ├─ @UsePipes(ZodValidationPipe(createOrderRequestSchema)) ← Format validation └─ Passes validated body to OrderOrchestrator ↓ 3. OrderOrchestrator.createOrder() ├─ Calls OrderValidator.validateCompleteOrder() │ ├─ Format validation (schema) ← Zod schema │ ├─ Business validation (schema) ← Domain validation │ ├─ User mapping check (infrastructure) ← BFF validation │ ├─ Payment method check (infrastructure) ← BFF validation │ ├─ SKU validation (infrastructure) ← BFF validation │ └─ Business rules (delegates to domain) ← Domain validation └─ Continue with order creation ``` **Assessment**: ✅ Clean separation, proper delegation --- ## 📝 Notes ### Comparison with Domain Layer - **Domain Layer**: Pure business logic, no dependencies on infrastructure - **BFF Layer**: Infrastructure-dependent validation (DB, external APIs) - **Clear Boundary**: BFF delegates pure business rules to domain functions ### Previous Validation Cleanup The codebase has already: - Eliminated password validation duplication - Moved SKU validation to domain layer - Consolidated order business rules This review builds on that work to identify remaining improvements. --- ## 🔗 Related Documents - [VALIDATION_AUDIT_REPORT.md](./VALIDATION_AUDIT_REPORT.md) - Domain validation overlaps - [docs/validation/VALIDATION_PATTERNS.md](docs/validation/VALIDATION_PATTERNS.md) - Validation patterns guide - [docs/validation/VALIDATION_CLEANUP_SUMMARY.md](docs/validation/VALIDATION_CLEANUP_SUMMARY.md) - Previous cleanup work