23 KiB
Orchestrator Refactoring Plan
Overview
This document outlines the refactoring plan for BFF orchestrators to align with enterprise-grade code structure patterns used by companies like Google, Amazon, and Microsoft.
Date: 2026-02-03 Branch: alt-design (post-merge from main)
Orchestrator Assessment Summary
| Orchestrator | Lines | Status | Priority |
|---|---|---|---|
order-fulfillment-orchestrator.service.ts |
~990 | ❌ Needs Refactoring | HIGH |
subscriptions-orchestrator.service.ts |
~475 | ⚠️ Minor Issues | LOW |
auth-orchestrator.service.ts |
~324 | ✅ Mostly Good | LOW |
order-orchestrator.service.ts |
~270 | ✅ Good | NONE |
sim-orchestrator.service.ts |
~200 | ✅ Excellent | NONE |
billing-orchestrator.service.ts |
~47 | ✅ Perfect | NONE |
HIGH Priority: Order Fulfillment Orchestrator
Current Problems
-
Inline Anonymous Functions in Distributed Transaction (lines 172-557)
- 10 transaction steps defined as inline arrow functions
- Each step contains 20-80 lines of business logic
- Violates Single Responsibility Principle
- Difficult to unit test individual steps
-
File Size: ~990 lines (should be < 300 for an orchestrator)
-
Mixed Concerns: Helper methods embedded in orchestrator
extractConfigurations()(60+ lines) - data extractionextractContactIdentity()(50+ lines) - data extractionformatBirthdayToYYYYMMDD()(30 lines) - date formatting
-
Duplicate Logic: Step tracking pattern repeated in each step
Target Architecture
apps/bff/src/modules/orders/
├── services/
│ ├── order-fulfillment-orchestrator.service.ts (~200 lines)
│ ├── order-fulfillment-steps/
│ │ ├── index.ts
│ │ ├── validation.step.ts
│ │ ├── sf-status-update.step.ts
│ │ ├── sim-fulfillment.step.ts
│ │ ├── sf-activated-update.step.ts
│ │ ├── whmcs-mapping.step.ts
│ │ ├── whmcs-create.step.ts
│ │ ├── whmcs-accept.step.ts
│ │ ├── sf-registration-complete.step.ts
│ │ └── opportunity-update.step.ts
│ └── mappers/
│ └── order-configuration.mapper.ts
Refactoring Steps
Step 1: Create Step Interface and Base Class
// apps/bff/src/modules/orders/services/order-fulfillment-steps/fulfillment-step.interface.ts
import type { OrderFulfillmentContext } from "../order-fulfillment-orchestrator.service.js";
import type { TransactionStep } from "@bff/infra/database/services/distributed-transaction.service.js";
export interface FulfillmentStepConfig {
context: OrderFulfillmentContext;
logger: Logger;
}
export interface FulfillmentStep {
readonly id: string;
readonly description: string;
readonly critical: boolean;
/**
* Build the transaction step for the distributed transaction
*/
build(config: FulfillmentStepConfig): TransactionStep;
/**
* Check if this step should be included based on context
*/
shouldInclude(context: OrderFulfillmentContext): boolean;
}
Step 2: Extract Each Step to Its Own Class
Example: SIM Fulfillment Step
// apps/bff/src/modules/orders/services/order-fulfillment-steps/sim-fulfillment.step.ts
import { Injectable, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import type { FulfillmentStep, FulfillmentStepConfig } from "./fulfillment-step.interface.js";
import type { OrderFulfillmentContext } from "../order-fulfillment-orchestrator.service.js";
import { SimFulfillmentService } from "../sim-fulfillment.service.js";
import { OrderConfigurationMapper } from "../mappers/order-configuration.mapper.js";
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
@Injectable()
export class SimFulfillmentStep implements FulfillmentStep {
readonly id = "sim_fulfillment";
readonly description = "SIM activation via Freebit (PA05-18 + PA02-01 + PA05-05)";
readonly critical = true; // Set dynamically based on SIM type
constructor(
private readonly simFulfillmentService: SimFulfillmentService,
private readonly configMapper: OrderConfigurationMapper,
@Inject(Logger) private readonly logger: Logger
) {}
shouldInclude(context: OrderFulfillmentContext): boolean {
return context.orderDetails?.orderType === "SIM";
}
build(config: FulfillmentStepConfig) {
const { context } = config;
return {
id: this.id,
description: this.description,
execute: async () => this.execute(context),
rollback: async () => this.rollback(context),
critical: context.validation?.sfOrder?.SIM_Type__c === "Physical SIM",
};
}
private async execute(context: OrderFulfillmentContext) {
if (context.orderDetails?.orderType !== "SIM") {
return { activated: false, simType: "eSIM" as const };
}
const sfOrder = context.validation?.sfOrder;
const configurations = this.configMapper.extractConfigurations(
context.payload?.configurations,
sfOrder
);
const assignedPhysicalSimId = this.extractAssignedSimId(sfOrder);
const voiceMailEnabled = sfOrder?.SIM_Voice_Mail__c === true;
const callWaitingEnabled = sfOrder?.SIM_Call_Waiting__c === true;
const contactIdentity = this.configMapper.extractContactIdentity(sfOrder);
this.logger.log("Starting SIM fulfillment (before WHMCS)", {
orderId: context.sfOrderId,
simType: sfOrder?.SIM_Type__c,
assignedPhysicalSimId,
voiceMailEnabled,
callWaitingEnabled,
hasContactIdentity: !!contactIdentity,
});
const result = await this.simFulfillmentService.fulfillSimOrder({
orderDetails: context.orderDetails,
configurations,
assignedPhysicalSimId,
voiceMailEnabled,
callWaitingEnabled,
contactIdentity,
});
context.simFulfillmentResult = result;
return result;
}
private async rollback(context: OrderFulfillmentContext) {
this.logger.warn("SIM fulfillment step needs rollback - manual intervention may be required", {
sfOrderId: context.sfOrderId,
simFulfillmentResult: context.simFulfillmentResult,
});
}
private extractAssignedSimId(sfOrder?: SalesforceOrderRecord | null): string | undefined {
return typeof sfOrder?.Assign_Physical_SIM__c === "string"
? sfOrder.Assign_Physical_SIM__c
: undefined;
}
}
Step 3: Extract Configuration Mapper
// apps/bff/src/modules/orders/services/mappers/order-configuration.mapper.ts
import { Injectable } from "@nestjs/common";
import type { SalesforceOrderRecord } from "@customer-portal/domain/orders/providers";
import type { ContactIdentityData } from "../sim-fulfillment.service.js";
@Injectable()
export class OrderConfigurationMapper {
/**
* Extract and normalize configurations from payload and Salesforce order
*/
extractConfigurations(
rawConfigurations: unknown,
sfOrder?: SalesforceOrderRecord | null
): Record<string, unknown> {
const config: Record<string, unknown> = {};
if (rawConfigurations && typeof rawConfigurations === "object") {
Object.assign(config, rawConfigurations as Record<string, unknown>);
}
if (sfOrder) {
this.fillFromSalesforceOrder(config, sfOrder);
}
return config;
}
/**
* Extract contact identity data from Salesforce order porting fields
*/
extractContactIdentity(sfOrder?: SalesforceOrderRecord | null): ContactIdentityData | undefined {
if (!sfOrder) return undefined;
const firstnameKanji = sfOrder.Porting_FirstName__c;
const lastnameKanji = sfOrder.Porting_LastName__c;
const firstnameKana = sfOrder.Porting_FirstName_Katakana__c;
const lastnameKana = sfOrder.Porting_LastName_Katakana__c;
const genderRaw = sfOrder.Porting_Gender__c;
const birthdayRaw = sfOrder.Porting_DateOfBirth__c;
if (!this.validateNameFields(firstnameKanji, lastnameKanji, firstnameKana, lastnameKana)) {
return undefined;
}
const gender = this.validateGender(genderRaw);
if (!gender) return undefined;
const birthday = this.formatBirthdayToYYYYMMDD(birthdayRaw);
if (!birthday) return undefined;
return {
firstnameKanji: firstnameKanji!,
lastnameKanji: lastnameKanji!,
firstnameKana: firstnameKana!,
lastnameKana: lastnameKana!,
gender,
birthday,
};
}
/**
* Format birthday from various formats to YYYYMMDD
*/
formatBirthdayToYYYYMMDD(dateStr?: string | null): string | undefined {
if (!dateStr) return undefined;
if (/^\d{8}$/.test(dateStr)) {
return dateStr;
}
const isoMatch = dateStr.match(/^(\d{4})-(\d{2})-(\d{2})/);
if (isoMatch) {
return `${isoMatch[1]}${isoMatch[2]}${isoMatch[3]}`;
}
try {
const date = new Date(dateStr);
if (!isNaN(date.getTime())) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}${month}${day}`;
}
} catch {
// Parsing failed
}
return undefined;
}
private fillFromSalesforceOrder(
config: Record<string, unknown>,
sfOrder: SalesforceOrderRecord
): void {
const fieldMappings: Array<[string, keyof SalesforceOrderRecord]> = [
["simType", "SIM_Type__c"],
["eid", "EID__c"],
["activationType", "Activation_Type__c"],
["scheduledAt", "Activation_Scheduled_At__c"],
["mnpPhone", "MNP_Phone_Number__c"],
["mnpNumber", "MNP_Reservation_Number__c"],
["mnpExpiry", "MNP_Expiry_Date__c"],
["mvnoAccountNumber", "MVNO_Account_Number__c"],
["portingFirstName", "Porting_FirstName__c"],
["portingLastName", "Porting_LastName__c"],
["portingFirstNameKatakana", "Porting_FirstName_Katakana__c"],
["portingLastNameKatakana", "Porting_LastName_Katakana__c"],
["portingGender", "Porting_Gender__c"],
["portingDateOfBirth", "Porting_DateOfBirth__c"],
];
for (const [configKey, sfField] of fieldMappings) {
if (!config[configKey] && sfOrder[sfField]) {
config[configKey] = sfOrder[sfField];
}
}
// Handle MNP flag specially
if (!config["isMnp"] && sfOrder.MNP_Application__c) {
config["isMnp"] = "true";
}
}
private validateNameFields(
firstnameKanji?: string | null,
lastnameKanji?: string | null,
firstnameKana?: string | null,
lastnameKana?: string | null
): boolean {
return !!(firstnameKanji && lastnameKanji && firstnameKana && lastnameKana);
}
private validateGender(genderRaw?: string | null): "M" | "F" | undefined {
return genderRaw === "M" || genderRaw === "F" ? genderRaw : undefined;
}
}
Step 4: Create Step Builder Service
// apps/bff/src/modules/orders/services/order-fulfillment-steps/fulfillment-step-builder.service.ts
import { Injectable, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import type { TransactionStep } from "@bff/infra/database/services/distributed-transaction.service.js";
import type { OrderFulfillmentContext } from "../order-fulfillment-orchestrator.service.js";
// Import all steps
import { ValidationStep } from "./validation.step.js";
import { SfStatusUpdateStep } from "./sf-status-update.step.js";
import { SimFulfillmentStep } from "./sim-fulfillment.step.js";
import { SfActivatedUpdateStep } from "./sf-activated-update.step.js";
import { WhmcsMappingStep } from "./whmcs-mapping.step.js";
import { WhmcsCreateStep } from "./whmcs-create.step.js";
import { WhmcsAcceptStep } from "./whmcs-accept.step.js";
import { SfRegistrationCompleteStep } from "./sf-registration-complete.step.js";
import { OpportunityUpdateStep } from "./opportunity-update.step.js";
@Injectable()
export class FulfillmentStepBuilder {
private readonly allSteps: FulfillmentStep[];
constructor(
validationStep: ValidationStep,
sfStatusUpdateStep: SfStatusUpdateStep,
simFulfillmentStep: SimFulfillmentStep,
sfActivatedUpdateStep: SfActivatedUpdateStep,
whmcsMappingStep: WhmcsMappingStep,
whmcsCreateStep: WhmcsCreateStep,
whmcsAcceptStep: WhmcsAcceptStep,
sfRegistrationCompleteStep: SfRegistrationCompleteStep,
opportunityUpdateStep: OpportunityUpdateStep,
@Inject(Logger) private readonly logger: Logger
) {
// Define step execution order
this.allSteps = [
sfStatusUpdateStep,
simFulfillmentStep,
sfActivatedUpdateStep,
whmcsMappingStep,
whmcsCreateStep,
whmcsAcceptStep,
sfRegistrationCompleteStep,
opportunityUpdateStep,
];
}
/**
* Build transaction steps based on context
*/
buildTransactionSteps(context: OrderFulfillmentContext): TransactionStep[] {
const config = { context, logger: this.logger };
return this.allSteps
.filter(step => step.shouldInclude(context))
.map(step => step.build(config));
}
}
Step 5: Refactor Orchestrator to Use Step Builder
// apps/bff/src/modules/orders/services/order-fulfillment-orchestrator.service.ts (REFACTORED)
import { Injectable, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { DistributedTransactionService } from "@bff/infra/database/services/distributed-transaction.service.js";
import { FulfillmentStepBuilder } from "./order-fulfillment-steps/fulfillment-step-builder.service.js";
import { OrderFulfillmentValidator } from "./order-fulfillment-validator.service.js";
import { OrderFulfillmentErrorService } from "./order-fulfillment-error.service.js";
import { OrderEventsService } from "./order-events.service.js";
import { OrdersCacheService } from "./orders-cache.service.js";
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import { FulfillmentException } from "@bff/core/exceptions/domain-exceptions.js";
// ... types remain the same ...
@Injectable()
export class OrderFulfillmentOrchestrator {
constructor(
@Inject(Logger) private readonly logger: Logger,
private readonly stepBuilder: FulfillmentStepBuilder,
private readonly validator: OrderFulfillmentValidator,
private readonly errorService: OrderFulfillmentErrorService,
private readonly transactionService: DistributedTransactionService,
private readonly orderEvents: OrderEventsService,
private readonly ordersCache: OrdersCacheService
) {}
async executeFulfillment(
sfOrderId: string,
payload: Record<string, unknown>,
idempotencyKey: string
): Promise<OrderFulfillmentContext> {
const context = this.initializeContext(sfOrderId, idempotencyKey, payload);
this.logger.log("Starting transactional fulfillment orchestration", {
sfOrderId,
idempotencyKey,
});
try {
// Step 1: Validate
const validation = await this.validator.validateFulfillmentRequest(sfOrderId, idempotencyKey);
context.validation = validation;
if (validation.isAlreadyProvisioned) {
return this.handleAlreadyProvisioned(context);
}
// Step 2: Build and execute transaction steps
const steps = this.stepBuilder.buildTransactionSteps(context);
const result = await this.transactionService.executeDistributedTransaction(steps, {
description: `Order fulfillment for ${sfOrderId}`,
timeout: 300000,
continueOnNonCriticalFailure: true,
});
if (!result.success) {
throw new FulfillmentException(result.error || "Fulfillment transaction failed", {
sfOrderId,
idempotencyKey,
stepsExecuted: result.stepsExecuted,
stepsRolledBack: result.stepsRolledBack,
});
}
this.logger.log("Transactional fulfillment completed successfully", {
sfOrderId,
stepsExecuted: result.stepsExecuted,
duration: result.duration,
});
await this.invalidateCaches(context);
return context;
} catch (error) {
await this.handleFulfillmentError(context, error as Error);
throw error;
}
}
private initializeContext(
sfOrderId: string,
idempotencyKey: string,
payload: Record<string, unknown>
): OrderFulfillmentContext {
return {
sfOrderId,
idempotencyKey,
validation: null,
payload,
steps: [],
};
}
private handleAlreadyProvisioned(context: OrderFulfillmentContext): OrderFulfillmentContext {
this.logger.log("Order already provisioned, skipping fulfillment", {
sfOrderId: context.sfOrderId,
});
this.orderEvents.publish(context.sfOrderId, {
orderId: context.sfOrderId,
status: "Completed",
activationStatus: "Activated",
stage: "completed",
source: "fulfillment",
message: "Order already provisioned",
timestamp: new Date().toISOString(),
payload: { whmcsOrderId: context.validation?.whmcsOrderId },
});
return context;
}
private async invalidateCaches(context: OrderFulfillmentContext): Promise<void> {
const accountId = context.validation?.sfOrder?.AccountId;
await Promise.all([
this.ordersCache.invalidateOrder(context.sfOrderId),
accountId ? this.ordersCache.invalidateAccountOrders(accountId) : Promise.resolve(),
]).catch(error => {
this.logger.warn("Failed to invalidate caches", { error: extractErrorMessage(error) });
});
}
private async handleFulfillmentError(
context: OrderFulfillmentContext,
error: Error
): Promise<void> {
await this.invalidateCaches(context);
await this.errorService.handleAndReport(context, error);
this.orderEvents.publishFailure(context.sfOrderId, error.message);
}
getFulfillmentSummary(context: OrderFulfillmentContext) {
// ... same as before, no changes needed ...
}
}
Step 6: Update Module Registration
// apps/bff/src/modules/orders/orders.module.ts
// Add new providers
import { FulfillmentStepBuilder } from "./services/order-fulfillment-steps/fulfillment-step-builder.service.js";
import { OrderConfigurationMapper } from "./services/mappers/order-configuration.mapper.js";
import {
SfStatusUpdateStep,
SimFulfillmentStep,
SfActivatedUpdateStep,
WhmcsMappingStep,
WhmcsCreateStep,
WhmcsAcceptStep,
SfRegistrationCompleteStep,
OpportunityUpdateStep,
} from "./services/order-fulfillment-steps/index.js";
@Module({
providers: [
// Existing services...
// New step classes
FulfillmentStepBuilder,
OrderConfigurationMapper,
SfStatusUpdateStep,
SimFulfillmentStep,
SfActivatedUpdateStep,
WhmcsMappingStep,
WhmcsCreateStep,
WhmcsAcceptStep,
SfRegistrationCompleteStep,
OpportunityUpdateStep,
],
})
export class OrdersModule {}
LOW Priority: Subscriptions Orchestrator
Current Issues
-
Business logic in filter methods (lines 193-250):
getExpiringSoon()- date filtering logicgetRecentActivity()- date filtering logicsearchSubscriptions()- search logic
-
Cache helper methods could be in a separate service
Recommended Changes
-
Extract date filtering to a utility:
// @bff/core/utils/date-filter.util.ts export function filterByDateRange<T>( items: T[], dateExtractor: (item: T) => Date, range: { start?: Date; end?: Date } ): T[] { ... } -
Consider extracting
SubscriptionFilterServiceif filtering becomes more complex
Status: Not urgent - current implementation is acceptable
LOW Priority: Auth Orchestrator
Current Issues
getAccountStatus()method (lines 241-296) contains complex conditional logic- Could be extracted to
AccountStatusResolverservice
- Could be extracted to
Recommended Changes
// apps/bff/src/modules/auth/application/account-status-resolver.service.ts
@Injectable()
export class AccountStatusResolver {
async resolve(email: string): Promise<AccountStatus> {
// Move logic from getAccountStatus here
}
}
Status: Not urgent - method is well-contained and testable as-is
No Changes Needed
Order Orchestrator ✅
- Clean thin delegation pattern
- Appropriate size (~270 lines)
- Single private method is justified
SIM Orchestrator ✅
- Excellent thin delegation
- All methods delegate to specialized services
getSimInfohas reasonable composition logic
Billing Orchestrator ✅
- Perfect thin facade pattern
- Pure delegation only
- Model example for others
Implementation Checklist
Phase 1: High Priority (Order Fulfillment Orchestrator)
- Create
order-fulfillment-steps/directory - Create
fulfillment-step.interface.ts - Create
OrderConfigurationMapperservice - Extract
SfStatusUpdateStep - Extract
SimFulfillmentStep - Extract
SfActivatedUpdateStep - Extract
WhmcsMappingStep - Extract
WhmcsCreateStep - Extract
WhmcsAcceptStep - Extract
SfRegistrationCompleteStep - Extract
OpportunityUpdateStep - Create
FulfillmentStepBuilderservice - Refactor
OrderFulfillmentOrchestratorto use step builder - Update
OrdersModulewith new providers - Add unit tests for each step class
- Add integration tests for step builder
Phase 2: Low Priority
- Extract
SubscriptionFilterService(if needed) - Extract
AccountStatusResolver(if needed)
Testing Strategy
Step Classes
Each step class should have unit tests that verify:
shouldInclude()returns correct boolean based on contextexecute()performs expected operationsrollback()handles cleanup appropriately- Error cases are handled correctly
Step Builder
Integration tests should verify:
- Correct steps are included for SIM orders
- Correct steps are included for non-SIM orders
- Step order is maintained
- Context is properly passed between steps
Orchestrator
E2E tests should verify:
- Complete fulfillment flow works
- Partial failures trigger rollbacks
- Already provisioned orders are handled
- Error reporting works correctly
Benefits of This Refactoring
- Testability: Each step can be unit tested in isolation
- Maintainability: Changes to one step don't affect others
- Readability: Orchestrator becomes a thin coordinator
- Extensibility: New steps can be added without modifying orchestrator
- Reusability: Steps can potentially be reused in other workflows
- Debugging: Easier to identify which step failed
- Code Review: Smaller, focused PRs for each step
References
- Google Engineering Practices: https://google.github.io/eng-practices/
- Microsoft .NET Application Architecture: https://docs.microsoft.com/en-us/dotnet/architecture/
- Clean Architecture by Robert C. Martin
- Domain-Driven Design by Eric Evans