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.
| Table | Owner | Who else needs it, and how |
|---|---|---|
catalog_products | Catalog | Orders prices an order with catalog.products(skus); Reports lists
products with catalog.list(). |
inventory_stock, inventory_holds | Inventory | Orders calls reserve, release, and commit; Reports asks available(skus). |
orders_orders, orders_lines | Orders | Reports 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.
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
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.
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;
} 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.
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.
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.
06 / How it fails
Shared tables fail late: at the next schema change, or the day a module leaves.
| What goes wrong | What happens | What handles it |
|---|---|---|
| A report joins modules’ tables | It 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 module | The 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 state | The 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 constant | A 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 root | Code 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 row | A 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 table | Neither 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.
| Question | Plain prompt | Ownership prompt |
|---|---|---|
| Sales report after five orders, two of them refused | Correct | Correct |
| A customer’s orders, newest first, with product names | Correct | Correct |
| After a restart on the same file | Everything kept; the next order is ord_4 | Everything kept; the next order is ord_4 |
| Tables, each created by one module | 6: products, stock, reservations, reservation_lines, orders, order_items | 7: 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 table | 25, of which 0 name another module’s table | 29, of which 0 name another module’s table |
| Joins or foreign keys across modules | None | None |
| Mug revenue from two sales, after its price goes from 1800 to 2000 | 3600 before, 3600 after | 3600 before, 4000 after |
| Table owners written down | No; MODULES.md names each module’s store | DATA.md |
| Its own tests | 51 of 51 pass, 10.2 s | 58 of 58 pass, 10.4 s |
| Planted: Catalog reads Orders’ table | Tests pass: missed | Tests fail: caught |
| Planted: the same, with the table name from the registry | Not applicable: no table registry | Tests pass: missed |
| Planted: server.ts joins Orders’ and Catalog’s tables | Tests pass: missed | Tests pass: missed |
| Code added or changed, not counting tests | 7 files, +498 −89 | 8 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.
+ 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 }[]; +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.
+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
IFinCREATE TABLE IF NOT EXISTSas 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/tmpand 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.
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.
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 }; }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.
// 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
- Modular monolith drew the module lines these tables follow.
- Module contracts shaped the front doors Reports calls.
- Communication between modules covers the events a read model is kept from.
- Coupling and cohesion names what a cross-module join does to two modules.
- Enforcement layer runs the ownership check on every agent change.