Index

An Anti-Corruption Layer Must Own the Translation

Payments adds a refund state named reversed. Order Management already has a default branch for provider failures, so the new event keeps moving:

anything except completed ──▶ provider-refund-failed

Nothing crashes. That is the problem.

The provider has reported a fact, but Orders has invented its consequence. A reversed refund might reopen the order, create a debt, require manual recovery, or leave the order cancelled while Finance resolves the money. The provider enum cannot choose among those policies.

An anti-corruption layer protects a downstream model only when it owns this semantic decision: it translates into downstream language, rejects meanings the downstream has not accepted, and covers every integration path it claims to isolate. An Adapter class that returns a renamed provider DTO does none of those things.

The same message answers different questions

This article uses a synthetic continuation of the cancellation example from the context-map article. Payments exposes a provider lifecycle:

requested → pending → completed
                    ↘ declined
                    ↘ reversed

Order Management does not need a second refund lifecycle. It needs decisions about cancellation:

awaiting-refund-outcome
cancellation-may-complete
manual-refund-recovery-required
unsupported-refund-outcome

The lists overlap, but they are not interchangeable. completed can support “cancellation may complete” after the event’s order, amount, and currency match the captured payment. A recognized decline can support manual recovery. reversed supplies no Order decision at all.

That mismatch is the reason to translate. Changing pending into RefundPending would only change spelling.

Eric Evans’s DDD Reference describes an Anticorruption Layer as a downstream isolating layer which expresses the upstream system in terms of the downstream model. Context Mapper likewise places the ACL role on the downstream. The direction matters: Orders owns the language it needs to protect, so Orders must own the translation result.

Conformity is a real alternative

The synthetic fixture keeps a direct-conformity path as a control. It imports the provider enum into Order policy, validates the event version and money, and handles every state known before reversed appears:

if (state === ProviderRefundState.Pending) {
  return 'awaiting-refund-outcome'
}

if (state === ProviderRefundState.Completed) {
  return 'cancellation-may-complete'
}

if (state === ProviderRefundState.Declined) {
  return 'manual-refund-recovery-required'
}

This design is not automatically careless. A Conformist relationship is often the clearer choice when both sides mean the same thing, the upstream model is stable enough, and a local type would merely duplicate it. Translation has a maintenance cost. It should buy semantic independence, not architectural decoration.

The control becomes revealing when reversed arrives. Its fallback accepts the event as a generic provider failure. The system stays available by making an unsupported business decision.

The anti-corruption path does the opposite. It recognizes reversed as a valid provider state and refuses to translate it:

if (!supportedStates.has(event.data.state) ||
    event.data.state === 'reversed') {
  return rejected(
    event,
    'unsupported-refund-state',
    `Order Management has no decision for ${event.data.state}.`,
  )
}

Refusal is not more sophisticated than conformity. It is more accurate under this constraint: the models disagree and Payments cannot decide Order policy.

Translation ends in downstream language

A transport mapper commonly produces a local object with the same fields:

ProviderRefundDto ──▶ LocalRefundDto

That can isolate a serialization library, but it has not yet protected the domain model. If LocalRefundDto.state still contains provider values and Order policy still switches over them, the upstream language crossed the boundary intact.

The fixture’s translation result is deliberately smaller:

{
  "decision_id": "refund-event:evt-completed-1",
  "source_event_id": "evt-completed-1",
  "order_id": "order-1001",
  "kind": "cancellation-may-complete"
}

No provider state or reason code enters Order policy. The translator checks the envelope, supported version, order reference, amount, currency, provider state, and recognized decline reasons. Only then does it return an Order-owned outcome.

The boundary is also narrow in the other direction. The translator does not save an order, publish events, or orchestrate recovery. Those are downstream application decisions. Putting them into the translation layer would protect the vocabulary while hiding control flow in an integration adapter.

Unknown meaning is a terminal translation failure

An unsupported state will not become supported after five retries. Neither will an unknown event version, malformed amount, or unrecognized decline reason. Treating all failures as transient adds queue traffic without adding information.

The fixture returns a structured rejection instead:

{
  "source_event_id": "evt-reversed-1",
  "order_id": "order-1001",
  "code": "unsupported-refund-state",
  "retryable": false,
  "action": "quarantine-for-review"
}

This record does not prove that a dead-letter queue, alert, or repair console exists. The fixture contains none of those. It establishes the information an operator path would need: the upstream identity, affected order, failure category, retry classification, and next action.

Money disagreement also stops at the boundary. A completed refund for the wrong amount or currency cannot authorize cancellation merely because its state is familiar. The translator rejects both cases before Order policy sees an outcome.

Delivery identity remains distinct from business identity. Replaying the same provider event creates the same decision ID, and Order Management records that decision once. A retry can therefore repeat delivery without repeating the transition.

The layer protects only the paths that use it

One correct webhook handler does not protect a replay command, batch importer, or repair script that deserializes provider events directly. “All traffic goes through the ACL” is an architectural claim, not a property of the class name.

The reproduction declares a smaller scope: refund outcomes entering Orders through the provider webhook and the provider replay path. Static analysis confirms that both import the Order-owned translator. Refund creation is explicitly excluded because this fixture does not test that interaction.

Microsoft’s Anti-Corruption Layer guidance makes the same scope distinction: a layer may mediate all communication or a selected subset. The honest map and code inventory must say which. Claiming a third ingress path without implementing its translation fails the fixture instead of silently expanding the boundary.

The same analysis checks the other side of the seam. Order policy contains no provider enum, reason code, transport version, or state switch. The translator contains no Order persistence or orchestration. Behavioral tests and source dependency checks prove different things; this boundary needs both.

The change moves; it does not disappear

An ACL adds code. It may also add latency and another operational component if deployed as a separate service. Microsoft’s guidance calls out those costs and also notes that the layer can remain a component inside the application. A network hop is not part of the pattern’s definition.

Executable before-and-after variants derive three changed files in either design. The conformity path changes its provider contract, Order policy, and expected result. The ACL path changes its provider contract, translator, and expected result. Its Order policy remains byte-for-byte unchanged.

That is not a scorecard victory. The useful difference is where the unresolved meaning appears. Direct conformity exposes it inside Order policy after the provider enum arrives. The ACL exposes it as an unsupported boundary input until an Order owner decides what it means.

The retained evidence tries 13 ways to weaken that distinction: leak the provider enum into Order policy, return the provider DTO, default an unknown state, accept reversed as completed, ignore amount or currency, accept an unknown version, lose event identity, bypass replay translation, generate a new identity on redelivery, retry semantic failure, orchestrate inside the translator, or claim a missing ingress path. Every mutation fails. That proves the synthetic seam; it does not prove a real team’s ownership or maintenance cost.

Choose the relationship before naming the adapter

Use direct conformity when the upstream language is acceptable and a local translation would only mirror it. Use an anti-corruption layer when the downstream would otherwise inherit policy from a model it does not control, and the semantic disagreement is valuable enough to maintain explicitly. Use Separate Ways when the integration itself costs more than the business relationship is worth.

Those choices can reverse. Remove the ACL when the models genuinely converge, when the protected interaction disappears, or when its translation work no longer buys meaningful independence. Expand it only when another ingress path needs the same protection and can be verified.

For the synthetic refund relationship, reversed remains unsupported. The correct next step is not another else branch. An Order owner must decide the customer-visible and operational consequence, add that outcome to Order language, and then teach the translator. Until that happens, the rejection is the model protecting itself.