Assist_Design/docs/decisions/003-map-once-use-everywhere.md

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?

  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

// ❌ 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);