Different shapes can carry the same fact.
A carrier calls a shipment in_transit, sends shipped_at as text,
and uses cents for money. The delivery domain wants a checked ShipmentStatus, a timestamp it can compare, and a Money value whose
currency is explicit. These are not merely different property names: they have different owners
and different failure rules.
Mapping too late leaks the carrier into domain rules and UI code. Mapping everywhere duplicates parsing and makes a provider change a search across the whole application. A named edge keeps the translation close to the external contract and gives it one set of tests.
Translate once at the boundary; do not make the domain remember every outside vocabulary.
Read the direct payloadTypeScript · convenient fields become shared assumptions
// The direct path lets provider vocabulary reach every caller.
export function directLabel(payload: CarrierShipment): string {
return `${payload.tracking_number} · ${payload.carrier_status}`;
}
export function directIsMoving(payload: CarrierShipment): boolean {
return payload.carrier_status === 'in_transit';
}
export function directAmount(payload: CarrierShipment): string {
return `${payload.amount_cents} ${payload.currency}`;
} The direct path reads status, shipped_at, and amount_cents wherever they are useful. That is fair for a tiny one-caller script.
Once the carrier can change independently or several callers need the same shipment meaning,
those fields become accidental domain API.
Put each decision where its meaning lives.
The adapter can reject malformed carrier data and translate its vocabulary. It should not decide whether a late shipment earns a refund; that rule belongs to the delivery domain.
Decode and translate
Field names, timestamps, provider statuses, currency units, and missing fields.
State what it means
Shipment status, money invariants, late-delivery policy, and allowed transitions.
Orchestrate the use
Ask the carrier, map the result, apply the workflow, and choose a response.
| Decision | Mapper | Domain |
|---|---|---|
| “in_transit” becomes what? | Translate provider status | Use the canonical shipment status |
| Is “2026-09-16” parseable? | Validate and reject malformed input | Compare a valid instant or date value |
| Does a late shipment refund? | Do not decide | Apply the business policy |
| What does the UI display? | Do not own the screen | Return a view/projection for the caller |
Read the Go mappingGo · explicit result and error at the edge
func mapShipment(payload CarrierShipment) (Shipment, []Issue) {
issues := []Issue{}
trackingID := strings.TrimSpace(payload.TrackingNumber)
statusMap := map[string]ShipmentStatus{"label_created": LabelCreated, "in_transit": InTransit, "delivered": Delivered}
status, knownStatus := statusMap[payload.CarrierStatus]
shippedAt, timeErr := time.Parse(time.RFC3339, payload.ShippedAt)
if trackingID == "" {
issues = append(issues, Issue{Field: "tracking_number", Message: "is required"})
}
if !knownStatus {
issues = append(issues, Issue{Field: "carrier_status", Message: "is not a supported carrier status"})
}
if timeErr != nil {
issues = append(issues, Issue{Field: "shipped_at", Message: "must be an ISO timestamp"})
}
if payload.AmountCents < 0 {
issues = append(issues, Issue{Field: "amount_cents", Message: "must be a non-negative integer"})
}
if payload.Currency != "EUR" && payload.Currency != "USD" {
issues = append(issues, Issue{Field: "currency", Message: "must be EUR or USD"})
}
if len(issues) > 0 {
return Shipment{}, issues
}
return Shipment{TrackingID: trackingID, Status: status, ShippedAt: shippedAt, Cents: payload.AmountCents, Currency: payload.Currency}, nil
} Go makes the translation result and error explicit. The mapper can return a useful issue without allowing a provider status or malformed timestamp to become a domain value by accident.
Change the location of the mapping and watch the ownership move.
Choose where a carrier payload is translated. Mapping at the adapter keeps provider vocabulary outside. Mapping in the domain mixes boundary parsing with business meaning. Mapping in the UI makes every screen another adapter.
Choose the layer that receives the decision.
Translate `in_transit` and `delivered` into the canonical status set.
External adapter
Watch for A provider field that leaks inward becomes vocabulary every caller must remember.
Choose the owner before writing the transform.
For each decision, ask whether it is about an outside representation, a domain invariant, or a caller’s view.
A good adapter can change without teaching the domain a new dialect.
The example’s carrier adapter maps carrier_status into a canonical status, parses
the shipment date, and converts cents into a money object. It returns a domain-ready shipment
or named boundary issues. Any rule about what a status means for the delivery, such as when a
shipment counts as late, belongs to the domain code that receives the shipment, not to the adapter.
Test the edges that force a decision: an unknown provider status, an empty tracking number, a date with the wrong shape, a negative amount, a second currency, and a successful response with fields the domain does not need. Mapping tests pin the external contract; domain tests pin meaning; end-to-end tests pin their handoff.
A carrier payload carries external names, strings, and a provider-specific kind.
// The direct path lets provider vocabulary reach every caller.
export function directLabel(payload: CarrierShipment): string {
return `${payload.tracking_number} · ${payload.carrier_status}`;
}
export function directIsMoving(payload: CarrierShipment): boolean {
return payload.carrier_status === 'in_transit';
}
export function directAmount(payload: CarrierShipment): string {
return `${payload.amount_cents} ${payload.currency}`;
} func directLabel(payload CarrierShipment) string {
return payload.TrackingNumber + " · " + payload.CarrierStatus
}
func directIsMoving(payload CarrierShipment) bool {
return payload.CarrierStatus == "in_transit"
}
func directAmount(payload CarrierShipment) string {
return fmt.Sprintf("%d %s", payload.AmountCents, payload.Currency)
} Reject drift
Malformed or unknown external data stops before it can masquerade as a valid domain value.
Keep vocabulary local
Provider names and units stay in the adapter; canonical types travel inward.
Project for the caller
The application returns only the fields a page, job, or API contract needs.
A component should not become a carrier integration.
Build frontends?Keep external vocabulary out of rendering code.
Where it already is in your components
A component that reads carrier_status or turns shipped_at into a label
is already mapping. Every screen that does it holds its own copy of the carrier’s vocabulary.
When you have to own it
When a provider’s names or statuses would reach your markup, own where the translation
lives. The textbook components receive a delivery facade and render a display projection.
The wild components map carrier_status and shipped_at in the component,
so provider changes now belong to every screen that copied the transform.
The UI receives a display projection from the application and never sees carrier fields.
type ShipmentView = { trackingId: string; displayStatus: string; amount: string };
type DeliveryApp = { getShipmentView(trackingId: string): ShipmentView | null };
export function ShipmentCard({ app, trackingId }: { app: DeliveryApp; trackingId: string }) {
const shipment = app.getShipmentView(trackingId);
if (!shipment) return <p>Shipment not found</p>;
return (
<p>
<strong>{shipment.trackingId}</strong> · {shipment.displayStatus} · {shipment.amount}
</p>
);
}
Mapping layers fail when they absorb decisions that belong elsewhere.
Business policy in the adapter
Parsing “late” is mapping; granting a refund is domain policy. Keep the rule where its owner can see it.
Silent unknowns
Do not turn an unknown provider status into “pending” without an explicit forward-compatibility decision.
Two-way confusion
Inbound carrier-to-domain and outbound domain-to-carrier mappings can have different shapes and failure rules.
God mappers
A mapper that knows every endpoint, screen, and business rule is an application service wearing an adapter’s name.
When raw evidence deserves a homePreserve it deliberately, not accidentally
Support workflows may need the original carrier response for reconciliation. Store it in an explicitly owned raw-evidence record with retention and access rules. That is different from passing the carrier DTO through the domain because it is convenient.
Add a mapping layer when the boundary has a different owner or vocabulary.
Share a shape when one team, one process, one meaning, and a thin read-only path make the extra model needless. Map when an external provider evolves separately, several consumers need different projections, the domain has invariants, or the outside representation would otherwise become your language.
Keep the mapper close to its boundary, make rejected input visible, and keep business decisions in the domain. If the transform is pure, it should be easy to test without a network or database.
Keep this questionUse it at the next integration boundary.
Which fields belong to the outside contract, which facts belong to the domain, and who owns the decision when they disagree?
A mapper makes a contract’s ownership executable.
Translate at the edge so the inside can speak in the language it owns.
Connections to follow nextRelated lessons
- Domain model vs. DTO asks whether two shapes have earned separation.
- Validation at the edge checks input before trust.
- Serialization hazards shows why a wire representation needs deliberate choices.
- Module boundaries keeps the adapter’s public surface and dependencies visible.
- Why
- The carrier’s names, dates, and statuses evolve on the carrier’s schedule, not yours.
- What
- One adapter maps the payload to a
Shipmentin domain terms, or returns issues. - Constraint
- Business decisions stay in the domain; the mapper only translates and rejects.
- Fallback
- An unknown carrier status or an invalid timestamp is rejected at the adapter.
- Reconsider when
- One team, one meaning, and a thin read-only path make the second model needless.