← Concepts & practices
Choice Boundaries and contracts

The mapping layer

Translate at the edge, keep meaning inside.

A delivery service receives a carrier payload with snake_case names, string dates, provider statuses, and a cents value. Let that shape travel inward and every rule starts speaking carrier. Map it at the boundary, validate the distinctions the domain needs, and keep the translation in the adapter that owns the external contract.

The judgment to keep

A mapper is a translation boundary, not a dumping ground for business policy. It owns external names, parsing, and boundary rejection; the domain owns meaning and invariants; the caller owns orchestration and the output projection.

TypeScriptGo One carrier shipment · two mapping directions.
Start with the translation

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
mapping.ts · direct carrier fields
// 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.

Choose the owner

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.

External adapter

Decode and translate

Field names, timestamps, provider statuses, currency units, and missing fields.

Domain

State what it means

Shipment status, money invariants, late-delivery policy, and allowed transitions.

Application

Orchestrate the use

Ask the carrier, map the result, apply the workflow, and choose a response.

Where a mapping decision belongs
DecisionMapperDomain
“in_transit” becomes what?Translate provider statusUse the canonical shipment status
Is “2026-09-16” parseable?Validate and reject malformed inputCompare a valid instant or date value
Does a late shipment refund?Do not decideApply the business policy
What does the UI display?Do not own the screenReturn a view/projection for the caller
Read the Go mappingGo · explicit result and error at the edge
mapping.go · carrier adapter
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.

Follow a payload

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.

Carrier shipment

Choose the layer that receives the decision.

Runs a local mapping model
Carrier payload→Shipment status
01Carrier payload arrives at the external adapter
02Decide whether the value is representation, meaning, or policy
03Translate `in_transit` and `delivered` into the canonical status set.
04Pass shipment status to the next owner.
Decision

Translate `in_transit` and `delivered` into the canonical status set.

Location

External adapter

External adapter owns the decision.

Watch for A provider field that leaks inward becomes vocabulary every caller must remember.

The controls model ownership; they do not call a carrier or store raw payloads.
Place the work

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 carrier adds `delayed`. Where should an unknown value be handled?
A valid shipment is late. Who decides whether to refund?
Two pages need different labels for the same shipment. What is a good boundary?
Support needs the original carrier response. What is safest?
Feedback stays on this page; it is not saved.
Keep the mapper small

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.

TypeScriptReading
mapping.ts
// 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}`;
}
GoAlongside
mapping.go
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)
}
Input boundary

Reject drift

Malformed or unknown external data stops before it can masquerade as a valid domain value.

Translation

Keep vocabulary local

Provider names and units stay in the adapter; canonical types travel inward.

Output boundary

Project for the caller

The application returns only the fields a page, job, or API contract needs.

Recognize it in UI code

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.

ReactAlready in your code
textbook.tsx · domain projection
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>
	);
}
Keep the translation honest

Mapping layers fail when they absorb decisions that belong elsewhere.

01

Business policy in the adapter

Parsing “late” is mapping; granting a refund is domain policy. Keep the rule where its owner can see it.

02

Silent unknowns

Do not turn an unknown provider status into “pending” without an explicit forward-compatibility decision.

03

Two-way confusion

Inbound carrier-to-domain and outbound domain-to-carrier mappings can have different shapes and failure rules.

04

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.

Make the call

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?

Take the idea with you

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
Why
The carrier’s names, dates, and statuses evolve on the carrier’s schedule, not yours.
What
One adapter maps the payload to a Shipment in 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.