Data ownership across services and the joins you lose
The expensive part of a split is never the code. It is the database, and specifically the queries that currently join across the line you are about to draw. Teams that skip this and let several services share tables end up with all the operational cost of distribution and none of the independence. Before committing to any boundary, it is worth knowing exactly which queries you are giving up.
What does data ownership actually mean?
One service writes a piece of data, and every other service that needs it either asks that service or keeps its own copy. No other service connects to those tables, reads them for a report, or writes to them during a migration. The rule is absolute because a single exception reintroduces the coupling: once another team's code depends on your column, you cannot rename it.
The strongest way to state ownership is with database permissions rather than agreement. Each service connects as its own user, and that user has no grant on any other service's schema. Anything less relies on everybody remembering, which is exactly what breaks under a deadline.
Ownership is about writes above all. Several services holding a cached copy of a product name is normal and healthy. Two services both writing the product name is a data corruption incident waiting for the right race condition.
What breaks when a join becomes a network call?
Four things, and the fourth is the one that surprises people. Referential integrity disappears, because no database can enforce a foreign key to a table it cannot see. Transactions disappear across the boundary, so two writes can no longer be made atomic. Reporting queries that span services stop being possible in the operational database.
The fourth is filtered pagination. A query like 'show the next twenty open orders for customers on the enterprise plan, sorted by value' is trivial in one database and genuinely difficult when the plan lives in one service and the orders in another. You cannot page a result set you have to assemble in memory, and fetching everything to filter it does not survive contact with production volumes.
This is the query to look for before you choose a boundary. Cross-service filtered sorting is the requirement that most often forces a redesign, and it is usually discovered late, in a screen nobody thought about during planning.
How do you replace a join?
Four options, and the right answer varies per query rather than per system. Most real architectures use all four in different places, and picking one globally is how teams end up fighting their own design.
| Approach | How it works | Use when | Cost |
|---|---|---|---|
| API composition | Caller fetches from each service and combines in memory | Small result sets, a detail screen | Latency adds up, cannot sort or page across services |
| Replicated read model | Service keeps a local copy fed by events from the owner | Filtering, sorting or paging across the boundary | Eventual consistency, plus a copy to keep correct |
| Reference data cached | Slow-changing lookup data copied wholesale | Currencies, plans, categories, country lists | Refresh strategy, stale entries after edits |
| Move the boundary | Put both sides of the query in one service | The join is central to a core workflow | A larger service, which is often the correct answer |
Is duplicating data across services acceptable?
Yes, and refusing to do it is a more common mistake than doing it carelessly. Normalisation is a property of a single database, not a principle of system design. Across services, one writer and many readers is the intended shape, and the copies are caches with a defined refresh mechanism rather than rival sources of truth.
What makes it safe is being explicit about two things: which service is authoritative, and how stale a copy is allowed to be. A shipping service holding a customer's address from six seconds ago is fine. A billing service charging against a plan that changed a week ago is not. The staleness budget is a business decision and it should be written down per copy, because it determines whether events, polling, or a synchronous lookup is the right mechanism.
The failure mode to watch for is a copy that nobody refreshes because the event that would update it was never published. That is why the owning service should emit changes as a matter of course rather than on request from each new consumer.
Where does reporting go?
Out of the operational path entirely. Reporting is the single most common reason teams keep a shared database, because someone needs a query that spans every service and the only place all the data exists together is production. Solving this by granting the reporting tool access to every service's tables destroys every boundary in the system at once.
The standard answer is to stream changes out. Each service either publishes domain events or exposes its changes through change data capture, and both land in a warehouse or lake where analysts can join freely. The warehouse becomes the only place a cross-service join is allowed, which is a defensible rule because nothing in the request path depends on it.
Plan this at the same time as the split rather than after. A migration that delivers services and leaves the finance team without their monthly figures gets reversed, and rightly so.
How do you handle deletes and orphans?
By accepting that references can point at things that no longer exist, and designing for it. Without foreign keys nothing stops an order referencing a deleted customer, so every service needs a defined behaviour for a missing reference: show a placeholder, mark the record for review, or reject the operation. Deciding this per relationship takes an hour and prevents a class of production incidents.
Deletion requests make this concrete. When a person exercises a right to erasure, the data about them exists in the copies as well as the original, so deletion has to propagate as an event that every holder acts on. If nobody has designed that path, the answer to a regulator is that you do not know where the data is.
The practical test: pick your two largest tables and list the ten queries that run against them most often. Count how many join across the boundary you are proposing. If it is most of them, the boundary is wrong, and you have learnt that for the cost of an afternoon rather than a quarter.
Common questions
- Should microservices share a database?
- No. Once two services read the same tables, neither can change its schema without coordinating, which removes the independence that justified the split while keeping every cost of distribution. Enforce ownership with database permissions rather than agreement: each service connects as its own user with no grant on another service's schema, so a violation fails at runtime instead of passing review.
- How do you join data across microservices?
- Four ways, chosen per query. Compose in the caller by fetching from each service, which suits small result sets. Keep a local read model updated by events from the owning service, which is the only option that supports filtering, sorting and paging across the boundary. Cache slow-changing reference data wholesale. Or move the boundary so both sides of the query live in one service.
- Is it acceptable to duplicate data between services?
- Yes, and it is usually required. Normalisation applies within a database, not across a system. The safe pattern is one writer and many readers, where each copy has a named authoritative source and a written staleness budget. A shipping service using an address from six seconds ago is fine; a billing service charging against a week-old plan is not, and the budget is what distinguishes them.
- How do you run reports across microservices?
- Outside the operational path. Each service publishes domain events or streams changes through change data capture into a warehouse, and the warehouse becomes the only place cross-service joins are permitted. Giving a reporting tool direct access to every service's tables removes every boundary in the system. Plan this alongside the split, because a migration that breaks monthly financial reporting gets reversed.
- What replaces foreign keys between services?
- Nothing enforces them, so each service needs a defined behaviour for a reference that no longer resolves: show a placeholder, flag for review, or reject the operation. Decide this per relationship rather than discovering it in production. Deletion is the case that matters most, since erasing a record has to propagate as an event to every service holding a copy of it.