# BFF & Domain Structure Cleanup — Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Improve BFF and domain package structure for cleanliness, predictability, and maintainability through 4 incremental PRs. **Architecture:** Incremental approach — each PR is independently shippable. Order: (1) Schema tightening, (2) Provider error codes + classes, (3) Repository + UoW layer, (4) Auth feature decomposition. **Tech Stack:** NestJS 11, Prisma 7, Zod, TypeScript (strict mode already enabled) **Key insight from audit:** `tsconfig.base.json` already has `strict: true` + `exactOptionalPropertyTypes` + `noUncheckedIndexedAccess`. No TS strictness changes needed. --- ## PR 1: Tighten Domain Schema Validation **Branch:** `refactor/tighten-support-schema` **Context:** `packages/domain/support/schema.ts` defines strict enum schemas (lines 36-38) but doesn't use them in the main `supportCaseSchema` (lines 48-50). Instead it uses `z.string()`. The Salesforce mapper at `packages/domain/support/providers/salesforce/mapper.ts:200-205` already transforms Japanese values to English display labels BEFORE calling `supportCaseSchema.parse()`, so strict enums will work. ### Task 1: Tighten supportCaseSchema **Files:** - Modify: `packages/domain/support/schema.ts:44-55` **Step 1: Update the schema to use defined enum schemas** Replace lines 48-50: ```typescript // Before: status: z.string(), // Allow any status from Salesforce priority: z.string(), // Allow any priority from Salesforce category: z.string().nullable(), // Maps to Salesforce Type field // After: status: supportCaseStatusSchema, priority: supportCasePrioritySchema, category: supportCaseCategorySchema.nullable(), ``` **Step 2: Update the filter schema to use enums too** Replace lines 69-76: ```typescript // Before: export const supportCaseFilterSchema = z .object({ status: z.string().optional(), priority: z.string().optional(), category: z.string().optional(), search: z.string().trim().min(1).optional(), }) .default({}); // After: export const supportCaseFilterSchema = z .object({ status: supportCaseStatusSchema.optional(), priority: supportCasePrioritySchema.optional(), category: supportCaseCategorySchema.optional(), search: z.string().trim().min(1).optional(), }) .default({}); ``` **Step 3: Build domain package and type-check** Run: `pnpm domain:build && pnpm type-check` Expected: PASS — the mapper already outputs values matching these enums. **Step 4: Commit** ```bash git add packages/domain/support/schema.ts git commit -m "refactor: tighten support schema to use defined enum validators" ``` ### Task 2: Add JSDoc to intentional escape hatches **Files:** - Modify: `packages/domain/customer/contract.ts` — find interfaces with `[key: string]: unknown` **Step 1: Read the customer contract file to find exact locations** Read: `packages/domain/customer/contract.ts` **Step 2: Add JSDoc comments explaining the escape hatches** For each interface with `[key: string]: unknown`, add: ```typescript /** * Raw Salesforce record — intentionally permissive. * The Salesforce API returns org-specific fields that vary by configuration. * Domain mappers validate specific fields; unknown fields are ignored. */ ``` **Step 3: Build and type-check** Run: `pnpm domain:build && pnpm type-check` Expected: PASS **Step 4: Commit** ```bash git add packages/domain/customer/contract.ts git commit -m "docs: add JSDoc to intentional escape hatches in raw type interfaces" ``` ### Task 3: Create PR Run: `gh pr create --title "refactor: tighten support schema enum validation" --body "..."` --- ## PR 2: Provider Error Codes + Typed Error Classes **Branch:** `refactor/provider-error-classes` **Context:** Error detection in `apps/bff/src/integrations/whmcs/connection/services/whmcs-error-handler.service.ts` and `apps/bff/src/integrations/salesforce/services/salesforce-error-handler.service.ts` uses brittle `message.includes()` string matching. The existing `DomainHttpException` at `apps/bff/src/core/http/domain-http.exception.ts` already carries structured error codes. We need provider-specific error classes that extend this pattern. ### Task 1: Create provider error code enums in domain **Files:** - Create: `packages/domain/common/provider-errors.ts` - Modify: `packages/domain/common/index.ts` — add export **Step 1: Create the provider error code enums** Create `packages/domain/common/provider-errors.ts`: ```typescript /** * Provider-specific error codes for structured error detection. * * These codes identify known error conditions from external providers. * Used by provider error classes in the BFF to replace brittle string matching. */ export const WhmcsProviderError = { // Authentication AUTH_FAILED: "WHMCS_AUTH_FAILED", INVALID_CREDENTIALS: "WHMCS_INVALID_CREDENTIALS", // Not found CLIENT_NOT_FOUND: "WHMCS_CLIENT_NOT_FOUND", INVOICE_NOT_FOUND: "WHMCS_INVOICE_NOT_FOUND", PRODUCT_NOT_FOUND: "WHMCS_PRODUCT_NOT_FOUND", // Validation VALIDATION_ERROR: "WHMCS_VALIDATION_ERROR", // Network/Infrastructure TIMEOUT: "WHMCS_TIMEOUT", NETWORK_ERROR: "WHMCS_NETWORK_ERROR", RATE_LIMITED: "WHMCS_RATE_LIMITED", HTTP_ERROR: "WHMCS_HTTP_ERROR", // Generic API_ERROR: "WHMCS_API_ERROR", } as const; export type WhmcsProviderErrorCode = (typeof WhmcsProviderError)[keyof typeof WhmcsProviderError]; export const SalesforceProviderError = { // Authentication SESSION_EXPIRED: "SF_SESSION_EXPIRED", AUTH_FAILED: "SF_AUTH_FAILED", // Query QUERY_ERROR: "SF_QUERY_ERROR", RECORD_NOT_FOUND: "SF_RECORD_NOT_FOUND", // Network/Infrastructure TIMEOUT: "SF_TIMEOUT", NETWORK_ERROR: "SF_NETWORK_ERROR", RATE_LIMITED: "SF_RATE_LIMITED", // Generic API_ERROR: "SF_API_ERROR", } as const; export type SalesforceProviderErrorCode = (typeof SalesforceProviderError)[keyof typeof SalesforceProviderError]; export const FreebitProviderError = { ACCOUNT_NOT_FOUND: "FREEBIT_ACCOUNT_NOT_FOUND", TIMEOUT: "FREEBIT_TIMEOUT", API_ERROR: "FREEBIT_API_ERROR", } as const; export type FreebitProviderErrorCode = (typeof FreebitProviderError)[keyof typeof FreebitProviderError]; ``` **Step 2: Export from domain/common/index.ts** Add to barrel file: ```typescript export { WhmcsProviderError, SalesforceProviderError, FreebitProviderError, type WhmcsProviderErrorCode, type SalesforceProviderErrorCode, type FreebitProviderErrorCode, } from "./provider-errors.js"; ``` **Step 3: Build domain** Run: `pnpm domain:build` Expected: PASS **Step 4: Commit** ```bash git add packages/domain/common/provider-errors.ts packages/domain/common/index.ts git commit -m "feat: add provider error code enums to domain package" ``` ### Task 2: Create BaseProviderError and provider error classes **Files:** - Create: `apps/bff/src/integrations/common/errors/base-provider.error.ts` - Create: `apps/bff/src/integrations/common/errors/whmcs.errors.ts` - Create: `apps/bff/src/integrations/common/errors/salesforce.errors.ts` - Create: `apps/bff/src/integrations/common/errors/freebit.errors.ts` - Create: `apps/bff/src/integrations/common/errors/index.ts` **Step 1: Create base provider error** Create `apps/bff/src/integrations/common/errors/base-provider.error.ts`: ```typescript import type { HttpStatus } from "@nestjs/common"; import type { ErrorCodeType } from "@customer-portal/domain/common"; /** * Base class for all provider-specific errors. * * Carries structured metadata for error classification without string matching. * The UnifiedExceptionFilter detects subclasses and maps them to API responses. */ export abstract class BaseProviderError extends Error { abstract readonly provider: "whmcs" | "salesforce" | "freebit"; abstract readonly providerCode: string; abstract readonly domainErrorCode: ErrorCodeType; abstract readonly httpStatus: HttpStatus; constructor( message: string, public readonly providerMessage: string, public readonly cause?: unknown ) { super(message); this.name = this.constructor.name; } } ``` **Step 2: Create WHMCS error classes** Create `apps/bff/src/integrations/common/errors/whmcs.errors.ts`: ```typescript import { HttpStatus } from "@nestjs/common"; import { ErrorCode } from "@customer-portal/domain/common"; import { WhmcsProviderError } from "@customer-portal/domain/common"; import { BaseProviderError } from "./base-provider.error.js"; export class WhmcsApiError extends BaseProviderError { readonly provider = "whmcs" as const; readonly providerCode = WhmcsProviderError.API_ERROR; readonly domainErrorCode = ErrorCode.EXTERNAL_SERVICE_ERROR; readonly httpStatus = HttpStatus.BAD_GATEWAY; constructor(message: string, providerMessage: string, cause?: unknown) { super(message, providerMessage, cause); } } export class WhmcsNotFoundError extends BaseProviderError { readonly provider = "whmcs" as const; readonly providerCode: string; readonly domainErrorCode = ErrorCode.NOT_FOUND; readonly httpStatus = HttpStatus.NOT_FOUND; constructor( resource: "client" | "invoice" | "product", providerMessage: string, cause?: unknown ) { super(`WHMCS ${resource} not found`, providerMessage, cause); const codeMap = { client: WhmcsProviderError.CLIENT_NOT_FOUND, invoice: WhmcsProviderError.INVOICE_NOT_FOUND, product: WhmcsProviderError.PRODUCT_NOT_FOUND, } as const; this.providerCode = codeMap[resource]; } } export class WhmcsAuthError extends BaseProviderError { readonly provider = "whmcs" as const; readonly providerCode = WhmcsProviderError.AUTH_FAILED; readonly domainErrorCode = ErrorCode.EXTERNAL_SERVICE_ERROR; readonly httpStatus = HttpStatus.SERVICE_UNAVAILABLE; constructor(providerMessage: string, cause?: unknown) { super("WHMCS authentication failed", providerMessage, cause); } } export class WhmcsInvalidCredentialsError extends BaseProviderError { readonly provider = "whmcs" as const; readonly providerCode = WhmcsProviderError.INVALID_CREDENTIALS; readonly domainErrorCode = ErrorCode.INVALID_CREDENTIALS; readonly httpStatus = HttpStatus.UNAUTHORIZED; constructor(providerMessage: string, cause?: unknown) { super("Invalid login credentials", providerMessage, cause); } } export class WhmcsValidationError extends BaseProviderError { readonly provider = "whmcs" as const; readonly providerCode = WhmcsProviderError.VALIDATION_ERROR; readonly domainErrorCode = ErrorCode.VALIDATION_FAILED; readonly httpStatus = HttpStatus.BAD_REQUEST; constructor(providerMessage: string, cause?: unknown) { super("WHMCS validation failed", providerMessage, cause); } } export class WhmcsTimeoutError extends BaseProviderError { readonly provider = "whmcs" as const; readonly providerCode = WhmcsProviderError.TIMEOUT; readonly domainErrorCode = ErrorCode.TIMEOUT; readonly httpStatus = HttpStatus.GATEWAY_TIMEOUT; constructor(providerMessage: string, cause?: unknown) { super("WHMCS request timed out", providerMessage, cause); } } export class WhmcsNetworkError extends BaseProviderError { readonly provider = "whmcs" as const; readonly providerCode = WhmcsProviderError.NETWORK_ERROR; readonly domainErrorCode = ErrorCode.NETWORK_ERROR; readonly httpStatus = HttpStatus.BAD_GATEWAY; constructor(providerMessage: string, cause?: unknown) { super("WHMCS network error", providerMessage, cause); } } export class WhmcsRateLimitError extends BaseProviderError { readonly provider = "whmcs" as const; readonly providerCode = WhmcsProviderError.RATE_LIMITED; readonly domainErrorCode = ErrorCode.RATE_LIMITED; readonly httpStatus = HttpStatus.TOO_MANY_REQUESTS; constructor(providerMessage: string, cause?: unknown) { super("WHMCS rate limit exceeded", providerMessage, cause); } } export class WhmcsHttpError extends BaseProviderError { readonly provider = "whmcs" as const; readonly providerCode = WhmcsProviderError.HTTP_ERROR; readonly domainErrorCode: string; readonly httpStatus: HttpStatus; constructor( upstreamStatus: number, providerMessage: string, domainErrorCode: string, httpStatus: HttpStatus, cause?: unknown ) { super(`WHMCS HTTP ${upstreamStatus} error`, providerMessage, cause); this.domainErrorCode = domainErrorCode; this.httpStatus = httpStatus; } } ``` **Step 3: Create Salesforce and Freebit error classes** (follow same pattern as WHMCS) Create `apps/bff/src/integrations/common/errors/salesforce.errors.ts` — classes: `SalesforceApiError`, `SalesforceSessionExpiredError`, `SalesforceQueryError`, `SalesforceTimeoutError`, `SalesforceNetworkError`, `SalesforceRateLimitError`. Create `apps/bff/src/integrations/common/errors/freebit.errors.ts` — classes: `FreebitApiError`, `FreebitAccountNotFoundError`, `FreebitTimeoutError`. **Step 4: Create barrel file** Create `apps/bff/src/integrations/common/errors/index.ts`: ```typescript export { BaseProviderError } from "./base-provider.error.js"; export * from "./whmcs.errors.js"; export * from "./salesforce.errors.js"; export * from "./freebit.errors.js"; ``` **Step 5: Type-check** Run: `pnpm type-check` Expected: PASS **Step 6: Commit** ```bash git add apps/bff/src/integrations/common/ git commit -m "feat: add typed provider error classes for WHMCS, Salesforce, Freebit" ``` ### Task 3: Update UnifiedExceptionFilter to handle BaseProviderError **Files:** - Modify: `apps/bff/src/core/http/exception.filter.ts` **Step 1: Read the current exception filter** Read: `apps/bff/src/core/http/exception.filter.ts` **Step 2: Add BaseProviderError detection in extractErrorDetails()** In the error extraction logic, add a check before the generic Error handling: ```typescript import { BaseProviderError } from "@bff/integrations/common/errors/index.js"; // In extractErrorDetails(): if (exception instanceof BaseProviderError) { return { status: exception.httpStatus, code: exception.domainErrorCode, message: exception.message, }; } ``` **Step 3: Type-check** Run: `pnpm type-check` Expected: PASS **Step 4: Commit** ```bash git add apps/bff/src/core/http/exception.filter.ts git commit -m "feat: add BaseProviderError support to unified exception filter" ``` ### Task 4: Migrate WhmcsErrorHandlerService to use error classes **Files:** - Modify: `apps/bff/src/integrations/whmcs/connection/services/whmcs-error-handler.service.ts` **Step 1: Read the current handler (already read above, 314 lines)** **Step 2: Refactor to throw typed error classes instead of DomainHttpException with string matching** Replace `handleApiError()`: ```typescript handleApiError(errorResponse: WhmcsErrorResponse, action: string): never { const message = errorResponse.message; const errorCode = errorResponse.errorcode; // ValidateLogin: user credentials are wrong if (action === "ValidateLogin" && this.isValidateLoginInvalidCredentials(message, errorCode)) { throw new WhmcsInvalidCredentialsError(message); } // Not-found outcomes if (this.isNotFoundError(action, message)) { const resource = this.getNotFoundResource(action); throw new WhmcsNotFoundError(resource, message); } // Auth failures (API key issues) if (this.isAuthenticationError(message, errorCode)) { throw new WhmcsAuthError(message); } // Validation failures if (this.isValidationError(message, errorCode)) { throw new WhmcsValidationError(message); } // Default throw new WhmcsApiError(`WHMCS ${action} failed`, message); } ``` Replace `handleRequestError()`: ```typescript handleRequestError(error: unknown, context?: string): never { const message = extractErrorMessage(error); if (this.isTimeoutError(error)) { this.logger.warn("WHMCS request timeout", { error: message }); throw new WhmcsTimeoutError(message, error); } if (this.isNetworkError(error)) { this.logger.warn("WHMCS network error", { error: message }); throw new WhmcsNetworkError(message, error); } if (this.isRateLimitError(error)) { throw new WhmcsRateLimitError(message, error); } // HTTP status errors const httpStatusError = this.parseHttpStatusError(message); if (httpStatusError) { const mapped = this.mapHttpStatusToDomainError(httpStatusError.status); throw new WhmcsHttpError( httpStatusError.status, message, mapped.code, mapped.domainStatus, error ); } // Re-throw provider errors as-is if (error instanceof BaseProviderError) throw error; if (error instanceof DomainHttpException) throw error; throw new WhmcsApiError( context ? `WHMCS ${context} failed` : "WHMCS operation failed", message, error ); } ``` Note: The private `isTimeoutError`, `isNetworkError`, `isRateLimitError` methods still use string matching on Node.js error messages — this is acceptable because Node.js network errors ARE identified by their message strings. The key improvement is that business-level errors (not found, auth, validation) use structured codes, and all callers get typed error classes they can `instanceof` check. **Step 3: Type-check** Run: `pnpm type-check` Expected: PASS **Step 4: Commit** ```bash git add apps/bff/src/integrations/whmcs/connection/services/whmcs-error-handler.service.ts git commit -m "refactor: migrate WHMCS error handler to typed provider error classes" ``` ### Task 5: Migrate SalesforceErrorHandlerService to use error classes **Files:** - Modify: `apps/bff/src/integrations/salesforce/services/salesforce-error-handler.service.ts` Follow same pattern as Task 4. Read the file first, then replace `DomainHttpException` throws with typed `SalesforceSessionExpiredError`, `SalesforceTimeoutError`, etc. **Step 1: Read the current Salesforce error handler** **Step 2: Refactor to throw typed error classes** **Step 3: Type-check** Run: `pnpm type-check` Expected: PASS **Step 4: Commit** ```bash git add apps/bff/src/integrations/salesforce/services/salesforce-error-handler.service.ts git commit -m "refactor: migrate Salesforce error handler to typed provider error classes" ``` ### Task 6: Migrate FreebitErrorHandlerService **Files:** - Modify: `apps/bff/src/integrations/freebit/services/freebit-error-handler.service.ts` Follow same pattern. Read file first, then migrate. **Step 1-4:** Same as Tasks 4 and 5. ```bash git commit -m "refactor: migrate Freebit error handler to typed provider error classes" ``` ### Task 7: Create PR Run: `gh pr create --title "refactor: add typed provider error classes replacing string matching" --body "..."` --- ## PR 3: Repository + Unit of Work Layer **Branch:** `refactor/repository-pattern` **Context:** 14 files import PrismaService directly. An existing `UserAuthRepository` at `apps/bff/src/modules/users/infra/user-auth.repository.ts` already demonstrates the repository pattern. An existing `TransactionService` at `apps/bff/src/infra/database/services/transaction.service.ts` provides transaction management with retries and rollback coordination. We need to formalize the repository pattern with a base class and add a Unit of Work that leverages the existing TransactionService. **Existing patterns to build on:** - `UserAuthRepository` — already wraps PrismaService for user operations - `TransactionService` — already provides `$transaction()` with retries, rollback, isolation levels - `PrismaModule` (global) — provides PrismaService, TransactionService, DistributedTransactionService ### Task 1: Create BaseRepository **Files:** - Create: `apps/bff/src/infra/database/base.repository.ts` **Step 1: Create the generic base repository** ```typescript import type { PrismaService } from "./prisma.service.js"; /** * Generic base repository for Prisma entities. * * Provides type-safe CRUD operations that can be overridden by concrete repositories. * Accepts an optional transactional client for UoW participation. * * @typeParam TDelegate - The Prisma model delegate (e.g., PrismaService['user']) * @typeParam TEntity - The entity type returned by queries * @typeParam TCreateInput - The input type for create operations * @typeParam TUpdateInput - The input type for update operations * @typeParam TWhereUnique - The unique identifier filter type * @typeParam TWhere - The general where filter type */ export abstract class BaseRepository { constructor(protected readonly prisma: PrismaService) {} protected abstract get delegate(): { findUnique(args: { where: TWhereUnique }): Promise; findFirst(args: { where: TWhere }): Promise; findMany(args: { where?: TWhere; skip?: number; take?: number; orderBy?: unknown; }): Promise; create(args: { data: TCreateInput }): Promise; update(args: { where: TWhereUnique; data: TUpdateInput }): Promise; delete(args: { where: TWhereUnique }): Promise; count(args?: { where?: TWhere }): Promise; }; async findById(where: TWhereUnique): Promise { return this.delegate.findUnique({ where }); } async findOne(where: TWhere): Promise { return this.delegate.findFirst({ where }); } async findMany( where?: TWhere, options?: { skip?: number; take?: number; orderBy?: unknown } ): Promise { return this.delegate.findMany({ where, ...options }); } async create(data: TCreateInput): Promise { return this.delegate.create({ data }); } async update(where: TWhereUnique, data: TUpdateInput): Promise { return this.delegate.update({ where, data }); } async delete(where: TWhereUnique): Promise { return this.delegate.delete({ where }); } async count(where?: TWhere): Promise { return this.delegate.count({ where }); } } ``` **Step 2: Type-check** Run: `pnpm type-check` Expected: PASS **Step 3: Commit** ```bash git add apps/bff/src/infra/database/base.repository.ts git commit -m "feat: add BaseRepository generic class for typed CRUD operations" ``` ### Task 2: Create UnitOfWork service **Files:** - Create: `apps/bff/src/infra/database/unit-of-work.service.ts` **Step 1: Create UoW that wraps the existing TransactionService** ````typescript import { Injectable, Inject } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import type { Prisma } from "@prisma/client"; import { TransactionService, type TransactionOptions, type TransactionResult, } from "./services/transaction.service.js"; /** * Unit of Work — coordinates multi-entity operations within a single transaction. * * Wraps the existing TransactionService to provide a cleaner API for orchestrating * multiple repository operations atomically. * * @example * ```typescript * const result = await this.unitOfWork.execute(async (tx) => { * const user = await tx.user.create({ data: userData }); * await tx.idMapping.create({ data: { userId: user.id, ... } }); * return user; * }); * ``` */ @Injectable() export class UnitOfWork { constructor( private readonly transactionService: TransactionService, @Inject(Logger) private readonly logger: Logger ) {} /** * Execute a function within a database transaction. * All Prisma operations on the `tx` client are atomic. */ async execute( fn: (tx: Prisma.TransactionClient) => Promise, options?: TransactionOptions ): Promise> { return this.transactionService.executeTransaction(async (tx, context) => { context.addOperation("UnitOfWork.execute"); return fn(tx); }, options); } } ```` **Step 2: Register in PrismaModule** Modify `apps/bff/src/infra/database/prisma.module.ts`: - Add `UnitOfWork` to providers and exports **Step 3: Type-check** Run: `pnpm type-check` Expected: PASS **Step 4: Commit** ```bash git add apps/bff/src/infra/database/unit-of-work.service.ts apps/bff/src/infra/database/prisma.module.ts git commit -m "feat: add UnitOfWork service wrapping TransactionService" ``` ### Task 3: Create concrete repositories **Files:** - Create: `apps/bff/src/infra/database/repositories/sim.repository.ts` - Create: `apps/bff/src/infra/database/repositories/id-mapping.repository.ts` - Create: `apps/bff/src/infra/database/repositories/audit-log.repository.ts` - Create: `apps/bff/src/infra/database/repositories/index.ts` **Step 1: Identify which Prisma models need repositories** Check which models are accessed directly via PrismaService across the 14 files. The existing `UserAuthRepository` handles `user`. Create repositories for other models: `sim`, `idMapping`, `auditLog`, etc. **Step 2: Create each repository extending BaseRepository** Example `sim.repository.ts`: ```typescript import { Injectable } from "@nestjs/common"; import type { Sim, Prisma } from "@prisma/client"; import { BaseRepository } from "../base.repository.js"; import { PrismaService } from "../prisma.service.js"; @Injectable() export class SimRepository extends BaseRepository< Sim, Prisma.SimCreateInput, Prisma.SimUpdateInput, Prisma.SimWhereUniqueInput, Prisma.SimWhereInput > { constructor(prisma: PrismaService) { super(prisma); } protected get delegate() { return this.prisma.sim; } } ``` **Step 3: Create barrel file and register in PrismaModule** **Step 4: Type-check** Run: `pnpm type-check` Expected: PASS **Step 5: Commit** ```bash git add apps/bff/src/infra/database/repositories/ apps/bff/src/infra/database/prisma.module.ts git commit -m "feat: add concrete repository classes for Sim, IdMapping, AuditLog" ``` ### Task 4: Migrate services to use repositories **Files:** - Modify: Each of the 14 files that import PrismaService (except `prisma.service.ts` and `prisma.module.ts` themselves, `transaction.service.ts`, and `health.controller.ts`) **Step 1: Migrate one service at a time** For each service, replace: ```typescript // Before: constructor(private readonly prisma: PrismaService) {} // ... await this.prisma.sim.update({ where: { id }, data }); // After: constructor(private readonly simRepository: SimRepository) {} // ... await this.simRepository.update({ id }, data); ``` **Step 2: Update the module that provides each service to import the repository** **Step 3: Type-check after each migration** Run: `pnpm type-check` Expected: PASS **Step 4: Commit per service group** ```bash git commit -m "refactor: migrate SIM services to use SimRepository" git commit -m "refactor: migrate audit services to use AuditLogRepository" # etc. ``` ### Task 5: Create PR Run: `gh pr create --title "refactor: add repository + unit of work layer for database access" --body "..."` --- ## PR 4: Auth Feature-Based Decomposition **Branch:** `refactor/auth-feature-modules` **Context:** The auth module has 59 TypeScript files and 48 NestJS providers in a single `auth.module.ts`. It already has good directory layering (`presentation/`, `infra/`, `application/`), but all providers are registered in one module, making the DI graph hard to reason about. **Current auth directory structure:** ``` modules/auth/ ├── auth.module.ts # 48 providers ├── auth.types.ts ├── application/ │ ├── auth-orchestrator.service.ts │ ├── auth-health.service.ts │ └── auth-login.service.ts ├── presentation/http/ │ ├── auth.controller.ts # Main auth routes │ ├── get-started.controller.ts # Get-started routes │ ├── guards/ │ ├── interceptors/ │ └── utils/ ├── infra/ │ ├── token/ # 8 token services │ ├── otp/ # 2 OTP services │ ├── login/ # 1 login session service │ ├── trusted-device/ # 1 trusted device service │ ├── rate-limiting/ # 1 rate limiting service │ └── workflows/ # 10 workflow services + 6 step services ├── constants/ ├── decorators/ └── utils/ ``` **Target: 6 feature modules** — tokens, otp, sessions, login, get-started, password-reset — plus a shared module for guards/decorators and a thin orchestrator module. ### Task 1: Create TokensModule **Files:** - Create: `apps/bff/src/modules/auth/tokens/tokens.module.ts` **Step 1: Read all token service files** Read: All files in `apps/bff/src/modules/auth/infra/token/` **Step 2: Create the TokensModule** Move the 8 token services into a self-contained module. The files stay in their current location — we just create a new NestJS module that owns them: ```typescript import { Module } from "@nestjs/common"; import { JoseJwtService } from "../infra/token/jose-jwt.service.js"; import { TokenGeneratorService } from "../infra/token/token-generator.service.js"; import { TokenRefreshService } from "../infra/token/token-refresh.service.js"; import { TokenStorageService } from "../infra/token/token-storage.service.js"; import { TokenRevocationService } from "../infra/token/token-revocation.service.js"; import { TokenBlacklistService } from "../infra/token/token-blacklist.service.js"; import { AuthTokenService } from "../infra/token/token.service.js"; import { PasswordResetTokenService } from "../infra/token/password-reset-token.service.js"; import { TokenMigrationService } from "../infra/token/token-migration.service.js"; @Module({ providers: [ JoseJwtService, TokenGeneratorService, TokenRefreshService, TokenStorageService, TokenRevocationService, TokenBlacklistService, AuthTokenService, PasswordResetTokenService, TokenMigrationService, ], exports: [ JoseJwtService, AuthTokenService, TokenBlacklistService, TokenRefreshService, PasswordResetTokenService, TokenMigrationService, ], }) export class TokensModule {} ``` **Step 3: Type-check** Run: `pnpm type-check` Expected: PASS **Step 4: Commit** ```bash git add apps/bff/src/modules/auth/tokens/ git commit -m "feat: create TokensModule for auth token services" ``` ### Task 2: Create OtpModule **Files:** - Create: `apps/bff/src/modules/auth/otp/otp.module.ts` Follow same pattern — owns `OtpService` and `GetStartedSessionService` from `infra/otp/`. ### Task 3: Create SessionsModule **Files:** - Create: `apps/bff/src/modules/auth/sessions/sessions.module.ts` Owns `LoginSessionService` (from `infra/login/`), `TrustedDeviceService` (from `infra/trusted-device/`). ### Task 4: Create LoginModule **Files:** - Create: `apps/bff/src/modules/auth/login/login.module.ts` Owns `AuthLoginService` (from `application/`), `LoginOtpWorkflowService` (from `infra/workflows/`), `FailedLoginThrottleGuard`, `LoginResultInterceptor`. Imports: TokensModule, SessionsModule, OtpModule. ### Task 5: Create GetStartedModule **Files:** - Create: `apps/bff/src/modules/auth/get-started/get-started.module.ts` Owns `GetStartedCoordinator`, all signup workflow services, step services. Imports: TokensModule, OtpModule. ### Task 6: Create PasswordResetModule **Files:** - Create: `apps/bff/src/modules/auth/password-reset/password-reset.module.ts` Owns `PasswordWorkflowService`. Imports: TokensModule. ### Task 7: Create SharedAuthModule **Files:** - Create: `apps/bff/src/modules/auth/shared/shared-auth.module.ts` Owns guards (`GlobalAuthGuard`, `PermissionsGuard`), decorators (`@Public`, `@OptionalAuth`), `AuthRateLimitService`. ### Task 8: Refactor AuthModule to orchestrator **Files:** - Modify: `apps/bff/src/modules/auth/auth.module.ts` **Step 1: Replace 48 inline providers with module imports** ```typescript @Module({ imports: [ TokensModule, OtpModule, SessionsModule, LoginModule, GetStartedModule, PasswordResetModule, SharedAuthModule, // External modules UsersModule, MappingsModule, IntegrationsModule, CacheModule, WorkflowModule, ], providers: [AuthOrchestrator, AuthHealthService], exports: [AuthOrchestrator, TokensModule, SharedAuthModule], }) export class AuthModule {} ``` **Step 2: Verify all routes work** Run: `pnpm type-check` Expected: PASS **Step 3: Commit** ```bash git add apps/bff/src/modules/auth/ git commit -m "refactor: decompose AuthModule into 6 feature modules" ``` ### Task 9: Verify and create PR **Step 1: Full type-check and lint** Run: `pnpm type-check && pnpm lint` Expected: PASS **Step 2: Create PR** Run: `gh pr create --title "refactor: decompose auth module into feature-based sub-modules" --body "..."` --- ## Execution Checklist | PR | Branch | Estimated Tasks | Dependencies | | --- | --------------------------------- | --------------- | ------------ | | 1 | `refactor/tighten-support-schema` | 3 tasks | None | | 2 | `refactor/provider-error-classes` | 7 tasks | None | | 3 | `refactor/repository-pattern` | 5 tasks | None | | 4 | `refactor/auth-feature-modules` | 9 tasks | None | All PRs are independent and can be worked on in parallel or any order. ## Review Checkpoints After each PR: 1. Run `pnpm type-check` — must pass 2. Run `pnpm lint` — must pass 3. Run `pnpm domain:build` — must pass (for domain changes) 4. Manually verify no circular imports: check that feature modules only import what they declare 5. Verify no behavioral changes: all existing API routes return identical responses