4.4 KiB
4.4 KiB
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?
-
No drift: One transformation = one place where the mapping can be wrong
-
No confusion: Developers know exactly where to look for mapping logic
-
No wrapper services: Domain mappers are functions, not services. No need for
InvoiceTransformerServicethat just callstransformWhmcsInvoice() -
Simpler data flow: Raw data → domain type. No intermediate representations.
The Anti-Pattern: Multiple Transformations
// ❌ 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
// ✅ 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
// 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
// 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
// ❌ 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);