Skip to content

    System Design

    Decomposing Microservices: Data Ownership and Distributed Monoliths

    Decide where to cut a system into microservices, give each service one owner for its data, replace joins and transactions, and spot a distributed monolith.

    By Published 13 min read

    Share

    Standard lessonadvancedBackend System Design

    Before this: Monolith, Modular Monolith and Microservices

    What you'll learn: By the end of this lesson you will be able to test a proposed service boundary against deployment, data and runtime coupling, use the write-set of each business transaction to find cuts that hold, give every piece of data exactly one owning service and enforce that in the database, say what replaces the joins and transactions you lose (API composition, local read models, sagas), and recognise a distributed monolith before it ships.

    Picture a company that splits its accounts office into three departments, each with its own door and sign. All three still file papers in one cabinet. The org chart says three teams, but nobody can rearrange a drawer without asking the other two, and when the cabinet jams, all three stop.

    That is the failure this lesson is about. Architecture styles already names the distributed monolith and gives the strangler pattern, and ShopStream Stage 8 splits a whole system into seven services. Here the subject is narrower and deeper: how to choose the cut, and what "each service owns its data" means in practice. Choosing the contexts themselves is the job of domain-driven design and bounded contexts.

    Verified against the sources in "Official documentation" and the pages linked in the text, and PostgreSQL 18 documentation, on 2026-11-08.

    The core idea

    A split into services is real only if each service can change, deploy and fail without the others, and that depends mostly on who owns the data. Give each piece of data one owning service: the only code that writes it, and the only door to it. Everyone else gets it by asking the owner, or by keeping a labelled copy fed by the owner's events.

    Three illustrations run through the lesson, each labelled:

    • Minimal example: a schema per service with database permissions, and one event that feeds a read-only copy.
    • Realistic example: ShopStream's "in stock" badge when Inventory leaves the shared database, in "In practice".
    • Failure example: a reporting job that reads another service's table.

    What you are actually splitting

    Fowler and Lewis describe microservices as a way to build an application as a suite of small services that are independently deployable. The word that matters is independently. A service boundary can be coupled in three separate ways, and each has a quick test.

    Coupling What it looks like The test
    Deployment Two services must be released together, in order, or from one pipeline Can this service go to production alone on an ordinary afternoon?
    Data Another service reads or writes this service's tables, or a library of shared domain classes forces lockstep upgrades Can the owner change its schema without asking anyone?
    Runtime A synchronous call chain means one slow service stalls the rest With every other service stopped, what does this one still do?

    microservices.io describes the shared-database variant the same way: developers must coordinate schema changes with every other service that uses the same tables, and the services can interfere with each other at runtime. Fowler calls an integration database "a point of coupling between the applications that access it" and says it should be avoided. A shared schema is the most common reason a "microservice" is not independent.

    Where to draw the line

    Start from the business, not the technology. The Azure Architecture Center gives a sequence: begin with a bounded context (a microservice should not span more than one), examine the aggregates inside it (they are often good candidates because they come from business requirements, are cohesive, are a boundary of persistence and should be loosely coupled), then adjust for non-functional needs such as team size, scale and security.

    A practical companion is the write-set of each business transaction. List every use case that changes data (place an order, reserve stock, take a payment) and write down which aggregates it must change together. Evans's reference defines the aggregate as the unit that governs transactions: inside its boundary consistency rules apply synchronously, and across boundaries updates are handled asynchronously. Vernon adds that modifying several aggregate instances in one transaction can be a sign the consistency boundaries are wrong, or that eventual consistency is the right tool.

    flowchart TD accTitle: Should two things be separate services accDescr: A decision flow. If a use case must change both pieces of data atomically and a gap would break a business rule, keep them in one service. If they are in different bounded contexts and a short delay is acceptable, split them and coordinate by events. If they share a team and release rhythm and need not scale apart, keep them together for now. S["Two pieces of data, A and B"] --> Q1{"Does one use case need A and B to change atomically, and would a gap break a business rule?"} Q1 -->|"yes"| K1["Keep them in one service: one transaction, one owner"] Q1 -->|"no"| Q2{"Do A and B belong to different bounded contexts?"} Q2 -->|"no"| K2["Keep together for now: splitting later is easier than merging"] Q2 -->|"yes"| Q3{"Would separate teams, release rhythm or scaling pay for the added network and consistency cost?"} Q3 -->|"no"| K2 Q3 -->|"yes"| SP["Split: private data each, coordinate by events or a saga"]

    Figure 1. A decision per pair of data, not per noun. How to read this: start at the top for each pair. The first question is the strict one: if a business rule needs both to change together, they stay one service. Only a pair that survives all three questions earns a network between them.

    Two cautions come from the sources. When in doubt, the Azure guidance says to start with coarser services, because splitting one into two is easier than moving functionality across several. Fowler's "monolith first" note says even experienced architects in familiar domains have great difficulty getting boundaries right at the beginning. Boundaries are hypotheses, cheaper to move inside one deployable.

    Data ownership, concretely

    "Each service owns its data" is a rule about write authority and access. microservices.io states the pattern as keeping each service's persistent data private and accessible only via its API, so that a service's transactions involve only its own database. It lists three ways to do this in a relational database: private tables per service, a schema per service, or a database server per service. They differ in how strongly the boundary is enforced and in cost.

    Option What protects the boundary Cost
    Private tables Convention only Cheapest; the first shortcut breaks it
    Schema per service Database permissions, if you set them One database to run; shared failure and capacity
    Database server per service The network and the server's own login rules The most to run; the strongest isolation

    The cheap options hold only if permissions back them. In PostgreSQL, users cannot access objects in schemas they do not own unless the schema owner grants USAGE on it, but schemas are not rigidly separated: a user can reach objects in any schema of the database if they have the privileges. That makes the minimal example a permissions example (illustrative names; the statements follow the PostgreSQL 18 syntax and were not run here):

    CREATE ROLE inventory_svc LOGIN;
    CREATE ROLE catalogue_svc LOGIN;
    CREATE SCHEMA inventory AUTHORIZATION inventory_svc;
    CREATE SCHEMA catalogue AUTHORIZATION catalogue_svc;
    -- catalogue_svc has no USAGE on schema inventory, so a query such as
    -- SELECT * FROM inventory.stock run as catalogue_svc is refused.

    A shared application login would make every schema reachable, so use one credential per service.

    Owned data: private stores, events out, labelled copies inCatalogue to Catalogue data; Inventory to Inventory data; Inventory to Event log (publishes); Event log to Stock copy (feeds); Catalogue to Stock copy (reads)OWNER OF CATALOGUE DATAOWNER OF STOCK DATACatalogue: owns productsCatalogueowns productsCatalogue data: privateCatalogue dataprivateInventory: owns stockInventoryowns stockInventory data: privateInventory dataprivateEvent log: StockChangedEvent logStockChangedStock copy: read-onlyStock copyread-onlypublishesfeedsreads

    Figure 2. One writer per piece of data, copies fed by events. How to read this: solid arrows are calls, flowing dashes are events. Catalogue never touches Inventory's store. It reads a copy it holds itself, and the copy is only ever written by the consumer of Inventory's events.

    What you give up, and what replaces it

    Splitting the data removes tools you used without thinking. Fowler and Lewis note that distributed transactions are notoriously difficult, so microservice architectures emphasise coordination without transactions, accept eventual consistency, and deal with problems through compensating operations. Remote calls also cost more than in-process calls, so remote APIs need to be coarser-grained, which is often more awkward to use.

    You lose What replaces it Cost
    A join across the two tables API composition (call both owners), or a local read model Extra calls, or a copy that can be stale
    One ACID transaction over both A saga: a reliably coordinated sequence of local transactions, undone by compensating transactions (microservices.io) A failure path to design and test; see distributed transactions
    A foreign key An id plus a rule for orphans Dangling references need reconciliation
    Reporting straight from live tables Events or exports into a warehouse A pipeline to run

    Copies are caches

    A local read model is a table the consumer owns and the producer's events keep fresh. The owner publishes a small, versioned event:

    { "type": "StockChanged", "productId": "p-100", "storeId": "s-12", "available": 5, "version": 42 }

    The consumer applies an event only if its version is higher than the stored one, so redelivery and reordering are harmless. Decisions that need the truth, such as reserving the last unit, are made by the owner, never from the copy. How the events travel reliably (outbox, brokers, replay) is in message queues, event streaming and ShopStream Stage 5.

    sequenceDiagram accTitle: A read model fed by owner events accDescr: Inventory changes stock in its own store and publishes a StockChanged event. The Catalogue consumer applies it to its availability copy only if the version is newer. A product page reads the copy. At checkout the reservation goes to Inventory, the owner. participant I as Inventory participant L as Event log participant C as Catalogue consumer participant R as Availability copy participant P as Product page I->>I: update stock in its own store I->>L: publish StockChanged v42 L->>C: deliver event C->>R: apply only if v42 is newer than stored P->>R: read available (may lag by seconds) P->>I: at checkout, reserve (owner decides)

    Figure 3. The copy serves reads, the owner decides writes. How to read this: reads are fast and slightly stale, which is fine for a badge. The reservation at the bottom goes to the owner, which is the only place the real number is allowed to decide.

    Failure example: the table someone else reads

    A distributed monolith often begins with one harmless SELECT. A nightly finance export, written by another team, reads Orders' tables directly because that was quickest. No one records the dependency. A later release renames a column.

    sequenceDiagram accTitle: A hidden reader breaks on a schema change accDescr: The Orders team renames a column and deploys. Its own tests pass. A finance export owned by another team reads the table directly, fails at its next run and produces an empty report. The dependency is found only after the failure. participant O as Orders team participant DB as Orders tables participant F as Finance export O->>DB: rename column, tests pass, deploy Note over O,DB: no contract lists the export as a consumer F->>DB: nightly SELECT on the old column name DB-->>F: error F-->>F: empty report, nobody paged

    Figure 4. A dependency with no contract. How to read this: the owner's tests pass because the reader is invisible to them. The coupling exists only in the database, not on any diagram.

    What breaks: a report, and trust in the boundary. Detection: connection metadata (PostgreSQL's pg_stat_activity view shows each session's user and application name) exposes sessions that should not exist. Prevention: separate credentials, no cross-schema grants, and a published contract (an API or an event) for every legitimate consumer.

    Spotting a distributed monolith

    Architecture styles lists the symptoms. The three tests turn them into a review gate: a service that fails any one is not decoupled yet. Conway's law explains why this recurs. Melvin Conway observed that an organisation that designs a system produces a design that is a copy of its communication structure. The Azure guidance draws the practical rule: if one team must own several unrelated bounded contexts, or one bounded context needs coordination across many teams, revisit either the boundaries or the team structure.

    Fowler's "microservice premium" is the price: work on automated deployment, monitoring, failure handling and eventual consistency. His guideline is not to consider microservices unless the system is too complex to manage as a monolith.

    Choice Gain Cost Wrong when
    Keep one deployable, modular inside One transaction, no network between modules Releases move together Teams block each other constantly
    Split with private data per service Independent change and deployment Sagas, copies, more to operate You cannot say who owns each table
    Split but keep a shared database Joins and ACID stay Schema and runtime coupling You wanted independence: this is the distributed monolith

    The other side of this topic

    The same pressure appears in the browser. A large web application owned by many teams can be cut into independently deployed front-end pieces (micro-frontends), and Conway's law applies there too: the pieces tend to follow the teams. The Frontend System Design track plans a lesson on this; it is not published yet, so there is no link. The rule carries over: a boundary pays only where a team can ship without waiting for another.

    In practice: ShopStream's stock badge leaves the shared database

    This is the realistic example, an educational scenario on ShopStream with illustrative figures. Nothing in it describes a real company.

    This is a realistic scenario, not a case history.

    The situation

    At Stage 8, ShopStream's Catalogue and Inventory code runs in one modular deployable on one PostgreSQL database with a schema each. The product list shows an "in stock at your store" badge by joining catalogue.product to inventory.stock. The team wants to extract Inventory first, because stock changes constantly and Inventory's release pace differs from Catalogue's. The join stands in the way.

    Starting architecture

    Before: Catalogue joins straight into Inventory's tablesShoppers to Modular monolith (browse); Modular monolith to PostgreSQL (join across schemas)ShoppersShoppersModular monolith: both contextsModularmonolithboth contextsPostgreSQL: two schemasPostgreSQLtwo schemasbrowsejoin across schemas

    Figure 5. The seam to cut. How to read this: one deployable and one database. The join across schemas is the coupling that must become an event flow before Inventory can leave.

    Options considered

    Option Behaviour Cost Risk
    A. Keep the cross-schema grant "temporarily" Nothing changes for Catalogue None now The grant becomes permanent; Inventory cannot change its schema freely
    B. Catalogue calls Inventory per request Always current A call per browse request, up to about 2,300 per second at the shared peak Inventory's outages become Catalogue's outages
    C. Inventory publishes StockChanged; Catalogue keeps a read model Reads stay local and fast A consumer, a copy, a lag to watch Stale badge; mitigated by owner-decided reservation

    The decision

    Option C. The badge is advisory, so seconds of staleness are acceptable. Reserving stock stays with Inventory ("available" there means on hand minus reserved), so a stale badge can disappoint but cannot oversell. The Catalogue schema gets an availability table, one row per product and store, written only by the consumer.

    Rollout and verification

    Run the consumer beside the old join and compare badge values. Track consumer lag and rows that disagree with Inventory. When disagreement stays at zero, revoke the grant with REVOKE USAGE ON SCHEMA inventory FROM catalogue_svc; so the old path cannot return, then move Inventory to its own deployable and database.

    The 2 a.m. failure

    The page reads: checkouts fail with "out of stock" for items the site shows as available. The consumer has stalled after a bad message, so the copy is hours old. Checks: consumer lag, the newest version applied against the newest published, and a sample comparison with Inventory. Mitigation: restart the consumer, send the bad message to a dead-letter queue, replay from the last good position, and meanwhile change the badge text to "check at checkout" when lag is high. The lasting fix is an alert on lag, a periodic snapshot reconcile, and a rule that the badge is never an input to a reservation.

    Architect's notes

    • Draw the data first. List each table and write one owner next to it. Two owners means the cut is wrong.
    • Credentials enforce what diagrams promise. One login per service, no cross-schema grants.
    • Contracts, not tables. Every reader goes through an API or a versioned event.
    • Prefer coarse, then split. A merged service splits more easily than scattered ones merge.
    • Give copies a name and an age. A read model needs a staleness budget.

    Common mistakes

    • Splitting the code and keeping the database. The coupling moved to the schema.
    • Shared domain libraries. One "common" model package forces lockstep upgrades.
    • Cutting by layer or noun. "Validation service" follows technology, not the business.
    • Writing to a copy, or deciding from it. Stock checks and balances belong to the owner.

    Key takeaways

    • Independence has three tests: deploy alone, change the schema alone, keep working when others are down.
    • Draw boundaries around bounded contexts, using the write-set of each business transaction to find cuts that hold.
    • One owner per piece of data, enforced by database permissions, not by naming.
    • Joins become composition or read models, ACID across services becomes a saga, and reports come from events.
    • A copy is a cache: versioned, read-only, never the source of a decision.
    • A shared database behind several services is a distributed monolith; Conway's law says the team structure must support the cut.

    Practice exercises

    Exercise 1 (30 min). For ShopStream's seven contexts, write the write-set of "place an order" and mark every pair of aggregates it changes together. Which pairs would a saga have to cover?

    Exercise 2 (45 min). Take a system you know. List its tables and give each exactly one owner. Count the tables that have two or more writers.

    Exercise 3 (1 hour). Write the StockChanged consumer's apply rule in pseudocode, including redelivery and reordering, and list what the read model must never be used for.

    Exercise 4 (2 hours). Design the permissions (roles, schemas, grants) for Orders, Payments and Inventory in one PostgreSQL database, and say what a migration to a database per service would add.

    Official documentation

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.

    Get new articles by email

    Free. We email a confirmation link first, and you can unsubscribe from any email.

    Join engineers building production AI systems

    Comments