Ch. 10 · Microservices

Database per Service: Why Microservices Shouldn't Share Tables

Why read-only access to another service's tables still couples you to its schema, and how APIs, events and local copies replace sharing.

~6 min readbeginnerupdated Oct 7, 2026

A microservice is supposed to be something a team can change and deploy on its own. The fastest way to lose that property is to let two services read and write the same database tables. The code is split into services, but the data isn’t, and the schema becomes a contract nobody agreed to.

The principle is simple to state: every piece of data has exactly one owning service. That service is the only one that writes it, and the only one that knows its table layout. Everyone else asks the owner, through an API or through events. This note shows what breaks when that rule is ignored, and the realistic ways to give other services the data they need.

A refactor that breaks a team you’ve never met

An order service owns an orders table. A reporting service, built by a different team, needs revenue figures. Querying the table directly is the quickest route, so that’s what they do:

db.executescript("""
  CREATE TABLE orders (id TEXT PRIMARY KEY, customer_id TEXT, total REAL, status TEXT);
  INSERT INTO orders VALUES ('o-991', 'c-42', 42.50, 'PLACED'), ('o-992', 'c-7', 19.99, 'SHIPPED');
""")

# The reporting service, owned by another team, queries the order table directly.
REVENUE = "SELECT SUM(total) FROM orders WHERE status = 'SHIPPED'"
python

Months later, the order team moves money from floating point to integer cents, a sensible change that avoids rounding bugs. From their side it’s an internal refactor:

db.executescript("""
  ALTER TABLE orders RENAME COLUMN total TO total_cents;
  UPDATE orders SET total_cents = CAST(ROUND(total_cents * 100) AS INTEGER);
""")
python

Running this with Python’s sqlite3:

before: 19.99
after: sqlite3.OperationalError: no such column: total
Text

That’s the good outcome. The reporting job fails loudly, someone gets paged, and the two teams find each other. A worse version: the order team keeps the column name total and only changes the unit. Rerunning the same query after that change printed:

after, same column name: 1999.0
Text

No error. Revenue is now 100 times too high, and it’ll stay that way until a finance analyst notices the board deck looks suspicious.

Notice that the reporting service never wrote anything. “We only read from their tables” is the most common justification for a shared database, and the example shows why it doesn’t hold. Reading still couples you to column names, types, units, and the meaning of every status value.

What a shared database couples, beyond the schema

Schema coupling is the obvious problem. There are three more that interviewers like to hear about:

  • Invariants get bypassed. The order service enforces rules in code: an order can’t go from CANCELLED to SHIPPED, the total must match the line items. A second service writing UPDATE orders SET status = 'SHIPPED' skips all of it. The data can now be in states the owner believes are impossible.
  • Runtime coupling. A heavy reporting query holds locks or saturates I/O, and checkout slows down. One service’s load becomes another’s latency, and you can’t scale or tune them separately.
  • Unclear ownership. When a column has a strange value, which team investigates? When the schema needs a migration, who must approve? With five services touching one table, the honest answer is “everyone”, which in practice means nobody.

What “database per service” actually requires

The pattern’s name suggests one database server per service. That’s one way to do it, but not the requirement. The requirement is that a service’s data is private. Common implementations, from lightest to heaviest:

  • Private tables in a shared server. Each service gets its own schema (in the PostgreSQL sense) and its own database user, with grants that prevent access to other schemas. Cheap, and enforced by permissions rather than goodwill.
  • A database per service on a shared cluster. Better isolation of naming and migrations, still shared hardware.
  • Separate servers. Full isolation of load and failure, at the highest operational cost. Worth it for services with very different load profiles.

Whatever the layout, enforce it with credentials. If the reporting service’s database user can’t read orders, the shortcut isn’t available at 6pm on a deadline.

Getting data you don’t own

Once you stop sharing tables, other services still need order data. There are three standard routes, and choosing between them is the real design question.

Ask the owner through its API. The reporting service calls GET /orders?status=SHIPPED&since=.... The order service can change its tables freely as long as the API stays compatible. This is the simplest option and it’s always current. The cost is runtime coupling: if orders is down, so is that feature, and chatty APIs add latency.

Subscribe to events and keep a local copy. The order service publishes OrderPlaced, OrderShipped and so on. The reporting service consumes them and maintains its own table shaped for its queries. It no longer depends on the order service being up, and it can index and denormalise however it likes. The cost is staleness (the copy lags by however long event delivery takes) and the work of handling duplicates and ordering.

Feed an analytics store. For reporting across many services, a data pipeline (change data capture or event streams) lands data in a warehouse. Analysts query the warehouse, never the operational databases. The pipeline is the contract, and it can apply transformations when source schemas change.

Notice that the event and warehouse routes still have a contract. It’s just an explicit one, versioned and owned, instead of an accidental one made of column names.

The hard parts you give up and how they come back

Splitting data takes away two things a single database gave you for free, and it’s worth naming them before an interviewer does:

  • Cross-service joins. “Show orders with customer names” used to be a JOIN. Now it’s API composition (fetch orders, then fetch the customers in one batch call) or a read model that already has both, built from events.
  • Cross-service transactions. “Create the order and reserve stock atomically” can’t be one ACID transaction anymore. You use a saga: a sequence of local transactions with compensating actions if a later step fails, plus the outbox pattern so each step’s event is published reliably.

If two services constantly need joins and atomic updates across each other’s data, that’s a signal the boundary is wrong. Maybe they’re one service.

Interview practice: untangling a shared customer table

“Your company has six services, and all of them read the customers table. Two of them also write to it: the account service updates profiles, and the marketing service updates email preferences. You’ve been asked to make the account service the owner. How would you approach it?”

Answer

I’d do it incrementally, because a big-bang cutover across six teams won’t happen. First, inventory every access: which services read which columns, and which write. Database query logs or the grants on each service’s user will show this.

Then give writes a single home. The account service exposes an API for profile changes and also for email preferences (or marketing gets its own email_preferences table it owns outright, since that may be a separate concept). The marketing service switches its writes to that API or to its own table. After that, the account service is the only writer of customers.

For readers, the account service publishes CustomerUpdated events, and each reader either calls the API or builds a local copy of only the fields it needs. I’d migrate readers one at a time, revoking each service’s grant on customers as it moves, so the shortcut can’t creep back. When the last grant is gone, the account service can change its schema freely. I’d expect some readers to discover they needed far fewer columns than they were selecting, which is a nice side effect.

Further reading

More in Microservices

esc