Software Architecture · Domain Modeling
Anti-Corruption Layers at System Edges
How a translation layer keeps an external model and its failure semantics out of the domain, where the layer belongs in the call path, and when it costs more than it saves.
A payment provider returns a status of requires_action with a nested next_action object containing a redirect URL and a use_stripe_sdk flag. A shipping carrier reports EX for exception and POD for proof of delivery in a short status code field shared with many other codes. A legacy ledger exposes an account balance as a nine-digit string with two implied decimal places and a trailing sign character. None of these are wrong. All of them encode the vocabulary, the field layout, and the failure modes of a system that has its own reasons, and every one of them will change on a schedule the consuming domain does not control.
Letting such a model into the domain is the default outcome, because it is the path of least resistance: the JSON parses, the fields map one-to-one onto properties, and the application works. The cost appears later, when the domain’s own concepts need to change and cannot, because they are now expressed in someone else’s terms.
The external model arrives with its own priorities
An external model is optimised for the external system’s problems. A payment API models the state of a payment intent because that is what its state machine needs; it does not model the business question of whether an order may be fulfilled. A carrier’s API models scan events because that is what its network produces; it does not model a delivery promise. Using those models directly means the consuming domain inherits a taxonomy built for someone else, including its gaps.
The inheritance is not only conceptual. Field names enter the codebase and then the database, and a rename at the provider becomes a migration. Enumerations enter as string constants, and a new provider value that the consuming code has never seen arrives as an unrecognised state at runtime. Optionality enters as nullable fields whose nullability reflects the provider’s internal phases rather than any rule the domain holds.
An anticorruption layer is the deliberate countermeasure: a component at the boundary that accepts the external representation, translates it into the domain’s own types, and translates requests back. The name is accurate in that the layer’s job is to prevent the external model from spreading; it is a firewall drawn around a model, not around a network.
Translation, not delegation
The distinction between a translation layer and a thin wrapper is where the value sits. A wrapper forwards calls and reshapes names. A translation layer makes decisions: which external states map to which domain states, what to do with an unknown value, which external fields are ignored because the domain does not care, and which domain concepts require combining several external calls.
Ignoring fields is a real part of the job. A provider response with thirty fields may contribute four to the domain, and the other twenty-six should not appear in any domain type, because each one is a coupling point and a future migration. The mapping function is the only place that knows about the thirty.
// Domain vocabulary: a payment is authorised, captured, declined, or pending.
type PaymentOutcome =
| { kind: "authorised"; reference: string; capturedAt: Date | null }
| { kind: "declined"; code: string }
| { kind: "pending"; providerReference: string };
type ProviderCharge = {
id: string;
status: "succeeded" | "pending" | "failed" | "requires_action";
amount: number; // minor units
currency: string;
failure_code?: string | null;
};
function toPaymentOutcome(c: ProviderCharge): PaymentOutcome {
switch (c.status) {
case "succeeded":
return { kind: "authorised", reference: c.id, capturedAt: null };
case "failed":
// Unknown failure codes are preserved rather than mapped to a guess.
return { kind: "declined", code: c.failure_code ?? "unknown" };
case "pending":
case "requires_action":
// Both mean "the provider has not decided"; the distinction is the
// provider's workflow, not the domain's.
return { kind: "pending", providerReference: c.id };
}
}
Two decisions are visible in that function. requires_action is folded into pending, because the domain’s question is whether the money has moved, and a redirect is an implementation detail of how the provider gets there. And an unrecognised failure code is passed through as data rather than mapped to a domain category that might be wrong. The first is a translation; the second is a refusal to invent meaning.
The reverse direction matters as much. A domain command to capture a payment has to become a provider request with the provider’s identifiers and idempotency key. If the domain type carries a provider identifier “just in case”, the layer has already failed: that field is the external model leaking inward, and it will be read and written by code that has no business knowing about it.
Where the layer sits in the call path
The layer belongs between the domain and the transport client, not inside either. The transport client knows HTTP, retries, timeouts, and authentication. The layer knows the mapping. The domain knows the rules. Collapsing the layer into the client produces a client with business logic; collapsing it into the domain produces a domain that speaks JSON.
In a request path, the order is: use case calls a domain-facing port, the adapter implements the port by calling the translation layer, the translation layer calls the transport client. The port is expressed in domain terms — PaymentGateway.authorise(amount, method) returning a PaymentOutcome — so the use case never sees a provider type.
The layer is also the right place for caching and for idempotency keys, because both are properties of the external interaction rather than of the domain. A cached provider response must be cached in the external shape, before translation, or the cache will serve a domain object that has since been mutated. An idempotency key must be generated and stored per attempt, which requires knowing what counts as an attempt, which requires knowing the provider’s retry semantics.
Batch and bulk interfaces complicate the placement. If the provider accepts a hundred items per call, the layer has to translate a collection and correlate partial failures back to individual items, because the domain thinks in single items. That correlation is the layer’s job and is often the hardest part of it: a provider that returns an array of results without stable ordering requires matching on an identifier the layer supplied.
Failure semantics are part of the contract
An external system’s failures are as much a part of its model as its fields. A timeout is not the same as a decline; a 429 is not the same as a 500; a duplicate request is not the same as a new one. A layer that translates only success paths pushes the raw error types into the domain, and the domain then contains code that switches on HTTP status codes.
The translation should produce a small, closed set of outcomes that the domain can act on: succeeded, permanently rejected with a reason, transiently failed and safe to retry, and unknown because the outcome could not be determined. The last is the one that gets forgotten, and it is the one that matters. A request that timed out after the provider accepted it has an unknown outcome, and retrying may double-charge unless the provider supports idempotency keys. The layer knows whether it supplied one; the domain does not, and should not have to. The consumer-side counterpart of that problem, recognising a repeated message, is covered in Domain Events and State Transitions.
Retry budgets belong here too. Retrying a permanent rejection wastes time and can trigger rate limiting; retrying a transient failure without a bound can turn one slow dependency into a stalled request. The layer counts attempts, respects a deadline propagated from the use case, and stops. Those numbers are policy, and the domain should be able to ask for a deadline without knowing how it is honoured.
Over-isolation and the cost of a layer for everything
An anticorruption layer costs a set of parallel types, a set of mapping functions, and the tests for both. For a large external model that is a bargain. For a small, stable one it is ceremony. The judgement is roughly whether the external model’s vocabulary differs from the domain’s in ways that matter, and whether the external model is likely to change.
Three signals argue for the layer. The external model uses names that mean something different internally, which is the vocabulary conflict described in Bounded Contexts and Model Translation. The external model has states the domain must collapse, or domain states that require combining calls. Or the external system is legacy enough that its quirks are numerous and undocumented, in which case the layer is where that knowledge is concentrated and can be tested.
Three signals argue against it. The external model is a published standard that the domain has chosen to adopt, so there is no conflict to resolve. The integration carries one field. Or the layer would be a pass-through with no decisions, which adds indirection without removing coupling.
The failure mode of the over-applied layer is a set of mapping classes that mirror each other and a codebase where a single field change requires edits in five files. That is the same coupling the layer was meant to prevent, merely relocated. The failure mode of the under-applied layer is worse: the external model reaches the database, and from then on the domain’s history is written in a vendor’s schema.
A useful check is to ask whether the domain’s tests can run without the external system’s types being imported anywhere. If they can, the boundary is doing its job. If a test fixture needs a provider payload to construct a domain object, the boundary is decorative, and the model is the provider’s.
References
- Domain-Driven Design: Tackling Complexity in the Heart of Software — Eric Evans, Addison-Wesley, 2003
- Enterprise Integration Patterns — Gregor Hohpe and Bobby Woolf, Addison-Wesley, 2003
- Data on the Outside versus Data on the Inside — Pat Helland, CIDR 2005