Module 5: Extracting a Service

The anti-corruption layer

Overview

You already chose the piece: catalog, the monolith's clean leaf. Now comes the step that names the whole module and that decides whether the new service is born healthy or born sick: building the anti-corruption layer. It's the layer put at the boundary of the service that translates between the monolith's old model and the service's new and clean model, so the legacy's dirty language doesn't leak into the new design.

The problem it solves is concrete and treacherous. The monolith stores each product as a dictionary with cryptic names and dirty types —prod_id, desc (which is actually the name), prc_cents stored as text, act as 'Y'/'N'—. That model carried years of hasty decisions and forgotten compromises. If the new service spoke that same model, it would inherit all that debt: its code would be full of int(row["prc_cents"]) and comparisons with 'Y', and in a few months it would be as tangled as the monolith you wanted to leave behind. The ACL avoids that: it absorbs the old one's dirt at the boundary, and from the ACL inward the service only knows a clean model.

This lesson builds that translator and executes it field by field. You're going to see how the ACL takes {'prod_id': 1, 'desc': 'SSD 1TB', 'prc_cents': '8999', 'act': 'Y'} and converts it into Product(id=1, name='SSD 1TB', price_cents=8999, active=True): renaming fields (descname), normalizing types ('8999' string → 8999 int) and normalizing domain ('Y'True). And you're going to see, with a minimal business function, why the clean model matters: price_cents == 0 reads itself, while int(row["prc_cents"]) == 0 is the old one's jargon leaked into the new.

Connection with the module. Lesson 2 chose the bounded context; this one builds the ACL for that context, in the inbound direction (legacy → modern). Lesson 4 completes the ACL with the return direction (modern → legacy), so the monolith receives what it expects. Lessons 5 and 6 use this same ACL at the boundary while cutting the data. Notice the boundary: the ACL translates between two models. How the service's clean model is designed in depth —the domain rules, the aggregates, the value objects— is the material of the design guides; here the ACL is the extraction tool that keeps the old model from contaminating the new one.

An analogy: the interpreter who converts units and idioms

Imagine a negotiation between two partners: one speaks English and measures in miles, pounds, and dollars; the other speaks Spanish and measures in kilometers, kilos, and pesos. Between them there's a professional interpreter. Their job isn't just to swap words from one language to another; it's finer than that.

When the English partner says "the package weighs 5 pounds and costs 20 dollars," the interpreter doesn't say "the package weighs 5 pounds and costs 20 dollars" —that would force the Spanish partner to do the conversions in their head—. They say "the package weighs 2.3 kilos and costs 380 pesos": they translate the language, convert the units, and adapt the idioms so the Spanish partner receives everything in their world, ready to work, without having to decipher anything of the other's world. And they do the same going back.

The interpreter has a golden rule: one side's dirt doesn't cross to the other. If the English partner uses a weird internal jargon —"we'll handle it FOB"—, the interpreter doesn't repeat "FOB" and leave the Spaniard confused; they translate it to something the Spaniard understands in their own terms. All the weirdness of each side stays at the boundary, in the interpreter, and each partner converses as if the other spoke their language perfectly.

The anti-corruption layer is that interpreter. The monolith speaks the old language (desc, prc_cents as text, act as 'Y'). The service speaks the clean language (name, price_cents as a number, active as a boolean). The ACL, at the boundary, translates the language, converts the types (the text '8999' to the number 8999, like pounds to kilos) and adapts the domain ('Y' to the boolean True, like the idiom to something understandable). And its golden rule is the same: the old one's jargon stays in the ACL, and from the ACL inward the service converses in its own clean language.

Worked example: the field-by-field translation

We're not going to describe the translation: we're going to execute it, field by field, to see exactly what the ACL does to each piece of the old model. We take a legacy row from the monolith, pass it through the ACL (translate), and break down each field: what value it had in the old one, what field of the new one it falls into, with what value, and what transformation the ACL applied.

from dataclasses import dataclass

@dataclass
class Product:
    id: int
    name: str
    price_cents: int
    active: bool

# --- The ACL: a single translate() function that absorbs the legacy's jargon. ---
def translate(row):
    return Product(
        id=row["prod_id"],                 # copy as is
        name=row["desc"],                  # rename: 'desc' -> 'name'
        price_cents=int(row["prc_cents"]), # normalize type: str -> int
        active=(row["act"] == "Y"),        # normalize domain: 'Y'/'N' -> bool
    )

# --- The monolith stores the catalog in its old model. ---
legacy_row = {"prod_id": 1, "desc": "SSD 1TB", "prc_cents": "8999", "act": "Y"}

print("The ACL translates the old model -> the clean model, field by field\n")
prod = translate(legacy_row)
mapping = [
    ("prod_id",   legacy_row["prod_id"],   "id",          prod.id,          "copy"),
    ("desc",      legacy_row["desc"],      "name",        prod.name,        "rename field"),
    ("prc_cents", legacy_row["prc_cents"], "price_cents", prod.price_cents, "str -> int"),
    ("act",       legacy_row["act"],       "active",      prod.active,      "'Y'/'N' -> bool"),
]
print(f"{'legacy field':<12}{'value':>10}   ->  {'modern field':<13}{'value':>8}   transformation")
print("-" * 74)
for lf, lv, mf, mv, tr in mapping:
    print(f"{lf:<12}{repr(lv):>10}   ->  {mf:<13}{repr(mv):>8}   {tr}")

print("-" * 74)
print(f"\nClean Product: {prod}")

# --- Why the clean model matters: the business logic reads itself. ---
# Over the OLD model you'd have to write: int(row['prc_cents']) == 0
# Over the CLEAN model you write the obvious:
def is_free(product):
    return product.price_cents == 0

print("\nThe service's logic speaks the clean model, not the legacy's jargon:")
print(f"  is_free(product) -> {is_free(prod)}   (price_cents == 0, no parsing strings)")
print("\n  The ACL absorbs the old one's dirt at the boundary. From the ACL inward,")
print("  the service only knows Product: clear names and correct types.")

What to expect. When you run the file, the output is exactly this:

The ACL translates the old model -> the clean model, field by field

legacy field     value   ->  modern field    value   transformation
--------------------------------------------------------------------------
prod_id              1   ->  id                  1   copy
desc         'SSD 1TB'   ->  name         'SSD 1TB'   rename field
prc_cents       '8999'   ->  price_cents      8999   str -> int
act                'Y'   ->  active           True   'Y'/'N' -> bool
--------------------------------------------------------------------------

Clean Product: Product(id=1, name='SSD 1TB', price_cents=8999, active=True)

The service's logic speaks the clean model, not the legacy's jargon:
  is_free(product) -> False   (price_cents == 0, no parsing strings)

  The ACL absorbs the old one's dirt at the boundary. From the ACL inward,
  the service only knows Product: clear names and correct types.

Read the table row by row, because each one is a different type of translation an ACL does.

prod_idid (copy). The simplest case: the value doesn't change (1 stays 1), only the field's name. Even so the ACL passes it through its hands, because the name matters: prod_id is the monolith's cryptic convention (the prod_ prefix because the legacy tables repeated the entity's name in each column), and id is the service's clean convention. Renaming is the cheapest translation, but it counts.

descname (rename field). Here the ACL corrects a lie of the old model: the field is called desc (for "description"), but what it stores is the product's name. It's an inherited name nobody dared to change in the monolith because a thousand places depend on it. The new service doesn't drag along that lie: the ACL translates it to name, which tells the truth. From the ACL inward, nobody has to remember that "desc is actually the name."

prc_centsprice_cents (str → int). The most important translation, because it changes the type. The monolith stores the price as text ('8999') —an old decision, maybe because it came from a CSV, maybe because of a badly configured ORM—. Working with prices-as-text is an infinite source of bugs: you can't add them, compare them, or operate on them without converting them first. The ACL converts once, at the boundary (int(row["prc_cents"])), and from the ACL inward the price is a real whole number. Like the interpreter who converts pounds to kilos: the Spanish partner never sees a pound.

actactive ('Y'/'N' → bool). The ACL normalizes the domain: the legacy flag uses the strings 'Y' and 'N' (an inheritance from old databases that didn't have a boolean type), and the service wants a real boolTrue/False—. Comparing with 'Y' is fragile (what if it comes 'y' lowercase, or '1', or 'YES'?); a bool is unequivocal. The ACL absorbs that fragility at the boundary.

And below is the payoff of all this: is_free(product) -> False, computed as price_cents == 0. That's the point of the lesson. Over the old model, the same question would be written int(row["prc_cents"]) == 0 —with the int() reminding you on every line that the price is dirty text—. Over the clean model, it's written price_cents == 0, which reads itself. Multiply that difference by the hundreds of places where the service touches the price, the name, and the state, and you'll see why the ACL isn't bureaucracy: it's what keeps the new service's code clean throughout its whole life.

Deep dive: where the ACL lives and what it must NOT do

The ACL lives at the boundary of the service —in the layer that receives what comes from the monolith and delivers what the service consumes—, never in the heart of the domain. This location is deliberate and has an important architectural consequence:

                    service boundary
                          │
  monolith  ─────────────>│  ACL  ─────────────>  service domain
  (old model)             │ translate()           (Product, clean logic)
                          │
        the old one's dirt stays HERE, at the boundary;
        from the ACL inward, everything is a clean Product

The golden rule of the ACL is one of discipline, just like the facade's in module 3: the ACL only translates. It doesn't apply business rules, doesn't validate domain invariants, doesn't make decisions. Its only responsibility is to convert one model into another, faithfully. If the ACL starts to decide —"if the price comes empty, put 0"; "if the product is inactive, don't return it"—, it stops being a translator and becomes business logic hidden in the wrong layer, where nobody looks for it when something fails. The business decisions live in the service's domain (the example's is_free, and everything else); the translation lives in the ACL. One doesn't invade the other.

There's an honest nuance about the cost of the ACL. Writing and maintaining the translator is real work: each field of the old model has to be mapped, and when the old model changes, the ACL changes. It's not free. But it's a bounded and localized cost —it lives in a single place, the boundary— and it buys something expensive: that the service's model doesn't inherit the monolith's debt. The alternative (the service speaking the old model directly) seems cheaper at first and becomes very expensive later, when the old one's debt has spread throughout the new service. The ACL is an investment: you pay the translator today so as not to pay the contamination tomorrow.

And a note about the end of the ACL. As long as the monolith exists and speaks the old model, the ACL is needed. But the ACL isn't necessarily forever: when the monolith finishes migrating —when nobody speaks the old model anymore— the inbound ACL can be retired, because there's no dirty language to protect against anymore. In a long migration, the ACL is scaffolding: it sustains the translation while the two models coexist, and it can be dismantled when only the clean model remains.

Common mistakes

Having the new service speak the old model "to go fast." What happens: the team makes the catalog service receive and operate directly on the legacy dictionaries —row["prc_cents"], row["act"] == "Y"— without an ACL. Why it happens: writing the translator feels like extra work; the old model "already exists" and it seems faster to reuse it. How to spot it: the "new" service's code is sprinkled with int(row["prc_cents"]), comparisons with 'Y', and accesses to cryptic keys like desc. The old one's jargon is everywhere. How to fix it: the ACL exists precisely for this. If the service inherits the dirty model, it's born with the debt you wanted to leave behind, and the "new service" is just the monolith under another name. Write the translator at the boundary, and protect the service's domain with a clean model (Product). The ACL's cost is bounded; the cost of the contamination is infinite.

Putting business logic in the ACL. What happens: the ACL, in addition to translating, starts to decide —"if prc_cents comes empty, put it at 0"; "don't translate the inactive products"—. Why it happens: the ACL touches all the data that enters, it seems the convenient place to "fix along the way" or filter. How to spot it: the translator has ifs that aren't about format but about business rules; and when a rule fails, nobody looks for it in the translation layer. How to fix it: the ACL only translates, faithfully, one model into another. The decisions —what is a valid price, what products are shown— live in the service's domain, where they're looked for and tested. Mixing translation with business in the ACL hides the logic in the wrong layer and makes the translator unfaithful (it stops being a clean round-trip). Translate in the ACL; decide in the domain.

An unfaithful ACL that loses information in the translation. What happens: the inbound ACL converts the old model to the new one, but forgets a field or converts it wrong, and when you have to translate back (lesson 4) the data is no longer there or is deformed. Why it happens: when building the ACL you focus on the "important" fields and neglect the edge ones (a weird flag, a field almost nobody uses). How to spot it: the round-trip legacy → modern → legacy doesn't come back identical (you saw it in lesson 1). How to fix it: the ACL must be faithful —preserve all the information the other side needs—. Verify the round-trip over cases that cover the edges (price zero, inactive product, optional fields) and fix any row that doesn't come back identical before trusting the translation. A translator that loses words isn't a translator, it's a filter.

Exercises

Exercise 1 — The interpreter who converts. In the analogy of the interpreter between two partners, match each task of the interpreter with the corresponding transformation of the ACL: (a) change the language of the words, (b) convert pounds to kilos, (c) translate an internal idiom to something understandable. Then explain what the "golden rule" that the interpreter and the ACL share is.

See solution
  • (a) Change the language of the words → rename fields (descname, prod_idid). It's the most basic translation: the same information, said in the other side's vocabulary.
  • (b) Convert pounds to kilos → normalize types ('8999' string → 8999 int). It's not just changing the word; it's converting the unit/the type so the other side receives it in its own system, ready to operate.
  • (c) Translate an internal idiom to something understandable → normalize the domain ('Y'/'N'True/False). The 'Y' is an idiom of the old one (databases without booleans); the ACL translates it to a bool, the "language" the service understands without ambiguity.

The golden rule they share: one side's dirt stays at the boundary and doesn't cross to the other. The interpreter doesn't repeat the English partner's internal jargon and leave the Spaniard confused; they translate it. The ACL doesn't let 'Y' or prices-as-text enter the service's domain; it absorbs them at the boundary. Each side converses in its own clean language, and all the weirdness lives in the translator.

Exercise 2 — Read the translation. In the example's table, prc_cents='8999' was translated to price_cents=8999 with "str -> int", and act='Y' to active=True with "'Y'/'N' -> bool". (a) Why is the translation of prc_cents more valuable than that of prod_id? (b) What concrete bug does converting the price to int at the boundary avoid? (c) Why is is_free written better over the clean model?

See solution

(a) Because prod_id's only renames (the value 1 doesn't change), while prc_cents's changes the type ('8999' text → 8999 number). A rename is cosmetic; a type change changes what you can do with the data. After the prc_cents translation, the service can add, compare, and operate on the price as a number; before, any operation required converting first. The type translation buys capability, not just clarity.

(b) It avoids the classic bug of operating on text as if it were a number. If the price stays as a string '8999', then '8999' + '100' gives '8999100' (text concatenation, not addition), and '8999' < '900' compares alphabetically ('8' vs '9') instead of numerically —giving absurd results—. Converting to int once at the boundary eliminates that whole class of bugs from the service's code: from the ACL inward, the price is a number and behaves like one.

(c) Because over the clean model, is_free is written price_cents == 0 —direct, legible, with no reminders of dirt—. Over the old model it would be written int(row["prc_cents"]) == 0: the int() is there only to undo the text's dirt, and the key "prc_cents" forces you to remember the cryptic jargon. Each business function of the service would pay that conversion-and-deciphering tax. The ACL pays it once at the boundary, and all the domain's functions are written clean. Multiplied by hundreds of functions, that's the difference between a legible service and one as tangled as the monolith.

Exercise 3 — Diagnose the ACL. A team writes an ACL that, in addition to translating, includes this line: if row["act"] != "Y": return None (it doesn't translate the inactive products, returns None). (a) What rule of the ACL does this break? (b) What problem does it cause in the round-trip? (c) Where should that decision live?

See solution

(a) It breaks the golden rule that the ACL only translates, doesn't decide. "Don't return the inactive products" is a business rule (what products are shown), not a format translation. The ACL got into deciding, when its only job is to convert one model into another faithfully.

(b) It breaks the round-trip: an inactive product (act='N') enters the ACL and comes out as None, so you can no longer translate it back —you lost the data—. The legacy → modern → legacy doesn't come back identical for the inactive ones; it comes back None. The ACL stopped being a faithful translator: it filters instead of translating, and the information is lost at the boundary. The monolith, which expects to receive all its products (active and inactive), would receive fewer than it sent.

(c) That decision —"don't show inactive products"— lives in the service's domain, not in the ACL. The ACL translates all the products faithfully (active and inactive, preserving active=True/False), and then a function of the service's domain (for example, list_visible_products) decides which are shown according to the business rule. That way the translation stays clean and reversible, the rule stays where it's looked for and tested, and each layer does one thing: the ACL converts, the domain decides.

Summary and next step

In this lesson you built the heart of the module: the anti-corruption layer, the translator that absorbs the old model's jargon at the service's boundary. You saw, with the interpreter who converts language, units, and idioms, that a good ACL doesn't just rename: it normalizes types and domain so each side converses in its own clean language. And you executed it field by field: prod_id → id (copy), desc → name (rename, correcting a lie of the old one), prc_cents → price_cents (str → int), act → active ('Y'/'N' → bool), and you saw the payoff —is_free written as price_cents == 0, not as int(row["prc_cents"]) == 0—. You learned where the ACL lives (at the boundary, not in the domain), its golden rule (only translates, doesn't decide), and its bounded cost against the infinite contamination of the alternative.

Before moving on you should be able to: explain what the ACL contaminates and why; read a field-by-field translation and classify each transformation (rename, change type, normalize domain); state the golden rule of the ACL and why putting business logic in it breaks it; and say why the clean model makes the service's logic read itself.

Lesson 4 completes the ACL with its other direction. So far you translated only outbound (legacy → modern), so the service receives the clean model. But when the service responds, the monolith expects its old model back —if you return a Product, it breaks—. You're going to build the return translation (modern → legacy) and execute a request crossing the boundary twice: monolith → ACL → service → ACL → monolith. And you're going to verify that the service never saw a legacy key and the monolith never saw a Product —each side protected from the other, in both directions—.

Resources

  • Eric Evans, Domain-Driven Design (Addison-Wesley, 2003), chapter 14, section Anti-Corruption Layer — the original source of the pattern. Evans describes it as the layer that isolates one system's model from the model of another it must integrate with, translating between the two so one doesn't corrupt the other. The founding reading of this lesson. In English.
  • Sam Newman, Monolith to Microservices (O'Reilly, 2019), ch. 3 — the section on how an extracted service communicates with the monolith through a translation layer, and why the new service's model must not inherit the old one's schema. In English.
  • Martin Fowler, "BoundedContext" — martinfowler.com/bliki/BoundedContext.html. The conceptual context of the ACL: each bounded context has its own model, and at the boundaries between contexts an explicit translation is needed so the models don't mix. In English.
  • Microsoft, "Anti-corruption Layer pattern" — learn.microsoft.com/azure/architecture/patterns/anti-corruption-layer. The card of the pattern in the Azure architecture catalog: context, solution, and when to apply it when integrating a new system with a legacy one. Short and direct. In English.