Decomposing the Database

ModernLift · ·12 min read
Part 6 of 10

Decomposing the database means giving each service ownership of its own data instead of sharing one schema, so services stay truly independent. It is the hardest part of a monolith migration because the shared database is held together by joins and transactions that vanish once data is split. The path is incremental — enforce logical ownership first, break the joins by replacing them with APIs or events, split the schema, and replace cross-service transactions with sagas that maintain consistency through compensating actions.

Every part so far has been building toward this one, and quietly avoiding it. Part 5 found the boundaries; the code can be divided along them with effort. But underneath the code sits a single shared database where everything joins to everything, and splitting that is the step where most monolith decompositions stall or quietly give up — shipping “microservices” that all still read and write one schema.

That outcome is not a decomposition. A set of services sharing one database is a distributed monolith with extra network hops. So this part takes the hard seam head-on: how to give each service its own data without losing the consistency the monolith handed you for free.

Why the shared database is so hard to split

A relational database in a monolith does two enormous favors that you stop noticing because they never fail:

  • Joins. Any query can join across any tables, instantly and consistently. Need the customer, their orders, the line items, and the current prices in one result? One query, one trip.
  • ACID transactions. A business operation that touches five tables either commits completely or rolls back completely. You never see a half-finished state. Consistency is automatic.

The moment you split the data across services, both favors disappear. The join across two services’ data is now a network call to fetch one half and stitch it to the other. The transaction across two services’ data is now two separate commits with no shared rollback — either can succeed while the other fails, leaving the system in a state the monolith made impossible.

This is why splitting data is harder than splitting code. You are not just moving tables. You are taking on the job the database used to do for you: keeping data correct when it lives in more than one place.

The end state: database per service

The target is database-per-service — each service owns its data, and no other service touches that data except by asking the owning service through its API. This is not architectural purism. It is the only thing that makes a service genuinely independent: if two services share tables, either can break the other with a schema change, and neither can deploy a data migration without coordinating. Shared data is shared fate, which is exactly the coupling the whole migration was meant to remove.

But — and this is the part that keeps the migration safe — you do not get there in one move. You get there incrementally, the same way you get everywhere else in this series.

The incremental path to split data

1. Establish logical ownership first. Before moving a single byte, assign every table to exactly one owning service and enforce a rule: only the owner writes that table. Others may still read it during the transition, but writes funnel through the owner. This draws the data boundaries logically while everything still lives in one database — cheap to do, easy to reverse, and it surfaces the real entanglements before they cost you anything.

2. Break the cross-boundary joins. Find the queries that join across the new ownership lines and replace them. Some become an API call to the owning service. Some are better served by the owning service publishing events that other services use to keep a local read-only copy of just the fields they need. This step is the actual work, and it is where the design choices live — every broken join is a decision between calling for the data and caching a projection of it.

3. Physically separate the schemas. Once nothing joins across a boundary and only the owner writes its tables, you can move those tables into their own database. Because the logical boundary already holds, this becomes a controlled data migration rather than a leap — and it is exactly the kind of step that gets a parity gate: the data is proven complete and correct in its new home, with row counts and reconciliation checks, before anything depends on it there.

4. Replace cross-service transactions with sagas. The operations that used to be one atomic transaction across now-separate databases need a new mechanism. That mechanism is the saga.

Sagas: consistency without a distributed transaction

A saga replaces one atomic transaction with a sequence of local transactions — one per service — coordinated so the whole thing still behaves correctly. (The pattern dates to a 1987 paper by Hector Garcia-Molina and Kenneth Salem; it long predates microservices and was waiting for exactly this problem.)

Take placing an order: reserve inventory, charge payment, create the shipment. In the monolith, one transaction. As a saga, three local transactions in three services. If payment fails after inventory was reserved, the saga runs a compensating action — release the reserved inventory — to undo the earlier step. There is no shared commit; there is a choreography of steps and their undo operations.

Two ways to coordinate them:

  • Choreography — each service emits an event when it finishes, and the next service reacts. Decentralized and loosely coupled, but the overall flow is implicit and can be hard to follow across many services.
  • Orchestration — a coordinator explicitly drives the steps and triggers compensations. The flow is visible in one place and easier to reason about, at the cost of a central component.

What you give up is the strong guarantee. Sagas deliver eventual consistency: for a brief window, inventory may be reserved while payment is still pending. The system is correct over time, not at every instant. For most business operations that window is harmless and invisible — but you must design for it deliberately, including making every step idempotent so a retried message doesn’t charge a customer twice.

The reversal worth keeping: the database gave you consistency for free; distributed data makes you pay for it on purpose. Sagas are that payment, made explicit and under your control.

When the answer is: don’t split this data

This is the part of the migration where the honest answer is most often “maybe don’t.” Eventual consistency is genuinely harder to reason about than a transaction, and some domains cannot tolerate it: a core ledger where every read must reflect every committed write, or a regulatory process that demands strict atomicity, may be wrong to split at all. If a set of operations truly needs one atomic transaction across its data, keep that data together in one service — the right number of services for a tightly-coupled, consistency-critical domain may be one. Splitting data you cannot keep consistent is not modernization; it is manufacturing a class of bug the monolith never had. The discipline is knowing which data can live with eventual consistency and which cannot, and being honest about the difference before you cut.

Where this leads

Once the data is split and each service owns its own, the system needs a coherent front door — something that takes the outside world’s requests and routes them to the right service, so callers never have to know the system came apart at all. Part 7, API Gateways & Service Boundaries, covers the seam in front of the services: the gateway, the contracts between services, and how to keep the boundaries stable as the system evolves.

Frequently asked questions

Why is splitting the database the hardest part of microservices?
Because a shared relational database holds the system together with joins and ACID transactions that work for free as long as everything lives in one place. Splitting the data removes both at once: queries that used to join across tables now have to call across services, and operations that used to be one atomic transaction now span multiple databases with no shared commit. You have to rebuild consistency by hand, which is far harder than splitting code.
Should each microservice have its own database?
As a target, yes — a service that shares a database with another service is not really independent, because either can break the other through the schema, and neither can deploy a data change alone. Database-per-service is what makes a service autonomous. But you reach it incrementally: start by enforcing that only one service writes a given table, then break the cross-service joins, then physically separate the schemas. Sharing during the transition is fine; sharing as the end state is not.
What is the saga pattern?
A saga maintains consistency across services without a distributed transaction. Instead of one atomic commit, a business operation becomes a sequence of local transactions, one per service, coordinated by events or a central orchestrator. If a later step fails, the saga runs compensating actions that undo the earlier steps. You trade the strong guarantee of a single transaction for eventual consistency, which is usually the only option once data lives in separate databases.
All 10 parts of Monolith to Microservices →