148 lines
4.4 KiB
Markdown
148 lines
4.4 KiB
Markdown
# ADR-003: Map Once, Use Everywhere
|
|
|
|
**Date**: 2025-01-15
|
|
**Status**: Accepted
|
|
|
|
## Context
|
|
|
|
The Customer Portal integrates with multiple external systems (WHMCS, Salesforce, Freebit) that each have their own data structures. These raw API responses need to be transformed into normalized domain types.
|
|
|
|
Common anti-patterns:
|
|
|
|
- Transforming data multiple times at different layers
|
|
- Creating "transformer services" that wrap domain mappers
|
|
- Leaking raw provider types into application code
|
|
|
|
## Decision
|
|
|
|
**Transform external data exactly once** in domain mappers. Everything else uses the normalized domain types directly.
|
|
|
|
```
|
|
External API → Integration Service → Domain Mapper → Domain Type → Use Directly
|
|
↑
|
|
SINGLE transformation
|
|
```
|
|
|
|
## Rationale
|
|
|
|
### Why Single Transformation?
|
|
|
|
1. **No drift**: One transformation = one place where the mapping can be wrong
|
|
|
|
2. **No confusion**: Developers know exactly where to look for mapping logic
|
|
|
|
3. **No wrapper services**: Domain mappers are functions, not services. No need for `InvoiceTransformerService` that just calls `transformWhmcsInvoice()`
|
|
|
|
4. **Simpler data flow**: Raw data → domain type. No intermediate representations.
|
|
|
|
### The Anti-Pattern: Multiple Transformations
|
|
|
|
```typescript
|
|
// ❌ BAD: Multiple transformation layers
|
|
const raw = await whmcsApi.getInvoice(id);
|
|
const intermediate = customNormalize(raw); // First transform
|
|
const domainType = transformWhmcsInvoice(intermediate); // Second transform
|
|
const viewModel = mapToViewModel(domainType); // Third transform
|
|
```
|
|
|
|
### The Pattern: Single Transformation
|
|
|
|
```typescript
|
|
// ✅ GOOD: Single transformation
|
|
const raw = await whmcsApi.getInvoice(id);
|
|
const invoice = transformWhmcsInvoice(raw); // One transform
|
|
// Use invoice directly everywhere
|
|
```
|
|
|
|
### Alternatives Considered
|
|
|
|
| Approach | Pros | Cons |
|
|
| ------------------------- | --------------------------- | -------------------------------- |
|
|
| **Multiple layers** | "Separation of concerns" | Complexity, drift, hard to trace |
|
|
| **Transformer services** | Injectable, testable | Unnecessary indirection |
|
|
| **Single transformation** | Simple, traceable, no drift | Mappers must handle all cases |
|
|
|
|
## Consequences
|
|
|
|
### Positive
|
|
|
|
- Clear, traceable data flow
|
|
- No wrapper services to maintain
|
|
- One place for all transformation logic
|
|
- Domain types used consistently everywhere
|
|
|
|
### Negative
|
|
|
|
- Domain mappers must handle all edge cases
|
|
- Can't easily add "view-specific" transformations (but this is usually a sign of missing domain concepts)
|
|
|
|
## Implementation
|
|
|
|
### Integration Service Pattern
|
|
|
|
```typescript
|
|
// apps/bff/src/integrations/whmcs/services/whmcs-invoice.service.ts
|
|
@Injectable()
|
|
export class WhmcsInvoiceService {
|
|
async getInvoice(id: number): Promise<Invoice> {
|
|
// 1. Fetch raw data
|
|
const raw = await this.whmcsClient.getInvoice({ invoiceid: id });
|
|
|
|
// 2. Transform ONCE with domain mapper
|
|
return transformWhmcsInvoice(raw, {
|
|
defaultCurrencyCode: this.currency.code,
|
|
});
|
|
|
|
// 3. Return domain type - used directly everywhere
|
|
}
|
|
}
|
|
```
|
|
|
|
### Domain Mapper
|
|
|
|
```typescript
|
|
// packages/domain/billing/providers/whmcs/mapper.ts
|
|
export function transformWhmcsInvoice(raw: unknown, context: TransformContext): Invoice {
|
|
// Validate input
|
|
const validated = whmcsInvoiceRawSchema.parse(raw);
|
|
|
|
// Transform to domain model
|
|
const result: Invoice = {
|
|
id: validated.invoiceid,
|
|
status: mapInvoiceStatus(validated.status),
|
|
total: {
|
|
amount: parseFloat(validated.total),
|
|
currency: context.defaultCurrencyCode,
|
|
},
|
|
// ... all mappings in one place
|
|
};
|
|
|
|
// Validate output
|
|
return invoiceSchema.parse(result);
|
|
}
|
|
```
|
|
|
|
### What NOT to Do
|
|
|
|
```typescript
|
|
// ❌ DON'T create wrapper services
|
|
@Injectable()
|
|
export class InvoiceTransformerService {
|
|
transform(raw: WhmcsInvoice): Invoice {
|
|
return transformWhmcsInvoice(raw); // Pointless wrapper
|
|
}
|
|
}
|
|
|
|
// ❌ DON'T transform multiple times
|
|
const raw = await api.fetch();
|
|
const normalized = this.normalizer.normalize(raw);
|
|
const mapped = this.mapper.map(normalized);
|
|
const enriched = this.enricher.enrich(mapped);
|
|
```
|
|
|
|
## Related
|
|
|
|
- [BFF Integration Patterns](../development/bff/integration-patterns.md)
|
|
- [Domain Structure](../development/domain/structure.md)
|
|
- [ADR-004: Domain Provider Isolation](./004-domain-provider-isolation.md)
|