Assist_Design/docs/plans/2026-02-25-bff-domain-structure-cleanup-plan.md
barsa cc8aa917c2 fix: update Salesforce connection login URL to use test environment
- Change default login URL from production to test Salesforce environment for safer development and testing.
2026-03-02 15:23:33 +09:00

33 KiB

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:

// 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:

// 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

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:

/**
 * 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

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:

/**
 * 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:

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

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:

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:

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:

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

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:

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

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():

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():

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

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

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.

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

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<TEntity, TCreateInput, TUpdateInput, TWhereUnique, TWhere> {
  constructor(protected readonly prisma: PrismaService) {}

  protected abstract get delegate(): {
    findUnique(args: { where: TWhereUnique }): Promise<TEntity | null>;
    findFirst(args: { where: TWhere }): Promise<TEntity | null>;
    findMany(args: {
      where?: TWhere;
      skip?: number;
      take?: number;
      orderBy?: unknown;
    }): Promise<TEntity[]>;
    create(args: { data: TCreateInput }): Promise<TEntity>;
    update(args: { where: TWhereUnique; data: TUpdateInput }): Promise<TEntity>;
    delete(args: { where: TWhereUnique }): Promise<TEntity>;
    count(args?: { where?: TWhere }): Promise<number>;
  };

  async findById(where: TWhereUnique): Promise<TEntity | null> {
    return this.delegate.findUnique({ where });
  }

  async findOne(where: TWhere): Promise<TEntity | null> {
    return this.delegate.findFirst({ where });
  }

  async findMany(
    where?: TWhere,
    options?: { skip?: number; take?: number; orderBy?: unknown }
  ): Promise<TEntity[]> {
    return this.delegate.findMany({ where, ...options });
  }

  async create(data: TCreateInput): Promise<TEntity> {
    return this.delegate.create({ data });
  }

  async update(where: TWhereUnique, data: TUpdateInput): Promise<TEntity> {
    return this.delegate.update({ where, data });
  }

  async delete(where: TWhereUnique): Promise<TEntity> {
    return this.delegate.delete({ where });
  }

  async count(where?: TWhere): Promise<number> {
    return this.delegate.count({ where });
  }
}

Step 2: Type-check

Run: pnpm type-check Expected: PASS

Step 3: Commit

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

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<T>(
    fn: (tx: Prisma.TransactionClient) => Promise<T>,
    options?: TransactionOptions
  ): Promise<TransactionResult<T>> {
    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

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:

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

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:

// 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

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:

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

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

@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

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