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
- Encapsulation: All system-specific logic hidden from application layer
- Single Transformation: Uses domain mappers, no additional mapping
- Returns Domain Types: Application layer works with domain types only
- 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
Related Documentation
Last Updated: October 2025
Status: ✅ Active Pattern