01 / The prompt
“Build me a small online shop backend.”
Products, stock, orders, and a fake card payment. What came back when we asked an agent was one file that did all of it, correctly. At that size, that is a perfectly good answer.
Then the shop grows, and the file gets split the way most codebases get split: by kind of
code. A catalog folder, an orders folder, an admin folder, and one db.ts that all of them read. The folders look
modular. But every one of them reaches into the same stock table, so a change to how stock is
kept touches all of them.
Shopify described where that leads in a codebase far larger than this one: with high coupling, “a seemingly innocuous change could trigger a cascade of unrelated test failures” (Deconstructing the Monolith, 2019). Their answer was not a fleet of services. It was one deployment, reorganized “by real-world concepts (like orders, shipping, inventory, and billing)”, each behind a public API.
Agents make the easy path easier. GitClear’s analysis of 211 million changed lines found that from 2021 to 2024 the share of moved lines, the signature of refactoring, fell from 24.8% to 9.5%, while copy-pasted lines rose from 8.4% to 12.3%, trends it links to the adoption of AI assistants (AI Copilot Code Quality). That is a correlation, not a verdict on any one tool. It is also what reaching into the nearest table looks like at scale.
02 / Name the shape
A module is a folder, plus an owner.
A modular monolith is one deployment made of modules. Each module owns a piece of the business and its data, and exposes a public API, its front door. Other modules use that API and nothing else. It still ships as one process, with one release.
Orders may call Inventory’s public API and nothing else in Inventory. Nothing outside Inventory touches the stock table.
Here is the shop, split by who owns what.
| Module | Owns | Front door |
|---|---|---|
| Catalog | Products and prices | product, list, lowStock |
| Inventory | Stock on hand and holds | available, reserve, release, commit |
| Orders | Placed orders | place |
| Payments | Payment attempts | charge |
Words to put in a prompt or a review
- Module
- A folder with an owner, a front door, and data nobody else touches.
- Front door
- The public API, such as an
index.ts. The only file others import. - Internals
- Everything behind the front door, free to change without asking anyone.
- Owns its data
- One module reads and writes its tables. Others ask it.
- Allowed dependencies
- A written list of which modules may call which.
- Composition root
- The one place that builds every module and connects them.
Folders, modules, and services are three different thingsTwo axes
A folder is where files sit. A module is a boundary: an owner, a front door, and private data. A service is a deployment: its own process and release. Modularity is about who may know about what; deployment is about what runs where. You can have folders without modules, modules in one deployment, or services whose code is a tangle. Module contracts takes up what changes when a module’s front door becomes a network call, and Modular monolith vs. services weighs the deployment decision itself.
03 / Follow one change
Same shop, two layouts. Where does the next change land?
Both layouts pass the same scenarios. The difference only shows up when something has to change. Watch three likely changes land in each, then open Try it and pick your own, or let an agent add one import and see what moves.
Where does the next change land?
Folders build
Every folder reads one shared db.ts
- admin admin/low-stock.ts
- (root) app.ts
- catalog catalog/products.ts
- (root) db.ts
- orders orders/place.ts
- payments payments/charge.ts
6 files
Modular build
Each module owns its tables behind index.ts
- (root) app.ts
- catalog catalog/index.ts
- inventory inventory/index.ts
- inventory inventory/internal/stock.ts
- orders orders/index.ts
- payments payments/index.ts
6 files
The same shop, organized two ways.
Both builds pass the same shared scenarios: the same prices, the same stock, the same declined card. Only where the code lives is different.
Reduced motion: choose a scene to see its completed state.
Read this scene
Both builds pass the same shared scenarios: the same prices, the same stock, the same declined card. Only where the code lives is different.
Folders build: no files highlighted.
Modular build: no files highlighted.
Watch restarts the story when you come back. Step through keeps your step. Try it starts from the unchanged shops each time you open it.
04 / Read the shape
A front door, a module that uses three of them, and one place that connects them.
Basic form is Inventory’s front door. In the wild is Orders placing an order through three front doors and giving stock back when payment fails. At the call site is the composition root. Notice what Orders asks for: not the Inventory module, just the three things it needs from it.
Inventory’s front door. The types other modules may use, and createInventory re-exported from a file inside the folder. Go keeps the same API in package inventory and its table in inventory/internal/stock.
// Inventory's public API. Other modules import this file, never a file inside this folder.
export type Reservation = { id: string; sku: string; qty: number };
export type ReserveResult =
| { status: 'reserved'; reservation: Reservation }
| { status: 'rejected'; reason: 'unknown-sku' | 'out-of-stock' };
export interface Inventory {
/** Units on hand minus units held, or null for a sku Inventory does not stock. */
available(sku: string): number | null;
/** Hold units for an order. Assumes a positive whole quantity. */
reserve(sku: string, qty: number): ReserveResult;
/** Give held units back. Releasing an unknown or finished hold does nothing. */
release(reservationId: string): void;
/** Turn held units into sold units. */
commit(reservationId: string): void;
}
export { createInventory } from './internal/stock.ts'; var (
ErrUnknownSKU = errors.New("unknown-sku")
ErrOutOfStock = errors.New("out-of-stock")
)
// Inventory is the module's public API. Its tables live in internal/stock,
// which the Go compiler will not let any package outside inventory/ import.
type Inventory struct {
table *stock.Table
}
func New(seed map[string]int) *Inventory {
return &Inventory{table: stock.New(seed)}
}
// Available is units on hand minus units held.
func (i *Inventory) Available(sku string) (int, bool) {
onHand, ok := i.table.OnHand(sku)
if !ok {
return 0, false
}
return onHand - i.table.Held(sku), true
}
// Reserve holds units for an order and returns the hold's id.
func (i *Inventory) Reserve(sku string, qty int) (string, error) {
free, ok := i.Available(sku)
if !ok {
return "", ErrUnknownSKU
}
if qty > free {
return "", ErrOutOfStock
}
return i.table.Hold(sku, qty).ID, nil
}
// Release gives held units back.
func (i *Inventory) Release(id string) { i.table.Release(id) }
// Commit turns held units into sold units.
func (i *Inventory) Commit(id string) { i.table.Commit(id) } Inside InventoryThe file nobody else imports
The tables live here, in variables no other module can reach. available is stock on hand minus units held for orders still being placed.
import type { Inventory, Reservation } from '../index.ts';
// Inventory's tables. Only code in the inventory folder imports this file.
export function createInventory(seed: Readonly<Record<string, number>>): Inventory {
const db = {
stock: new Map(Object.entries(seed)),
reservations: new Map<string, Reservation>()
};
let next = 1;
const held = (sku: string) => {
let total = 0;
for (const reservation of db.reservations.values())
if (reservation.sku === sku) total += reservation.qty;
return total;
};
const available = (sku: string) => {
const onHand = db.stock.get(sku);
return onHand === undefined ? null : onHand - held(sku);
};
return {
available,
reserve(sku, qty) {
const free = available(sku);
if (free === null) return { status: 'rejected', reason: 'unknown-sku' };
if (qty > free) return { status: 'rejected', reason: 'out-of-stock' };
const reservation = { id: `hold-${next++}`, sku, qty };
db.reservations.set(reservation.id, reservation);
return { status: 'reserved', reservation: { ...reservation } };
},
release(reservationId) {
db.reservations.delete(reservationId);
},
commit(reservationId) {
const reservation = db.reservations.get(reservationId);
if (!reservation) return;
db.reservations.delete(reservationId);
db.stock.set(reservation.sku, (db.stock.get(reservation.sku) ?? 0) - reservation.qty);
}
};
} The behavior both layouts promiseChecked by 9 shared scenarios in TypeScript and Go
- An order needs a non-empty list of lines with a sku and a whole, positive quantity, and
a card. Anything else is
bad-request. - An unknown sku is
unknown-sku, and nothing is held. If any sku does not have enough stock across all its lines, the order isout-of-stockand nothing is held. - A card ending in 0000 is declined, and every held unit goes back. Otherwise the stock is taken and the order gets the next id.
- Low stock lists products with 3 or fewer units available, in catalog order.
The expectations were written from these rules rather than copied from either layout, and the TypeScript and Go tests both check every one.
Reading the TypeScriptindex.ts, import type, and closures
index.ts exports types and re-exports createInventory from the
file inside. The tables are local variables in that function, so the only way to reach
them is through the object it returns. import type lets Catalog and Orders name Inventory’s interface without depending
on its code.
TypeScript has no way to say “only this folder may import that file.” Any file can write ../inventory/internal/stock.ts, and it will compile. That is why section 09 adds a rule.
Reading the Gointernal/ and interfaces owned by the caller
Go does have that rule. A package under inventory/internal/ “can be
imported only by code in the directory tree rooted at” inventory/ (Go 1.4 release notes). This lesson’s Go test copies the module, adds a package that imports internal/stock, and checks that go build refuses it.
Orders declares the small interfaces it needs, Prices, Stock, and Charger, and the composition root passes the real
modules in. Orders depends on what it asks for, not on how Inventory is built.
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), or go run . in the Go folder. Both print:
rejected: declined caps available after a declined card: 2 placed order-1 for 6000 low stock: cap 0, poster 0
05 / Review the agent’s diff
“I read the count instead of duplicating it.”
Reuse is a good instinct, and an agent that avoids copying code is doing something right. Read which door this reuse walks through.
06 / How it fails
One process means some failures stay shared.
Modules draw lines in the code. They do not give each module its own process, memory, or release. Here is how the shop fails with modules and a single deployment.
| What goes wrong | What happens | What handles it |
|---|---|---|
| Half done: stock held, then the card is declined | Stock would stay held for an order that never happened. | Orders releases every hold before returning. Checked by the “a declined card keeps no stock” scenario. |
| One module is slow | A long loop in Catalog blocks the one event loop every module shares, and checkout waits too. | Nothing in the module boundary. Measure it, and move the work to a background job or another process. |
| One module crashes | An unhandled error in Payments stops the process, and the catalog goes down with it. | Error handling at the module’s edges, and a supervisor that restarts the process. |
| A bad release of one module | Every module ships together, so every module rolls back together. | Tests per module, and a release that can roll back quickly. |
| A boundary quietly erodes | A deep import compiles and every test passes. Nobody notices until the module has to move. | The module rule in section 09. Checked by the test that adds one deep import and expects exactly one violation. |
The first and last rows are what modules change. The middle three are what one deployment keeps, and they are the honest reasons a team eventually gives a module its own process.
07 / Is it worth it?
Modules cost a front door per module. Here is what they buy.
| Change | Folders build | Modular build |
|---|---|---|
| A second client, such as an admin app | It imports the shared tables too, and becomes one more reader of every one. | It calls the same front doors the shop already uses. |
| Replace a dependency: a real payment provider | Change payments/charge.ts and the one file that imports it. | Change the Payments module. About the same here. |
| Change a rule: keep stock per warehouse | 4 files, 6 lines | 1 file, 3 lines |
| A second team takes over stock | 4 files that read or write stock, the table itself among them | 0 files outside Inventory |
The costs are real. Every module needs an API someone designed, calls go through an extra layer, and a boundary in the wrong place is worse than none, because now every change crosses it. Shopify said so plainly after years of enforcing module boundaries with its Packwerk tool: “domains and the boundaries between them do not reflect the way Shopify’s code actually functions in practice”, its privacy checks were removed, and “the technical debt introduced from privacy checking is still a long way from being paid off.” The tool kept its value “holding the line against new dependencies at the base layer” (A Packwerk Retrospective, 2024).
So before you split a codebase into modules, write down what you will measure:
- Boundary violations as a baseline that may only go down, the way Shopify used its violation lists to divide up the work.
- Pull requests that touch more than one module. If most of them do, the lines are in the wrong place.
- Test time per module. A module whose tests need the whole app has not really separated.
This lesson did not measure these on a real team. Decide what result you would accept before you start, so the reorganization is judged by something other than how tidy the folders look.
08 / Ask for it
Two prompts, two builds, then the same ticket for both.
Two agents running Claude Sonnet received the same shop request. One prompt described the
shop. The other added an Architecture block: modules by capability, each
owning its data behind an index.ts, orders reserving stock only through
Inventory’s API, and a MODULES.md listing allowed dependencies. Then fresh agents
got the same ticket for each build: sort products by bestselling. A script started all four builds
and read their structure with the same import scanner the lab uses.
| Question | Plain prompt | Architecture prompt |
|---|---|---|
| What came back | 1 TypeScript file | 8 TypeScript files in 4 modules: catalog, inventory, orders, payments |
| Imports that skip a module’s front door | No modules to check | 0 |
| Where stock is stored, and the code that touches it | the stock field on each product record in server.ts: 5 lines in 3 functions | the stock Map in inventory/store.ts: 12 lines, all in inventory/store.ts |
| A declined card keeps no stock | Yes | Yes |
| After the ticket: the change | 1 file changed, +23 −9 | 3 files changed, +33 −4 |
| After the ticket: where sold lives | A new field on the same product record | Orders’ own records, through a new function in orders/index.ts |
| After the ticket: imports that skip a front door | No modules to check | 0 |
| After the ticket: bestselling order is correct | Yes | Yes |
| After the ticket: GET /products without sort unchanged | Yes | No: it gained sold |
Both builds work, and both handled the ticket correctly. At this size, the one-file build’s change was the smaller one. Modules do not make a single small change cheaper; they decide where changes are allowed to go.
Look at where each agent put the sold counts. The plain build added a field to the product
record, which already held the name, the price, and the stock. The architecture build left
Catalog and Inventory alone, gave Orders a public function over its own records, and joined
the numbers in the one place allowed to. The agent that received that ticket read MODULES.md before it read any code, and the module rule still found zero violations
afterwards.
@@ -10,6 +10,7 @@ interface Product {
name: string;
price: number; // cents
stock: number; // units available
+ sold: number; // units sold across successfully placed orders
}
- // Compute total from the merged quantities.
+ // Compute total from the merged quantities and record units sold.
let total = 0;
for (const [sku, qty] of requested) {
const product = products.get(sku)!;
total += product.price * qty;
+ product.sold += qty;
} @@ -57,3 +57,16 @@ export function createOrder(input: CreateOrderInput): CreateOrderResult {
export function getOrder(id: string): OrderRecord | undefined {
return _get(id);
}
+
+// Total units sold per sku, across every order that was successfully
+// created (orders is only ever saved after payment is approved, so
+// declined or out-of-stock attempts are never counted here).
+export function getSoldQuantities(): Map<string, number> {
+ const totals = new Map<string, number>();
+ for (const order of _all()) {
+ for (const item of order.items) {
+ totals.set(item.sku, (totals.get(item.sku) ?? 0) + item.qty);
+ }
+ }
+ return totals;
+} Then the last row. The ticket said GET /products without a sort must stay
unchanged, and in the architecture build it gained a sold field. The boundaries
held, because something written down described them. The response shape drifted, because
nothing did. That is the next lesson’s subject, Module contracts, and the reason the
prompt snippet at the end names both.
How the runs were made and checkedFour builds, recorded as written
- Round one sent both prompts at the same time to fresh agents. Round two copied each build and gave a fresh agent the same ticket, which says nothing about architecture. No agent knew about the others, the lesson, or the checker.
- The ticket was chosen because the architecture build had no public way to list orders,
so its agent had to add one or reach into
orders/store.ts. - All four builds are kept byte for byte, with checksums and both diffs. The checker restores each one, scans it with Enforcement layer’s import scanner, and starts a fresh server per question.
- The checker’s first attempt never started a server: its readiness check failed while the server had in fact started, so it was left running. The check now uses a fresh port per server and stops any server that does not answer.
- Neither prompt said what an unknown sku should return. Both builds answer 409
out-of-stock, where this lesson’s own examples return
unknown-sku. - One run of each prompt and ticket is a sample, not a measurement of the model.
09 / Hold it there
A boundary nobody checks is a suggestion.
The architecture build kept its boundaries through one ticket because the next agent read MODULES.md. The next agent might not. Three layers keep them without relying on
anyone’s reading habits.
The language’s own door
Go refuses to build a package that imports another folder’s
internal/code, and this lesson’s Go test proves it on the shop. TypeScript has no equivalent, so the next layer has to do that job.A rule a check runs on every change
These two rules run on Enforcement layer’s engine. In this lesson’s tests the modular shop has 0 violations, the folders shop has 5 violations, and a single deep import added to Catalog produces exactly one. Existing violations can go in a known-violations baseline that may only shrink, as Architecture as rules shows; Enforcement layer wires the check so an agent’s change cannot skip it.
rules.ts import type { Rule } from '../../enforcement-layer/examples/rules.ts'; // The module boundary, written as rules for the engine Enforcement layer runs. // Paths are relative to a shop's root folder. /** Rules for a shop whose top-level module folders have these names. */ export function moduleRulesFor(names: readonly string[]): Rule[] { const modules = names.join('|'); return [ { name: 'modules-meet-at-the-front-door', severity: 'error', comment: 'A module may import another module only through its index.ts, never a file inside it.', from: { path: `^(${modules})/` }, to: { path: `^(${modules})/`, pathNot: `^$1/|^(${modules})/index\\.ts$` } }, { name: 'no-shared-tables', severity: 'error', comment: 'Each module owns its data. No module imports a database every module shares.', from: { path: `^(${modules})/` }, to: { path: '^db\\.ts$' } } ]; } export const moduleRules = moduleRulesFor(['catalog', 'inventory', 'orders', 'payments', 'admin']);A check on the contract, not just the imports
An import rule cannot see a response gaining a field. The architecture build’s ticket kept every import clean and still changed
GET /products. A test that pins each front door’s shape, or each endpoint’s, catches what the import graph cannot.
Your feature folders already want front doorsAn index.ts is a feature’s front door. A shared store is where features lose theirs.
Where it already is in your components
A frontend with features/cart and features/checkout has the same
choice to make. Checkout can import the cart’s front door, its index.ts, or it can import features/cart/store directly because the
file is right there. The first keeps the cart free to change how it stores lines. The second
means checkout breaks the next time it does. Enforcement layer’s frontend rules include one
for exactly this: one feature per folder, with shared code passed in or named as shared.
When you have to own it
The harder version is a global store every feature writes to: the header badge, the product page, and checkout all push into the same cart array, so all three know its shape. The move is the same as Inventory’s. The cart owns its lines, and everyone else gets functions: add, remove, and a count. The array becomes an internal detail nobody else can reach.
Checkout reads the cart through the cart feature’s front door, and names the deep import it avoids.
// Checkout needs the cart's lines and total. It imports the cart feature's
// front door, cart/index.ts, and nothing inside that folder.
import { useCart, formatPrice } from './cart';
// Not: import { cartStore } from './cart/store';
// That file is the cart's private state. Reaching into it means the next
// change to how the cart stores lines breaks checkout too.
export default function Summary() {
const { lines, total } = useCart();
return (
<section aria-label="Order summary">
<ul>
{lines.map((line) => (
<li key={line.sku}>
{line.qty} × {line.name} · {formatPrice(line.price * line.qty)}
</li>
))}
</ul>
<p>Total {formatPrice(total)}</p>
</section>
);
}
10 / Make the call
Draw the lines when a change starts landing in more than one place.
One file is fine for a prototype, a script, or a shop this size, and plain folders are fine while one person holds the whole thing in their head. Reach for modules when changes keep crossing folders, when a second person or team owns part of the code, or when you can already see a piece that might one day run on its own. Keep the deployment single until something concrete, a release schedule, a resource, a team, says otherwise.
The rest of this section builds on this shop: what a module’s contract should promise (Module contracts), how modules talk (Communication between modules), how each owns its data in one database (Module-owned data in one database), what happens to a transaction when two modules can no longer commit together (Consistency without a shared transaction), and how to move one out (Extracting a service).
Take it with you
Explain the shop without saying “modular monolith”: “Stock belongs to Inventory. Anyone who needs it asks Inventory, and a check fails the build if they reach past it. It all still ships as one app.” Then open your own codebase, pick the table most things read, and count how many folders touch it.
Paste into your next prompt, and fill in the blanks
Organize the code by business capability into modules: <modules>, each in its own folder. Each module owns its data. No module reads or writes another module's storage. Each module exposes its public API from its folder's index.ts. Other modules import only that file. Wire the modules together in one place, and keep one deployment. Write the allowed dependencies in MODULES.md, and add a check that fails when an import breaks them. Keep existing response shapes unless the task says to change them.
Connections to follow nextRelated lessons
- Client–server architecture draws the first line in every app: what the browser may decide.
- Hexagonal / ports & adapters organizes a module’s own inside and outside.
- Enforcement layer turns the module rule into a check an agent cannot skip.
- Modular monolith vs. services weighs the deployment decision this lesson leaves single.