Software Architecture · Legacy Systems

Anti-Corruption Layers and the Cost of Model Translation

What a translation boundary between a replacement system and a legacy one must handle beyond field renaming, where information is lost in the mapping, and when the layer is not worth its cost.

A replacement system that reads the legacy model directly inherits the legacy model. The inheritance is not stylistic. Field names, null conventions, code values, and implicit relationships all propagate into the new domain, and within a few years the new system reproduces the compromises of the old one while also carrying the cost of having been rewritten. An anti-corruption layer is the boundary that prevents this: a translation component whose entire job is to convert between the legacy representation and the new model, so that neither side’s assumptions cross the boundary.

The layer is easy to describe and hard to get right, because most of its work is not renaming fields. It is deciding what the legacy data means when the legacy system never said.

What crosses the boundary

Four kinds of translation happen at an anti-corruption layer, and only the first is obvious.

Protocol and transport. The legacy system speaks SOAP, fixed-width files over FTP, a proprietary socket protocol, or stored procedures. The layer converts these to whatever the new system uses. This part is mechanical and well understood; the failure modes are framing (fixed-width records with no delimiter), encoding (EBCDIC code pages on mainframe sources, where a byte is not ASCII), and character-set conversion errors that silently replace unrepresentable characters.

Structure. Legacy data is frequently denormalised into one wide record per transaction, or normalised into tables whose relationships are implied by naming conventions rather than declared by constraints. The layer reassembles the new model’s aggregates from these fragments, which requires knowing which legacy rows belong to one logical entity. That knowledge is usually discovered by reading queries rather than by reading schema comments.

Identity. Legacy identifiers are often composite, reused, or semantically meaningful: an account number whose leading digits encode a branch, a customer key that was recycled when a record was merged. The new system wants opaque identifiers, and the layer must maintain a correspondence. Whether the mapping is a table, a deterministic derivation, or a lookup service is a design decision with a long tail, because the mapping becomes the authoritative record of which new entity corresponds to which old one.

Meaning. This is the part that cannot be automated. A legacy status column holding 'A', 'B', 'C', and the unlisted ' ' means something specific that the new domain must express as an enum with defined transitions. The mapping from one to the other is a specification, and writing it exposes every place where the legacy system’s behaviour was never stated.

Translation is lossy, and the loss is the design

Consider a single field: an amount stored as an integer number of minor units in one currency per row, with the currency in a separate column that is sometimes empty. The new model wants a money type: an amount and an ISO 4217 currency code, both mandatory.

type Money struct {
    Minor    int64
    Currency string // ISO 4217 alpha-3
}

func translateAmount(minor int64, currency string, account string) (Money, error) {
    switch currency {
    case "USD", "EUR", "GBP", "JPY":
        return Money{Minor: minor, Currency: currency}, nil
    case "":
        // Missing currency is not zero. Resolve from the account's home currency
        // only if that rule is documented; otherwise fail loudly.
        return Money{}, fmt.Errorf("account %s: amount %d has no currency", account, minor)
    default:
        return Money{}, fmt.Errorf("account %s: unsupported currency %q", account, currency)
    }
}

The interesting decision is the empty case. Three options exist: default it to a configured currency, reject the row, or carry an explicit unknown. Defaulting produces data that looks valid and is not, and the error surfaces much later in a reconciliation where the cause is no longer visible. Rejecting blocks the migration on rows the legacy system has been happily processing for years. Carrying unknown pushes the ambiguity into the new model, where it will need handling in every consumer.

None of these is universally right. What makes the layer valuable is that the choice is made once, written down, and enforced in one place, rather than being made implicitly by whichever adapter happened to run first.

The same analysis applies to every field with a hidden convention: dates stored as YYYYMMDD strings where 00000000 means unknown; timestamps stored in local time with no zone, in a system that has operated across a daylight-saving transition; booleans stored as Y/N with a third value for “not asked”; free-text address fields where the country is embedded in the last line.

One direction or two

A read-only layer is a projection: legacy data in, new model out. It can be stateless, cached, and replaced when the legacy system goes away.

A bidirectional layer also translates writes. This is substantially harder because the new model can express states the legacy system cannot store. A new order status of CANCELLED_AFTER_SHIPMENT has no legacy code. The layer must either reject the transition, decompose it into a sequence of legacy updates, or map it to a legacy code that means something adjacent. Each compromises the new model’s invariants in a way that the new model cannot see.

The common resolution is to make the legacy system the writer for as long as it is authoritative and to keep the new model’s write path behind a feature that is only enabled when the replacement owns the data. That keeps the translation one-directional during the transition and confines the hard bidirectional case to the cutover, where it is unavoidable and temporary.

Where the layer belongs in the deployment

An anti-corruption layer is code, and code has to run somewhere. Three placements are common, and they trade latency against coupling.

In the replacement process, as a package. Cheapest to call: no network hop, no serialisation. But it means the replacement has a compile-time or startup dependency on legacy client libraries, which is exactly the coupling the layer was meant to prevent. It is acceptable when the legacy interface is a database and the replacement can reach a read replica.

As a separate service. Cleanest boundary, independently deployable, and the natural place to cache legacy reads. It adds a network hop and becomes another component to operate during a migration that already has too many.

Inside the routing facade, for narrow transformations. Convenient when the facade already sees the request, but it turns the facade into an application, which the strangler routing design argues against.

The placement that ages best is usually the separate service for reads, because legacy reads are slow and benefit from caching and because a service boundary makes the eventual deletion of the layer a deployment rather than a refactor.

Caching introduces the layer’s own consistency question. A cache in front of a legacy read makes the replacement’s view of the data older than the legacy system’s, which is tolerable for a reference list of country codes and not tolerable for a balance that a write path will act on. The safe rule is to cache only data the legacy system treats as static, and to key every other lookup on freshness rather than on time: a value read during the same request that will consume it needs no cache at all, while a value read across requests needs an invalidation source, and the legacy system usually offers none. Without an invalidation source, a time-to-live is a statement about how stale the replacement is allowed to be, and it belongs in the configuration with a comment stating that.

Failure modes worth watching

Silent coercion. The layer converts a value it does not understand into the nearest valid value: an unknown status to PENDING, a null to zero, a truncated string. Every such conversion should be a counted event, not a code path that returns silently. A metric on coercion counts turns a class of invisible data corruption into a number that someone can watch.

Rounding drift. Converting between representations of money, especially between a decimal type and a binary floating-point one, introduces errors that accumulate when the value is written back. The layer must use integer minor units or a decimal type end to end, and the conversion boundary must be explicit.

The mapping table as a second source of truth. If the identifier correspondence lives in a table that is updated by a separate process, that table becomes a system that needs backups, reconciliation, and ownership. It is not a detail of the layer; it is data with a lifecycle.

Model leakage through shared types. A layer that returns legacy DTOs, or that exposes a legacy enum, has not isolated anything. The test is whether the new domain can be compiled and tested without the legacy client library present.

Drift when the legacy system changes. The legacy system is not frozen during the migration; it continues to receive bug fixes and schema changes. The translation must be covered by tests that run against a captured legacy schema and a set of representative records, and those fixtures need refreshing when the legacy side changes. Without that, the layer’s assumptions rot silently, which is the same problem characterization tests solve for code: make the assumption executable so that breaking it is visible.

References

  1. Domain-Driven Design: Tackling Complexity in the Heart of Software — Eric Evans, Addison-Wesley, 2003
  2. Monolith to Microservices — Sam Newman, O'Reilly, 2019
  3. ISO 4217: Currency Codes — International Organization for Standardization