Module 8: Project — Modernizing a Slice of Mercado

Extract the service with an anti-corruption layer

Overview

In the previous step, the modern ran "alongside" the legacy behind the facade, but it was still a piece of the same system. The method's third step —module 5 turned into action— is the real extraction: taking the catalog out to its own service, with its own clean model and its own data, without the monolith changing a single line. The piece that makes it possible is the anti-corruption layer (ACL): a translator that converts the monolith's old model (prod_id, desc, prc_cents as string) to the service's clean model (Product) and back, in both directions, so that each world lives enclosed in its own language.

The extraction isn't a single leap; it's a journal in phases, and each phase changes the catalog's guts a little more while the monolith keeps receiving exactly what it always did. It starts as an internal function of the monolith (the catalog is a function that returns the old model directly). It becomes a service with an ACL that works in Product but still reads the data from the shared DB (shared_db). Then the data moves to its own DB (owned_db), where the service stores it in its clean model. And finally the service is autonomous —its own model, its own data— and the monolith's internal function is deleted. Four phases, a total change inside, and the monolith none the wiser.

What makes this whole transformation safe is a property you're going to verify with two measures that have to come out the same (or True): that the monolith renders identical in the four phases —the old contract intact— and that the ACL's round-trip comes back faithful —to_legacy(to_modern(row)) == row—. If the monolith sees the same in phase 1 (internal function) and in phase 4 (autonomous service with its own data), the extraction was invisible to it, which is exactly the promise: modernizing without forcing the hundreds of consumers of the catalog inside the monolith to change.

Connection with the module. This is the method's third step (M5), and it produces two things for the following step: the service with its own model and its own data (owned_db in the Product model) and the return ACL, which keeps the monolith's contract intact whatever happens with the data inside. Lesson 5 leans on that ACL to migrate the data safely: the return ACL is what guarantees that, while the data moves from one store to another, the monolith keeps receiving its exact old model. Notice the boundary: here we execute the extraction technique —the ACL, the ownership of the data, the transient shared DB that gets cut—. Where the extracted service ends up (whether it becomes a microservice, an event service, a public API) and how that destination shape is designed are taught by the sister guides; lesson 8 tells you which.

An analogy: moving a restaurant's kitchen without closing the dining room

Think of a restaurant that's going to modernize its kitchen completely —change everything: the methods, the ingredients, even the language the chef gives orders in— but with an inviolable rule: the dining room doesn't change and the customers don't notice. The customer orders "the steak medium" as always, and receives "the steak medium" as always, on the same plate, at the same temperature. What happens behind the kitchen door can transform radically; what crosses that door toward the dining room has to be identical to what it always was.

The key to pulling it off is a translator at the kitchen door. The order comes in in the dining room's language ("steak medium, no salt") and the translator converts it to the kitchen's new language (the codes, the stations, the modern flow). The dish comes out in the new kitchen's language and the translator returns it to the dining room exactly as the customer expects it. Thanks to that translator, the kitchen can move in phases —first the knives change, then the cooking method, then even where the meat comes from— without a single table noticing the difference. And there's a test the manager runs at each phase: orders a dish, compares it with how it came out before, and if it's identical, that phase's move was invisible.

The ACL is that translator at the door. The monolith's request comes in in the old model (prod_id, desc, prc_cents), the ACL translates it to Product so the new service understands it, the service responds in Product, and the ACL translates it back to the old model before handing it to the monolith. The kitchen (the catalog) moves in phases —from internal function to service, from shared data to its own data—, but the dining room (the monolith) always receives its usual dish. And the manager's test is your verification: render at each phase and check that the monolith sees the same.

Worked example: the extraction journal in four phases

We're going to execute the catalog's complete extraction as a four-phase journal. The ACL —to_modern and to_legacy— translates between the old model (dict with prod_id, desc, prc_cents string, act) and the clean Product. In each phase, the catalog gets the data from a different place and with a different model inside, but returns the same legacy dict the monolith consumes. We verify two things: that the monolith renders identical in the four phases, and that the ACL's round-trip comes back faithful for each product.

# Step 3 of the method: extract the catalog to its own service with an anti-corruption
# layer that translates the old model (prod_id, desc, prc_cents string) to the clean
# one (Product) and back. Journal in phases: internal function -> ACL over shared_db ->
# owned_db -> autonomous service. The monolith receives IDENTICAL in the four phases.

from dataclasses import dataclass


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


# --- ACL: two translations, one per direction. ---
def to_modern(row):
    return Product(row["prod_id"], row["desc"], int(row["prc_cents"]), row["act"] == "Y")


def to_legacy(product):
    return {"prod_id": product.id, "desc": product.name,
            "prc_cents": str(product.price_cents), "act": "Y" if product.active else "N"}


LEGACY_KEYS = {"prod_id", "desc", "prc_cents", "act"}

# The catalog's data, in the old model (as it lives in the monolith's shared_db).
shared_db = [
    {"prod_id": 1, "desc": "SSD 1TB",   "prc_cents": "8999", "act": "Y"},
    {"prod_id": 3, "desc": "Webcam HD", "prc_cents": "5999", "act": "N"},
]

# The service, after the extraction, stores its data as Product in its own store.
owned_db = [to_modern(r) for r in shared_db]


# The monolith ONLY knows how to read the old model. Its render doesn't change in any phase.
def monolith_render(legacy):
    assert set(legacy) == LEGACY_KEYS, "the monolith only accepts the legacy dict"
    return f"[{legacy['prod_id']}] {legacy['desc']} - ${int(legacy['prc_cents'])/100:.2f}"


# --- The four phases of the extraction. Each one returns the legacy dict the
#     monolith consumes; inside, where the data comes from changes completely. ---
def phase_internal_function(prod_id):
    # Phase 1: the catalog is an INTERNAL FUNCTION of the monolith. It reads shared_db
    # and returns the old model directly. There's no service or ACL yet.
    row = next(r for r in shared_db if r["prod_id"] == prod_id)
    return dict(row)


def phase_acl_over_shared(prod_id):
    # Phase 2: the catalog is already a SERVICE with the Product model, but its data
    # is still in shared_db. The ACL translates on input and on output.
    row = next(r for r in shared_db if r["prod_id"] == prod_id)
    product = to_modern(row)            # input ACL: legacy -> Product
    return to_legacy(product)           # output ACL: Product -> legacy


def phase_owned_db(prod_id):
    # Phase 3: the data moved to owned_db (native Product). The service reads it
    # native; the ACL only translates on output. shared_db is no longer the source.
    product = next(p for p in owned_db if p.id == prod_id)
    return to_legacy(product)           # output ACL: Product -> legacy


def phase_autonomous_service(prod_id):
    # Phase 4: the service is autonomous (owned_db, its own model) and the monolith's
    # internal function is already deleted. The monolith only consumes it via the facade.
    product = next(p for p in owned_db if p.id == prod_id)
    return to_legacy(product)


phases = [
    ("1. internal function",   "old model",   phase_internal_function),
    ("2. ACL over shared_db",   "Product+ACL", phase_acl_over_shared),
    ("3. owned_db",             "Product+ACL", phase_owned_db),
    ("4. autonomous service",   "Product+ACL", phase_autonomous_service),
]

print("Catalog extraction journal: 4 phases, the monolith sees the SAME\n")
print(f"{'phase':<24}{'inside':<14}{'the monolith renders'}")
print("-" * 78)
renders = []
for name, inside, fn in phases:
    legacy = fn(prod_id=1)
    rendered = monolith_render(legacy)
    renders.append(rendered)
    print(f"{name:<24}{inside:<14}{rendered}")
print("-" * 78)
print(f"\n  Do the 4 phases render identical?  {len(set(renders)) == 1}")

# --- ACL verification: the round-trip must return identical for each product. ---
print("\nRound-trip of the ACL: to_legacy(to_modern(row)) == row")
all_ok = True
for row in shared_db:
    back = to_legacy(to_modern(row))
    ok = back == row
    all_ok = all_ok and ok
    print(f"  prod_id={row['prod_id']}: round-trip {'OK' if ok else 'BROKEN'}")
print(f"\n  global round_trip: {all_ok}")
print("  The monolith responded identical in the 4 phases while its guts went")
print("  from internal function to autonomous service with its own data. The ACL held")
print("  the old contract intact in each phase: extracting without the monolith noticing.")

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

Catalog extraction journal: 4 phases, the monolith sees the SAME

phase                   inside        the monolith renders
------------------------------------------------------------------------------
1. internal function    old model     [1] SSD 1TB - $89.99
2. ACL over shared_db   Product+ACL   [1] SSD 1TB - $89.99
3. owned_db             Product+ACL   [1] SSD 1TB - $89.99
4. autonomous service   Product+ACL   [1] SSD 1TB - $89.99
------------------------------------------------------------------------------

  Do the 4 phases render identical?  True

Round-trip of the ACL: to_legacy(to_modern(row)) == row
  prod_id=1: round-trip OK
  prod_id=3: round-trip OK

  global round_trip: True
  The monolith responded identical in the 4 phases while its guts went
  from internal function to autonomous service with its own data. The ACL held
  the old contract intact in each phase: extracting without the monolith noticing.

Read the journal phase by phase, looking at the inside column (what changes) against the render column (what does not change).

Phase 1 — internal function. The catalog is a function inside the monolith that reads shared_db and returns the old model directly, without translation. It's the starting point: there's no service or ACL, just monolith code speaking its own language. The monolith renders [1] SSD 1TB - $89.99.

Phase 2 — ACL over shared_db. The catalog is already a service with the Product model. When the request arrives, the input ACL translates the shared_db row to Product (to_modern), the service works in its clean model, and the output ACL translates back to legacy (to_legacy) before handing it to the monolith. The data is still in the shared DB, but the service's model is already clean. The monolith renders [1] SSD 1TB - $89.99 —identical—.

Phase 3 — owned_db. The data moved to the service's own DB (owned_db), where it now lives as native Products. The service reads it without translating on input (it's already Product); only the output ACL translates to legacy for the monolith. The data's source changed completely —from the legacy row in shared_db to the native Product in owned_db—, but the monolith renders [1] SSD 1TB - $89.99 —identical—.

Phase 4 — autonomous service. The service is entirely independent: its own model, its own data, and the monolith's internal function is already deleted. The monolith only consumes it through the facade. It keeps rendering [1] SSD 1TB - $89.99 —identical—.

And here's the extraction's verdict: Do the 4 phases render identical? True. Between phase 1 (a function inside the monolith with the old model) and phase 4 (an autonomous service with its own model and its own data), the catalog transformed completely inside —it changed what it is, how it models the data, and where it gets it from—, and the monolith noticed absolutely nothing. Every dish came out of the dining room identical, even though the kitchen had moved entirely.

The global round_trip: True is what guarantees that invisibility. The round-trip verifies to_legacy(to_modern(row)) == row for each product: if you take a legacy row, translate it to Product and translate it back, you get the original legacy row, byte by byte —including prod_id=3, the inactive product (act: "N")—. That fidelity of the ACL in both directions is what makes the monolith always receive its exact old model. Without it, extracting the service would force rewriting the hundreds of consumers of the catalog inside the monolith —the big-bang the method exists to avoid—.

Deep dive: why the return direction saves the monolith, and the transient shared DB

Two ideas of this step deserve development: why the return ACL is the critical half, and what happens with the shared DB during the extraction.

The return direction is the one that keeps the promise. It's easy to see why the input direction is needed (legacy → modern): without it, the service would have to speak the dirty model, which is what the extraction wants to avoid. But the return direction (modern → legacy) is the less obvious one and the most critical for safety, because it's the one that keeps the promise to the monolith: "you're going to keep receiving exactly what you were receiving". The monolith has, spread throughout its code, hundreds of places that consume the catalog —the checkout, the cart, the emails, the admin panel—, all expecting row["desc"] and row["prc_cents"]. When you extract the catalog, you do not rewrite those hundreds of consumers to speak Product; that would be the big-bang. Instead, the return ACL makes the service, seen from the monolith, behave identical to the internal function it replaced: it receives the old, it returns the old.

                     bidirectional ACL
                          │
   monolith ──[legacy]───>│───[modern]──> catalog service
   (hundreds of           │               (Product model,
    consumers,            │                its own data in owned_db)
    unchanged)     <──────│<──────────────
                   [legacy]     [modern]
                          │
   the promise: the monolith receives its old model intact, whatever happens inside

The round-trip you verified is the return ACL applied to data that doesn't change: to_legacy(to_modern(row)) == row. If the round-trip gives True for all cases, you know the return direction is faithful —that the monolith will receive exactly its old model—. That's why it's verified: it's the proof that the promise to the monolith is kept in each phase.

The shared DB is transient, and it gets cut. Notice the transition from phase 2 (ACL over shared_db) to phase 3 (owned_db). In phase 2, the service already has its own model but shares the data with the monolith: both read from shared_db. That state is useful as a bridge —it lets you extract the model without moving the data yet—, but it's transient and dangerous if it stays: as long as two systems share a table, neither truly owns its data, a schema change affects both, and the coupling the extraction wanted to break is still alive in the data layer. That's why the method doesn't stop at phase 2: the data moves to owned_db (phase 3), where the service is the only one that reads and writes it, and the shared DB gets cut. The ownership of the data —that the service owns its store— is what completes the extraction; a "service" that still reads the monolith's tables isn't extracted, only disguised. Lesson 5 executes that data move in detail (dual-write, backfill, parallel-run); here it's enough to see that phase 3 requires it and why.

An honest nuance about the return ACL: it only translates the format, it doesn't filter the content. If the service evolves its behavior (calculates a different price, marks a product differently), the ACL translates that new result to the old format as is —it doesn't "correct" it to keep the monolith happy—. Old format, behavior that may be new: that's the separation the ACL maintains. Putting business logic in the return ACL would hide behavior at the edge and break the clarity of "each world, its model".

Common mistakes

Building only the input direction of the ACL and forgetting the return. What happens: the team writes to_modern (the service already receives Product) and takes the ACL for granted —until the monolith receives the service's response and blows up, because it got a Product where it expected a dict—. Why it happens: the input direction is the visible one (it makes the service "work"); the return one is discovered late, when the monolith consumes the response. How to spot it: the monolith fails reading row["desc"] on something that isn't a dict, or you have to start patching monolith consumers so they understand Product. How to fix it: the ACL is bidirectional by definition. Write to_legacy alongside to_modern and verify the round-trip. The return direction is the one that keeps the promise of not touching the hundreds of monolith consumers; without it, extracting forces rewriting them —the big-bang you wanted to avoid—.

Leaving the service reading the shared DB and declaring it "extracted". What happens: the team puts in the ACL, the service already has its own model, and —satisfied— stops at phase 2, with the service still reading shared_db. Why it happens: phase 2 already "works" (the model is clean, the monolith receives what it needs), and moving the data is extra work. How to spot it: the "service" doesn't have its own table; it queries the monolith's; a monolith schema change breaks it. How to fix it: a truly extracted service owns its data —it's the only one that reads and writes its store (owned_db)—. The shared DB is a transient bridge, not a destination: if it stays, the coupling in the data layer is still alive and the extraction is half-done. Complete phase 3: move the data and cut the shared DB.

Filtering the service's response with monolith logic in the return ACL. What happens: the return ACL, besides translating the format, starts adjusting the response "to keep the monolith happy" —rounding a price, hiding a new field—. Why it happens: the output edge looks like the place to "arrange" the response to the old one's taste. How to spot it: the return ACL has logic that isn't format translation but a decision about the content. How to fix it: the return ACL only translates the format modern → legacy, faithfully. If the service produces new behavior, the ACL translates it as is to the old format; it doesn't "correct" it. The decisions about what content to return belong to the service's domain. Putting logic in the return ACL hides behavior at the edge and breaks the format/behavior separation that makes clear who decides what.

Exercises

Exercise 1 — The guts change, the render doesn't. The journal shows four phases where the monolith always renders [1] SSD 1TB - $89.99. (a) What changes, concretely, between phase 1 and phase 4 inside? (b) What piece guarantees the monolith sees the same despite those changes? (c) Why is it so important that the monolith notices nothing?

See solution

(a) Between phase 1 and phase 4 everything internal to the catalog changes: it goes from being a function inside the monolith that returns the old model directly, to being an autonomous service with its own model (Product instead of the legacy dict) and its own data (owned_db instead of shared_db), consumed by the monolith only through the facade. It changed what it is (function → service), how it models (legacy dict → Product), and where it gets the data from (shared_dbowned_db).

(b) The anti-corruption layer (the bidirectional ACL): to_modern translates on input and to_legacy on output, so no matter how the service models or where it gets the data from, what crosses toward the monolith is always the same old legacy dict. The green round-trip (to_legacy(to_modern(row)) == row) certifies that the return translation is faithful.

(c) Because the monolith has hundreds of consumers of the catalog (checkout, cart, emails, admin), all expecting the old model. If the monolith "noticed" the change —received a Product instead of a dict—, you'd have to rewrite those hundreds of consumers, which is exactly the big-bang the method fights. The monolith noticing nothing is what lets you extract the catalog without touching the rest of the monolith: the extraction stays contained in one slice.

Exercise 2 — The service that doesn't own its data. A team puts in the ACL, the service already speaks Product, and declares the catalog "extracted" —but the service still reads shared_db, the monolith's table—. (a) What phase of the journal did it stop at? (b) What problem does it leave open? (c) What does it lack to be truly extracted?

See solution

(a) It stopped at phase 2 (ACL over shared_db): the service already has a clean model and the ACL translates in both directions, but the data is still in the shared DB with the monolith.

(b) It leaves open the coupling in the data layer: as long as the service and the monolith share the table, neither truly owns that data. A monolith schema change breaks the service (and vice versa), the service can't evolve its storage freely, and the boundary the extraction wanted to create isn't complete —it's in the model but not in the data—. The shared DB is a transient bridge; if it stays, the coupling that was meant to be broken is still alive.

(c) It lacks completing phase 3 (owned_db): moving the catalog's data to the service's own DB, where the service is the only one that reads and writes it, and cutting the shared DB. The ownership of the data —that the service owns its store— is what completes the extraction. A service with its own model but shared data is disguised, not extracted. (The mechanics of moving that data without downtime is lesson 5.)

Exercise 3 — The broken round-trip. Suppose someone "improves" the ACL so to_legacy returns prc_cents as an integer instead of a string ("it's cleaner"). (a) What would the round-trip measure differently? (b) What would happen in the monolith? (c) What's the rule this change violates?

See solution

(a) The round-trip to_legacy(to_modern(row)) == row would stop giving True: taking {"prc_cents": "8999", ...}, translating it to Product (price_cents=8999) and back, to_legacy would produce {"prc_cents": 8999, ...} (integer) instead of {"prc_cents": "8999", ...} (string). The comparison 8999 == "8999" is False, so the round-trip would mark BROKEN for each product.

(b) The monolith, which does int(legacy['prc_cents']) expecting a parseable string —or which elsewhere compares prc_cents as a string—, could break or behave differently. Even though int(8999) works, any monolith consumer that depends on prc_cents being a string (a comparison, a serialization, a log) would break. The "cleaner" changed the format the monolith expects, and the monolith didn't change to accommodate it.

(c) It violates the rule that the return ACL translates to the exact format the monolith expects, faithfully, without "improving" it. The service's clean model can have price_cents as an integer (that's the service's business), but when crossing back to the monolith, the ACL must deliver prc_cents as a string —the old format, as is—. The green round-trip is exactly the proof that this fidelity is maintained; breaking it is the sign that the ACL stopped keeping the promise to the monolith.

Summary and next step

In this lesson you took the method's third step: extracting the catalog to its own service with an anti-corruption layer (module 5). You executed the extraction journal in four phases —internal function → service with ACL over shared_dbowned_db → autonomous service— and verified the property that makes the whole transformation safe: the monolith rendered identical in the four phases (True) while its guts changed completely (what it is, how it models, where it gets the data from), and the ACL's round-trip came back faithful for each product (True), including the inactive one. You saw, with the restaurant kitchen that moves without the dining room noticing, that the ACL is the translator at the door —old model toward the monolith, clean model toward the service— and that the return direction is the one that keeps the promise of not touching the hundreds of monolith consumers. And you understood that the shared DB is a transient bridge that gets cut: a truly extracted service owns its data.

Before moving on you should be able to: explain why the ACL is bidirectional by definition; describe what the return direction saves (the contract with the monolith's consumers); verify an extraction with the identical render per phase and the round-trip; and distinguish an extracted service (its own data) from a disguised one (shared data).

Lesson 5 takes the fourth step: migrating the slice's data (module 6). The service already has its own model and —in phase 3— its own DB, but moving the catalog's data from shared_db to owned_db without downtime is a technique in itself. You're going to execute the complete cycle: dual-write + backfill fill the new store, the parallel-run compares row by row and finds 2 discrepancies (a missing row, one with a mistranslated field), the reconciliation fixes them, the parallel-run runs again and gives 0, and only then is the read-switch done. The ACL you put in place in this lesson is exactly what makes that move safe: it keeps the monolith's contract intact while the data moves underneath.

Resources

  • Eric Evans, Domain-Driven Design (Addison-Wesley, 2003), ch. 14, Anti-Corruption Layer — the source of the pattern: a layer with facades and adapters that translates both ways between two models, so the new model doesn't get contaminated with the old. In English.
  • Sam Newman, Monolith to Microservices (O'Reilly, 2019), ch. 3 — the comprehensive reference for extracting a service from a monolith: the ACL, the ownership of the data, and the transient shared DB that gets cut. The method's third step follows its script. In English.
  • Chris Richardson, "Microservice Architecture" — microservices.io. The catalog of decomposition patterns, including service extraction and shared-database handling; useful as an index of the pieces this step chains together. In English.
  • Microsoft, "Anti-corruption Layer pattern" — learn.microsoft.com/azure/architecture/patterns/anti-corruption-layer. The pattern's page with the diagram of the layer that translates between the two subsystems in both directions. In English.