400 lines
12 KiB
Markdown
400 lines
12 KiB
Markdown
|
|
# 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!
|
||
|
|
```
|
||
|
|
|
||
|
|
## 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
|
||
|
|
|
||
|
|
1. ✅ **Build queries** (SOQL, API parameters)
|
||
|
|
2. ✅ **Execute API calls** (HTTP, authentication)
|
||
|
|
3. ✅ **Use domain mappers** to transform responses
|
||
|
|
4. ✅ **Return domain types**
|
||
|
|
5. ❌ **NO additional mapping** beyond domain mappers
|
||
|
|
6. ❌ **NO business logic**
|
||
|
|
|
||
|
|
### Example: SalesforceOrderService
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
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, []);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### ✅ Correct Pattern: Direct Domain Mapper Usage
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// In integration service
|
||
|
|
const defaultCurrency = this.currencyService.getDefaultCurrency();
|
||
|
|
const invoice = Providers.Whmcs.transformWhmcsInvoice(rawInvoice, {
|
||
|
|
defaultCurrencyCode: defaultCurrency.code,
|
||
|
|
defaultCurrencySymbol: defaultCurrency.prefix || defaultCurrency.suffix,
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
### ❌ Anti-Pattern: Wrapper Services
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// DON'T DO THIS - Redundant wrapper!
|
||
|
|
@Injectable()
|
||
|
|
export class InvoiceTransformerService {
|
||
|
|
transformInvoice(whmcsInvoice: WhmcsInvoice): Invoice {
|
||
|
|
return Providers.Whmcs.transformWhmcsInvoice(whmcsInvoice, {...});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// This just adds an extra layer with no value!
|
||
|
|
```
|
||
|
|
|
||
|
|
## Query Builders
|
||
|
|
|
||
|
|
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
|
||
|
|
```
|
||
|
|
|
||
|
|
### Example: Order Query Builder
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
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]);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Build SOQL SELECT fields for OrderItem queries
|
||
|
|
*/
|
||
|
|
export function buildOrderItemSelectFields(additional: string[] = []): string[] {
|
||
|
|
const fields = [
|
||
|
|
"Id", "OrderId", "Quantity", "UnitPrice", "TotalPrice"
|
||
|
|
];
|
||
|
|
return UNIQUE([...fields, ...additional]);
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
## Common Integration Patterns
|
||
|
|
|
||
|
|
### Pattern 1: Simple Fetch and Transform
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
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
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
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
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
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: Create/Update Operations
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
async createOrder(orderFields: Record<string, unknown>): Promise<{ id: string }> {
|
||
|
|
this.logger.log({ orderType: orderFields.Type }, "Creating Salesforce Order");
|
||
|
|
|
||
|
|
try {
|
||
|
|
const created = await this.sf.sobject("Order").create(orderFields);
|
||
|
|
this.logger.log({ orderId: created.id }, "Salesforce Order created successfully");
|
||
|
|
return created;
|
||
|
|
} catch (error) {
|
||
|
|
this.logger.error("Failed to create Salesforce Order", { error });
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
## Integration with Caching
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
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:
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// ✅ 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
|
||
|
|
|
||
|
|
## Module Organization
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
@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 {}
|
||
|
|
```
|
||
|
|
|
||
|
|
## Anti-Patterns to Avoid
|
||
|
|
|
||
|
|
### ❌ Transformer Services
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// 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
|
||
|
|
|
||
|
|
### ❌ Mapper Services
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// DON'T: Wrap domain mappers in injectable services
|
||
|
|
@Injectable()
|
||
|
|
export class FreebitMapperService {
|
||
|
|
mapToSimDetails(response: unknown): SimDetails {
|
||
|
|
return FreebitProvider.transformFreebitAccountDetails(response);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
**Why it's bad:**
|
||
|
|
- Just wraps domain mapper
|
||
|
|
- Adds injection complexity for no benefit
|
||
|
|
|
||
|
|
**Instead:** Import domain mapper directly and use it
|
||
|
|
|
||
|
|
### ❌ Multiple Transformations
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// DON'T: Transform multiple times
|
||
|
|
const rawData = await api.fetch();
|
||
|
|
const intermediate = this.customTransform(rawData);
|
||
|
|
const domainType = Providers.X.transform(intermediate);
|
||
|
|
```
|
||
|
|
|
||
|
|
**Why it's bad:**
|
||
|
|
- Multiple transformation points
|
||
|
|
- Risk of data inconsistency
|
||
|
|
- Harder to maintain
|
||
|
|
|
||
|
|
**Instead:** Transform once using domain mapper
|
||
|
|
|
||
|
|
## Best Practices
|
||
|
|
|
||
|
|
### ✅ DO
|
||
|
|
|
||
|
|
1. **Keep integration services thin** - just fetch and transform
|
||
|
|
2. **Use domain mappers directly** - no wrappers
|
||
|
|
3. **Build queries in utils** - separate query construction
|
||
|
|
4. **Handle errors gracefully** - log and throw/return null
|
||
|
|
5. **Cache when appropriate** - reduce API calls
|
||
|
|
6. **Inject context explicitly** - currency, config, etc.
|
||
|
|
7. **Return domain types** - never return raw API responses
|
||
|
|
|
||
|
|
### ❌ DON'T
|
||
|
|
|
||
|
|
1. **Create wrapper services** - use domain mappers directly
|
||
|
|
2. **Add business logic** - belongs in domain or orchestrators
|
||
|
|
3. **Transform twice** - map once in domain
|
||
|
|
4. **Expose raw types** - always return domain types
|
||
|
|
5. **Hard-code queries** - use query builders
|
||
|
|
6. **Skip error handling** - always log failures
|
||
|
|
|
||
|
|
## Summary
|
||
|
|
|
||
|
|
**Integration services** are responsible for:
|
||
|
|
- 🔧 Building queries (SOQL, API params)
|
||
|
|
- 🌐 Executing API calls
|
||
|
|
- 🔄 Using domain mappers to transform responses
|
||
|
|
- 📦 Returning domain types
|
||
|
|
|
||
|
|
**Integration services** should NOT:
|
||
|
|
- ❌ Wrap domain mappers in services
|
||
|
|
- ❌ Add additional transformation layers
|
||
|
|
- ❌ Contain business logic
|
||
|
|
- ❌ Expose raw provider types
|
||
|
|
|
||
|
|
**Remember:** "Map Once, Use Everywhere" - domain mappers are the single source of truth for transformations.
|
||
|
|
|