Assist_Design/docs/BFF-INTEGRATION-PATTERNS.md

11 KiB

BFF Integration Layer Patterns

Overview

The BFF (Backend for Frontend) integration layer encapsulates all external system interactions and infrastructure concerns. This document defines the patterns and best practices for creating integration services.


Architecture Principles

Domain Layer vs Integration Layer

Domain Layer (packages/domain/):

  • Business types and validation schemas
  • Raw provider types (data structures from external systems)
  • Transformation mappers (raw → domain)
  • Business validation logic
  • No infrastructure concerns

Integration Layer (apps/bff/src/integrations/):

  • Query builders (SOQL, GraphQL, etc.)
  • Connection services
  • Integration services
  • HTTP/API clients
  • No business logic

Integration Service Pattern

Purpose

Integration services encapsulate all interactions with a specific external system feature, providing a clean abstraction for the application layer.

Structure

@Injectable()
export class SalesforceOrderService {
  constructor(
    private readonly sf: SalesforceConnection,
    @Inject(Logger) private readonly logger: Logger
  ) {}

  async getOrderById(orderId: string): Promise<OrderDetails | null> {
    // 1. Build query (infrastructure concern)
    const soql = this.buildOrderQuery(orderId);
    
    // 2. Execute query
    const rawData = await this.sf.query(soql);
    
    // 3. Use domain mapper (single transformation!)
    return DomainProviders.Salesforce.transformOrder(rawData);
  }
}

Key Characteristics

  1. Encapsulation: All system-specific logic hidden from application layer
  2. Single Transformation: Uses domain mappers, no additional mapping
  3. Returns Domain Types: Application layer works with domain types only
  4. Infrastructure Details: Query building, field selection, etc. stay here

Query Builder Pattern

Location

Query builders belong in integration layer utils, not in domain.

Correct: apps/bff/src/integrations/salesforce/utils/order-query-builder.ts
Wrong: packages/domain/orders/providers/salesforce/query.ts

Example

/**
 * Build field list for Order queries
 * Infrastructure concern - not business logic
 */
export function buildOrderSelectFields(
  additional: string[] = []
): string[] {
  const fields = [
    "Id",
    "AccountId",
    "Status",
    // ... all Salesforce field names
  ];
  
  return UNIQUE([...fields, ...additional]);
}

Data Flow

Clean Architecture Flow

┌─────────────────────────────────────────┐
│ Controller (HTTP)                       │
│  - API endpoints                        │
│  - Request/Response formatting          │
└──────────────┬──────────────────────────┘
               │
┌──────────────▼──────────────────────────┐
│ Orchestrator (Application)              │
│  - Workflow coordination                │
│  - Uses integration services            │
│  - Works with domain types              │
└──────────────┬──────────────────────────┘
               │
    ┌──────────┴──────────┐
    │                     │
┌───▼───────────┐  ┌──────▼──────────────┐
│ Domain        │  │ Integration         │
│ (Business)    │  │ (Infrastructure)    │
│               │  │                     │
│ • Types       │  │ • SF OrderService   │
│ • Schemas     │  │ • Query Builders    │
│ • Mappers ────┼──┤ • Connections       │
│ • Validators  │  │ • API Clients       │
└───────────────┘  └─────────────────────┘

Single Transformation Principle

One transformation path:

Query (BFF) → Raw Data → Domain Mapper → Domain Type → Use Directly
             └────────── Single transformation ──────────┘

No double transformation:

❌ Query → Raw Data → Domain Mapper → Domain Type → BFF Mapper → ???

Integration Service Examples

Example 1: Salesforce Order Service

import { SalesforceConnection } from "./salesforce-connection.service";
import { buildOrderSelectFields } from "../utils/order-query-builder";
import { Providers } from "@customer-portal/domain/orders";

@Injectable()
export class SalesforceOrderService {
  constructor(
    private readonly sf: SalesforceConnection,
    @Inject(Logger) private readonly logger: Logger
  ) {}

  async getOrderById(orderId: string): Promise<OrderDetails | null> {
    // Build query using integration layer utils
    const fields = buildOrderSelectFields(["Account.Name"]).join(", ");
    const soql = `SELECT ${fields} FROM Order WHERE Id = '${orderId}'`;

    // Execute query
    const result = await this.sf.query(soql);
    if (!result.records?.[0]) return null;

    // Use domain mapper - single transformation!
    return Providers.Salesforce.transformSalesforceOrderDetails(
      result.records[0],
      []
    );
  }
}

Example 2: WHMCS Client Service

import { WhmcsConnection } from "./whmcs-connection.service";
import { Providers } from "@customer-portal/domain/customer";

@Injectable()
export class WhmcsClientService {
  constructor(
    private readonly whmcs: WhmcsConnection,
    @Inject(Logger) private readonly logger: Logger
  ) {}

  async getClientById(clientId: number): Promise<CustomerProfile | null> {
    // Build request parameters
    const params = { clientid: clientId };

    // Execute API call
    const rawClient = await this.whmcs.call("GetClientsDetails", params);
    if (!rawClient) return null;

    // Use domain mapper - single transformation!
    return Providers.Whmcs.transformWhmcsClient(rawClient);
  }
}

When to Create an Integration Service

Create When

You need to query/update data from an external system
The logic involves system-specific query construction
Multiple orchestrators need the same external data
You want to encapsulate provider-specific complexity

Don't Create When

Simple pass-through without any system-specific logic
One-time queries that won't be reused
Logic that belongs in domain (business rules)


Orchestrator Usage Pattern

Before (Direct Queries - Wrong)

@Injectable()
export class OrderOrchestrator {
  constructor(private readonly sf: SalesforceConnection) {}

  async getOrder(orderId: string) {
    // ❌ Building queries in orchestrator
    const soql = `SELECT Id, Status FROM Order WHERE Id = '${orderId}'`;
    const result = await this.sf.query(soql);
    
    // ❌ Orchestrator knows about Salesforce structure
    return DomainMapper.transform(result.records[0]);
  }
}

After (Integration Service - Correct)

@Injectable()
export class OrderOrchestrator {
  constructor(
    private readonly salesforceOrderService: SalesforceOrderService
  ) {}

  async getOrder(orderId: string) {
    // ✅ Clean delegation to integration service
    return this.salesforceOrderService.getOrderById(orderId);
    // ✅ Receives domain type, uses directly
  }
}

Mapper Usage Pattern

Direct Domain Mapper Usage (Correct)

import { Providers } from "@customer-portal/domain/orders";

// ✅ Use domain mapper directly - no wrapper service
const whmcsItems = Providers.Whmcs.mapFulfillmentOrderItems(items);

Redundant Service Wrapper (Wrong)

// ❌ Don't create service wrappers that just delegate
@Injectable()
export class OrderWhmcsMapper {
  mapOrderItemsToWhmcs(items: OrderItem[]) {
    // Just wraps domain mapper - no value added!
    return Providers.Whmcs.mapFulfillmentOrderItems(items);
  }
}

Module Configuration

Integration Module

@Module({
  imports: [ConfigModule],
  providers: [
    SalesforceConnection,
    SalesforceOrderService,
    SalesforceAccountService,
  ],
  exports: [
    SalesforceConnection,
    SalesforceOrderService, // Export for use in application layer
  ],
})
export class SalesforceModule {}

Application Module

@Module({
  imports: [
    SalesforceModule, // Import integration modules
    WhmcsModule,
  ],
  providers: [
    OrderOrchestrator, // Uses integration services
  ],
})
export class OrdersModule {}

Testing Integration Services

Unit Test Pattern

describe("SalesforceOrderService", () => {
  let service: SalesforceOrderService;
  let mockConnection: jest.Mocked<SalesforceConnection>;

  beforeEach(() => {
    mockConnection = {
      query: jest.fn(),
    } as any;
    
    service = new SalesforceOrderService(mockConnection, mockLogger);
  });

  it("should transform raw SF data using domain mapper", async () => {
    const mockRawOrder = { Id: "123", Status: "Active" };
    mockConnection.query.mockResolvedValue({
      records: [mockRawOrder],
    });

    const result = await service.getOrderById("123");

    // Verify domain mapper was used (single transformation)
    expect(result).toBeDefined();
    expect(result.id).toBe("123");
  });
});

Benefits of This Pattern

Architecture Cleanliness

  • Clear separation of concerns
  • Domain stays pure (no infrastructure)
  • Integration complexity encapsulated

Code Quality

  • Easier to test (mock integration services)
  • Easier to swap providers (change integration layer only)
  • No duplication (single transformation path)

Developer Experience

  • Clear patterns to follow
  • No confusion about where code belongs
  • Self-documenting architecture

Migration Checklist

When adding new external system integration:

  • Create integration service in apps/bff/src/integrations/{provider}/services/
  • Move query builders to apps/bff/src/integrations/{provider}/utils/
  • Define raw types in domain packages/domain/{feature}/providers/{provider}/raw.types.ts
  • Define mappers in domain packages/domain/{feature}/providers/{provider}/mapper.ts
  • Export integration service from provider module
  • Use integration service in orchestrators
  • Use domain mappers directly (no wrapper services)
  • Test integration service independently


Last Updated: October 2025
Status: Active Pattern