- Streamlined the README.md for clarity and conciseness. - Deleted outdated documentation files related to Freebit SIM management, SIM management API data flow, and various architectural guides to reduce clutter and improve maintainability. - Updated the last modified date in the README to reflect the latest changes.
13 KiB
BFF Integration Layer Patterns
Overview
The BFF Integration Layer is responsible for communicating with external systems (Salesforce, WHMCS, Freebit, etc.) and transforming their responses into domain types. This document describes the clean architecture patterns we follow.
Core Principle: "Map Once, Use Everywhere"
Domain mappers are the ONLY place that transforms raw provider data to domain types.
Everything else just uses the domain types directly.
┌──────────────┐ ┌───────────────────┐ ┌──────────────┐ ┌────────────┐
│ External API │ → │ Integration │ → │ Domain │ → │ Use │
│ (Raw Data) │ │ Service │ │ Mapper │ │ Everywhere │
└──────────────┘ └───────────────────┘ └──────────────┘ └────────────┘
↑
SINGLE transformation
in domain layer!
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
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 Pattern
Structure
Each integration service follows this pattern:
apps/bff/src/integrations/{provider}/
├── services/
│ ├── {provider}-connection.service.ts # HTTP client, auth
│ ├── {provider}-{entity}.service.ts # Entity-specific operations
│ └── {provider}-orchestrator.service.ts # Coordinates multiple operations
├── utils/
│ └── {entity}-query-builder.ts # Query construction (SOQL, etc.)
└── {provider}.module.ts
Integration Service Responsibilities
- ✅ Build queries (SOQL, API parameters)
- ✅ Execute API calls (HTTP, authentication)
- ✅ Use domain mappers to transform responses
- ✅ Return domain types
- ❌ NO additional mapping beyond domain mappers
- ❌ NO business logic
Example: SalesforceOrderService
import { Injectable } from "@nestjs/common";
import { SalesforceConnection } from "./salesforce-connection.service";
import { buildOrderSelectFields } from "../utils/order-query-builder";
import {
Providers as OrderProviders,
type OrderDetails,
type SalesforceOrderRecord,
} from "@customer-portal/domain/orders";
@Injectable()
export class SalesforceOrderService {
constructor(private readonly sf: SalesforceConnection) {}
async getOrderById(orderId: string): Promise<OrderDetails | null> {
// 1. Build query (infrastructure concern)
const fields = buildOrderSelectFields(["Account.Name"]).join(", ");
const soql = `
SELECT ${fields}
FROM Order
WHERE Id = '${orderId}'
LIMIT 1
`;
// 2. Execute query
const result = await this.sf.query(soql);
const order = result.records?.[0];
if (!order) return null;
// 3. Use domain mapper (SINGLE transformation!)
return OrderProviders.Salesforce.transformSalesforceOrderDetails(order, []);
}
}
Query Builder Pattern
Query builders belong in the BFF integration layer because they are infrastructure concerns.
Location
apps/bff/src/integrations/{provider}/utils/
├── order-query-builder.ts
├── catalog-query-builder.ts
└── soql.util.ts
Correct: apps/bff/src/integrations/salesforce/utils/order-query-builder.ts
Wrong: packages/domain/orders/providers/salesforce/query.ts ❌
Example
import { UNIQUE } from "./soql.util";
/**
* Build SOQL SELECT fields for Order queries
*/
export function buildOrderSelectFields(additional: string[] = []): string[] {
const fields = [
"Id",
"AccountId",
"Status",
"Type",
"EffectiveDate",
"OrderNumber",
"TotalAmount",
"CreatedDate",
];
return UNIQUE([...fields, ...additional]);
}
Common Integration Patterns
Pattern 1: Simple Fetch and Transform
async getEntity(id: string): Promise<DomainType | null> {
// 1. Build query
const soql = buildQuery(id);
// 2. Execute
const result = await this.connection.query(soql);
if (!result.records?.[0]) return null;
// 3. Transform with domain mapper
return Providers.{Provider}.transform{Entity}(result.records[0]);
}
Pattern 2: Fetch with Related Data
async getOrderWithItems(orderId: string): Promise<OrderDetails | null> {
// 1. Build queries for both order and items
const orderSoql = buildOrderQuery(orderId);
const itemsSoql = buildOrderItemsQuery(orderId);
// 2. Execute in parallel
const [orderResult, itemsResult] = await Promise.all([
this.sf.query(orderSoql),
this.sf.query(itemsSoql),
]);
const order = orderResult.records?.[0];
if (!order) return null;
const items = itemsResult.records ?? [];
// 3. Transform with domain mapper (handles both order and items)
return OrderProviders.Salesforce.transformSalesforceOrderDetails(order, items);
}
Pattern 3: Batch Transform with Error Handling
async getInvoices(clientId: number): Promise<Invoice[]> {
// 1. Fetch from API
const response = await this.whmcsClient.getInvoices({ clientId });
if (!response.invoices?.invoice) return [];
// 2. Get infrastructure context (currency)
const defaultCurrency = this.currencyService.getDefaultCurrency();
// 3. Transform batch with error handling
const invoices: Invoice[] = [];
for (const whmcsInvoice of response.invoices.invoice) {
try {
const transformed = Providers.Whmcs.transformWhmcsInvoice(whmcsInvoice, {
defaultCurrencyCode: defaultCurrency.code,
defaultCurrencySymbol: defaultCurrency.prefix || defaultCurrency.suffix,
});
invoices.push(transformed);
} catch (error) {
this.logger.error(`Failed to transform invoice ${whmcsInvoice.id}`, { error });
}
}
return invoices;
}
Pattern 4: Integration with Caching
async getInvoice(invoiceId: number, userId: string): Promise<Invoice> {
// 1. Check cache
const cached = await this.cacheService.getInvoice(userId, invoiceId);
if (cached) return cached;
// 2. Fetch from API
const response = await this.whmcsClient.getInvoice({ invoiceid: invoiceId });
// 3. Transform with domain mapper
const defaultCurrency = this.currencyService.getDefaultCurrency();
const invoice = Providers.Whmcs.transformWhmcsInvoice(response, {
defaultCurrencyCode: defaultCurrency.code,
defaultCurrencySymbol: defaultCurrency.prefix || defaultCurrency.suffix,
});
// 4. Cache and return
await this.cacheService.setInvoice(userId, invoiceId, invoice);
return invoice;
}
Context Injection Pattern
Some transformations need infrastructure context (like currency). Pass it explicitly:
// ✅ Correct: Inject infrastructure context
const defaultCurrency = this.currencyService.getDefaultCurrency();
const subscription = Providers.Whmcs.transformWhmcsSubscription(whmcsProduct, {
defaultCurrencyCode: defaultCurrency.code,
defaultCurrencySymbol: defaultCurrency.prefix || defaultCurrency.suffix,
});
Why this is clean:
- Domain mapper is pure (deterministic for same inputs)
- Infrastructure concern (currency) is injected from BFF
- No service wrapper needed
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
}
}
Anti-Patterns to Avoid
❌ Transformer/Wrapper Services
// DON'T: Create wrapper services that just call domain mappers
@Injectable()
export class InvoiceTransformerService {
transformInvoice(raw: WhmcsInvoice): Invoice {
return Providers.Whmcs.transformWhmcsInvoice(raw, {...});
}
}
Why it's bad:
- Adds unnecessary layer
- No value beyond what domain mapper provides
- Makes codebase harder to understand
Instead: Use domain mapper directly in integration service
❌ Multiple Transformations
// DON'T: Transform multiple times
const rawData = await api.fetch();
const intermediate = this.customTransform(rawData);
const domainType = Providers.X.transform(intermediate);
Instead: Transform once using domain mapper
Module Organization
@Module({
imports: [ConfigModule],
providers: [
// Connection services
SalesforceConnection,
// Entity-specific integration services
SalesforceOrderService,
SalesforceAccountService,
// Main service
SalesforceService,
],
exports: [
SalesforceService,
SalesforceOrderService, // Export for use in other modules
],
})
export class SalesforceModule {}
Best Practices Summary
✅ DO
- Keep integration services thin - just fetch and transform
- Use domain mappers directly - no wrappers
- Build queries in utils - separate query construction
- Handle errors gracefully - log and throw/return null
- Cache when appropriate - reduce API calls
- Inject context explicitly - currency, config, etc.
- Return domain types - never return raw API responses
❌ DON'T
- Create wrapper services - use domain mappers directly
- Add business logic - belongs in domain or orchestrators
- Transform twice - map once in domain
- Expose raw types - always return domain types
- Hard-code queries - use query builders
- Skip error handling - always log failures
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