- Change default login URL from production to test Salesforce environment for safer development and testing.
11 KiB
BFF & Domain Structure Cleanup Design
Date: 2026-02-25 Status: Approved Approach: Incremental — 5 independent PRs, each shippable alone
Context
A thorough codebase audit of the BFF and domain packages identified structural improvements to make the codebase cleaner, more predictable, and more maintainable — aligned with enterprise standards (Stripe, Netflix, Airbnb patterns).
What's already strong: Controller patterns, logging (nestjs-pino), configuration (Zod-validated env), security (Helmet, CORS, rate limiting), error registry (82 domain error codes), domain schemas (schema-first Zod approach), queue patterns (BullMQ with idempotency).
What needs improvement: TypeScript strictness, database access abstraction, provider error handling, auth module organization, and domain schema validation gaps.
PR 1: Enable TypeScript Strict Mode
Problem
apps/bff/tsconfig.json has no strict, no noImplicitAny, no strictNullChecks. Type safety bugs pass silently through compilation.
Design
- Enable
strict: truein the basetsconfig.node.json(affects both BFF and domain) - Keep
strictPropertyInitialization: falseinapps/bff/tsconfig.jsononly (NestJS DI injects via decorators — properties are initialized but not in constructors) - Fix all compilation errors surfaced — each one is a real bug the compiler was hiding
What strict enables
noImplicitAny— no silentanyinferencestrictNullChecks—null/undefinedmust be handled explicitlystrictBindCallApply— correct types for.bind(),.call(),.apply()strictFunctionTypes— contravariant parameter checkingnoImplicitThis— explicitthistypingalwaysStrict— emit"use strict"in all files
Risk
Medium — will surface potentially many errors. Each fix prevents a real bug.
PR 2: Repository + Unit of Work Layer
Problem
Raw prisma.entity.findUnique() calls scattered throughout services. Tightly coupled to Prisma API, hard to test and mock, no transactional boundaries.
Design
apps/bff/src/infra/database/
├── prisma.service.ts # Existing — unchanged
├── unit-of-work.service.ts # NEW — transaction coordinator
├── base.repository.ts # NEW — generic typed CRUD base
└── repositories/
├── user.repository.ts # Per-entity repositories
├── sim.repository.ts
├── subscription.repository.ts
└── index.ts # Barrel exports
Base Repository
Generic class typed to the Prisma model delegate. Provides:
findById(id)— single record by IDfindOne(where)— single record by arbitrary criteriafindMany(where, options?)— filtered list with paginationcreate(data)— insert new recordupdate(id, data)— update by IDdelete(id)— soft or hard delete by IDcount(where?)— count matching records
Each concrete repository (e.g., UserRepository) extends BaseRepository and can add domain-specific queries.
Unit of Work
Wraps prisma.$transaction() with interactive transactions. Provides a transactional Prisma client to repositories, ensuring atomicity across multiple entity operations.
class UnitOfWork {
async execute<T>(fn: (tx: TransactionalClient) => Promise<T>): Promise<T> {
return this.prisma.$transaction(fn);
}
}
Repositories accept an optional tx parameter to participate in a transaction.
Migration Strategy
Services switch from:
this.prisma.user.findUnique({ where: { id } });
to:
this.userRepository.findById(id);
Change is mechanical and can be done file-by-file.
Risk
Low — additive change, then mechanical migration.
PR 3: Provider Error Codes + Typed Error Classes
Problem
Provider error detection uses brittle string matching. Example from order-fulfillment-error.service.ts: checks if error message includes "Payment method missing". Breaks when provider messages change.
Design — Two Layers
Layer 1: Provider Error Classes (Infrastructure)
apps/bff/src/integrations/common/errors/
├── base-provider.error.ts # BaseProviderError (extends Error)
├── whmcs.errors.ts # WhmcsApiError, WhmcsAuthError, WhmcsNotFoundError
├── salesforce.errors.ts # SalesforceApiError, SalesforceQueryError
├── freebit.errors.ts # FreebitApiError, FreebitTimeoutError
└── index.ts
BaseProviderError structure:
abstract class BaseProviderError extends Error {
abstract readonly provider: "whmcs" | "salesforce" | "freebit";
abstract readonly providerCode: string;
abstract readonly domainErrorCode: ErrorCode;
readonly providerMessage: string;
readonly httpStatus: number;
}
Each concrete error class (e.g., WhmcsPaymentMethodMissingError) carries structured metadata and maps to a domain ErrorCode.
Layer 2: Provider Error Code Enums (Domain)
packages/domain/common/
├── errors.ts # Existing — add provider error mapping
├── provider-errors.ts # NEW — provider-specific error codes
Provider error code enums define the known error conditions per provider:
export const WHMCS_ERROR = {
PAYMENT_METHOD_MISSING: "WHMCS_PAYMENT_METHOD_MISSING",
CLIENT_NOT_FOUND: "WHMCS_CLIENT_NOT_FOUND",
INVOICE_NOT_FOUND: "WHMCS_INVOICE_NOT_FOUND",
// ...
} as const;
Error Flow
- Integration service catches raw provider error
- Translates to typed error class:
throw new WhmcsPaymentMethodMissingError(rawMessage) - UnifiedExceptionFilter catches
BaseProviderErrorsubclass - Maps
domainErrorCode→ API error response using existing error registry
Migration
Replace all instanceof Error + string checks with instanceof WhmcsNotFoundError etc.
Risk
Low — new code first, then migrate string checks one at a time.
PR 4: Auth Feature-Based Decomposition
Problem
Auth module has 48 providers in one module. Handles login, registration, password reset, OTP, sessions, and get-started flows in a single flat structure. Hard to scan, reason about, and modify independently.
Design — 6 Feature Modules
apps/bff/src/modules/auth/
├── auth.module.ts # Orchestrator — imports feature modules
├── auth.controller.ts # Thin top-level router (if needed)
├── auth-orchestrator.service.ts # Cross-feature coordination
│
├── login/
│ ├── login.module.ts
│ ├── login.controller.ts # /auth/login routes
│ └── services/
│ ├── login.service.ts
│ └── login-otp-workflow.service.ts
│
├── get-started/
│ ├── get-started.module.ts
│ ├── get-started.controller.ts # /auth/get-started routes
│ └── services/
│ ├── get-started-coordinator.service.ts
│ ├── get-started-session.service.ts
│ └── get-started-otp.service.ts
│
├── tokens/
│ ├── tokens.module.ts
│ └── services/
│ ├── auth-token.service.ts
│ ├── token-blacklist.service.ts
│ └── token-refresh.service.ts
│
├── otp/
│ ├── otp.module.ts
│ └── services/
│ ├── otp-generation.service.ts
│ └── otp-verification.service.ts
│
├── sessions/
│ ├── sessions.module.ts
│ └── services/
│ ├── session-manager.service.ts
│ └── trusted-device.service.ts
│
├── password-reset/
│ ├── password-reset.module.ts
│ ├── password-reset.controller.ts # /auth/password-reset routes
│ └── services/
│
└── shared/
├── guards/ # GlobalAuthGuard, PermissionsGuard
├── decorators/ # @Public, @OptionalAuth
└── services/ # Shared step services
Key Principles
- Each feature module owns its full vertical slice (controller, services)
tokens/andotp/are shared infrastructure modules imported by login and get-startedsessions/manages session state and trusted devices- Top-level
auth.module.tsbecomes a thin orchestrator that imports feature modules and re-exports shared guards - NestJS
RouterModulemounts feature controllers under/auth/*prefixes - Shared guards and decorators remain in
shared/and are exported globally
Migration Strategy
- Create the new module/directory structure
- Move existing services into their feature modules
- Update module imports/exports
- Verify all routes still work via integration tests
- Remove the old flat structure
Risk
Medium — restructure touches many files and NestJS DI wiring. Must verify all auth flows work after migration.
PR 5: Tighten Domain Schema Validation
Problem
support/schema.ts uses z.string() for status, priority, and category fields instead of the defined enum schemas. The mapper already transforms Japanese Salesforce values to English display labels before parsing, so the strict enums will work correctly.
Design
Tighten the schema:
// Before (loose):
status: z.string(),
priority: z.string(),
category: z.string().nullable(),
// After (strict):
status: supportCaseStatusSchema,
priority: supportCasePrioritySchema,
category: supportCaseCategorySchema.nullable(),
Also add JSDoc comments to intentional escape hatches:
customer/contract.tsinterfaces with[key: string]: unknowncommon/errors.tswithz.record(z.string(), z.unknown())
Behavior Change
If an unmapped Salesforce status arrives, supportCaseSchema.parse() will throw a Zod validation error instead of silently passing through a raw Japanese string. This is the correct behavior — unknown statuses should fail loudly so the mapping can be updated.
Risk
Low — small targeted change. The getStatusDisplayLabel() fallback (?? status) currently passes through unknown values silently, which masks configuration issues.
Execution Order
PRs are independent but this order minimizes friction:
- PR 1 (TypeScript strict) — surfaces hidden issues that may affect other PRs
- PR 5 (Schema tightening) — smallest, quick win
- PR 3 (Provider error codes) — new infrastructure, no migration yet
- PR 2 (Repository + UoW) — new infrastructure, then mechanical migration
- PR 4 (Auth decomposition) — largest change, do last when other patterns are stable
Out of Scope (Future Work)
These items were identified in the audit but deferred:
- Testing infrastructure — zero test files exist; separate initiative needed
- Circuit breaker pattern — for external service resilience
- Request correlation across async operations — AsyncLocalStorage propagation
- Database health checks — add to /health endpoint
- DLQ visibility — expose failed queue jobs
- API versioning strategy — document approach for breaking changes
- Prometheus metrics export — replace in-memory queue metrics