406 lines
11 KiB
Markdown
406 lines
11 KiB
Markdown
|
|
# 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
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
@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
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
/**
|
||
|
|
* 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
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
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
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
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)
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
@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)
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
@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)
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
import { Providers } from "@customer-portal/domain/orders";
|
||
|
|
|
||
|
|
// ✅ Use domain mapper directly - no wrapper service
|
||
|
|
const whmcsItems = Providers.Whmcs.mapFulfillmentOrderItems(items);
|
||
|
|
```
|
||
|
|
|
||
|
|
### Redundant Service Wrapper (Wrong)
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// ❌ 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
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
@Module({
|
||
|
|
imports: [ConfigModule],
|
||
|
|
providers: [
|
||
|
|
SalesforceConnection,
|
||
|
|
SalesforceOrderService,
|
||
|
|
SalesforceAccountService,
|
||
|
|
],
|
||
|
|
exports: [
|
||
|
|
SalesforceConnection,
|
||
|
|
SalesforceOrderService, // Export for use in application layer
|
||
|
|
],
|
||
|
|
})
|
||
|
|
export class SalesforceModule {}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Application Module
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
@Module({
|
||
|
|
imports: [
|
||
|
|
SalesforceModule, // Import integration modules
|
||
|
|
WhmcsModule,
|
||
|
|
],
|
||
|
|
providers: [
|
||
|
|
OrderOrchestrator, // Uses integration services
|
||
|
|
],
|
||
|
|
})
|
||
|
|
export class OrdersModule {}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Testing Integration Services
|
||
|
|
|
||
|
|
### Unit Test Pattern
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
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
|
||
|
|
|
||
|
|
- [Domain Package README](../../packages/domain/README.md)
|
||
|
|
- [ORDERS-ARCHITECTURE-REVIEW.md](../ORDERS-ARCHITECTURE-REVIEW.md)
|
||
|
|
- [Schema-First Approach](../../packages/domain/SCHEMA-FIRST-COMPLETE.md)
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
**Last Updated**: October 2025
|
||
|
|
**Status**: ✅ Active Pattern
|
||
|
|
|