← Architecture
From modular monolith to services One deployment, many owners

Modular monolith

A folder, plus an owner.

Ask for a small shop and you get code that works. Ask again in a year, and the question is whether one change still lands in one place. That depends on something the first prompt rarely says: who owns the stock, and who is allowed to touch it.

TypeScriptGoOne shop, two layouts, four recorded builds.

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.

Modules in the example shop
ModuleOwnsFront door
CatalogProducts and pricesproduct, list, lowStock
InventoryStock on hand and holdsavailable, reserve, release, commit
OrdersPlaced ordersplace
PaymentsPayment attemptscharge

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.

Modular monolith

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

01/ 04
Same shop, two layouts

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.

TypeScriptReading
inventory/index.ts
// 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';
GoAlongside
inventory/inventory.go
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.

inventory/internal/stock.ts
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 is out-of-stock and 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.

The agent’s pull request

“Listings now show how many are in carts. Catalog reads the held count straight from Inventory rather than keeping its own. All tests pass.”

// inventory/internal/stock.ts
			(added) export const heldBySku = new Map<string, number>();
			      db.reservations.set(reservation.id, reservation);
			(added)       heldBySku.set(sku, (heldBySku.get(sku) ?? 0) + qty);
			
			// catalog/index.ts
			import type { Inventory } from '../inventory/index.ts';
			(added) import { heldBySku } from '../inventory/internal/stock.ts';
			  ...product,
			  stock: inventory.available(product.sku) ?? 0,
			(added)   inCarts: heldBySku.get(product.sku) ?? 0
			
You are reviewing this change. What do you do?

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.

Failure modes of a modular monolith
What goes wrongWhat happensWhat handles it
Half done: stock held, then the card is declinedStock 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 slowA 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 crashesAn 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 moduleEvery module ships together, so every module rolls back together.Tests per module, and a release that can roll back quickly.
A boundary quietly erodesA 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.

The same four changes, counted in the example shops where the scan can. The scan finds the stock table by its name: db.stock and stock: new Map.
ChangeFolders buildModular build
A second client, such as an admin appIt 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 providerChange payments/charge.ts and the one file that imports it.Change the Payments module. About the same here.
Change a rule: keep stock per warehouse4 files, 6 lines1 file, 3 lines
A second team takes over stock4 files that read or write stock, the table itself among them0 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.

What the checker found, run 2026-09-14
QuestionPlain promptArchitecture prompt
What came back1 TypeScript file8 TypeScript files in 4 modules: catalog, inventory, orders, payments
Imports that skip a module’s front doorNo modules to check0
Where stock is stored, and the code that touches itthe stock field on each product record in server.ts: 5 lines in 3 functionsthe stock Map in inventory/store.ts: 12 lines, all in inventory/store.ts
A declined card keeps no stockYesYes
After the ticket: the change1 file changed, +23 −93 files changed, +33 −4
After the ticket: where sold livesA new field on the same product recordOrders’ own records, through a new function in orders/index.ts
After the ticket: imports that skip a front doorNo modules to check0
After the ticket: bestselling order is correctYesYes
After the ticket: GET /products without sort unchangedYesNo: 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.

server.ts · plain prompt, after the ticket
@@ -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;
   }
orders/index.ts · architecture prompt, after the ticket
@@ -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.

  1. 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.

  2. 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']);
  3. 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.

ReactAlready in your code
features/checkout/Summary.tsx
// 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

Take the shop into your editor. Add a Shipping module that needs stock and prices, and decide which front doors it may use before you write any code.

Back to architecture →