Module 5: Extracting a Service
Owning the data
Overview
With the bidirectional ACL ready, the catalog service already speaks a clean model and the monolith didn't find out. But there's a question the ACL doesn't answer and that decides whether the extraction is real or theater: where does the service get its data from? If the "new service," despite its clean model, keeps reading the monolith's products table, it's not extracted —it's disguised—. This lesson attacks the second cut of the extraction, the data one: a truly extracted service must own its data.
Owning the data means one precise thing: the service is the only one that reads and writes its own store. Nobody else touches its tables directly —not the monolith, not another service, not a loose script—. If someone needs data from catalog, they ask the service for it (through its API, with the ACL translating), they don't take it from the tables on their own. That exclusivity is what gives the service real control over its domain: it can change its schema, its indexes, its whole database, without breaking anyone —because nobody else depends on the internal form of its data, only on its API—.
The anti-pattern is tempting and common: the "service" that shares the monolith's tables forever. It looks like an extraction (there's a service, there's a clean model, there's an ACL) but underneath it's still anchored to the old one's database. This lesson makes it visible with a detector: we instrument the monolith's table so it records who touches it, and we run two "services" —a fake one that reads the monolith's tables, a real one that has its own store—. The detector catches the fake one (2 accesses to the monolith's table) and absolves the real one (0 accesses). Moving the code isn't extracting; extracting is cutting the data dependency.
Connection with the module. Lessons 3 and 4 cut the model (the ACL translates old↔new). This lesson introduces the cut of the data: why the service must own its store. Lesson 6 executes that cut in phases —from the transient shared DB to the own DB—. Notice the boundary: here we define whose the data is (the ownership). How the records are moved from one store to another without shutting down the system —dual-write, backfill, parallel-run— is module 6. Ownership is "who's in charge of the tables"; the migration is "how the data is moved without downtime." They're two different things and this module does the first.
An analogy: the own bank account
Return to the child becoming independent. They may have moved to their own apartment (they have their space, their model of life), but as long as they keep using your credit card for everything, they're not self-sufficient: every purchase goes through your account, you see their expenses, and if you cancel the card they're left unable to pay anything. They share the account. Real independence arrives when the child opens their own bank account: their money is theirs, their transactions are theirs, and what they do with their account doesn't touch yours or vice versa.
The shared account has a problem that goes beyond dependence: the tangle. As long as they share the account, you can't change anything about your account without risking affecting them —change banks, adjust the limit, close a card— because their expenses are mixed with yours. And they can't organize their finances their own way because they're tied to yours. The shared account chains both of them: neither can evolve without consulting the other.
When the child opens their own account, both are freed. You reorganize your account without affecting them; they manage theirs however they want. If they need to coordinate —they borrow from you, you transfer to them— they do it through an explicit transfer between accounts, not by reaching into the other's account. That explicit transfer is the service's API (with the ACL): the agreed way to ask for data, instead of reading the other's tables directly.
In the extraction: the shared card is the monolith's table the "service" keeps reading; the own account is the service's owned_store; and the explicit transfer between accounts is asking the service for the data through its API instead of taking it from its tables. A service that shares the monolith's tables is the child who still has your card: it seems independent, but any change in the shared account tangles them both.
Worked example: the detector for services that don't own their data
We're not going to trust that a service is well extracted: we're going to measure it. We instrument the monolith's table (MonolithDB) so it records each access and who made it. Then we build two "catalog services": a fake one (FakeCatalogService) that, despite returning clean Products, takes the data from the monolith's table; and a real one (CatalogService) that has its own owned_store. We run the two and ask the detector how many times each one touched the monolith's table. A truly extracted service touches it zero times.
from dataclasses import dataclass
@dataclass
class Product:
id: int
name: str
price_cents: int
active: bool
# --- The monolith's table. It's instrumented: records who touches it. ---
class MonolithDB:
def __init__(self):
self.products = [
{"prod_id": 1, "desc": "SSD 1TB", "prc_cents": "8999", "act": "Y"},
{"prod_id": 2, "desc": "USB-C Hub", "prc_cents": "3200", "act": "Y"},
]
self.accessed_by = [] # access log: who read the tables
def read_products(self, who):
self.accessed_by.append(who) # it's recorded that 'who' touched the table
return self.products
monolith_db = MonolithDB()
# --- FAKE service: "extracted" in name, but still reads the old one's table. ---
class FakeCatalogService:
def get(self, prod_id):
rows = monolith_db.read_products(who="FakeCatalogService") # <- couples
row = next(r for r in rows if r["prod_id"] == prod_id)
return Product(row["prod_id"], row["desc"], int(row["prc_cents"]), row["act"] == "Y")
# --- REAL service: owns its own store. The monolith does NOT lend it tables. ---
class CatalogService:
def __init__(self):
self.owned_store = { # own data, in the clean model
1: Product(1, "SSD 1TB", 8999, True),
2: Product(2, "USB-C Hub", 3200, True),
}
def get(self, prod_id):
return self.owned_store[prod_id]
def is_truly_extracted(service_name, db):
touched = db.accessed_by.count(service_name)
return touched == 0, touched
print("A truly extracted service does NOT read the monolith's tables\n")
fake = FakeCatalogService()
fake.get(1); fake.get(2)
ok_fake, touched_fake = is_truly_extracted("FakeCatalogService", monolith_db)
real = CatalogService()
real.get(1); real.get(2)
ok_real, touched_real = is_truly_extracted("CatalogService", monolith_db)
print(f"{'service':<22}{'reads from':<20}{'touched monolith':>18}{'extracted?':>12}")
print("-" * 72)
print(f"{'FakeCatalogService':<22}{'monolith_db.products':<20}{touched_fake:>18}{('YES' if ok_fake else 'NO'):>12}")
print(f"{'CatalogService':<22}{'owned_store (own)':<20}{touched_real:>18}{('YES' if ok_real else 'NO'):>12}")
print("-" * 72)
print(f"\nAccess log of the monolith's table: {monolith_db.accessed_by}")
print("\n The fake 'service' shares the monolith's table: it's not extracted,")
print(" it just moved the code. The real one owns its store: zero accesses to the old table.")
print(" Owning the data = being the only one that reads and writes its own store.")
What to expect. When you run the file, the output is exactly this:
A truly extracted service does NOT read the monolith's tables
service reads from touched monolith extracted?
------------------------------------------------------------------------
FakeCatalogService monolith_db.products 2 NO
CatalogService owned_store (own) 0 YES
------------------------------------------------------------------------
Access log of the monolith's table: ['FakeCatalogService', 'FakeCatalogService']
The fake 'service' shares the monolith's table: it's not extracted,
it just moved the code. The real one owns its store: zero accesses to the old table.
Owning the data = being the only one that reads and writes its own store.
Read the two rows of the table, because the contrast is the whole lesson.
FakeCatalogService touched the monolith's table 2 times (one for each get), and the detector marks it NO extracted. Notice how deceptive this service is: it returns clean Products, it uses the ACL to translate (int(row["prc_cents"]), row["act"] == "Y"), it has all the appearance of a new service. But its data source is monolith_db.products —the old one's table—. From outside it looks extracted; inside it's still tied to the monolith's database. It's the child with the shared card: their own apartment, but every expense goes through your account.
CatalogService touched the monolith's table 0 times, and the detector marks it YES. Its data lives in owned_store —its own dictionary of Products—. When you ask it for a product, it takes it from its store, not from the monolith. That's why the Access log of the monolith's table below only lists ['FakeCatalogService', 'FakeCatalogService']: the real service never appears, because it never touched it. That log is the hard evidence of the ownership: who accessed the old one's tables, by name and surname.
The point of the lesson is that the ACL isn't enough. Both services use the clean model; both translate. The difference isn't in the model, it's in the data: who owns the store they come from. The fake one shares; the real one owns. And that difference is what decides whether you extracted something or just moved the code to another class. A detector as simple as "how many times did you touch the monolith's tables?" separates a real extraction from a disguise.
Deep dive: why exclusive ownership, and what breaks without it
Owning the data isn't a whim of purity; it's what makes a service able to evolve on its own. When the service is the only owner of its store, it has a freedom a monolith module never had: it can change its schema (rename columns, split tables, add indexes), change database engine (from the monolith's relational DB to one specialized in catalog), or change its whole storage strategy —and nobody finds out, because nobody else reads its tables—. The only thing the service promises the world is its API (with the ACL); the inside is its own business.
Compare that with the service that shares tables. If the monolith also reads the products table, then the "service" can't touch that table without risking breaking the monolith. Do you want to rename prc_cents to something sensible? You can't: the monolith reads that column. Do you want to add an index or change the type? You have to coordinate with the monolith. The shared table chains the evolution of both: neither can change its data without consulting the other. It's the worst of worlds —you extracted the code but not the freedom—.
SHARE THE TABLE (fake): OWN THE STORE (real):
monolith ──> products <── service monolith ──API──> service ──> owned_store
(shared (nobody else
table: reads this:
nobody can the service
change it evolves
without breaking alone)
the other)
There's a hard rule that sums up the ownership: each piece of data has a single owner, and only the owner writes. The others can read a copy (through the owner's API), but they don't write to the other's store or depend on its internal form. In Mercado, catalog owns the product data; if orders needs a product's price, it asks the catalog service, it doesn't take it from the catalog tables. This rule is what avoids the tangle of "two systems writing the same table," which is an inexhaustible source of consistency bugs.
And an honesty about the transient phase. In practice, a freshly extracted service almost always spends a while sharing the monolith's database —because cutting the data on day one is risky—. That's fine as a transient step, with a cutover date. The error isn't sharing the DB for a while; the error is sharing it forever, treating the transient phase as permanent. Lesson 6 treats exactly how that cut is executed —from the shared DB to the own one— without shutting down the system. Here what matters is the goal: the service will end up owning its data; sharing is a stopover, not the destination.
Common mistakes
The "service" that keeps reading the monolith's tables. What happens: the team creates the service with its clean model and its ACL, but underneath its queries go to the monolith's tables —SELECT * FROM products against the old one's DB—. Why it happens: it's the fastest; the data is already there, why copy it? How to spot it: this lesson's detector —how many times does the service touch the monolith's tables?— gives more than zero; or, in a real system, the DB logs show the service querying tables that "aren't its." How to fix it: an extracted service owns its data. If it still reads the monolith's tables, the extraction is half done —it has the model cut (ACL) but not the data one—. The plan has to include giving the service its own store and cutting the access to the old one's tables (lesson 6). A service that shares tables isn't a service, it's a monolith module with a facade.
Sharing the database forever. What happens: the service is extracted "for now" over the shared DB, and that "for now" becomes permanent —two years later, the service and the monolith still write the same tables—. Why it happens: the transient phase works well enough not to hurt, and cutting the DB is work with no visible reward. How to spot it: there's no cutover date; the shared DB has been on the "things we'll fix someday" list for a long time; changing any table requires coordinating two teams. How to fix it: the shared DB is a stopover with an expiration date, not the destination. When you extract a service over the shared DB, schedule the cut from day one and treat it as part of the extraction, not as an optional extra. A service that shares the DB forever didn't achieve independence; it achieved a more expensive coupling to maintain, now split across two processes.
Letting others write to the service's store. What happens: the service has its owned_store, but to "go fast" a migration script, or the monolith, write directly to it. Why it happens: writing directly to the table is faster than calling the service's API. How to spot it: there's more than one writer touching the service's store —the service and "someone else"—. How to fix it: the hard rule is only the owner writes. If someone needs to change catalog's data, they do it through the service's API (which validates, translates, and maintains the invariants), not by reaching into the store. Two writers over the same store reintroduce the consistency bugs that exclusive ownership avoids —and make the "owner" not really control its data—. Everyone reads through the API; only the owner writes.
Exercises
Exercise 1 — The shared card. With the analogy of the child and the bank account, explain: (a) what the child continuing to use your credit card represents; (b) why sharing the account chains both of them, not just the child; (c) what the explicit transfer between own accounts represents.
See solution
(a) The child continuing to use your card represents the service that shares the monolith's tables: it has its own space (its apartment / its clean model), but its data still goes through the monolith's account. It's not self-sufficient; it depends on the other's account to work.
(b) Because the shared account tangles the finances of both: you can't change anything about your account (change banks, adjust limits) without risking affecting the child's expenses mixed in there, and they can't organize their finances tied to yours. The same with the shared table: the monolith can't change the schema without risking breaking the service, and the service can't evolve its data without coordinating with the monolith. The shared table chains the evolution of both, not just the service's.
(c) The explicit transfer between own accounts represents asking for the data through the service's API (with the ACL) instead of reading its tables directly. When each one has their account, if they need to coordinate money they do it through an agreed transfer, not by reaching into the other's account. In the extraction: if orders needs a product's price, it asks the catalog service through its API, it doesn't take it from the catalog tables. The explicit transfer is the respected boundary; reaching into the other's account is sharing tables.
Exercise 2 — Read the detector. In the output, FakeCatalogService touched the monolith's table 2 times (NO extracted) and CatalogService touched it 0 times (YES). (a) Why is FakeCatalogService deceptive despite returning clean Products? (b) What hard evidence from the Access log confirms that the real service is extracted? (c) Why isn't "using the ACL" enough to be extracted?
See solution
(a) Because FakeCatalogService has all the appearance of an extracted service —it returns Product, it uses the ACL to translate types and domain— but its data source is monolith_db.products, the old one's table. The cleanliness is in the output model, not in the ownership of the data. It's a disguise: new model outside, monolith's data inside. The appearance deceives; the detector doesn't.
(b) The Access log of the monolith's table: ['FakeCatalogService', 'FakeCatalogService'] — the real service (CatalogService) doesn't appear in the list. The table recorded everyone who touched it, and the real service is never there because it never touched it: it took its data from owned_store. That log, with the name and surname of each accessor, is the hard proof of who owns the data and who shares it.
(c) Because the ACL cuts the model (translates old↔clean), but doesn't cut the data (where it comes from). A service can use the ACL perfectly and still read the monolith's tables —like the fake one—. The extraction has two cuts: the model one (ACL, lessons 3-4) and the data one (ownership, this lesson; DB cut, lesson 6). "Using the ACL" solves the first; owning the store solves the second. Missing the second leaves the extraction half done, with the service still tied to the old one's DB.
Exercise 3 — Evolve the schema. The catalog service wants to internally rename prc_cents to price_cents in its storage and add an index by category. (a) Can it do so if it owns its data (owned_store)? (b) Can it do so if it shares the products table with the monolith? (c) What general principle does this illustrate?
See solution
(a) Yes, no problem. If the service owns its store, nobody else reads or depends on its internal form: it can rename columns, add indexes, change DB engine, whatever it wants. The only thing it promises the world is its API (with the ACL); as long as the API doesn't change, the inside is its own business. The monolith doesn't even find out, because it asks for the data through the API, not through the table.
(b) No, or not without pain. If the monolith also reads the products table, renaming prc_cents would break all the monolith's queries that read that column, and adding an index or changing a type would have to be coordinated with the monolith's team so as not to break anything. The shared table turns an internal change of the service into a coordinated change between two systems. The evolution stays chained.
(c) It illustrates the principle that exclusive ownership of the data is what buys the freedom to evolve. A service that owns its store can change internally without breaking anyone; one that shares tables can't touch its data without negotiating. A service's independence isn't measured by having its own process or its own model, but by controlling its own data: as long as someone else reads or writes its store, it's not the owner, it's a co-tenant —and a co-tenant can't remodel the house to their liking—.
Summary and next step
In this lesson you attacked the second cut of the extraction, the data one: owning the data. You saw, with the child who opens their own bank account, that real independence doesn't arrive with the own apartment (the clean model) but with the own account (the own store), and that the shared account chains the evolution of both. And you measured it: a detector instrumented the monolith's table and caught the FakeCatalogService (2 accesses, NO extracted) against the real CatalogService (0 accesses, YES), demonstrating that the ACL isn't enough —the difference is in who owns the data—. You learned the hard rule (each piece of data has a single owner, and only the owner writes), why exclusive ownership buys the freedom to evolve, and why the shared DB is a stopover with an expiration date, not the destination.
Before moving on you should be able to: define what owning the data means; explain why the shared table chains the evolution of both sides; use a detector to distinguish an extracted service from a disguised one; and state the rule of "a single owner, only the owner writes."
Lesson 6 executes the cut this lesson asked for: taking the service from the shared DB to the own DB without shutting down the system. You're going to see the phases of the cut —first the service reads from the shared table through the ACL (transient), then the monolith stops touching the table and asks the service, and in the end the service owns its owned_db and the shared_db is retired— and you're going to verify that, in the three phases, the ACL keeps the monolith's contract identical. The ownership of the data is the goal; cutting the shared DB is how you get there.
Resources
- Sam Newman, Monolith to Microservices (O'Reilly, 2019), ch. 4, "Decomposing the Database" — the central chapter on data ownership: why each service must own its data, the problems of the shared database, and the patterns to separate it. The go-to reference for this lesson and the next. In English.
- Chris Richardson, "Pattern: Database per service" — microservices.io/patterns/data/database-per-service.html. The card of the pattern: each service owns its own database, and nobody accesses another's tables directly. The principle this lesson makes measurable. In English.
- Chris Richardson, "Pattern: Shared database" — microservices.io/patterns/data/shared-database.html. The opposite anti-pattern, with its forces and why it's sometimes used as a transient step; useful for understanding what's being avoided. In English.
- Martin Fowler, "BoundedContext" — martinfowler.com/bliki/BoundedContext.html. The foundation: a bounded context includes its own model and its data; the context's boundary is also the data-ownership boundary. In English.