The Runtime Theory
Software Architecture

Service Boundaries and Data Ownership: Why Shared Databases Couple Services

Each service must own its data — shared schemas, cross-aggregate transactions, and lock contention couple services invisibly. How to split data and what change data capture actually changes.

The Runtime Theory Team4 min read#service-boundaries#data-ownership#change-data-capture#databases
On this page

The most violated rule in service-oriented architecture is also the one that decides whether everything else works: a service must own its data. Not "access it sometimes," not "read from the same tables when convenient" — own. Other services may consume that data only through the owner's API or through events the owner publishes. When this rule breaks — the shared database — every benefit of the service boundary evaporates while all its costs remain. This article is about the mechanism of that coupling, and the tooling — change data capture — that makes honest ownership possible.

Why a shared database couples services

A shared schema is a hidden interface, and it is the worst kind: one with no version, no owner, and no review process. The coupling is concrete, not philosophical:

1. Schema changes ripple without a contract. Service A renames a column; Service B's query, written six months ago against a table it doesn't own, breaks at runtime. The migration is now a cross-team coordination event — no independent deploys. The service boundary promised independence; the shared schema took it back.

2. Lock contention and hot tables. One table serving several services means one contention point. A long transaction in Service B holds locks that stall Service A's writes. You cannot isolate load, you cannot scale the hot table for one consumer without scaling it for all, and capacity planning for one service becomes a system-wide exercise.

3. Transactions cross aggregates. The moment a "service" issues a transaction touching another service's tables, the two services share an atomicity domain. They must agree on rollback semantics, and the failure of one aborts the other's work. That is a distributed transaction by another name — the exact coupling services were meant to remove.

4. One query can reach anything. With no enforced boundary, discipline is the only guard. Every JOIN across "service" tables is a hidden dependency that no API contract documents and no review can reliably catch. The system's true structure — who depends on whose data — becomes unreadable, and the failure domains blur into one.

The ownership map: the first deliverable

Before splitting anything, produce the map: every table, its owning service, and its consumers. This is the artifact that makes the split honest, because it reveals reality before the diagrams do. Then split per aggregate, not per table: an aggregate — an order, a user, an invoice — moves as a unit, including its history and its referential integrity rules.

Two mechanical truths about splitting data:

  • Foreign keys become application-level references. Service A's order.user_id is no longer constrained by the database. Orphaned rows and dangling references become possible; the application — and reconciliation jobs — must absorb what the DB used to enforce.
  • IDs must be globally unique. Local auto-increment IDs collide the moment two services generate them. UUIDs (or a global ID service) are the migration price of the split.

Change data capture: the honest handoff

The problem: Service A owns the orders table, but Service B needs order data in its own store. The naive answer — B reads A's table — is the shared database again. The honest answer is change data capture (CDC): the database's replication stream, not application code, publishes each committed change as an event.

yaml
# Debezium-style CDC connector config
connector:
  class: io.debezium.connector.postgresql.PostgresConnector
  config:
    database.hostname: orders-db
    plugin.name: pgoutput
    publication.name: orders_publication
    table.include.list: public.orders
    topic.prefix: order-service

The connector tails the write-ahead log and emits one event per committed row change — order-service.public.orders becomes a topic of inserts, updates, and deletes. Service B subscribes and maintains its own projection: its own table, its own schema, its own lifecycle, zero shared tables. B's store is its data, derived from events the owner published.

The tradeoffs are honest and must be priced in:

  • The event shape is the source schema. A schema change on orders changes the emitted payloads; every consumer breaks at once unless payload versioning and contract tests exist. CDC moves the shared-interface problem from SQL to JSON — better, but not free.
  • Ordering and duplicates. Per-table ordering is preserved, global ordering is not, and the stream is at-least-once: consumers need the same idempotency discipline as any event system.
  • Events are not queries. B maintains a projection; it can never issue ad-hoc queries against A's data. If B's needs change shape frequently, projection churn is your new cost.

Data ownership is not a nicety; it is the mechanism that makes independent deploys, failure domains, and scaling real. A shared database voids all three while keeping every operational cost of the split — the worst of both worlds. The order of work matters: ownership map first, aggregate migration second, CDC or outbox as the handoff protocol, and referential-integrity gaps absorbed into application code and reconciliation jobs. Done in that order, a split degrades gracefully. Done with the database still shared, it is a diagram with a tax attached.