The Database Two Services Shared Until They Couldn't

Two teams, one PostgreSQL instance, zero ownership boundaries. A consulting story about what happens when microservices share a database — and how we eventually untangled it.


The engagement started with what sounded like a deployment problem. A fintech client had two backend teams — Orders and Billing — both complaining that deploys were getting blocked by the other team. I expected to find a CI bottleneck or a shared staging environment causing contention. Instead, I found a single PostgreSQL instance that both services treated as their own.

Not a shared connection string to separate schemas. Not read replicas. One database, one schema, 147 tables, and no agreement on who owned which ones.

How it got this way

The usual story. The system started as a monolith. When the company split into two teams two years earlier, someone carved the monolith into two services along team boundaries but left the database alone. "We'll split the data layer later," someone said in a Slack thread I found from 2024. They never did.

For a while it worked fine. Both services read from and wrote to the same orders, line_items, invoices, and payments tables. The Orders service would insert an order, and the Billing service would poll for new rows and generate invoices. No event bus, no API calls between services — just two applications reaching into the same tables.

The cracks showed up gradually. A schema migration in the Billing service renamed a column from amount to total_amount_cents. Reasonable change — the old name was ambiguous about currency units. The migration ran in CI, the Billing service deployed fine, and then the Orders service started throwing 500s because its ORM still expected a column called amount. That incident took forty minutes to diagnose because the error pointed at the Orders service, not the migration that actually caused it.

After that, both teams started running the other team's test suite before deploying. Which meant every deploy needed both teams' tests to pass. Which meant a flaky test in Billing could block an Orders deploy. Which is when I got the call.

The real cost wasn't technical

I could talk about the schema coupling, the inability to scale the two services independently, the fact that a slow query from Billing's monthly reporting job was eating I/O that the Orders service needed for real-time checkout. Those were all real problems.

But the biggest cost was human. The two tech leads had to coordinate every database change in a shared Slack channel. They had a weekly "schema sync" meeting that both described as painful. Each team had a mental model of which tables were "theirs," but those models didn't fully agree — the payments table was claimed by both. When I asked each lead to draw a diagram of their service's data ownership, the overlap looked like this:

The payments table was the hot zone. Orders wrote to it when a payment was initiated. Billing read from it to reconcile. Both had triggers on it. Neither team felt comfortable changing it.

What we actually did

The textbook answer is "split the database." In practice, that's a project that can take months and introduces risk at every step. We didn't do it all at once.

Week one: draw the actual ownership lines. I sat both leads down and we went through every table. We split them into three categories: clearly owned by Orders (31 tables), clearly owned by Billing (26 tables), and contested (8 tables, including payments). The contested tables became the work list.

Weeks two through four: introduce an API boundary for contested data. Instead of Billing reading directly from the payments table, we built a thin internal API on the Orders service that exposed payment status. Billing started calling that API instead of querying the table. This was the most tedious part — not technically hard, but it required changing dozens of queries and testing every edge case around payment states.

Warning

If you're tempted to skip the API and use database views as an abstraction layer — don't. We tried that first. The views still couple you to the underlying schema, and they give you a false sense of isolation that breaks the moment someone needs to change a column type.

Month two: move Billing's tables into a separate schema, then a separate instance. Once the cross-service queries were gone, this was surprisingly straightforward. We created a new schema, migrated Billing's tables with pg_dump and pg_restore, pointed the Billing service at the new location, and ran both in parallel for a week to verify consistency.

The payments table itself ended up living in the Orders database. Billing got its own billing_payments table that stored the subset of payment data it needed for reconciliation, populated by events from the Orders API. Yes, this is data duplication. It's also the kind of duplication that gives you independent deployability, independent scaling, and two teams that don't need a weekly meeting to change a column name.

The part I'd do differently

We spent too long trying to avoid data duplication. I kept looking for a clean boundary where each fact lived in exactly one place. That boundary doesn't exist when two services have legitimately different views of the same business concept. An order's payment and a billing reconciliation record are related, but they serve different purposes and change at different rates.

The shared database worked for two years. That's not nothing. The mistake wasn't starting with a shared database — it was not having a plan for when to stop sharing it. If both teams had agreed upfront on ownership boundaries and enforced them with schema-level permissions, the gradual split would have been far less painful.

How many of your services are "temporarily" sharing a database right now?