← Architecture
From modular monolith to services Decompose the data

Module-owned data in one database

Share the file. Not the tables.

One database is the right start for a modular monolith: one backup, one connection, one transaction when you need it. The trouble starts with the first query that joins two modules’ tables, because from then on neither module can change its tables, or leave, without the other.

TypeScriptGoOne shop, two ways to write its reports, two recorded builds.

01 / The prompt

“Keep the shop’s data in SQLite, and add a sales report.”

Once every table sits in one file, a sales report is one query away: join the products, the order lines, and the stock, group by product, sort by revenue. It is the answer a SQL course would give, it is fast, and it quietly ties three modules’ tables into a statement that belongs to none of them.

In the lesson’s shop, written that way, moving Orders to a database of its own breaks 2 of the reports’ statements, moving Catalog breaks 2, and moving Inventory breaks 1. Written so that Reports asks each module, the same moves break none.

Neither recorded agent in section 08 wrote that join. One of them made a quieter mistake with the price, and the check it wrote to guard ownership missed two of three planted queries.

02 / Name the shape

One database file, and every table has exactly one owner.

Module-owned data means each module’s tables are read and written only by that module’s code. Other modules get the data by asking the owner, or by keeping their own copy from the owner’s events. The database stays shared; the tables do not. It is the same boundary as a service with its own database, drawn before anyone has to run a second server.

Share the database file, not the tables. No query joins two modules’ tables, and no foreign key points from one module’s table into another’s.

Here is every table in the lesson’s shop, and how everyone else reaches it.

Tables in the example shop, their owners, and how other modules read them
TableOwnerWho else needs it, and how
catalog_productsCatalogOrders prices an order with catalog.products(skus); Reports lists products with catalog.list().
inventory_stock, inventory_holdsInventoryOrders calls reserve, release, and commit; Reports asks available(skus).
orders_orders, orders_linesOrdersReports asks salesBySku() and ordersFor(email). Each line keeps the price it sold at, so Orders never needs Catalog to answer.

Words to put in a prompt or a review

Table ownership
One module reads and writes a table; every other module goes through it.
Schema per module
A namespace, and ideally a database user, for each module’s tables inside one database.
Cross-module join
A query that reads two modules’ tables at once. It belongs to neither.
Cross-module foreign key
A constraint that makes one module’s rows depend on another module’s.
Snapshot
A fact copied at the moment it mattered, such as the price paid on an order line.
Read model
A table one module keeps from another module’s events, to answer its own queries.
Owning your tables is not freeWhat you give up

Joins move into code, and a report makes three calls where one query would do. No foreign key stops an order line from naming a product Catalog later deletes, so the modules have to agree on what happens then. Some data is copied on purpose, like the price on an order line, and copies can disagree with their source; that is the point for a price paid, and a bug for a product name. A report that has to be fast gets a read model of its own, kept from events, which is a moment behind.

03 / Move a module’s tables out

Same shop, same answers, two ways to write the reports. What breaks when a module leaves?

Both columns keep Catalog, Inventory, and Orders exactly as they are, and pass the same shared scenarios. On the left, Reports asks each module. On the right, Reports joins their tables. Every statement shown is read from the lesson’s source. Watch three modules move out, then open Try it and plant a query of your own.

Module-owned data

Move a module’s tables to their own database

Reports ask each module

  • catalog catalog_products
  • inventory inventory_holds, inventory_stock
  • orders orders_lines, orders_orders

27 statements

Reports join the tables

  • catalog catalog_products
  • inventory inventory_holds, inventory_stock
  • orders orders_lines, orders_orders

29 statements

01/ 04
One database, same answers

27 statements and 29 statements of SQL, one database.

Both builds keep every table in one SQLite file and pass the same 6 shared scenarios. On the left, Reports asks Catalog, Inventory, and Orders for their parts. On the right, Reports runs two SQL joins across their tables.

Reduced motion: choose a scene to see its completed state.

Read this scene

Both builds keep every table in one SQLite file and pass the same 6 shared scenarios. On the left, Reports asks Catalog, Inventory, and Orders for their parts. On the right, Reports runs two SQL joins across their tables.

Reports ask each module: 27 statements.

Reports join the tables: 29 statements.

Watch restarts the story when you come back. Step through keeps your step. Try it starts from the lesson’s shop each time you open it.

04 / Read the shape

A handle that reaches only your tables, a module that keeps what it needs, and a report that asks.

Basic form is the schema each module is given. In the wild is Orders placing an order. At the call site is Reports, which owns no tables at all. Notice what Orders stores on each line, and what Reports never writes.

The handle each module gets: one schema that runs SQL only against tables named with its own prefix. Go keeps the same rule over JSON files.

TypeScriptReading
db/index.ts
export interface Schema {
	readonly owner: string;
	exec(sql: string): void;
	run(sql: string, ...params: Value[]): void;
	all(sql: string, ...params: Value[]): Row[];
	get(sql: string, ...params: Value[]): Row | undefined;
	/** Runs `work` in one transaction, inside this module's own tables. */
	transaction<T>(work: () => T): T;
}

/** The guard every statement passes: a table without the owner's prefix fails before SQLite sees it. */
export function checkOwner(owner: string, sql: string): string {
	for (const table of tablesIn(sql))
		if (!table.startsWith(`${owner}_`))
			throw new Error(`${owner} may not use ${table}: it belongs to another module`);
	return sql;
}
GoAlongside
db/db.go
type Database struct {
	mu     sync.Mutex
	path   string
	tables map[string]json.RawMessage
}

// Schema is one module's view of the database.
type Schema struct {
	owner string
	db    *Database
}

// ErrNotYours is returned when a module names a table another module owns.
type ErrNotYours struct{ Owner, Table string }

func (e ErrNotYours) Error() string {
	return fmt.Sprintf("%s may not use %s: it belongs to another module", e.Owner, e.Table)
}

// check is the guard every Load and Save passes: a table without the owner's
// prefix fails before the database is touched.
func (s *Schema) check(table string) error {
	if !strings.HasPrefix(table, s.owner+"_") {
		return ErrNotYours{s.owner, table}
	}
	return nil
}
The whole database moduleThe guard, and the escape hatch

schema(owner) checks every statement’s tables before SQLite sees it and refuses any that do not start with the owner’s prefix. unguarded() hands out the raw connection. The lesson uses it only for the joined reports, so the comparison can run; in real code, its callers are the list to review.

db/index.ts
import { DatabaseSync } from 'node:sqlite';
import { tablesIn } from './tables.ts';

// One database file for the whole shop, handed to each module as a schema that
// can only touch that module's tables. A statement that names another module's
// table fails when it runs, before it ever reaches SQLite.

export type Value = string | number | null;
export type Row = Record<string, Value>;

export interface Schema {
	readonly owner: string;
	exec(sql: string): void;
	run(sql: string, ...params: Value[]): void;
	all(sql: string, ...params: Value[]): Row[];
	get(sql: string, ...params: Value[]): Row | undefined;
	/** Runs `work` in one transaction, inside this module's own tables. */
	transaction<T>(work: () => T): T;
}

/** The guard every statement passes: a table without the owner's prefix fails before SQLite sees it. */
export function checkOwner(owner: string, sql: string): string {
	for (const table of tablesIn(sql))
		if (!table.startsWith(`${owner}_`))
			throw new Error(`${owner} may not use ${table}: it belongs to another module`);
	return sql;
}

export { tablesIn } from './tables.ts';

export function openDatabase(path = ':memory:') {
	const db = new DatabaseSync(path);

	function schema(owner: string): Schema {
		const check = (sql: string) => checkOwner(owner, sql);
		return {
			owner,
			exec: (sql) => db.exec(check(sql)),
			run: (sql, ...params) => void db.prepare(check(sql)).run(...params),
			all: (sql, ...params) => db.prepare(check(sql)).all(...params) as Row[],
			get: (sql, ...params) => db.prepare(check(sql)).get(...params) as Row | undefined,
			transaction(work) {
				db.exec('BEGIN');
				try {
					const result = work();
					db.exec('COMMIT');
					return result;
				} catch (error) {
					db.exec('ROLLBACK');
					throw error;
				}
			}
		};
	}

	return {
		schema,
		/**
		 * The whole database, with no guard. Only the joined example in this lesson
		 * uses it, to show what the guard is there to stop.
		 */
		unguarded: () => db,
		close: () => db.close()
	};
}

export type Database = ReturnType<typeof openDatabase>;
The same reports, written as joinsSame answers, three owners welded

These pass every shared scenario the owned reports pass. They are shorter and make one query each. They also name every table the report touches, in a file none of those tables belong to.

joined/reports.ts
async salesReport(): Promise<ReportRow[]> {
	const rows = sql
		.prepare(
			`SELECT p.sku, p.name,
				COALESCE(sold.units, 0) AS unitsSold,
				COALESCE(sold.revenue, 0) AS revenue,
				COALESCE(stock.units, 0) AS stock
			FROM catalog_products p
			LEFT JOIN (SELECT sku, SUM(qty) AS units, SUM(qty * price) AS revenue
				FROM orders_lines GROUP BY sku) sold ON sold.sku = p.sku
			LEFT JOIN (SELECT sku, SUM(units) AS units
				FROM inventory_stock GROUP BY sku) stock ON stock.sku = p.sku
			ORDER BY revenue DESC, p.sku ASC`
		)
		.all() as {
		sku: string;
		name: string;
		unitsSold: number;
		revenue: number;
		stock: number;
	}[];
	return rows.map((row) => ({ ...row, lowStock: row.stock <= 3 }));
},
The behavior these examples promiseChecked by 6 shared scenarios, 32 steps
  • The sales report lists every product with units sold and revenue from placed orders only, stock available now, and low stock at 3 or fewer, sorted by revenue and then sku.
  • A declined card or a rejected order leaves no order, no sale, and no change in stock.
  • A customer’s orders come newest first, with each product’s name.
  • Closing the database and opening the same file again keeps everything.
  • A module that names another module’s table is refused before the statement runs.

The expectations were written from these rules rather than copied from either implementation, and the TypeScript and Go tests both check every one. The TypeScript scenarios run against both the owned and the joined reports.

Reading the TypeScriptnode:sqlite, synchronous on purpose

DatabaseSync ships with Node 22.13 and later, and runs each statement before returning. A transaction is BEGIN, the work, then COMMIT, or ROLLBACK if it throws, and nothing else can run in between. Module front doors stay async, as in Module contracts, so a module can move behind a network call without its callers changing.

Reading the Gothe same rule without SQLite

Go has no SQLite in its standard library, and the lesson takes no dependencies, so the Go shop keeps its tables in one JSON file. The rule is the same: Schema("orders") loads and saves only tables named orders_, and returns ErrNotYours for anything else.

Run it yourselfNo dependencies

Save the complete files at the paths in their banners. Then run node --experimental-strip-types run.ts (Node 22.18 or later; it prints an experimental warning for SQLite), or go run . in the Go folder. Both print:

mug: 2 sold, 3600 revenue, 10 left
cap: 1 sold, 3000 revenue, 1 left, low
poster: 0 sold, 0 revenue, 0 left, low
ada's latest: order-2, Wool cap
refused: orders may not use catalog_products: it belongs to another module

05 / Review the agent’s diff

“One query instead of three calls. Same output, all tests pass.”

It is faster to read. Read what it makes Orders and Catalog unable to change.

The agent’s pull request

“The sales report made three calls. I replaced them with a single SQL query. Same output, all tests pass.”

// reports/index.ts
			(removed) const products = await catalog.list();
			(removed) const [stock, sales] = await Promise.all([
			(removed)   inventory.available(products.map((product) => product.sku)),
			(removed)   orders.salesBySku()
			(removed) ]);
			(added) // One query instead of three calls.
			(added) const rows = db.unguarded().prepare(`
			(added)   SELECT p.sku, p.name, COALESCE(sold.units, 0) AS unitsSold,
			(added)     COALESCE(sold.revenue, 0) AS revenue, COALESCE(stock.units, 0) AS stock
			(added)   FROM catalog_products p
			(added)   LEFT JOIN (SELECT sku, SUM(qty) AS units, SUM(qty * price) AS revenue
			(added)     FROM orders_lines GROUP BY sku) sold ON sold.sku = p.sku
			(added)   LEFT JOIN (SELECT sku, SUM(units) AS units
			(added)     FROM inventory_stock GROUP BY sku) stock ON stock.sku = p.sku
			(added)   ORDER BY revenue DESC, p.sku ASC`).all();
			
You are reviewing this change. What do you do?

06 / How it fails

Shared tables fail late: at the next schema change, or the day a module leaves.

Failure modes of data shared between modules in one database
What goes wrongWhat happensWhat handles it
A report joins modules’ tablesIt works until one of them changes a column or moves out. In the story, the joined reports break on every move: 2, 2, and 1 statements.Reports ask the owners, or keep a read model from their events.
A foreign key into another moduleThe database will not split along the module line, and a delete in one module fails in another.Keep the other module’s id as a plain value, and ask the owner when it matters.
Data derived from another module’s current stateThe ownership build multiplies units sold by today’s catalog price. When the mug’s price changed, its past revenue went from 3600 to 4000.Store the fact at the moment it happened, in the module that needs it.
A table name hidden in a constantA search for table names finds nothing. The ownership build’s check passed with a planted query written that way.Check the SQL with the names resolved, or deny access in the database.
SQL in the composition rootCode no module owns reaches every table. The ownership build’s check does not look at server.ts.The composition root calls modules and holds no SQL.
A report that asks once per rowA hundred products become a hundred calls to Inventory.Front doors that take lists, like available(skus), and a read model if that is still too slow.
Two modules write the same tableNeither can change the table without the other, and nobody knows which rule wins.One module writes; the others ask, or react to its events.

07 / Is it worth it?

Ownership costs joins you cannot write, and pays off the day a module has to change.

The costs are real: queries that would be one join become several calls, a report that has to be fast needs a read model and its lag, foreign keys stop at module lines, and every copied fact needs a reason. For a small app one team will never split, a join with a comment saying you chose it can be the better call.

So measure where you are, and after:

  • Statements that name another module’s table, from a check like the one in section 09. The target is zero; the count tells you how far away that is.
  • Statements that break if one module moves out, the story’s number, for the module you are most likely to extract.
  • Schema changes that touched more than one module’s code, per quarter.
  • The report’s time at the 95th percentile with production-sized data, before and after moving from joins to calls.

The first two say how coupled the data is; the last says what untangling it costs the people waiting for the report. This lesson did not measure a real shop.

08 / Ask for it

One starting point, one SQLite ticket, two prompts.

Two agents running Claude Sonnet each got a copy of the shop as the Communication between modules run left it, and the same ticket: keep all data in SQLite at SHOP_DB, survive a restart, and add a sales report and a customer’s order history. One prompt added a Data ownership block: prefixed tables with owners in a DATA.md, no SQL outside a module and no statement naming another module’s table, the owner’s API or events for anything else, and a check that fails on cross-module SQL. A script ran both builds with a fresh database for every question.

What the checker found, run 2026-09-14
QuestionPlain promptOwnership prompt
Sales report after five orders, two of them refusedCorrectCorrect
A customer’s orders, newest first, with product namesCorrectCorrect
After a restart on the same fileEverything kept; the next order is ord_4Everything kept; the next order is ord_4
Tables, each created by one module6: products, stock, reservations, reservation_lines, orders, order_items7: catalog_products, inventory_stock, inventory_reservations, inventory_reservation_lines, inventory_low_stock, orders_orders, orders_order_items
SQL statements, and ones naming another module’s table25, of which 0 name another module’s table29, of which 0 name another module’s table
Joins or foreign keys across modulesNoneNone
Mug revenue from two sales, after its price goes from 1800 to 20003600 before, 3600 after3600 before, 4000 after
Table owners written downNo; MODULES.md names each module’s storeDATA.md
Its own tests51 of 51 pass, 10.2 s58 of 58 pass, 10.4 s
Planted: Catalog reads Orders’ tableTests pass: missedTests fail: caught
Planted: the same, with the table name from the registryNot applicable: no table registryTests pass: missed
Planted: server.ts joins Orders’ and Catalog’s tablesTests pass: missedTests pass: missed
Code added or changed, not counting tests7 files, +498 −898 files, +540 −101

On what the ticket asked, the builds are the same. Both answer the report and the order history correctly, keep everything across a restart, and create data/shop.db when SHOP_DB is not set. Both put each module’s SQL in that module’s own store, build both new endpoints in server.ts by calling module functions, and write no join or foreign key across modules. The plain agent did not need telling: the shop it was given already had a store per module and a MODULES.md that said who may depend on whom.

The plain agent did one thing better. It saved the price on every order line and computes revenue from it. The ownership agent multiplied units sold by the catalog’s current price, and wrote in DATA.md that nothing in the shop could change a price. The checker changed the mug’s price from 1800 to 2000 between restarts: the plain build still reports 3600 for two mugs sold at 1800, and the ownership build reports 4000.

orders/store.ts · plain prompt
+  CREATE TABLE IF NOT EXISTS order_items (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    order_seq INTEGER NOT NULL,
+    sku TEXT NOT NULL,
+    qty INTEGER NOT NULL,
+    price INTEGER NOT NULL
+  );
+export function _salesBySku(): { sku: string; qty: number; revenue: number }[] {
+  const rows = db().prepare(`SELECT sku, SUM(qty) as qty, SUM(qty * price) as revenue FROM order_items GROUP BY sku`)
+    .all() as unknown as { sku: string; qty: number; revenue: number }[];
server.ts · ownership prompt
+function salesReport(): SalesReportRow[] {
+  const products = listProducts();
+  const sold = new Map(getSoldQuantities().map((s) => [s.sku, s.qty]));
+  const stocks = new Map(getAvailableForSkus(products.map((p) => p.sku)).map((s) => [s.sku, s.available]));
+
+  const rows: SalesReportRow[] = products.map((p) => {
+    const unitsSold = sold.get(p.sku) ?? 0;
+    const stock = stocks.get(p.sku) ?? 0;
+    return {
+      sku: p.sku,
+      name: p.name,
+      unitsSold,
+      revenue: p.price * unitsSold,

What the ownership block bought is a prefix on every table, a written list of owners, and a test. The test searches each module’s folder for other modules’ table names. But the same build keeps every table name in one registry, db/tables.ts, and every query reads its name from there, so a module that imports another module’s constant never spells the name out. The checker planted three queries that break the rule. The build’s test caught the one with the name written out, and passed with the other two. This lesson’s check found 3 of 3 in the ownership build, and 2 of 2 in the plain build, which has no check of its own.

tests/ownership.test.ts and catalog/store.ts · ownership prompt
+function mentionsTable(source: string, table: string): boolean {
+  const re = new RegExp(`\\b${table}\\b`);
+  return re.test(source);
+}
+    .prepare(`SELECT sku, name, price FROM ${CATALOG_TABLES.products} ORDER BY rowid`)
How the runs were made and checkedTwo builds, recorded as written
  • The starting point is the Communication between modules lesson’s recorded messages build, byte for byte, with its checksums checked before the runs. Both agents were launched at the same time; neither was told about the other, the lesson, or the checker.
  • All builds are kept byte for byte with checksums and diffs. For every question the checker restores a build into a fresh folder with its own database file, and stops and restarts the server where the question needs it. It runs its own mail provider, so the email feature keeps working without a real service.
  • Its static scan finds SQL in string literals, gives each table to the module that creates it, and writes in names that come from constants before scanning. Each planted query goes into a fresh copy, in code nothing calls, followed by the build’s own tests.
  • The checker ran three times, and every run is kept. The first ran on the ownership build alone and read IF in CREATE TABLE IF NOT EXISTS as a table name, because the build writes its table names through constants. The second fixed that, wrote the constants in, and added the planted queries. The third added the price change. Every answer the first two runs share with the third is the same.
  • Both agents stepped outside their folders. The plain agent listed the folder that held both builds, and stopped its test servers three times with pkill -f "server.ts", which stops every process with that name; the first one stopped a server the ownership agent was running at that moment. The ownership agent wrote and deleted a file in the session’s scratch area, listed that area, ran two test servers on the port reserved for the plain agent’s mail provider, and once started the server from this repository’s folder, where it failed to load. Both wrote scratch files and logs to /tmp and deleted them.
  • One run of each prompt is a sample, not a measurement of the model.

09 / Hold it there

Ownership written in a document is a wish. Three layers make it a fact.

The next report, the next migration, the next agent in a hurry will reach for the shortest query. Here is what stops it, from the one the lesson’s shop has to the one a production database gives you.

  1. A handle that reaches only your tables

    Each module gets a schema object that refuses statements naming other modules’ tables, at the moment they run. It is shown in section 04, and the shop’s tests check that it refuses a read and a foreign key into Catalog’s table.

  2. A check that reads the SQL

    A table belongs to the module whose code creates it, and any statement elsewhere that names it fails the check. It runs over source, so it catches a shortcut in review, before anything runs. It reads names written in the SQL; the lesson’s checker adds a step that writes in names from constants, which is the case the ownership build’s own test missed. Enforcement layer runs checks like this on every agent change.

    ownership.ts
    /** The SQL statements in one file: string literals that hold SQL and name at least one table. */
    export function sqlIn(file: SourceFile): Omit<Statement, 'owners'>[] {
    	return literals(file.source).flatMap(({ text, index }) => {
    		const tables = tablesIn(text);
    		if (!SQL.test(text) || tables.length === 0) return [];
    		return [
    			{
    				file: file.path,
    				line: file.source.slice(0, index).split('\n').length,
    				module: file.module ?? moduleOf(file.path),
    				sql: text.replace(/\s+/g, ' ').trim(),
    				tables
    			}
    		];
    	});
    }
    
    export function checkOwnership(files: SourceFile[]) {
    	const found = files.flatMap(sqlIn);
    	const owners: Record<string, string> = {};
    	for (const statement of found)
    		for (const match of statement.sql.matchAll(
    			/\bcreate\s+table\s+(?:if\s+not\s+exists\s+)?([a-z_][a-z0-9_]*)/gi
    		))
    			owners[match[1].toLowerCase()] ??= statement.module;
    	const ownerOf = (table: string) => owners[table] ?? NOBODY;
    	const statements: Statement[] = found.map((statement) => ({
    		...statement,
    		owners: [...new Set(statement.tables.map(ownerOf))]
    	}));
    	const violations: Violation[] = statements.flatMap((statement) =>
    		statement.tables
    			.filter((table) => ownerOf(table) !== statement.module)
    			.map((table) => ({
    				file: statement.file,
    				line: statement.line,
    				module: statement.module,
    				table,
    				owner: ownerOf(table)
    			}))
    	);
    	return { owners, statements, violations };
    }
  3. Permissions the database enforces

    SQLite has no users, so the lesson stops at the first two. In PostgreSQL or MySQL, give each module its own schema and its own database user with rights only there. A join across modules then fails in the database, whatever the code, the constants, or the agent did.

Your client cache is already one shared databaseA client cache is one store every feature shares. Its keys have owners too.

Where it already is in your components

A query cache or a global store is one database for the whole front end. A checkout page that reads the cart feature’s cache entry directly has joined the cart’s data: when the cart team changes the key or the shape under it, checkout breaks, and nothing in the cart feature said checkout was reading.

When you have to own it

Once several features persist state in the browser, give each one a handle to its own keys, the way the shop gives each module a schema, and let other features ask that feature for what they need.

Checkout shows the cart summary the cart feature provides, instead of reading its cache entry.

ReactAlready in your code
CheckoutSummary.tsx
// The cart feature owns the cart's cached data, and the key it lives under.
// Checkout asks the cart feature for what it needs, instead of reading the
// cache entry directly.
type CartSummary = { itemCount: number; total: number };

export default function CheckoutSummary({ useCartSummary }: { useCartSummary: () => CartSummary }) {
	// Not: queryClient.getQueryData(['cart', 'lines']) and adding it up here.
	// That key, and the shape stored under it, are the cart feature's to change.
	const { itemCount, total } = useCartSummary();
	return (
		<p>
			{itemCount} items · {(total / 100).toFixed(2)}
		</p>
	);
}

10 / Make the call

Keep one database. Give every table one owner, and make the owner the way in.

Start a modular monolith with one database and a prefix or schema per module. Keep every query inside the module that owns its tables, store what a module needs to answer later in its own tables, and let reports ask the owners. When a report has to be fast, give it a read model of its own. Skip all of it only for an app that will stay small, with one team, and write that choice down.

The payoff comes on the day a module changes its tables or moves to its own database: the list of what breaks is that module’s own code, not a search across the codebase.

Take it with you

Explain it without saying “schema”: “Every table has one team’s name on it, and anyone else who wants that data asks that team’s code.” Then find the query in your own code that touches the most modules’ tables, and ask which module would have to change it if one of them left.

Paste into your next prompt, and fill in the blanks

Keep one database, but give each module its own tables, named with the module's name as a prefix, and list the owner of every table in DATA.md.
Only a module's own code reads or writes its tables: no SQL outside module folders, including the composition root, and no joins or foreign keys across modules.
When a module needs another module's data, it calls that module's public API, or keeps its own copy updated from that module's events. Store what a module needs to answer later, such as the price paid on each order line, in that module's own tables.
Add a check that fails when any SQL statement names a table another module owns, whether the name is written out or comes from a constant, and show it failing on a planted cross-module query.
Where the database supports it, give each module its own schema and a database user that can reach only that schema.
Connections to follow nextRelated lessons

Take the shop into your editor. Give Reports a reports_sales table it keeps from order events, and decide what the report shows while it catches up.

Back to architecture →