Module 5: Extracting a Service
Module introduction: extracting a service
Why this module exists here
So far you modernized the legacy without taking it out of the monolith. In module 3 you put a facade in front of GET /products and diverted the HTTP traffic from the old to the new by percentage —but the "new" lived alongside, in the same deployment—. In module 4 you inserted an abstraction into the code and migrated the shipping calculation's implementation from the inside, with a flag —but it was still the same process, the same database—. Both patterns change who executes a functionality; neither takes it out of the monolith. This module takes that step: extract a bounded context into its own service, with its own model and its own data.
Here's the complete idea, in one sentence: you choose a piece of the monolith with its own cohesion —Mercado's catalog—, you take it out to a service that has a clean model (not the legacy's jargon) and owns its own data (doesn't share tables forever), and you put a translator at the boundary that converts between the monolith's old model and the service's new model, in both directions, so neither contaminates the other. That translator —the anti-corruption layer— is what lets the monolith keep working without changing a line while the new service breathes with a design of its own.
This module teaches that mechanics in depth, applied to Mercado's catalog. Remember the case: Mercado is a legacy monolith with catalog, orders, payments, and shipping living in a single codebase, over a single database. The monolith calls catalog as an internal function that speaks its old model —prod_id, desc, prc_cents—. We're going to take catalog out to its own service that speaks a clean model —Product(id, name, price_cents)—, with an anti_corruption_layer that translates between the two, and take its data from the shared DB to a DB of its own, all without the monolith finding out.
Connection with the module. This is the map lesson. We don't yet get into the detail of each step; we install the metaphor (helping a child become independent: first they live at home, then they rent with your guarantee, then their own house and their own accounts; the ACL as the translator between two who speak different languages), the complete cycle (find the bounded context → insert the ACL → own the data → cut the shared DB) and the vocabulary (bounded_context, anti_corruption_layer, legacy_model, Product, translate, shared_db, owned_db). And we run a map example: a minimal anti_corruption_layer that translates the old model to the clean model and back, to see that the monolith doesn't change while the service speaks another language. Lesson 2 finds the bounded context; lesson 3 builds the ACL; lesson 4 does it in both directions; lesson 5 gives the service ownership of its data; lesson 6 cuts the shared DB; lesson 7 decides the extraction order; and lesson 8 integrates it into Mercado's catalog. Notice the boundary: here we teach how a piece is extracted (the ACL, the data, the cut). Where the service arrives (microservice, events, API) is for the style guides; migrating its data without downtime is module 6; and the strangler that diverted the traffic to the service was module 3.
And the usual promise: nothing is asserted "from memory," everything is executed. Each simulation runs with Python 3.14 and only the standard library, with fixed data, so the output you see in each "What to expect" block is the literal output of running the code. You can copy it and reproduce it identically.
An analogy: helping a child become independent
Think of a child who becomes independent from home. They don't go from living with you to being self-sufficient overnight; they go through stages, and in each one they own a bit more of their life.
Stage 1 — they live at home. They sleep in their room, eat from your fridge, their bills are mixed with yours: the electricity, the water, the internet, all go on the same bill. There's no clear boundary between their expenses and yours. This is the bounded context that still shares the monolith's database: the functionality exists, but its data is mixed with everyone else's, in the same tables.
Stage 2 — they rent with your guarantee. They move to their own apartment, but you sign the contract as guarantor, and maybe you send them money at first. They already have their space and start making their decisions, but they still depend on you for the important things. This is the service extracted but transient: it already has its own process and its own model, but its data still rests on the shared DB while the cut is finished.
Stage 3 — their own house and their own accounts. They buy or rent in their name, pay their own bills, decide what they eat without consulting you. They're self-sufficient. This is the service with complete ownership of its data: its own database (owned_db), its own model, and no dependency on the monolith's tables. The shared_db was cut.
And here comes the key piece, the one that names the module. Imagine the child moves to another country and now speaks another language, while you keep speaking the same one. For them to keep negotiating —who pays what, when they visit— they need a translator that converts what each one says into the other's language, in both directions. That translator doesn't change what either one thinks; it only prevents them from misunderstanding each other. The anti-corruption layer is that translator: the monolith keeps speaking its old model, the service speaks its new model, and the ACL translates between the two so neither has to learn the other's language —and, above all, so the dirty language of the old one doesn't leak into the clean design of the new one, or vice versa—.
Here are the module's pieces, already visible in the analogy:
- The bounded context is the child: a unit with its own identity, worth making independent.
- Sharing the DB (
shared_db) is the stage of living at home with the accounts mixed. - Owning the data (
owned_db) is the stage of their own house with their own accounts. - The anti-corruption layer is the translator between two who speak different languages.
- The extraction order is starting with the most independent child, not the one that still depends on everyone.
Worked example: a minimal anti_corruption_layer translating Mercado's catalog
We're not going to describe the translator: we're going to execute it, even if in its smallest version. The idea is to have the three minimal pieces —the monolith's old model, the service's clean model, and the anti_corruption_layer that translates between them— and see, with our own eyes, that the monolith can keep speaking its old language while the service speaks a clean one, with the ACL in the middle.
The monolith stores each product as a dictionary with cryptic names and dirty types: prod_id (the id), desc (the name, but the field is called "desc" for description, a legacy inheritance), prc_cents (the price in cents, but stored as a string), and act (active, but as 'Y'/'N'). The new service wants to speak a clean model: a Product with id, name, price_cents (a real int) and active (a real bool). The anti_corruption_layer translates in both directions: to_modern converts the old dict into a clean Product, and to_legacy converts it back.
from dataclasses import dataclass
# --- The OLD model: the form Mercado's legacy monolith uses today. ---
# Cryptic names, price as a string, active as "Y"/"N": the real legacy.
legacy_rows = [
{"prod_id": 1, "desc": "SSD 1TB", "prc_cents": "8999", "act": "Y"},
{"prod_id": 2, "desc": "USB-C Hub", "prc_cents": "3200", "act": "Y"},
{"prod_id": 3, "desc": "Webcam HD", "prc_cents": "0", "act": "N"},
]
# --- The NEW, clean model: the one the catalog service wants to speak. ---
@dataclass
class Product:
id: int
name: str
price_cents: int
active: bool
# --- The anti-corruption layer: translates old <-> new at the boundary. ---
def to_modern(row):
# legacy -> modern: clear names, correct types, no jargon of the old one.
return Product(
id=row["prod_id"],
name=row["desc"],
price_cents=int(row["prc_cents"]),
active=(row["act"] == "Y"),
)
def to_legacy(product):
# modern -> legacy: back to the form the monolith expects to receive.
return {
"prod_id": product.id,
"desc": product.name,
"prc_cents": str(product.price_cents),
"act": "Y" if product.active else "N",
}
# --- The monolith called catalog as an internal function with the old model. ---
# This function does NOT change: it keeps receiving and returning the legacy dict.
def legacy_catalog_lookup(prod_id):
for row in legacy_rows:
if row["prod_id"] == prod_id:
return row
return None
print("The ACL translates the monolith's old model <-> the service's clean model\n")
for row in legacy_rows:
prod = to_modern(row)
print(f" legacy: {row}")
print(f" modern: {prod}\n")
print("Round-trip: legacy -> modern -> legacy must come back identical\n")
print(f"{'prod_id':>8}{'round-trip identical?':>26}")
print("-" * 34)
all_ok = True
for row in legacy_rows:
back = to_legacy(to_modern(row))
ok = back == row
all_ok = all_ok and ok
print(f"{row['prod_id']:>8}{('YES' if ok else 'NO'):>26}")
print("-" * 34)
print(f"\nRound-trip intact in all the rows: {all_ok}")
print(f"The monolith stays the same: legacy_catalog_lookup(2) = {legacy_catalog_lookup(2)}")
print("\n The monolith didn't change (speaks legacy). The service speaks Product (clean).")
print(" The ACL is the translator at the boundary: nobody contaminates the other.")
What to expect. When you run the file, the output is exactly this:
The ACL translates the monolith's old model <-> the service's clean model
legacy: {'prod_id': 1, 'desc': 'SSD 1TB', 'prc_cents': '8999', 'act': 'Y'}
modern: Product(id=1, name='SSD 1TB', price_cents=8999, active=True)
legacy: {'prod_id': 2, 'desc': 'USB-C Hub', 'prc_cents': '3200', 'act': 'Y'}
modern: Product(id=2, name='USB-C Hub', price_cents=3200, active=True)
legacy: {'prod_id': 3, 'desc': 'Webcam HD', 'prc_cents': '0', 'act': 'N'}
modern: Product(id=3, name='Webcam HD', price_cents=0, active=False)
Round-trip: legacy -> modern -> legacy must come back identical
prod_id round-trip identical?
----------------------------------
1 YES
2 YES
3 YES
----------------------------------
Round-trip intact in all the rows: True
The monolith stays the same: legacy_catalog_lookup(2) = {'prod_id': 2, 'desc': 'USB-C Hub', 'prc_cents': '3200', 'act': 'Y'}
The monolith didn't change (speaks legacy). The service speaks Product (clean).
The ACL is the translator at the boundary: nobody contaminates the other.
Read the output in three stretches, because each one shows a face of the module.
The first stretch shows the outbound translation (legacy → modern) row by row. The monolith has {'prod_id': 1, 'desc': 'SSD 1TB', 'prc_cents': '8999', 'act': 'Y'} —a dictionary with obscure names and a price stored as text—, and the ACL converts it into Product(id=1, name='SSD 1TB', price_cents=8999, active=True) —an object with clear names, the price as a whole number, and the state as a boolean—. Notice product 3: the '0' string became 0 int, and the 'N' became active=False. The ACL doesn't just rename fields; it normalizes the types and the domain. From the ACL inward, the service never sees a 'Y' or a price-as-text: it sees clean bool and int.
The second stretch is the round-trip: legacy → modern → legacy comes back identical in the three rows (YES, YES, YES), and the line Round-trip intact in all the rows: True confirms it in a single verification. This matters because the ACL translates in both directions: if it takes the old dict, converts it into a Product, and converts it back, it has to return exactly the dict it started with. If the round-trip failed, the ACL would be losing or deforming information in the translation —and the monolith, which expects its old model intact, would receive something different—.
The third stretch is the heart of the module in one line: The monolith stays the same: legacy_catalog_lookup(2) = {'prod_id': 2, ...}. The monolith's internal function didn't change; it keeps receiving and returning the same old legacy dict. We installed a service with a clean model and a translator at the boundary without touching the monolith. That's a well-done extraction: the child moved out and learned another language, but you keep speaking yours, and the translator prevents them from misunderstanding each other.
That the round-trip gives True and that the monolith stays identical isn't a detail: it's the property that makes the extraction safe. Just like in the strangler the facade at 0% was transparent, here the ACL —well built— leaves the monolith exactly as it was, while the new service starts to live its own life with a design of its own.
The complete cycle of the extraction
That example touched the pattern's pieces without developing them. It's worth seeing the order in which they're installed, because it's the backbone of the lessons that follow:
Step Lesson What you do Service data
──────────────────────────── ──────── ───────────────────────────────────── ──────────────────
1. Find the bounded ctx L2 measure the seam, pick the leaf in shared_db
2. Insert the ACL L3-L4 translate old <-> new, both directions in shared_db
3. Own the data L5 the service is the only one that R/W copy to owned_db
4. Cut the shared DB L6 shared_db -> owned_db, monolith the same in owned_db
A nuance of order, as a warning: the ACL (step 2) is inserted before cutting the data (steps 3 and 4), not after. First you put the translator at the boundary —the service already speaks its clean model, even though its data is still in the shared DB—, and only when the ACL is tested in both directions do you cut the data dependency. Lesson 6 treats it in depth; the table's order is logical.
And this is how the steps fit into the module's flow:
flowchart LR
C["monolith<br/>(speaks legacy)"] --> ACL["anti_corruption_layer<br/>(translates old <-> new)"]
ACL -->|to_modern| S["catalog service<br/>(speaks Product)"]
S -->|to_legacy| ACL
S -.->|step 3-4| DB["owned_db<br/>(own data)"]
C -.->|stops touching| SDB["shared_db<br/>(retired)"]
Read it like this: the monolith no longer calls catalog as an internal function; it talks to the service through the ACL. The ACL translates the request from the old model (to_modern) to the clean model the service understands, and translates the response back (to_legacy) to the model the monolith expects. Over time, the service stops reading the shared_db and starts owning its owned_db, and the monolith stops touching the shared table. In the end, catalog is a self-sufficient service and the monolith is one slice smaller.
The map: where this module sits in the guide
Extracting a service is the third migration pattern, and the one that reaches furthest: it doesn't just change who executes, but the topology —a piece leaves the monolith for its own process, with its own model and its own data—. This is how it connects with the rest:
flowchart TD
M1["M1 · Why not rewrite<br/>(the conviction)"]
M2["M2 · Characterize the legacy<br/>(the safety net)"]
M3["M3 · Strangler fig<br/>(migrate from outside, by traffic)"]
M4["M4 · Branch by abstraction<br/>(migrate from inside, by code)"]
M5["M5 · Extract a service<br/>(anti-corruption layer, own data)"]
M6["M6 · Migrate data without downtime"]
M7["M7 · Measure the progress"]
M1 --> M2 --> M3 --> M4 --> M5 --> M6 --> M7
Read it like this: in M1 you convinced yourself that incremental wins; in M2 you put the safety net with characterization tests; in M3 you learned to divert traffic with an external facade; in M4 you learned to migrate the implementation inside the code; here, in M5, you take the piece out to its own service with an ACL and its own data; in M6 you migrate that data without downtime; and in M7 you measure the progress until you turn off the old. The three migration patterns —strangler (M3), branch by abstraction (M4), and extraction (M5)— are cousins: the extraction often uses the other two as tools (a strangler diverts the traffic to the new service; an internal abstraction prepares the cut), but it goes further: it changes where the piece lives.
And the boundary with the sibling guides of the ecosystem: this guide is the extraction technique, not the destination. Where the extracted catalog arrives —whether it ends up being a microservice, an event-oriented service, or a public API— is taught by architectural-styles-and-boundaries, event-driven-architecture, and api-design-and-integration. The decision to extract —the ADR, the cost, the reversibility— is architecture-decisions-and-tradeoffs. And migrating the service's data without downtime —dual-write, backfill, parallel-run— is module 6: here we cut the ownership of the data (whose the tables are), not the mechanics of moving the records without shutting down the system.
Common mistakes
Believing that extracting is "moving the code to another folder." What happens: the team takes the catalog function from the monolith, copies it to a new module called catalog_service, and considers the extraction done —but the "service" keeps reading the same tables of the monolith and speaking the same old model—. Why it happens: moving files is visible and fast; cutting the data dependency and translating the model is tedious and invisible. How to spot it: ask "what tables does this service read from?". If the answer includes the monolith's tables, it's not extracted, just relocated. How to fix it: the extraction has two cuts that "moving the code" doesn't make —the model cut (the ACL translates old↔new, so the service speaks clean) and the data cut (the service owns its store, doesn't share tables)—. Without those two cuts, you extracted nothing; you moved a folder. Lessons 3 to 6 are exactly those two cuts.
Extracting without an ACL and dragging the old model into the new service. What happens: to "go fast," the team makes the new service speak the monolith's old model directly —prod_id, desc, prc_cents as a string— without a translator. Why it happens: writing the ACL feels like extra work; it seems easier for the new one to use the model that already exists. How to spot it: the "new" service's code is full of int(row["prc_cents"]) and row["act"] == "Y" —the old one's jargon, leaked into the new design—. How to fix it: the ACL exists precisely to avoid that contamination. If the new service inherits the old one's dirty model, it's born with the same debt you wanted to leave behind, and in a few months it's as tangled as the monolith. Lesson 3 shows why the clean model matters: the service's logic reads itself (price_cents == 0) instead of parsing strings. The translator at the boundary is what buys that cleanliness.
Exercises
Exercise 1 — The child's stages. With the analogy of helping a child become independent, match each stage with what happens in the extraction: (a) they live at home with the accounts mixed, (b) they rent with your guarantee, (c) their own house and their own accounts. Then say what the translator between two who speak different languages represents, and why it's needed in both directions.
See solution
- (a) They live at home with the accounts mixed → the bounded context that shares the
shared_db. The functionality exists, but its data is mixed with everyone else's in the same tables: there's no data boundary. - (b) They rent with your guarantee → the service extracted but transient. It already has its own process and its own model, but its data still rests on the shared DB while the cut is finished.
- (c) Their own house and their own accounts → the service with
owned_db. It fully owns its data; the dependency on the monolith's tables was cut. It's self-sufficient.
The translator represents the anti-corruption layer: it converts what each side says into the other's language. It's needed in both directions because the two sides speak different languages and both have to understand each other: when the monolith asks the service for something, you have to translate from the old model to the new (to_modern), so the service understands it clean; and when the service responds, you have to translate from the new model to the old (to_legacy), so the monolith receives what it expects. If the translator only translated one way, half the conversation would stay in a language the other doesn't understand —and something would break—.
Exercise 2 — Read the round-trip. In the example, the round-trip legacy → modern → legacy came back identical in the three rows (True). (a) Why is it important that it comes back identical and not just "equivalent"? (b) What information did the ACL have to preserve so that the '8999' string came back exactly as '8999' and not as 8999? (c) If the round-trip failed on a row, what would it tell you about the ACL?
See solution
(a) Because the monolith didn't change: it still expects its exact old model —prc_cents as a string, act as 'Y'/'N'—. If the round-trip came back "equivalent but different" (for example, prc_cents as 8999 int instead of '8999' string), the monolith would receive a type it doesn't expect and could break when processing it. Transparency toward the monolith demands total equality, not equivalence.
(b) The ACL had to remember, in the return direction (to_legacy), that the monolith stores the price as a string: that's why to_legacy does str(product.price_cents), converting the clean 8999 int of the Product back to the '8999' string the monolith expects. The outbound translation normalizes (int(row["prc_cents"])) and the return one de-normalizes (str(...)). The ACL knows the two forms and knows which corresponds to each side.
(c) That the ACL is losing or deforming information in the translation. A round-trip that doesn't come back identical means that to_modern followed by to_legacy isn't the identity: maybe to_legacy forgot a field, or converted a type incorrectly. It's a sign that the translator isn't faithful yet, and that the monolith would receive something different from what it gave. Before trusting the extraction, the round-trip has to give True in all the cases, including the edge ones (price zero, inactive product).
Exercise 3 — Extract, strangle, or branch by abstraction? For each situation, say which of the three cousin patterns applies and why: (a) divert the HTTP traffic of GET /products from the old to the new by percentage; (b) change the implementation of calculate_shipping(), an internal function, from the inside with a flag; (c) take the whole catalog out to its own process, with its own model and its own database.
See solution
- (a) Divert the
GET /productstraffic by percentage → strangler fig (module 3). There's a network boundary (an HTTP endpoint) to interpose a facade that decides, request by request, old vs new. The interposition point is the traffic. - (b) Change
calculate_shipping()from the inside with a flag → branch by abstraction (module 4). It's an internal function, without a network boundary. The change is made by inserting an abstraction into the code and migrating the implementation behind it. The interposition point is the code. - (c) Take
catalogout to its own process with its model and its DB → extract a service (this module, 5). Here the goal isn't just to change who executes, but to move the piece to its own topology: its process, its clean model (with an ACL that translates), and its own data (with the cut of the shared DB). The extraction often uses the strangler (to divert the traffic to it) and branch by abstraction (to prepare the cut) as tools, but it goes further: it changes where the piece lives and whose its data is.
The underlying lesson: the three patterns achieve incremental migration, and they're distinguished by how much they move. The strangler moves the traffic; branch by abstraction moves the implementation; the extraction moves the whole piece —code, model, and data— out of the monolith. This module is the most ambitious of the three.
Summary and next step
In this lesson you installed the third migration pattern, the one that really breaks the monolith: extract a bounded context into its own service. You saw its complete idea —find the piece, take it out to a service with a clean model and its own data, and put a translator at the boundary— and its metaphor: the child who becomes independent in stages (lives at home with the accounts mixed → rents with your guarantee → their own house and their own accounts), with the ACL as the translator between two who speak different languages. And you executed it in its minimal version: an anti_corruption_layer that translated the monolith's old model (prod_id, desc, prc_cents) to the service's clean model (Product) and back, with a verified round-trip, while the monolith stayed identical.
Before moving on you should be able to: name the four steps of the extraction and in which lesson each one is developed; explain why the ACL translates in both directions; read a round-trip and understand why it has to come back identical; and distinguish when the extraction applies from when the strangler and branch by abstraction apply.
Lesson 2 does the first step: finding the bounded context. Before extracting, you have to decide what to extract, and not every piece of the monolith is a good candidate. You're going to learn what a bounded context is, and you're going to measure the seam of Mercado's four modules —the calls that cross each one's boundary, how many enter and how many leave— to see, with numbers, why catalog is the clean leaf that's best to extract first. The extraction starts by choosing the piece well.
Resources
- Eric Evans, Domain-Driven Design (Addison-Wesley, 2003), chapters on Bounded Context and Anti-Corruption Layer — the original source of the two central concepts of this module. Evans defines the bounded context as the limit within which a model is consistent, and the anti-corruption layer as the layer that translates between two models so one doesn't corrupt the other. The founding reading of the module. In English.
- Sam Newman, Monolith to Microservices (O'Reilly, 2019), ch. 3, "Splitting the Monolith" — the chapter that treats service extraction as a technique: choosing what to extract, the database patterns (transient shared table, the ownership of the data), and how to cut the dependency. The go-to reference for lessons 5 and 6. In English.
- Martin Fowler, "BoundedContext" — martinfowler.com/bliki/BoundedContext.html. The short, clear card of the concept: why a big model is easier to handle divided into contexts with explicit boundaries. In English.
- Chris Richardson, "Pattern: Decompose by subdomain" — microservices.io/patterns/decomposition/decompose-by-subdomain.html. The card of the pattern of decomposing a monolith by subdomain (bounded context), in the microservices catalog. Short and direct. In English.