What are modular monolith database boundaries and why do cross-module table reads erode them?
A modular monolith splits one deployable application into modules with explicit public interfaces: billing, inventory, accounts, and so on. A database boundary extends that split to storage. Each module owns a defined set of tables. Only the owning module reads or writes them directly, and every other module goes through the owner's interface. The point is to keep the deployment simple, with one process and one database, while keeping the coupling that a service architecture would force on you.
Cross-module table reads are the most common way that boundary erodes, mostly because they are so easy. Everything sits in one database, so a developer in shipping who needs an order's status can join against orders in a single query. That works, and it ships. It also creates three problems:
- Hidden coupling. The dependency on another module's data lives in a SQL string, not in an import or an interface call. Nothing in the module graph shows it.
- Migrations that break other modules. When the orders team renames a column or splits a table, they are changing what they believe is private. They find out otherwise when another module's query fails at runtime.
- Extraction debt. If a module later becomes a separate service, every cross-module join has to be found and replaced with an API call or replicated data before the split can happen.
Code review rarely catches these reads, because a query touching another module's table looks like any other query. Boundaries hold reliably only when the database itself refuses the access. The rest of this guide is about making it do that.
Who should enforce table ownership, and when should you avoid it?
Enforcement pays off when the people touching a table are not the people who own it. Three situations make that likely:
- Several teams share one codebase. Each team ships changes to its own schema on its own schedule. Without enforcement, a column rename in billing silently breaks a report query in fulfilment, and nobody finds out until production.
- Service extraction is on the roadmap. Every cross-module read is a dependency you will have to unpick later. If the database already refuses those reads, extracting a module mostly means moving its schema and its access layer. Without that, you first have to audit the whole codebase for queries against its tables.
- Migrations keep breaking other teams. If postmortems keep tracing outages to "we didn't know they queried that table", ownership rules turn that surprise into a permission error in development.
The cost is real. Each new cross-module need requires an explicit interface instead of a quick join, and schema and role setup adds friction to local environments. Teams pay that cost upfront. What they get back is fewer broken migrations and a cheaper extraction later.
Delay enforcement in a small codebase run by one team, where everyone already knows who touches what. Delay it too when module boundaries are still moving. Enforcing a split that doesn't match the domain makes the wrong boundaries hard to change. In large legacy systems, don't switch everything on at once. Hundreds of existing violations will stall feature work, so enforce module by module, as section 7 describes.
How does schema per module with per-module database roles work?
Start by giving every table exactly one owning module and recording that ownership somewhere machine-readable. That can be a manifest, or a migration layout like migrations/billing/, where only billing's migrations create or alter billing's tables. Don't allow a "common" or "shared" bucket. An ownerless table becomes the new back door, and every module ends up coupled through it.
Then put each module's tables in its own schema, such as billing or catalog. Where schemas are awkward, use a consistent table prefix, though prefixes are harder to enforce with privileges. The setup works like this:
- Billing module → billing pool → role
billing_app→ schemabilling - Catalog module → catalog pool → role
catalog_app→ schemacatalog
Each role gets USAGE and table privileges on its own schema only. Revoke default access to public and set default privileges so that new tables don't leak access. If billing then queries catalog.products, the database rejects it with a permission error at runtime. You no longer depend on code review to catch it.
The isolation has costs:
- More connections. Each module needs its own pool, so totals multiply. As an illustrative estimate, 6 modules × 10 connections × 8 instances is 480 connections, compared with 80 for a single shared pool.
- Framework friction. Many ORMs assume one data source. You may have to fight them over multiple data sources, and a single transaction can no longer span two modules.
- Foreign keys across schemas. A foreign key to another module's table needs
REFERENCESprivilege on that table, which breaks the isolation. It also couples both modules' migrations. The usual choice is to store the ID without the constraint.
-- Owner roles run each module's migrations; they own the schema and its tables.
CREATE ROLE billing_owner NOLOGIN;
CREATE ROLE catalog_owner NOLOGIN;
-- App roles are what each module's connection pool logs in as.
CREATE ROLE billing_app LOGIN PASSWORD 'placeholder-billing';
CREATE ROLE catalog_app LOGIN PASSWORD 'placeholder-catalog';
-- One schema per module, owned by that module's owner role.
CREATE SCHEMA billing AUTHORIZATION billing_owner;
CREATE SCHEMA catalog AUTHORIZATION catalog_owner;
-- Close the shared back door: nobody gets implicit access to public.
REVOKE ALL ON SCHEMA public FROM PUBLIC;
-- Each app role can see only its own schema.
GRANT USAGE ON SCHEMA billing TO billing_app;
GRANT USAGE ON SCHEMA catalog TO catalog_app;
-- Tables created by future migrations get DML grants for the owning app role only.
ALTER DEFAULT PRIVILEGES FOR ROLE billing_owner IN SCHEMA billing
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO billing_app;
ALTER DEFAULT PRIVILEGES FOR ROLE billing_owner IN SCHEMA billing
GRANT USAGE, SELECT ON SEQUENCES TO billing_app;
ALTER DEFAULT PRIVILEGES FOR ROLE catalog_owner IN SCHEMA catalog
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO catalog_app;
ALTER DEFAULT PRIVILEGES FOR ROLE catalog_owner IN SCHEMA catalog
GRANT USAGE, SELECT ON SEQUENCES TO catalog_app;
-- Cross-module reference: store the ID without a foreign key constraint,
-- so billing never needs REFERENCES on catalog.products.
SET ROLE billing_owner;
CREATE TABLE billing.invoice_lines (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
product_id bigint NOT NULL -- catalog's ID, deliberately unconstrained
);
RESET ROLE;
-- Connected as billing_app, this fails at runtime:
-- SELECT * FROM catalog.products;
-- ERROR: permission denied for schema catalog
PostgreSQL setup that gives each module its own schema, owner role and login role, uses default privileges so new tables stay private to their module, and stores a cross-module ID without a foreign key so a billing query against catalog fails with a permission error.
How should other modules access data through the data access layer?
Once modules hold only each other's IDs, they still need a way to turn an ID into data. That way should be a published in-process interface owned by the target module, usually a query service or facade that returns plain DTOs. It should never be the owning module's ORM entities or repositories.
A leaked repository looks harmless. Suppose Billing injects CustomerRepository and calls findById. It gets back a managed Customer entity. Billing can now navigate lazy associations, trigger queries against tables it has no grant on, or mutate the entity and have the change flushed by a shared unit of work. Every column rename in Customers becomes a compile error or a runtime failure in Billing.
The facade version exposes CustomerQueries.getBillingContact(customerId), which returns an immutable BillingContactDto with only the fields Billing needs. Behind it, the Customers module is free to restructure tables, swap ORM mappings, or add caching. The DTO is the contract, and its shape is chosen for the consumer, not dictated by the schema.
Keep the interface in a separate public package of the module, and keep repositories and entities package-private or in an internal namespace. That way the compiler, not just convention, stops direct use.
The cost is real. You write and maintain mapping code. Calling the facade inside a loop also reproduces N+1 queries across the boundary. Design batch methods such as getBillingContacts(ids) from the start, so consumers fetch in one call instead of many.
// --- Leaked: Billing depends on Customers' ORM internals ---
class InvoiceServiceLeaky {
private final CustomerRepository customers; // internal type of Customers
InvoiceServiceLeaky(CustomerRepository customers) { this.customers = customers; }
String recipient(long customerId) {
Customer c = customers.findById(customerId).orElseThrow(); // managed entity
// Lazy navigation queries Customers' tables from Billing code
return c.getPrimaryAddress().getEmail();
}
}
// --- Facade: published API in com.example.customers.api ---
public record BillingContactDto(long customerId, String name, String email) {}
public interface CustomerQueries {
BillingContactDto getBillingContact(long customerId);
// Batch method avoids N+1 calls across the boundary
Map<Long, BillingContactDto> getBillingContacts(Collection<Long> customerIds);
}
// --- Billing uses only the published interface and DTOs ---
class InvoiceService {
private final CustomerQueries customers;
InvoiceService(CustomerQueries customers) { this.customers = customers; }
List<String> recipients(List<Invoice> invoices) {
Set<Long> ids = invoices.stream()
.map(Invoice::customerId)
.collect(Collectors.toSet());
Map<Long, BillingContactDto> contacts = customers.getBillingContacts(ids); // one call
return invoices.stream()
.map(i -> contacts.get(i.customerId()).email())
.toList();
}
}
Contrasts Billing injecting the Customers module's repository and entity with Billing calling a published CustomerQueries facade that returns immutable DTOs. It also includes a batch method so loops don't cause N+1 queries across the boundary.
How can CI catch cross-module data access before the database does?
Database roles reject a forbidden query only when it runs, which may be in staging or in production. CI can reject the same mistake when the pull request opens, and it is cheaper to fix there than in review or after an incident.
Three checks worth automating
Import boundaries. An architecture test asserts that code in orders imports only the public facade of billing, never its repositories, entities, or query classes. Most languages have a library or linter rule for this. If the test fails, someone reached past the data access layer, and that is usually the first step toward a cross-module query.
Migrations and raw SQL. A script walks each module's migration directory and SQL files and looks for schema-qualified references such as billing.invoices inside a module other than billing. Matching on schema prefixes produces fewer false positives than matching bare table names, which is another reason schema per module pays off.
ORM mappings. A test loads the ORM metadata, reads each entity's schema and table, and fails if the owning schema differs from the module where the entity class lives. This catches read-only "convenience" entities that map another module's table.
Allowlisting legacy violations
On an existing codebase, turning these checks on will fail the build hundreds of times. Record the current violations in a checked-in allowlist file and fail only on new entries. Keep the file under code ownership so that additions need sign-off. Make the check also fail when an allowlisted violation disappears, so the list can only shrink. This stops the debt from growing without blocking feature work while you pay it down.
#!/usr/bin/env python3
"""CI check: fail on new cross-module schema references in SQL files.
Assumes layout modules/<name>/... where each module owns the schema <name>.
The allowlist records known legacy violations and may only shrink.
"""
import re
import sys
from pathlib import Path
ROOT = Path("modules")
ALLOWLIST = Path("ci/cross_module_allowlist.txt") # owned via CODEOWNERS
MODULES = sorted(p.name.lower() for p in ROOT.iterdir() if p.is_dir())
# Schema-qualified names only (billing.invoices, "billing"."invoices");
# bare table names are ignored to keep false positives low.
REF = re.compile(
r'(?<![\w.])"?(' + "|".join(map(re.escape, MODULES)) + r')"?\."?(\w+)"?',
re.IGNORECASE,
)
found = set()
for module in MODULES:
for sql in (ROOT / module).rglob("*.sql"):
for schema, table in REF.findall(sql.read_text(encoding="utf-8")):
if schema.lower() != module: # referencing another module's schema
found.add(f"{sql.as_posix()} -> {schema.lower()}.{table.lower()}")
allowed = {
line.strip()
for line in ALLOWLIST.read_text(encoding="utf-8").splitlines()
if line.strip() and not line.lstrip().startswith("#")
}
new, fixed = sorted(found - allowed), sorted(allowed - found)
for v in new:
print(f"NEW cross-module reference: {v}")
for v in fixed:
print(f"STALE allowlist entry (violation is gone, delete this line): {v}")
sys.exit(1 if new or fixed else 0)
A CI script that scans each module's SQL and migration files for schema-qualified references to other modules' schemas. It fails on any violation missing from the checked-in allowlist, and also fails on allowlist entries that no longer match a violation, so the list can only shrink.
What are the alternatives when one module needs another module's data?
Blocking direct table reads only helps if teams have legitimate ways to get the data they need. Four options cover most cases.
| Option | Consistency | Performance | Coupling | Operational cost |
|---|---|---|---|---|
| Compose in application code | Strong | Extra round trips; N+1 risk unless calls are batched | Low, via the module's API | Low |
| Read-only view owned by producer | Strong | Database-side joins | High once consumers depend on its columns | Low, but view changes need versioning |
| Local projection from events | Eventual | Fast local joins | Low, via the event schema | High: replay, backfill, lost or out-of-order events |
| Dedicated reporting read model | Eventual | Built for wide queries | Isolated from transactional code | Medium to high |
Start with composition, and make the producing module expose batch lookups so callers fetch by a list of IDs instead of looping. Use a view when the join is too expensive to do in memory. Treat it as a public contract: name it with a version suffix and keep the old version until every consumer has moved off it. A view with no owner turns into a schema nobody can change. Use projections only when a module queries the data constantly and can accept stale reads. Keep reporting in its own read model so analysts' queries never shape transactional tables.
For writes, a single database transaction across two modules is often the right call inside one process. Allow it as a documented exception, routed through each module's API, and list it in the CI allowlist. Use events or an outbox when one side really can tolerate eventual consistency, or when that module is likely to be extracted into its own service. Otherwise you take on idempotency, retries and reordering problems that a single database already solves for you.
How do you migrate an existing monolith to enforced table ownership?
Start with an inventory, not a schema move. Enable pg_stat_statements or statement logging in a staging environment that runs realistic traffic, then attribute each query to the module that issued it. Tagging connections with application_name per module makes this practical. The result is a list of (module, table, operation) tuples. Every tuple where the module does not own the table is a violation.
Next, assign exactly one owner to every table, including the awkward shared ones. Then move tables into their owner's schema with ALTER TABLE ... SET SCHEMA. Existing queries keep working if you temporarily put the new schemas on search_path.
Before you revoke anything, grant each module's role SELECT on the specific tables the inventory showed it reads. Grant per table, never ON ALL TABLES IN SCHEMA and never ALTER DEFAULT PRIVILEGES. Broad grants silently approve future violations. To see what exists, query information_schema.role_table_grants for rows where the grantee belongs to a different module than table_schema. information_schema.view_table_usage catches views that join across schemas.
That query output becomes your measured violation list. Track its count per module and require that it only shrinks. Tighten one module at a time by replacing grants with data access layer calls, then revoking. Do not let teams fall back to a shared superuser connection or add CI suppressions without an expiry date.
A big-bang switch forces every team to fix every violation in the same release window. Staging the work lets each module absorb its share during normal feature work. It also lets you roll back one revoke instead of the whole migration.
-- Map each module's login role to the schema it owns (adjust to your naming).
WITH module_roles(role_name, owned_schema) AS (
VALUES ('billing_app', 'billing'),
('orders_app', 'orders'),
('catalog_app', 'catalog')
),
-- 1. Direct grants that let a module touch another module's tables.
grant_violations AS (
SELECT m.role_name AS module_role, g.table_schema, g.table_name,
g.privilege_type, 'grant' AS kind
FROM information_schema.role_table_grants g
JOIN module_roles m ON m.role_name = g.grantee
WHERE g.table_schema <> m.owned_schema
AND g.table_schema NOT IN ('pg_catalog', 'information_schema')
),
-- 2. Views in one module's schema that read another module's tables.
-- Run as an admin role: this view only lists tables owned by enabled roles.
view_violations AS (
SELECT m.role_name AS module_role, v.table_schema, v.table_name,
'SELECT via view ' || v.view_schema || '.' || v.view_name, 'view'
FROM information_schema.view_table_usage v
JOIN module_roles m ON m.owned_schema = v.view_schema
WHERE v.table_schema <> v.view_schema
AND v.table_schema IN (SELECT owned_schema FROM module_roles)
)
SELECT * FROM grant_violations
UNION ALL
SELECT * FROM view_violations
ORDER BY module_role, table_schema, table_name;
-- Transitional access: one table, read-only, never ON ALL TABLES IN SCHEMA.
GRANT USAGE ON SCHEMA billing TO orders_app;
GRANT SELECT ON billing.invoices TO orders_app;
-- Tighten later, once orders calls the billing data access layer instead.
REVOKE SELECT ON billing.invoices FROM orders_app;
A catalog query that lists cross-module table grants and cross-schema view dependencies as a trackable violation list, plus the narrow per-table grant and later revoke used during a staged rollout.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to modular monolith database boundaries, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.