Series overview
Part 14 of 2850% complete
2026-06-25•15 min read

Anti-corruption layer

Chapter 13’s LegacyCustomerBridge translated one field (customerTier) from the legacy monolith into order-service’s domain. This chapter formalizes that translation boundary properly, because the legacy customer data Northwind is about to integrate more deeply with is far messier than one field suggests.

1. Problem the Pattern Solves

To finish migrating checkout off the legacy monolith, order-service needs more customer data than just a tier string: shipping addresses, saved payment method references, and a “customer standing” flag that the legacy system encodes as an integer (0 = active, 1 = suspended, 2 = closed, 3 = suspended-pending-review — a scheme nobody on the current team fully remembers the history of, discovered only by reading fifteen-year-old PHP comments). The legacy customers table also uses a single address text blob for the full mailing address, no structured fields, parsed ad hoc by whichever PHP script needs it.

The tempting shortcut is to have order-service’s Customer domain type mirror the legacy schema directly — same integer status codes, same unstructured address string — since that’s the least code to write today. But that means every quirk of a fifteen-year-old schema, including its undocumented status codes and its unparsed address blobs, becomes part of order-service’s own clean domain model, permanently, even after the legacy system is eventually decommissioned.

Forces in tension:

  • Development speed vs. model integrity. Mirroring the legacy shape directly is faster to implement today, but it means order-service’s domain model — which Chapters 1–12 have kept carefully clean and well-named — inherits every legacy naming and structural quirk indefinitely.
  • Translation cost vs. long-term clarity. Writing an explicit translation layer takes more code up front (mapping integer codes to a meaningful enum, parsing unstructured addresses into fields) but keeps order-service’s own domain expressed in its own, coherent language.
  • Temporary need vs. permanent artifact. Chapter 13 established this bridge exists specifically for the migration window — but a translation layer, done well, has value that outlives the migration: it’s also exactly what’s needed if Northwind ever integrates with a different upstream system (a partner’s customer data, say) with its own, different quirks.

2. Core Idea

An anti-corruption layer (ACL) is a translation boundary between two systems with different domain models, ensuring that one system’s model, terminology, and quirks never leak into the other’s. Every interaction with the external (or legacy) system passes through the ACL, which translates its model into terms the local domain understands — and translates back, if needed, for any calls going the other way.

raw legacy shape

translated, clean domain type

Legacy monolith's model

customers table:

status INT, address TEXT

Anti-corruption layer

LegacyCustomerTranslator

order-service's own domain

Customer(id, standing: CustomerStanding, shippingAddress: Address)

raw legacy shape

translated, clean domain type

Legacy monolith's model

customers table:

status INT, address TEXT

Anti-corruption layer

LegacyCustomerTranslator

order-service's own domain

Customer(id, standing: CustomerStanding, shippingAddress: Address)

Participants:

  • Local domain model — order-service’s own Customer, CustomerStanding, and Address types, expressed entirely in terms that make sense to order-service’s own business logic, with no trace of the legacy schema’s quirks.
  • Translator — the ACL’s core component, converting the legacy representation into the local domain model (and back, for the rare case of writing to the legacy system).
  • Anti-corruption boundary — the architectural rule that no code outside the translator is allowed to reference the legacy shape directly; everything downstream of the ACL only ever sees the clean local model.

Commonly confused with:

  • A simple DTO mapping layer. Ordinary DTO-to-domain mapping (converting a REST request body into a domain object) exists between layers of the same system, sharing largely the same domain understanding. An ACL exists specifically at the boundary between two different domain models — often with genuinely conflicting assumptions (the legacy status integer’s meaning versus order-service’s own idea of what “customer standing” means) — that a plain mapper wouldn’t need to reconcile.
  • The Strangler Fig pattern (previous chapter). Strangler Fig is the migration strategy — routing traffic incrementally toward a new implementation. The ACL is a design technique usable at any boundary between differently-modeled systems, whether or not a migration is underway — Northwind happens to need one because of an in-progress migration, but an ACL in front of a stable, permanent third-party integration (a payment processor’s API, a partner’s inventory feed) is just as valid a use, with no migration in sight.
  • A Backend for Frontend (Chapter 4). A BFF shapes data for a specific client’s needs; an ACL shapes data to protect one domain model from another’s leaking in. A BFF’s job is client-facing convenience; an ACL’s job is model integrity — different motivations, even though both involve a translation step.

3. When to Use It

Strong indicators:

  • Integrating with a system (legacy, third-party, or another team’s service) whose domain model, terminology, or data quality doesn’t match your own, and letting it leak in would corrupt your domain’s clarity.
  • The upstream system’s model has known quirks, technical debt, or undocumented behavior (Northwind’s mysterious integer status codes) that shouldn’t become permanent fixtures of your own code.
  • You anticipate the upstream system might change or be replaced (exactly Northwind’s migration scenario) — an ACL isolates that future change to one component instead of scattering legacy-shaped code throughout your service.

Concrete use cases:

  • Legacy migrations, as here: protecting a new service’s clean domain model from an old system’s accumulated quirks during a Strangler Fig migration.
  • Third-party payment processor integration: a processor’s API often has its own vocabulary and status codes ("succeeded", "requires_capture", "canceled") that shouldn’t dictate the vocabulary payment-service’s own domain uses internally — an ACL translates the processor’s model into Northwind’s own PaymentStatus concept.
  • Multi-vendor logistics integrations: each shipping carrier’s API has different terminology and data shapes for “shipment status” — an ACL per carrier normalizes them into one coherent internal model, so the rest of the system never needs to know which carrier’s quirks it’s dealing with.
  • Mergers and acquisitions: integrating an acquired company’s systems, which almost always have a different, independently-evolved domain model, is one of the clearest real-world cases for an ACL at the integration boundary.

Prerequisites:

  • A clearly defined local domain model to translate into — an ACL protects something; if the local model isn’t yet well-defined, there’s nothing coherent to protect it from corruption toward.
  • Enough understanding of the external system’s model to translate it correctly, including its edge cases and undocumented behaviors (Northwind’s investigation into the legacy status codes, however tedious, is necessary work, not optional polish).

4. When Not to Use It

  • Both systems already share a compatible domain model. If the external system’s model already matches your own closely enough that translation would be a pass-through with no actual reconciliation happening, the ACL is ceremony without benefit — a plain DTO mapper suffices.
  • The integration is trivial and unlikely to reveal model conflicts. A single boolean flag or a well-designed, modern partner API with clean, well-named fields may not need a dedicated translation layer — evaluate the actual complexity of the mismatch, not the mere fact that two systems are involved.
  • Overengineering signal: building an elaborate, generic “translation framework” for a single, simple integration point. Northwind’s LegacyCustomerTranslator (Section 5) is a small, focused class — resist the urge to generalize it into reusable infrastructure before a second, genuinely similar integration need appears.
  • Risk: an ACL that’s incomplete — translating some fields but leaking others through untranslated, “just this once” — provides a false sense of protection while the corruption it’s meant to prevent seeps in anyway through the gaps. Enforce the boundary completely (Section 8) or not at all.

5. Implementation Example

The local domain model, expressed entirely in order-service’s own terms, with no reference to the legacy schema:

order-service/src/main/kotlin/in/o612/eng/northwind/order/api/Customer.kt
package `in`.o612.eng.northwind.order.api
import java.util.UUID
data class Customer(
val id: UUID,
val standing: CustomerStanding,
val shippingAddress: Address,
)
enum class CustomerStanding { ACTIVE, SUSPENDED, SUSPENDED_PENDING_REVIEW, CLOSED }
data class Address(val line1: String, val line2: String?, val city: String, val postalCode: String, val country: String)

Nothing here hints that the source of this data is a legacy system with integer status codes and unstructured address blobs — this is exactly the point.

The translator, the ACL’s core, doing the messy work in one place so nothing else has to:

order-service/src/main/kotlin/in/o612/eng/northwind/order/internal/legacy/LegacyCustomerTranslator.kt
package `in`.o612.eng.northwind.order.internal.legacy
import `in`.o612.eng.northwind.order.api.Address
import `in`.o612.eng.northwind.order.api.Customer
import `in`.o612.eng.northwind.order.api.CustomerStanding
/** The ONLY place in order-service allowed to know the legacy schema's
* shape. Every quirk of the fifteen-year-old customers table is
* reconciled here, once, rather than wherever customer data is used. */
internal class LegacyCustomerTranslator(private val addressParser: LegacyAddressParser) {
fun translate(legacy: LegacyCustomerRecord): Customer =
Customer(
id = legacy.id,
standing = translateStanding(legacy.statusCode),
shippingAddress = addressParser.parse(legacy.rawAddressBlob),
)
private fun translateStanding(statusCode: Int): CustomerStanding = when (statusCode) {
0 -> CustomerStanding.ACTIVE
1 -> CustomerStanding.SUSPENDED
2 -> CustomerStanding.CLOSED
3 -> CustomerStanding.SUSPENDED_PENDING_REVIEW
else -> error("Unknown legacy customer status code: $statusCode — investigate before treating as a default")
}
}
/** Raw shape exactly as the legacy system returns it — deliberately ugly,
* confined entirely to this package. */
internal data class LegacyCustomerRecord(val id: java.util.UUID, val statusCode: Int, val rawAddressBlob: String)

The error(...) branch for an unrecognized status code is deliberate: silently defaulting an unknown legacy status to ACTIVE, say, would be exactly the kind of quiet corruption an ACL exists to prevent — better to fail loudly and investigate than guess.

order-service/src/main/kotlin/in/o612/eng/northwind/order/internal/legacy/LegacyAddressParser.kt
package `in`.o612.eng.northwind.order.internal.legacy
import `in`.o612.eng.northwind.order.api.Address
/** Parses the legacy system's unstructured address blob. This regex-based
* approach is imperfect — a known, tracked limitation — but confining the
* imperfection to one class beats spreading ad hoc parsing everywhere
* address data is used, as the legacy PHP codebase itself does. */
internal class LegacyAddressParser {
private val addressPattern = Regex("""^(.+?),\s*(.+?),\s*(\w{5}),\s*(\w{2})$""")
fun parse(rawAddressBlob: String): Address {
val match = addressPattern.find(rawAddressBlob)
?: return Address(line1 = rawAddressBlob, line2 = null, city = "UNKNOWN", postalCode = "UNKNOWN", country = "US")
val (line1, city, postalCode, country) = match.destructured
return Address(line1, null, city, postalCode, country)
}
}

The bridge from Chapter 13, now going through the translator rather than exposing the legacy shape directly:

order-service/src/main/kotlin/in/o612/eng/northwind/order/internal/legacy/LegacyCustomerBridge.kt
package `in`.o612.eng.northwind.order.internal.legacy
import `in`.o612.eng.northwind.order.api.Customer
import org.springframework.web.client.RestClient
import java.util.UUID
internal class LegacyCustomerBridge(
private val legacyClient: RestClient,
private val translator: LegacyCustomerTranslator,
) {
fun getCustomer(customerId: UUID): Customer {
val legacyRecord = legacyClient.get().uri("/internal/api/customers/{id}", customerId)
.retrieve().body(LegacyCustomerRecord::class.java)
?: error("Customer $customerId not found in legacy system")
return translator.translate(legacyRecord)
}
}

Everything outside the legacy package — OrderService, the checkout flow, any future consumer of customer data within order-service — only ever sees Customer, CustomerStanding, and Address from the api package. No calling code needs to know, or could accidentally depend on, the fact that a translation from a messy legacy shape happened at all.

A test asserting the boundary holds — one of the few places in this series where a test verifies an architectural rule, not just behavior:

order-service/src/test/kotlin/in/o612/eng/northwind/order/ArchitectureTest.kt
package `in`.o612.eng.northwind.order
import com.tngtech.archunit.core.importer.ClassFileImporter
import com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses
import org.junit.jupiter.api.Test
class ArchitectureTest {
@Test
fun `only the legacy package may reference LegacyCustomerRecord`() {
val classes = ClassFileImporter().importPackages("in.o612.eng.northwind.order")
noClasses().that().resideOutsideOfPackage("in.o612.eng.northwind.order.internal.legacy..")
.should().dependOnClassesThat().haveSimpleName("LegacyCustomerRecord")
.check(classes)
}
}

This ArchUnit rule makes the anti-corruption boundary enforceable at build time, not just a convention documented in a comment — the same “don’t trust discipline alone, enforce it” lesson Chapter 1 established for module boundaries, applied here to a boundary between domain models instead of between modules.

6. Step-by-Step Flow

Legacy monolithLegacyCustomerTranslatorLegacyCustomerBridgeOrderService (business logic)Legacy monolithLegacyCustomerTranslatorLegacyCustomerBridgeOrderService (business logic)OrderService's checkout logic checkscustomer.standing == ACTIVE — never sees a raw status codegetCustomer(customerId)GET /internal/api/customers/{id}{statusCode: 3, address: "123 Main St, Springfield, 62704, US"}translate(legacyRecord)map statusCode 3 -> SUSPENDED_PENDING_REVIEWparse address blob -> structured AddressCustomer(standing=SUSPENDED_PENDING_REVIEW, ...)Customer (clean domain type)
Legacy monolithLegacyCustomerTranslatorLegacyCustomerBridgeOrderService (business logic)Legacy monolithLegacyCustomerTranslatorLegacyCustomerBridgeOrderService (business logic)OrderService's checkout logic checkscustomer.standing == ACTIVE — never sees a raw status codegetCustomer(customerId)GET /internal/api/customers/{id}{statusCode: 3, address: "123 Main St, Springfield, 62704, US"}translate(legacyRecord)map statusCode 3 -> SUSPENDED_PENDING_REVIEWparse address blob -> structured AddressCustomer(standing=SUSPENDED_PENDING_REVIEW, ...)Customer (clean domain type)
  1. Client action. A checkout request reaches order-service’s business logic, which needs to verify the customer’s standing before allowing the order.
  2. API request. OrderService calls LegacyCustomerBridge.getCustomer() — a method signature that reveals nothing about where the data actually comes from.
  3. Service behavior. The bridge fetches the raw legacy record and immediately hands it to the translator — the raw shape never escapes this one method’s scope.
  4. Database interaction. Handled entirely inside the legacy monolith; order-service has no direct database access to legacy data, only the bridge’s API call, consistent with the database-per-service discipline from Chapter 3 even during this cross-system migration period.
  5. Inter-service communication. One HTTP call to the legacy system, translated at the boundary — the same shape of interaction as any other synchronous call in this series (Chapter 6), with the added translation step.
  6. Error or failure handling. An unrecognized status code fails loudly (Section 5’s error(...)) rather than guessing — protecting the domain model’s integrity is worth a hard failure over a silent, wrong default.
  7. Observability signals. Track translation failures (unrecognized status codes, unparseable addresses) as their own metric — a rising rate signals either a legacy data-quality problem or an incomplete translator that needs a new case added.
  8. Final response. OrderService’s checkout logic makes its decision using CustomerStanding.SUSPENDED_PENDING_REVIEW — a meaningful, well-named value from its own domain — with zero awareness that a magic number 3 was ever involved.

7. Production Concerns

  • Timeouts, retries, idempotency. The bridge’s call to the legacy system is a synchronous network call like any other (Chapter 6’s concerns apply directly) — timeouts and retries belong at the bridge, not scattered into every caller of getCustomer().
  • Data consistency. The translator should fail loudly on unrecognized input (Section 5) rather than silently defaulting — a wrong guess about a legacy status code’s meaning could let a suspended customer place an order, a real business-rule violation disguised as a harmless default.
  • API versioning and backward compatibility. The local domain model (Customer, CustomerStanding, Address) is order-service’s own, stable contract — it can remain unchanged even if the legacy system’s internal representation shifts, so long as the translator is updated accordingly. This isolation is a large part of the pattern’s value.
  • Authentication and service-to-service trust. The bridge’s call to the legacy monolith’s internal API needs its own credential, scoped as narrowly as possible — treat this call with the same rigor as any other Chapter 6 service-to-service call, even though one side is a legacy system.
  • Logging, metrics, tracing. Log every translation explicitly when it hits an edge case (an unparseable address, an unrecognized status) with enough context to investigate — these logs are often the only visibility into a legacy system’s actual, undocumented data quality.
  • Kubernetes deployment. No new infrastructure — the ACL is a code-level pattern living entirely inside order-service’s existing deployment.
  • Testing strategy. Unit test the translator exhaustively against every known legacy status code and a representative sample of real (or realistically messy) address formats — this is where the actual risk in this pattern lives, and it’s cheap to test thoroughly since it’s pure, dependency-free logic.
  • Migration strategy. Once the legacy monolith’s customer-management capability is itself strangled (Chapter 13) and replaced by a proper customer-service, the entire legacy package — bridge, translator, parser — is deleted in one commit, and order-service’s domain model (Customer, CustomerStanding, Address) doesn’t change at all, since it was never coupled to the legacy shape in the first place. This is the ACL’s ultimate payoff.

8. Common Mistakes

  1. Letting the legacy shape leak through “just for one field.” Exposing the raw statusCode integer alongside the translated CustomerStanding enum “in case someone needs it” undermines the entire boundary — that one field is exactly how corruption creeps back in. Fix: enforce the boundary completely (the ArchUnit rule in Section 5), with no raw legacy types crossing it under any justification.
  2. Silently defaulting on unrecognized legacy data. Mapping an unknown status code to ACTIVE “to be safe” (or just to avoid a runtime error) can let a suspended customer through business rules meant to block them. Fix: fail loudly and investigate, as Section 5 does, rather than guessing a default that might be wrong in a consequential way.
  3. Building the translator as a generic, reusable framework prematurely. Abstracting LegacyCustomerTranslator into a generic “any-to-any schema mapper” before a second real translation need exists adds indirection for a problem that doesn’t yet exist. Fix: keep the translator specific and simple until a second, genuinely similar integration justifies generalizing it.
  4. Forgetting the ACL exists for a limited window and never revisiting it. Treating the legacy package as a permanent part of the codebase, never scheduling its removal once the legacy system is decommissioned. Fix: track the ACL’s removal as an explicit follow-up item tied to the Strangler Fig migration’s completion (Chapter 13), not an indefinite fixture.
  5. No test coverage on the translation edge cases. Testing only the happy-path status codes and a well-formatted address, while the legacy system’s real data includes malformed addresses and undocumented status codes the team hasn’t seen yet. Fix: deliberately test against the messiest real (or realistic) legacy data you can find, not just the clean cases.
  6. Putting business logic inside the translator. Adding a rule like “treat SUSPENDED_PENDING_REVIEW customers as ACTIVE for orders under $50” inside LegacyCustomerTranslator mixes translation (a mechanical, model-mapping concern) with business policy (which belongs in OrderService’s own domain logic). Fix: keep the translator a pure, mechanical mapper; business rules about what to do with a CustomerStanding value belong downstream of it.

9. Decision Guide

Problem signalUse this pattern?WhyAlternative
Integrating with a system whose model has real quirks or conflicts with your ownYesConfines the messiness to one translation point instead of scattering it through your domain—
Legacy or third-party system might be replaced or changed laterYesIsolates that future change to the ACL; the local domain model is unaffected—
Both systems already share a clean, compatible modelNoTranslation would be a pass-through with nothing to reconcilePlain DTO mapping
Integration is a single simple field with no real semantic conflictNoAn ACL’s structure is unneeded ceremony for a trivial caseDirect field mapping inline
Multiple current or anticipated integrations with genuinely similar translation needsYes, and consider shared translation infrastructureA second real case justifies generalizing, unlike a single one-off—

10. Hands-On Exercise

Extend it: add translation for the legacy system’s saved-payment-method reference, which the legacy schema stores as an opaque string in three different formats depending on which era of the PHP codebase created it (a known, real-world kind of mess). Design LegacyPaymentMethodTranslator to normalize all three into one local domain type, and decide what to do with a payment-method reference in a format your translator doesn’t recognize.

Simulate a failure: feed LegacyCustomerTranslator a status code your translateStanding function doesn’t handle, and confirm it fails loudly (per Section 5) rather than silently producing a wrong result — then decide, and justify, what the correct operational response to that failure should be (block the checkout? alert and use a safe default? something else?).

Decision question, with justification required: Northwind is now integrating with a third-party fraud-detection service (not a legacy system, a brand-new, well-documented partner API) for a future chapter. Does this integration need an anti-corruption layer, or is a plain DTO mapper sufficient? Use Section 4’s criteria to justify your answer, and identify what specific evidence about the partner API’s model would change it.

11. Key Takeaways

  • An anti-corruption layer confines one system’s domain model, terminology, and quirks to a single translation boundary, protecting the rest of your codebase from ever depending on them directly.
  • It’s a design technique usable at any mismatched-model boundary — legacy migrations, third-party integrations, multi-vendor APIs — not exclusively a migration tool, even though Northwind’s need for one arose from the Strangler Fig migration in the previous chapter.
  • Enforce the boundary completely, ideally at build time (an ArchUnit rule, as shown), rather than trusting convention alone — a single leaked field undermines the entire protection.
  • Fail loudly on data the translator doesn’t recognize rather than silently guessing a default — a wrong guess can violate a real business rule invisibly.
  • Keep the translator a pure, mechanical mapping layer; business logic about what to do with the translated value belongs in the domain layer downstream of it, not inside the translator itself.
  • The pattern’s biggest long-term payoff is deletability: once the external system it protects against is replaced or decommissioned, the entire ACL can be removed in one change, with zero impact on the local domain model it was protecting.
  • Don’t build generic translation infrastructure for a single integration — keep it specific and simple until a second, genuinely similar need justifies generalizing.
Spring BootKotlinMicroservices

Type to search the site.

↑↓ navigate⏎ openPowered by Pagefind