Skip to content

Database-per-Service & Why Shared Databases Break Down

What was run

A real SQLite database, shared_shop.db, read directly by two independent scripts standing in for two services. One script (orders_reads_shared.py) represents Orders Service reading a customers table it doesn't own. A real migration was then applied directly against that same file — standing in for Customers Service deploying independently — and the Orders Service script was re-run against the now-changed schema with no code changes of its own.

Before the migration, it works

# orders_reads_shared.py — Orders Service, reading a table it doesn't own, directly
row = conn.execute("SELECT id, name, email FROM customers WHERE id = 1").fetchone()
python3 orders_reads_shared.py
# Orders Service read customer: id=1 name=Ada Lovelace email=ada@example.com

Customers Service ships an unrelated, reasonable migration

ALTER TABLE customers DROP COLUMN email;
ALTER TABLE customers ADD COLUMN contact_info TEXT;  -- a JSON blob, to support phone + email + preferences later

This is, from Customers Service's point of view, an entirely internal, reasonable refactor: replacing one scalar column with a more flexible structure, something any team should be free to do without asking permission.

python3 orders_reads_shared.py
# Orders Service query FAILED: no such column: email

That's a real sqlite3.OperationalError, not a simulated one. Orders Service didn't change a single line of its own code. It broke because it depended directly on Customers Service's internal table structure — a dependency that was invisible until the moment it broke in production.

The fix: each service owns its own database, period

# customers_own_db.py — a real FastAPI service with its own, private SQLite file
conn = sqlite3.connect("customers_only.db")
@app.get("/customers/{cid}")
def get_customer(cid: int):
    row = conn.execute("SELECT id, name, contact_info FROM customers WHERE id = ?", (cid,)).fetchone()
    return {"id": row[0], "name": row[1], "contact_info": row[2]}
curl -s localhost:8041/customers/1
# {"id":1,"name":"Ada Lovelace","contact_info":"{\"email\":\"ada@example.com\"}"}

Now Customers Service can rename, drop, or restructure any column in customers_only.db it wants, as often as it wants, and the only thing any other service can depend on is the shape of the JSON this endpoint returns — a contract Customers Service controls and can version deliberately, the way Lesson 5 of Level 1 covered. No other process even has a file handle open on customers_only.db.

How It Actually Works

A shared database creates a dependency that bypasses every service boundary built in Level 1: it doesn't matter how cleanly you drew the line between Orders and Customers in your API design if both processes also read and write the same tables directly, because the real contract between them becomes the full SQL schema — every column, every type, every constraint — not the deliberately small, versioned API either team intended to expose. This is why the error above says "no such column," not "API contract violation": the dependency was never going through anything that could enforce or version a contract in the first place.

Database-per-service means each service's data lives in storage only that service's code can access directly. Every other service that needs that data must go through the owning service's API (synchronously, as a REST/gRPC call, or asynchronously, as an event) — never a direct connection to its tables. This gives a service real freedom to change its internal schema whenever it wants, as long as its public API contract stays stable (or is versioned deliberately when it can't). The real cost, covered across the rest of this level, is that this removes the one thing a shared database gave you for free: a single ACID transaction spanning both services' data. Lessons 4 and 5 build the saga and outbox patterns that replace that lost atomicity.

Common mistakes

  • Sharing one database "temporarily, just for this one urgent feature" — the coupling this creates is invisible until the exact moment shown above, and by then it's load-bearing production behavior, not a quick fix to revert.
  • Giving a second service read-only access to the first service's tables, thinking read-only avoids the coupling — it doesn't; the schema dependency and the breakage shown above are identical whether the second service writes or only reads.
  • Splitting services at the API layer while leaving their databases merged "to migrate the data later" — this is a distributed monolith in exactly the sense covered in Level 4, Lesson 8: two deployables, one database-level coupling.

Exercise

Reproduce this yourself: run setup_shared_db.py, confirm orders_reads_shared.py succeeds, then apply the DROP COLUMN / ADD COLUMN migration shown above and re-run it to see the real failure. Then write a second script, orders_reads_via_api.py, that gets the same customer data by calling http://localhost:8041/customers/1 instead of touching SQLite directly, and confirm that a hypothetical future migration to Customers Service's internal schema (renaming contact_info again, for instance) would no longer be able to break it, as long as the API response shape stays the same.