Assist_Design/docs/bff/BFF-INTEGRATION-PATTERNS-ARCHITECTURE.md

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