← Architecture
From modular monolith to services Design for the network

Module contracts

What crosses the front door.

A modular monolith keeps other modules out of a module’s insides. It does not decide what comes out of the front door. Everything that does, every result, field, and failure, is a promise, and the day a module becomes a service, every one of them has to survive a network.

TypeScriptGoThe same shop, two sets of contracts, three recorded builds.

01 / The prompt

“Stock is now kept in two warehouses.”

This lesson picks up the shop from Modular monolith, as an agent left it. That agent kept every module boundary through a ticket to sort products by bestselling, and changed two things nobody asked for. GET /products gained a sold field. And to count sales, Orders gained a public function that returns a JavaScript Map.

orders/index.ts · the Modular monolith run, after its ticket
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;
}

In one process, that function works perfectly. Sent as JSON, which is what happens the day Orders runs as its own service, a Map becomes {}, and every count is gone. Nothing failed. The contract was written for one process.

Every result a module hands out gets used. Hyrum Wright put it as a law: “With a sufficient number of users of an API, it does not matter what you promise in the contract: all observable behaviors of your system will be depended on by somebody” (Hyrum’s Law). Inside one codebase, an agent is one of those users, on every ticket.

02 / Name the shape

A contract is everything that crosses the front door.

A module contract is the set of operations a module offers, the data they take and return, and the ways they can fail. A contract that is ready for a network split promises only what would still be true if each call were an HTTP request.

Whatever crosses a module’s front door must mean the same thing after a trip through JSON, be safe to receive twice, and tell the caller only what the caller needs.

Here is what that rules out, using the shop’s own recorded code.

Contract shapes that work in one process and fail across a network
In the recorded codeIn one processAcross a network
getSoldQuantities(): MapWorksArrives as {}
getOrder(id) returns the stored recordThe page drops the cardThe card number crosses the network first
releaseStock(items) adds units backCalled onceA retried call adds them twice
getAvailable(sku) per productFive function callsFive network round trips for one page
A synchronous functionReturns a valueReturns a promise, so every caller changes

Words to put in a prompt or a review

Contract
A module’s operations, their data, and their named failures.
View
Data shaped for the caller, not copied from storage. Sometimes called a DTO.
Idempotent
Safe to repeat: the second identical call changes nothing.
Chatty
One call per item where one call for all would do.
Tolerant reader
A caller that reads the fields it needs and ignores the rest.
Contract test
A test that pins what a caller relies on, so the provider cannot drift from it.
Tolerant readers and consumer-driven contractsTwo older ideas

Martin Fowler’s advice to callers, from 2011: “be as tolerant as possible when reading data from a service,” and aim “to allow the provider to make any change that ought not to break your code” (Tolerant Reader). The frontend row in section 09 shows it.

Ian Robinson turned it around for providers in 2006: if each consumer writes down what it expects, those expectations “define which parts of that provider contract currently support the business value realized by the system, and which do not” (Consumer-Driven Contracts). Inside a modular monolith, a module’s tests of another module’s front door are exactly that.

03 / Move a module out

Same shop, two sets of contracts. What breaks when a module moves?

Both shops run the same four steps: the products page, a declined card, an order split across warehouses, and the confirmation page. The contracts on the left follow the recorded builds; the ones on the right are this lesson’s. Watch one module at a time move across a simulated network, then open Try it and move it yourself.

Module contracts

Move a module across the network

Every module in one process

Contracts shaped like the recorded builds

Running…

Contracts written for the network

Running…

01/ 04
One process

One process.

Every module in one process. Both shops list products, decline a card, take an order split across two warehouses, and show the confirmation page. From inside, the contracts look equally fine.

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

Read this scene

Every module in one process. Both shops list products, decline a card, take an order split across two warehouses, and show the confirmation page. From inside, the contracts look equally fine.

Contracts shaped like the recorded builds: .

Contracts written for the network: .

Watch restarts the story when you come back. Step through keeps your step. Try it runs both shops again every time you change a setting.

04 / Read the shape

A contract, a module that uses three of them, and the place a split would plug in.

Basic form is Inventory’s contract. In the wild is Orders placing an order through three contracts and handing out a view. At the call site is the composition root. Notice what never reaches Orders: warehouse names, how a line is split, and which units a reservation holds.

Inventory’s contract: plain data in and out, failures as named results, a reservation id instead of warehouse details, and one call for many skus. Go declares the same contract with JSON tags.

TypeScriptReading
inventory/index.ts
export type Line = { sku: string; qty: number };

/** One entry per requested sku, in request order. `null` means Inventory does not stock it. */
export type Availability = { sku: string; available: number | null };

export type ReserveResult =
	| { status: 'reserved'; reservationId: string }
	| { status: 'rejected'; reason: 'unknown-sku' | 'out-of-stock'; sku: string };

export type ReleaseResult =
	| { status: 'released' }
	| { status: 'rejected'; reason: 'unknown-reservation' | 'already-committed' };

export type CommitResult =
	| { status: 'committed' }
	| { status: 'rejected'; reason: 'unknown-reservation' | 'already-released' };

export type Location = { warehouse: string; units: number };

export interface Inventory {
	/** Units available across every warehouse, for many skus in one call. */
	available(skus: string[]): Promise<Availability[]>;
	/** Hold every line, or nothing. Which warehouse the units come from is Inventory's business. */
	reserve(lines: Line[]): Promise<ReserveResult>;
	/** Put held units back where they came from. Safe to call again. */
	release(reservationId: string): Promise<ReleaseResult>;
	/** Turn held units into sold units. Safe to call again. */
	commit(reservationId: string): Promise<CommitResult>;
	/** Units per warehouse, for the warehouse screen. `null` for a sku Inventory does not stock. */
	locations(sku: string): Promise<Location[] | null>;
}
GoAlongside
inventory/inventory.go
type Line struct {
	SKU string `json:"sku"`
	Qty int    `json:"qty"`
}

// Availability is one entry per requested sku. A nil Available means Inventory does not stock it.
type Availability struct {
	SKU       string `json:"sku"`
	Available *int   `json:"available"`
}

// ReserveResult is "reserved" with a ReservationID, or "rejected" with a Reason and the SKU.
type ReserveResult struct {
	Status        string `json:"status"`
	ReservationID string `json:"reservationId,omitempty"`
	Reason        string `json:"reason,omitempty"`
	SKU           string `json:"sku,omitempty"`
}

// Outcome is the result of Release ("released") and Commit ("committed"), or "rejected" with a Reason.
type Outcome struct {
	Status string `json:"status"`
	Reason string `json:"reason,omitempty"`
}

type Location struct {
	Warehouse string `json:"warehouse"`
	Units     int    `json:"units"`
}

// Inventory reports failures the caller should handle as values. The error is
// for the call itself failing, which starts to matter once it crosses a network.
type Inventory interface {
	Available(ctx context.Context, skus []string) ([]Availability, error)
	Reserve(ctx context.Context, lines []Line) (ReserveResult, error)
	Release(ctx context.Context, reservationID string) (Outcome, error)
	Commit(ctx context.Context, reservationID string) (Outcome, error)
	Locations(ctx context.Context, sku string) ([]Location, error)
}
Pretend a module is remoteThe wrapper the story and the tests use

overTheWire wraps any module. Every call’s arguments and result go through JSON, every call counts as a hop, and chosen calls can be delivered twice. It reports what JSON changed on the way. The lesson’s tests run the whole shop through it.

wire.ts
// Pretend a module runs somewhere else. Every call's arguments and result go
// through JSON, as they would over HTTP, every call is counted as a hop, and a
// call can be delivered twice, as an at-least-once network sometimes does.

export type Hop = {
	module: string;
	method: string;
	/** What JSON changed on the way, such as "result: a Map arrived as {}". */
	changed: string[];
	deliveries: number;
	/** The result as the caller received it, after JSON. */
	received: unknown;
};

export type WireOptions = {
	/** Methods to deliver twice, like a retry after a lost response. */
	deliverTwice?: string[];
};

const kindOf = (value: unknown): string =>
	value instanceof Map
		? 'a Map'
		: value instanceof Set
			? 'a Set'
			: value instanceof Date
				? 'a Date'
				: value instanceof Promise
					? 'a Promise'
					: typeof value === 'function'
						? 'a function'
						: value === undefined
							? 'undefined'
							: 'an object';

const isPlain = (value: unknown) =>
	value === null ||
	typeof value === 'string' ||
	typeof value === 'number' ||
	typeof value === 'boolean' ||
	Array.isArray(value) ||
	(typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype);

/** Where a value and its trip through JSON differ, as readable lines. */
export function jsonChanges(value: unknown, path = 'result'): string[] {
	// A call that returns nothing is fine over HTTP too. A property that
	// disappears is not, and is reported below.
	if (value === undefined && !path.includes('.') && !path.includes('[')) return [];
	const arrived = roundTrip(value);
	if (!isPlain(value)) {
		const shown = arrived === undefined ? 'nothing' : JSON.stringify(arrived);
		return [`${path}: ${kindOf(value)} arrived as ${shown}`];
	}
	if (typeof value === 'number' && !Number.isFinite(value))
		return [`${path}: ${value} arrived as null`];
	if (Array.isArray(value))
		return value.flatMap((item, index) => jsonChanges(item, `${path}[${index}]`));
	if (value !== null && typeof value === 'object')
		return Object.entries(value).flatMap(([key, item]) =>
			item === undefined
				? [`${path}.${key}: undefined was dropped`]
				: jsonChanges(item, `${path}.${key}`)
		);
	return [];
}

export function roundTrip<T>(value: T): T {
	return value === undefined ? value : JSON.parse(JSON.stringify(value));
}

/** Wrap a module so every method behaves like a remote call, recording each hop in `hops`. */
export function overTheWire<T extends object>(
	module: string,
	target: T,
	hops: Hop[],
	options: WireOptions = {}
): T {
	return new Proxy(target, {
		get(object, property, receiver) {
			const original = Reflect.get(object, property, receiver);
			if (typeof original !== 'function' || typeof property !== 'string') return original;
			return async (...args: unknown[]) => {
				const changed = jsonChanges(args, 'arguments');
				const deliveries = options.deliverTwice?.includes(property) ? 2 : 1;
				let result: unknown;
				for (let delivery = 0; delivery < deliveries; delivery++)
					result = await original.apply(object, roundTrip(args));
				changed.push(...jsonChanges(result));
				const received = roundTrip(result);
				hops.push({ module, method: property, changed, deliveries, received });
				return received;
			};
		}
	});
}
The behavior these examples promiseChecked by 8 shared scenarios, 44 steps
  • Stock is kept per warehouse and picked east first. Callers see one total per sku, and null for a sku Inventory does not stock.
  • A reservation holds every line or nothing, and returns an id. Releasing or committing the same id again changes nothing; the wrong one of the two is a named rejection.
  • Units released go back to the warehouse they came from. An unknown sku is unknown-sku, and nothing is held.
  • An order asks Catalog once for every price. Its view has the order id, lines, total, and the card’s last four digits, never the card.
  • Every result is identical after a trip through JSON.

The expectations were written from these rules rather than copied from either implementation, and the TypeScript and Go tests both check every one.

Reading the TypeScriptPromise, unions, and copies

Every contract method returns a Promise, even though every module is local today, so moving one out changes no caller. Results are unions with a status, and “none” is null, because JSON has no undefined.

Orders returns structuredClone of its view, so a caller that edits a result cannot edit Orders’ records, which is how a network would behave too.

Reading the GoJSON tags, pointers, and context

Contract types carry json tags, so the shape on the wire is written next to the field. A *int is how a Go contract says “a number, or null”. Methods take a context.Context and return an error for the call itself failing; business failures stay in the result.

Warehouses live in unexported types in package inventory, which nothing outside it can name. The test compares every result after json.Marshal.

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:

mug: east 8, west 4
rejected: declined
mug after a declined card: east 8, west 4
placed order-1 for 18000
mug after the order: east 0, west 2
order view: {"orderId":"order-1","lines":[{"sku":"mug","qty":10,"price":1800}],"total":18000,"cardLast4":"4242"}

05 / Review the agent’s diff

“Everything is still plain data.”

This change passes the JSON test and every other test. Read what it teaches Orders.

The agent’s pull request

“The confirmation page now shows which warehouse each line ships from. Inventory returns its picks with the reservation, and Orders keeps them with the order. Everything is still plain data, and all tests pass.”

// inventory/index.ts
			export type ReserveResult =
			(removed)   | { status: 'reserved'; reservationId: string }
			(added)   | { status: 'reserved'; reservationId: string; picks: { sku: string; warehouse: string; units: number }[] }
			
			// orders/index.ts
			if (held.status === 'rejected') return { status: 'rejected', reason: held.reason };
			(added) // Keep the picks so the confirmation page can say where each line ships from.
			(added) const stored = { orderId, lines: priced, total, cardLast4, card, picks: held.picks };
			
			// OrderView
			(added)   shipsFrom: { sku: string; warehouse: string; units: number }[];
			
You are reviewing this change. What do you do?

06 / How it fails

A contract fails quietly, in the module that did not change.

Failure modes of module contracts
What goes wrongWhat happensWhat handles it
A call arrives twiceReleasing items by sku and count adds the units back each time. In the checker, the starting point and the plain build ended at 22 mugs instead of 12.An id per state change, and a repeated call with the same id changes nothing.
A result does not survive JSONA Map arrives empty, undefined fields vanish, and the caller gets no error, just wrong data. In the story, the products page then crashes.Plain data in contracts, and a test that round-trips every result.
A result says too muchA stored record, card number included, becomes the public type, and every caller can now read the card.Views written for the caller.
A contract is chattyOne call per product is free in a process and a round trip each across a network: in the story, 5 hops for one page against 1.One call that returns what a page needs.
Unknown looks like zeroEvery recorded build returns 0 stock for a sku it has never heard of, so a typo reads as sold out.null for unknown, and a named failure where it matters.
A contract driftsA field is added, renamed, or dropped for one caller, and another caller that depended on it breaks without a build error.Contract tests that pin what each caller relies on.

None of these rows fail in one process, which is why they survive review. They fail on the day of the split, in the module nobody touched.

07 / Is it worth it?

A network-ready contract costs more code today. Here is what it buys.

The costs are real: async signatures for calls that are local, a view type per caller, a table of reservations to make release safe, and batch operations nobody needs until the network does. If a module will never leave the process, some of that is ceremony.

So measure before deciding a module is ready to split, and keep measuring as the contract changes:

  • Front-door results that change in a JSON round trip. The lesson’s wrapper counts them; the target is zero.
  • Calls across each boundary per page or request. Multiply by the latency you expect after the split.
  • State-changing calls that are not safe to repeat. Each is a bug waiting for the first retry.
  • Fields in results that no caller reads. Each is a promise someone will depend on.

Take the first two as a baseline before the split, and the page latency after it, so the move is judged by numbers rather than by how finished the contracts look. This lesson did not measure a real split.

08 / Ask for it

One starting point, one ticket, two prompts.

Two agents running Claude Sonnet each got a copy of the recorded shop from section 01 and the same ticket: keep stock in two warehouses, put a declined order’s units back where they came from, and add GET /orders/:id for a confirmation page. One prompt added a Contracts block: plain data, views without private fields, retry-safe calls, batch reads, pinned response shapes, and a CONTRACTS.md. A script checked the starting point and both results, each question in a fresh process or server.

What the checker found, run 2026-09-14
QuestionStarting pointPlain promptContracts prompt
Front-door results JSON changesinventory.getAllAvailable(): a Map arrived as {} · orders.getSoldQuantities(): a Map arrived as {}inventory.getAllAvailable(): a Map arrived as {} · orders.getSoldQuantities(): a Map arrived as {}None
Front-door calls that return a Promise0 of 60 of 70 of 6
Orders’ read of an order carries the card numberYesYesNo
What a reservation hands back to Orders{"ok":true}{"ok":true,"reserved":[{"sku":"mug","east":6,"west":4}]}{"ok":true,"reservationId":"res_2"}
Mug stock after the same release arrives twice (should be 12)2222 (east 16, west 6)12 (east 8, west 4)
Mug stock after a declined split order (should be east 8, west 4)1212 (east 8, west 4)12 (east 8, west 4)
GET /orders/:id404: no such route200 with orderId, items, total200 with id, items, total
Storage types re-exported from a front doorcatalog/index.ts: Product, orders/index.ts: OrderRecordcatalog/index.ts: Product, orders/index.ts: OrderRecordcatalog/index.ts: Product, inventory/index.ts: Warehouse
TestsNone21 of 21 pass, 0.8 s31 of 31 pass, 10.1 s
Code changed from the base, not counting tests—4 files, +96 −404 files, +231 −82

Both agents did the ticket correctly, and neither leaked the card through the new endpoint. The difference is in what each taught the modules around it. To put units back in the right warehouse, the plain build had Inventory hand Orders the east and west counts of every reservation, and Orders hand them back. Orders now knows how Inventory keeps its stock. The contracts build handed Orders a reservation id, and releasing it twice changes nothing.

orders/index.ts · plain prompt
-    releaseStock(stockItems);
+    // Put every unit back in the warehouse it came from, not just the sku
+    // and quantity — a line may have been split across east and west.
+    releaseStock(reserveResult.reserved);
inventory/index.ts · contracts prompt
+export function releaseStock(reservationId: string): void {
+  _release(reservationId);
 }

The contracts prompt did not get everything it asked for. Every front door in all three builds is still synchronous: the prompt said to design each call as if it might become an HTTP request, and never said async. Orders still asks Catalog for prices one line at a time, although the prompt preferred one call over one per item, and unknown skus still read as zero stock. CONTRACTS.md describes the contracts as retry-safe and failure-as-value throughout, which is truer of the new warehouse code than of the modules it did not touch. And the confirmation page returns id where the order endpoint returns orderId, a name its own shape tests now pin.

The prompt snippet in section 10 names async, null for unknown, and field names outright. The rest is the job of the tests in section 09: a written contract describes intent, and only a check keeps it.

How the runs were made and checkedThree builds, recorded as written
  • The starting point is the Modular monolith lesson’s recorded build, byte for byte, with its checksums checked before the runs. Both agents were launched at the same time; neither knew about the other, the lesson, or the checker.
  • All builds are kept byte for byte with checksums and diffs. The checker loads each build’s modules in a fresh Node process per question and starts a fresh server per HTTP question. To read stock per warehouse in the contracts build, whose front door no longer exposes it, the checker adds one export to a temporary copy of that build’s storage file.
  • The checker’s first attempt searched Orders’ code for warehouse names, found none in the plain build, and missed its leak: Orders passes the picks along without naming them. What a reservation returns is now the measure. The first attempt is kept.
  • The contracts agent wrote one log to /tmp, against the prompt, and ran kill -9 on two process ids it found by listing every server.ts process. One was the plain agent’s server, which the plain agent had already stopped 2.5 seconds earlier, so the kill found nothing.
  • One run of each prompt is a sample, not a measurement of the model.

09 / Hold it there

A contract nobody runs across a network is a guess.

The contracts agent wrote a careful CONTRACTS.md, and the code still disagreed with it in places. Three layers turn the contract into something a change cannot quietly break.

  1. The language’s types

    Types keep the obvious leaks out of a contract: a Go struct with json tags cannot return a map by accident, and a TypeScript contract whose results are unions of plain objects makes a Map stand out in review. Neither language can tell you a field should not be there.

  2. A test that sends every module over the wire

    This lesson’s specs check every result in the shared scenarios against its own trip through JSON, then build the shop with every module wrapped by overTheWire and pin the hops a listing and an order make, that nothing changes in transit, and the stock after a release arrives twice. The Go test compares every result after json.Marshal. Put the check where an agent’s change cannot skip it, as Enforcement layer shows.

  3. Contract tests that pin what callers rely on

    Pin each response shape and field name, as the contracts build did for its endpoints, and let each calling module state what it reads from another. The next sold field then shows up as a failing test, not a surprise after the split.

Your API client is already a contractA typed client that checks what comes back is a contract. A component that reads whatever the server sends is not.

Where it already is in your components

An order confirmation page that reads orderId, total, and cardLast4, and ignores everything else, is a tolerant reader. When the server adds a field, nothing changes; when it drops one, one parse fails with a message instead of the page rendering undefined.

When you have to own it

Once several components call the same API, give the frontend its own contract: a client module that components import instead of fetch, which returns plain view types and names every failure. It is the one file to change when the API does, and the place to test what the frontend actually relies on.

A confirmation page that parses the three fields it shows, and treats anything else as unavailable.

ReactAlready in your code
OrderConfirmation.tsx
import { useEffect, useState } from 'react';

// The confirmation page reads the few fields it shows, and ignores the rest.
// If the server adds a field, nothing here changes; if it drops one, the parse
// fails in one place and the page shows one fallback.
type OrderView = { orderId: string; total: number; cardLast4: string };

function parseOrder(body: unknown): OrderView | null {
	if (typeof body !== 'object' || body === null) return null;
	const { orderId, total, cardLast4 } = body as Record<string, unknown>;
	return typeof orderId === 'string' && typeof total === 'number' && typeof cardLast4 === 'string'
		? { orderId, total, cardLast4 }
		: null;
}

export default function OrderConfirmation({ orderId }: { orderId: string }) {
	const [order, setOrder] = useState<OrderView | 'loading' | 'unavailable'>('loading');

	useEffect(() => {
		fetch(`/api/orders/${encodeURIComponent(orderId)}`)
			.then((response) => (response.ok ? response.json() : null))
			.then((body) => setOrder(parseOrder(body) ?? 'unavailable'))
			.catch(() => setOrder('unavailable'));
	}, [orderId]);

	if (order === 'loading') return <p>Loading your order…</p>;
	if (order === 'unavailable') return <p>We couldn’t load this order. Check your email instead.</p>;
	return (
		<section aria-label="Order confirmed">
			<h2>Order {order.orderId} confirmed</h2>
			<p>
				{(order.total / 100).toFixed(2)} charged to the card ending {order.cardLast4}.
			</p>
		</section>
	);
}

10 / Make the call

Write the contract for the network before the network exists.

Plain data, views instead of records, and retry-safe state changes cost little and prevent leaks even in one process; write those from the start. Async signatures and batch operations cost more; add them to the modules you can see leaving, the ones with a separate team, a separate scaling need, or a separate release schedule. The one thing not to do is let a contract be decided by whatever the storage layer happened to return.

The next lesson in this section, Communication between modules, takes up the choice these calls face when a module is slow or down: wait with a timeout, or announce an event and let the other module catch up.

Take it with you

Explain it without saying “contract”: “Anything one part of the app hands another has to still make sense if it were sent as JSON, sent twice, or read by someone who should only see part of it.” Then pick a module in your own code and look at one function it exports: what would a JSON round trip do to its result?

Paste into your next prompt, and fill in the blanks

Treat each module's index.ts as its contract, and design every function in it as if the call were an HTTP request to a separate service.
Calls are async. Arguments and results are plain, JSON-safe data: no Map, Set, Date, class instances, functions, or undefined fields; use null for "none".
Return views written for the caller, never storage records, and never fields the caller has no business seeing (<the sensitive fields>).
Failures are results with named reasons. Unknown is not the same as zero.
Calls that change state are safe to retry: return an id, and make a repeated call with that id change nothing.
One call returns what a page needs; no call per item.
Keep existing response shapes and field names unless the task changes them, and add tests that pin them.
Add a test that runs every module through a JSON round trip, so a contract that would not survive the network fails the build.
Connections to follow nextRelated lessons

Take the shop into your editor. Move Payments across the wire, then decide what its contract must promise about a charge that times out.

Back to architecture →