67%

Lesson 2 of 3

Data Ownership Inside One Database

The hard half of modularity. One database can still give each module exclusive ownership of its tables — and doing so is what decides whether extraction is ever possible.

11 min read

Import rules stop code from reaching across a module boundary. They do nothing about a query. Two modules can respect every published surface and still be hopelessly coupled, because one of them joins to the other's tables — and that dependency is invisible to every tool that looks at imports.

Three ways to give a module its data, inside one database

Schema per module

Each module owns a database schema. Cross-schema queries are forbidden by convention and, where the database supports it, by grants.

  • Ownership is visible in the database itself, not only in documentation
  • Permissions can enforce it if each module connects with its own role
  • Extraction later is close to mechanical: move the schema
  • Cross-module reporting queries become awkward
  • Migrations must be organised per schema
  • Some tooling assumes a single schema

Choose when: Extraction is plausible, or the team is large enough that ownership needs to be visible outside the codebase. This is the strongest default.

Table prefixes in one schema

All tables live together, named `billing_*`, `catalogue_*`, and the rule is enforced by review and by repository placement.

  • Zero friction with tooling and migrations
  • Trivial to adopt in an existing codebase
  • Cross-module reporting stays easy
  • Nothing prevents a join across the prefix boundary
  • Ownership is a convention, so it decays like any convention

Choose when: You are retrofitting modularity onto an existing monolith and need a first step that does not require a migration.

A database per module

Each module connects to its own database instance while still deploying as one application.

  • Ownership is absolute and physically enforced
  • Extraction requires almost no data work
  • You lose cross-module transactions, which is most of what a monolith buys
  • Operational cost approaching that of services, without the independence

Choose when: Rarely. Usually only when a specific module has genuinely different storage or compliance requirements.

Ownership expressed in the database rather than in a wiki. The grant is what makes it true.sql
-- Each module owns a schema and connects with its own role.
CREATE SCHEMA billing   AUTHORIZATION app_billing;
CREATE SCHEMA catalogue AUTHORIZATION app_catalogue;

-- The billing role cannot read the catalogue's tables at all, so a
-- cross-module join fails immediately in development rather than becoming a
-- dependency nobody notices until an extraction is attempted.
REVOKE ALL ON SCHEMA catalogue FROM app_billing;

-- What billing is allowed to see is published deliberately, and is a contract
-- the catalogue module owns and can evolve.
CREATE VIEW catalogue.published_course_summary AS
  SELECT id, slug, title, published_at FROM catalogue.courses WHERE status = 'PUBLISHED';
GRANT SELECT ON catalogue.published_course_summary TO app_billing;

Use it deliberately, though. A transaction spanning four modules is a coupling too: it means all four must succeed together, and it makes any future extraction of one of them a redesign rather than a move. A useful discipline is to write cross-module flows as events by default and reach for the shared transaction only where atomicity is a business requirement rather than a convenience.

An in-process event, published transactionally, so subscribers cannot see a change that was rolled back.typescript
// The publishing module writes its own state and the event in one transaction.
await withTransaction(async (tx) => {
  const order = await orderRepository(tx).save(placedOrder);
  await outbox(tx).enqueue({
    type: 'order.placed',
    payload: { orderId: order.id, total: order.total.amountMinor },
  });
});

// Subscribers run after commit, in process. Each is a module reacting to a
// fact, and none of them is named by the publisher.
onEvent('order.placed', async (event) => {
  await notificationsService().sendOrderConfirmation(event.payload.orderId);
});

// The outbox is doing real work even without a network: it guarantees that a
// subscriber never observes an order that was rolled back, and it is exactly
// the mechanism that lets notifications become a separate service later
// without any caller changing.

In practice

Discovering the real boundary after two years

A retail platform had a well-enforced modular monolith with six modules and clean import rules. An attempt to extract the inventory module into a service stalled in the first week.

Constraints

  • Import rules had been enforced from the start
  • All modules shared one schema with table prefixes
  • Reporting queries joined freely across prefixes
  • Extraction was needed because inventory now required separate scaling

Decision

Pause the extraction. Spend six weeks moving each module to its own schema, replacing 34 cross-module joins with published views and events, then resume.

Why

The import rules had kept the code boundaries honest and had said nothing about the data. Thirty-four queries joined across module lines, most of them written by well-meaning engineers for reporting screens, and every one of them was a dependency that a network could not carry cheaply. The schema move made the true coupling visible and forced each cross-module read to become an explicit contract.

What it cost

Six weeks of work that delivered no visible feature, and two reporting screens got measurably slower because a join became two queries and an in-memory merge. In exchange, the extraction that followed took three weeks instead of an estimated six months, and the same schema discipline made two later extractions routine. The lesson recorded was to enforce data ownership from the first week, when it costs nothing, rather than after 34 joins exist.

A reporting screen needs order data, customer data and product data, owned by three different modules. Cross-module joins are forbidden. How should you build it?Reveal

Reporting is the case where the rule bites hardest and where a considered exception is usually right. Three reasonable options, in ascending order of investment. Call each module's surface and join in memory — fine for a screen with tens of rows, poor for thousands. Have the reporting module maintain its own read model, updated from events published by the three owners; this is more work up front and is the option that scales, and it makes reporting a consumer rather than a peer. Or declare reporting a legitimate cross-cutting reader with `SELECT` access to published views that each module owns and versions — cheapest, and it holds as long as the views are treated as contracts rather than as a licence to query the underlying tables. What you should not do is allow ad-hoc joins on the base tables, because that is precisely the coupling that turns out to be unbreakable two years later.

Key takeaways

  • Import rules constrain code; only data ownership constrains queries, and queries are the coupling that blocks extraction.
  • One writer per table, and ideally one reader — other modules go through the surface, events, or their own projection.
  • Schema per module with per-module database roles is the strongest practical default.
  • A cross-module transaction is a real advantage of the monolith and also a coupling; use it where atomicity is a business requirement.
  • Reporting needs a deliberate exception — published views or an owned read model, never ad-hoc joins on base tables.
Data Ownership Inside One Database · Architecture Atlas