← Architecture
From modular monolith to services Which modules have earned a deploy of their own

Modular monolith vs. services

Decide module by module, from a pressure and from readiness.

Section 6 built a shop one module at a time, gave each module a front door and its own tables, and moved Inventory into a service. The question left on the table is the one this section is named for. Let’s answer it for this shop, one module at a time, from what its code and its recorded runs actually say.

TypeScriptGoOne shop, five modules, a decision desk, and two recorded decisions.

01 / The prompt

“Should the shop move to microservices?”

The shop from section 6 sells mugs and totes. Inventory already runs as a service, and the warehouse team now owns it. One team works on everything else and releases weekly. A flash sale in November will send twenty times the usual traffic to the product pages. Card payments and email both go to outside providers that have had outages. Ask an agent the question, and three answers are plausible:

  • Split it. Catalog, orders, payments, and mailer each become a service, like Inventory, so each can scale and fail on its own.
  • Keep one deployment. Bring Inventory back; the modules already have front doors and their own tables.
  • Decide per module. A module moves when something one deployment cannot give presses on it, and not before it is ready.

Each can be argued well. Martin Fowler’s default is the second: “The majority of software systems should be built as a single monolithic application. Do pay attention to good modularity within that monolith, but don’t try to separate it into separate services.” The question the prompt never asks is what, exactly, is pressing on each module, and is that module ready to leave?

M. Fowler, “Microservice Premium”, 13 May 2015, fetched 23 September 2026.

02 / Name the choice

A pressure, and readiness.

A modular monolith is one deployment made of modules that each own their data and expose a front door; everything ships together and a call between modules is a function call. Services are deployed separately, each with its own data; a call between them crosses a network, and each ships when its owners decide.

A module becomes a service when something presses on it that one deployment cannot meet: another team owns it, it must release on its own schedule, or its failures must not reach the rest. And only once it is ready: nothing reaches past its front door, nothing else names its tables, and its callers have a tested answer for when it is gone. More traffic alone is met by more copies of the whole shop, once the shop is safe to run in copies.

What each shape gives the shop, and what it costs
Modular monolithServices
Releasing a changeOne release carries every module’s changesEach service releases when its owners are ready
A call between modulesA function call; it cannot time outA network call that can be slow, fail, or half-succeed
Twenty times the trafficRun more copies of the shopRun more copies of the busy service
One part failsA crash takes the process downThe rest keeps running, if its callers know what to do without it
Two writes in one checkoutStill needs a plan once a provider is involvedNeeds a plan for every write that crosses
What you operateOne process, one set of dashboardsA process, a deploy, and an on-call rota per service

Words to put in a prompt or a review

Modular monolith
One deployment of modules that each own their data behind a front door.
Pressure
A need one deployment cannot meet: another team, its own releases, isolated failure.
Readiness
A clean front door, tables nobody else names, and callers that cope without it.
Front door
The one file other modules may import: here, each module’s index.ts.
Distributed monolith
Services that must release together and fail together: the costs of both shapes.
Bounded answer
What a caller says, within a time limit, when the thing it called is gone.
Where the relay’s lessons feed the deskFour lessons, one shop

The desk reads the shop the section 6 relay left behind: the Extracting a service lesson’s recorded build, copied here byte for byte. Each earlier lesson left something the desk can check in that code:

Extracting a service adds the one measured fact the desk uses: when its checker made the Inventory service hang, the shop gave no answer before the checker gave up at 60 seconds.

03 / Follow the desk

Rule on each module, then change one condition.

The shop as recorded, the ticket’s pressures, and the desk’s verdict on every module. Then marketing asks to release the catalog every day. In Try it, change the pressures, say what callers do when a module is down, or plant a shortcut in the code.

From modular monolith to services

Which modules should be services?

As recorded

one deployment: the shopits own servicecatalogkeep in the shopinventoryfix firstorderskeep in the shoppaymentskeep in the shopmailerkeep in the shop
  • catalog: keep
  • inventory: fix first
  • orders: keep
  • payments: keep
  • mailer: keep
01/ 05
The shop

The shop the relay built.

Five modules. Inventory already runs as a service; catalog, orders, payments, and mailer run in the shop. Orders calls three of them during checkout, and mailer listens for events.

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

Read this scene

Five modules. Inventory already runs as a service; catalog, orders, payments, and mailer run in the shop. Orders calls three of them during checkout, and mailer listens for events.

As recorded. The shop the relay built. Five modules. Inventory already runs as a service; catalog, orders, payments, and mailer run in the shop. Orders calls three of them during checkout, and mailer listens for events.

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

04 / Read the shape

A verdict is a pressure plus readiness.

Basic form reads the code. In the wild decides one module. At the call site the desk rules on the whole shop and prints reasons a reviewer can check.

The scan: for each module, the imports that reach past its index.ts, the files outside it that name its tables, the modules that call it, whether it keeps data, and whether it already runs apart.

TypeScriptReading
desk.ts
export type SourceFile = { path: string; text: string };
export type Facts = {
	module: string;
	deepImports: string[];
	foreignTables: string[];
	readsTablesOf: string[];
	callers: string[];
	keepsData: boolean;
	remote: boolean;
};

const importPattern = /from\s+["'](\.{1,2}\/[^"']+)["']/g;
const tableName = /\b([a-z]+)_[a-z_]+\b/g;
const tableConstant = /\b([A-Z]+)_TABLES\b/g;

/** Modules whose tables a file names: a lowercase `<module>_<table>`, or a `<MODULE>_TABLES` constant. */
function tableOwners(text: string): string[] {
	const owners = [...text.matchAll(tableName)].map((m) => m[1]);
	for (const name of text.matchAll(tableConstant)) owners.push(name[1].toLowerCase());
	return owners;
}

/** Resolve `../x/y.ts` against the importing file's folder. */
function resolvePath(from: string, target: string): string {
	const parts = from.split('/').slice(0, -1);
	for (const piece of target.split('/')) {
		if (piece === '..') parts.pop();
		else if (piece !== '.') parts.push(piece);
	}
	return parts.join('/');
}

/** What the code says about each module. Tests are not read: they may reach anywhere. */
export function scan(files: SourceFile[], modules: string[]): Facts[] {
	const moduleOf = (path: string) => {
		const top = path.split('/')[0];
		return path.includes('/') && modules.includes(top) ? top : null;
	};
	const facts = new Map<string, Facts>(
		modules.map((m) => [
			m,
			{
				module: m,
				deepImports: [],
				foreignTables: [],
				readsTablesOf: [],
				callers: [],
				keepsData: false,
				remote: false
			}
		])
	);
	for (const file of files) {
		if (file.path.startsWith('tests/')) continue;
		const own = moduleOf(file.path);
		for (const match of file.text.matchAll(importPattern)) {
			const target = resolvePath(file.path, match[1]);
			const owner = moduleOf(target);
			if (!owner || owner === own) continue;
			const f = facts.get(owner)!;
			if (!target.endsWith('/index.ts')) f.deepImports.push(`${file.path} → ${target}`);
			else if (own && !f.callers.includes(own)) f.callers.push(own);
		}
		for (const owner of tableOwners(file.text)) {
			if (!facts.has(owner) || file.path.startsWith('db/')) continue;
			if (owner === own) facts.get(owner)!.keepsData = true;
			else {
				if (!facts.get(owner)!.foreignTables.includes(file.path))
					facts.get(owner)!.foreignTables.push(file.path);
				const reader = own && facts.get(own)!;
				if (reader && !reader.readsTablesOf.includes(owner)) reader.readsTablesOf.push(owner);
			}
		}
		const service = /^([a-z]+)-service\.ts$/.exec(file.path);
		if (service && facts.has(service[1])) facts.get(service[1])!.remote = true;
	}
	return modules.map((m) => {
		const f = facts.get(m)!;
		return { ...f, readsTablesOf: [...f.readsTablesOf].sort(), callers: [...f.callers].sort() };
	});
}
GoAlongside
main.go
type SourceFile struct {
	Path string `json:"path"`
	Text string `json:"text"`
}
type Facts struct {
	Module        string   `json:"module"`
	DeepImports   []string `json:"deepImports"`
	ForeignTables []string `json:"foreignTables"`
	ReadsTablesOf []string `json:"readsTablesOf"`
	Callers       []string `json:"callers"`
	KeepsData     bool     `json:"keepsData"`
	Remote        bool     `json:"remote"`
}

var (
	importPattern = regexp.MustCompile(`from\s+["'](\.{1,2}/[^"']+)["']`)
	tableName     = regexp.MustCompile(`\b([a-z]+)_[a-z_]+\b`)
	tableConstant = regexp.MustCompile(`\b([A-Z]+)_TABLES\b`)
	servicePath   = regexp.MustCompile(`^([a-z]+)-service\.ts$`)
)

// Scan reports what the code says about each module. Tests are not read: they may reach anywhere.
func Scan(files []SourceFile, modules []string) []Facts {
	moduleOf := func(p string) string {
		top, _, nested := strings.Cut(p, "/")
		if nested && slices.Contains(modules, top) {
			return top
		}
		return ""
	}
	facts := map[string]*Facts{}
	for _, m := range modules {
		facts[m] = &Facts{Module: m, DeepImports: []string{}, ForeignTables: []string{}, ReadsTablesOf: []string{}, Callers: []string{}}
	}
	for _, file := range files {
		if strings.HasPrefix(file.Path, "tests/") {
			continue
		}
		own := moduleOf(file.Path)
		for _, m := range importPattern.FindAllStringSubmatch(file.Text, -1) {
			target := path.Clean(path.Join(path.Dir(file.Path), m[1]))
			owner := moduleOf(target)
			if owner == "" || owner == own {
				continue
			}
			f := facts[owner]
			if !strings.HasSuffix(target, "/index.ts") {
				f.DeepImports = append(f.DeepImports, file.Path+" → "+target)
			} else if own != "" && !slices.Contains(f.Callers, own) {
				f.Callers = append(f.Callers, own)
			}
		}
		var owners []string
		for _, m := range tableName.FindAllStringSubmatch(file.Text, -1) {
			owners = append(owners, m[1])
		}
		for _, m := range tableConstant.FindAllStringSubmatch(file.Text, -1) {
			owners = append(owners, strings.ToLower(m[1]))
		}
		for _, owner := range owners {
			f, known := facts[owner]
			if !known || strings.HasPrefix(file.Path, "db/") {
				continue
			}
			if owner == own {
				f.KeepsData = true
				continue
			}
			if !slices.Contains(f.ForeignTables, file.Path) {
				f.ForeignTables = append(f.ForeignTables, file.Path)
			}
			if own != "" && !slices.Contains(facts[own].ReadsTablesOf, owner) {
				facts[own].ReadsTablesOf = append(facts[own].ReadsTablesOf, owner)
			}
		}
		if s := servicePath.FindStringSubmatch(file.Path); s != nil && facts[s[1]] != nil {
			facts[s[1]].Remote = true
		}
	}
	out := make([]Facts, 0, len(modules))
	for _, m := range modules {
		f := *facts[m]
		slices.Sort(f.ReadsTablesOf)
		slices.Sort(f.Callers)
		out = append(out, f)
	}
	return out
}
The behavior these examples promiseChecked by shared cases from a separate model
  • A file belongs to a module when its path starts with the module’s folder. Tests are not read; they may reach anywhere.
  • An import into another module that does not end in /index.ts is a deep import; one that does, from inside a module, makes that module a caller.
  • A file names a module’s table with a lowercase module_name word or a MODULE_TABLES constant; the table registry in db/ is skipped.
  • Only another team, its own releases, and isolated failures count as pressure. More traffic adds a reason and nothing else.
  • A module with no data and exactly one caller stays in the shop.
  • With a pressure, any blocker means fix first; none means extract, or stay a service if it already runs apart. With no pressure: keep, or bring it back.

Every expectation in cases.json was produced by a Python model written from these rules, over the same recorded files; it lives in model/cases.py.

Reading the TypeScriptRegular expressions and a path resolver

Imports are found with one regular expression and resolved by walking .. segments, so ../catalog/store.ts from orders/index.ts becomes catalog/store.ts. The verdict is a small decision table written as two ternaries, one for a module in the shop and one for a module that runs apart.

Reading the GoRE2, and a pointer for “not measured”

Go’s regexp is RE2: no back-references or look-ahead, which is why the table rule reads whole files rather than string literals. “Not measured” is a nil *bool, so it marshals to the same null the shared cases hold.

Run it yourselfNo dependencies

Copy the complete TypeScript file and run node --experimental-strip-types desk.ts with Node 22.18 or later. For Go, save main.go next to this go.mod and run go run .. Both print:

go.mod
module heyrian.dev/lessons/modular-monolith-vs-services

go 1.23
orders: keep (more traffic is met by running more copies of the shop; no pressure one deployment cannot meet)
pricing: keep (no data of its own and one caller (orders): a service would only forward)
stock: fix first (pressure: team; 1 import reaches past its index.ts; callers waited without limit when it was down (recorded))

05 / Review the agent’s diff

“Extracted the catalog ahead of the flash sale.”

Twenty times the product-page traffic is the first thing anyone reads in the ticket, and a catalog service is the first answer it invites. Neither recorded agent made this change; it is the one the question makes easy to ask for. Before you merge, ask what presses on the catalog that one deployment cannot meet.

The agent’s pull request

“Extracted the catalog into its own service ahead of the flash sale, so product pages can scale independently of checkout. Orders and the product list call it at CATALOG_URL. All tests pass.”

(added)// catalog-service.ts (new): the catalog on its own port
			(added)route('GET /products', () => listProducts());
			(added)route('GET /products/:sku', ({ sku }) => getProduct(sku));
			
			// orders/index.ts
			(removed)import { getProduct } from '../catalog/index.ts';
			(added)import { getProduct } from './catalog-client.ts'; // fetch(CATALOG_URL)
			
			// server.ts
			(removed)route('GET /products', () => withStock(listProducts()));
			(added)route('GET /products', async () => withStock(await catalog.list()));
			
You are reviewing this change. What do you do?

06 / How it fails

Each shape fails in its own place.

The modular monolith fails together and releases together. Services fail apart, and every boundary is somewhere a checkout can wait, half-finish, or answer wrongly.

How each shape fails the shop
What goes wrongWhat people seeWhere it comes from
Slow: a service hangsCheckout spins, then nothing. The customer does not know whether they bought a mug.Recorded: the relay’s Inventory service, 60 seconds without an answer.
Down: the shop crashesEverything is down at once, catalog included.Authored; the price of one process.
Wrong: two owners of one tableStock in the service and stock in the shop disagree after a move back.Shared case: scripts/inventory-move.ts names Inventory’s tables from outside it.
Stuck: a release waits for another teamThe warehouse team’s fix waits for the shop’s weekly release.Shared case: Inventory’s pressure; the reason it is a service.
Wasted: a service that forwardsA payment service that only calls the card provider adds a hop and a way to fail.Shared case: payments, one caller and no data.
Half-done: a charge across a boundaryThe card is charged and the order is not recorded.Covered in Consistency without a shared transaction; the recovery survives in the recorded build.

07 / Is it worth it?

For Inventory, yes. For the rest, not yet.

Run the shop as it stands (one deployment and Inventory apart) against every module as a service, over the same four kinds of change.

The same four changes, in each shape
ChangeThe shop and one serviceEvery module a service
A second client: a mobile appIt calls the shop’s endpoints.It calls the same endpoints, through a gateway. No real difference.
Replace a dependency: a new card providerA change in payments, released weekly.A change in the payments service. No difference in what changes.
Change a rule: a price that varies by warehouseCatalog and orders change together, in one release.Two services and a contract change, released in order.
A second team: the warehouseThey release Inventory on their own schedule.The same, for Inventory. Nobody else asked for it.

Before you move a module either way, decide what you will measure:

  • Time from a finished change to production, per team: the pressure a service is meant to relieve. If the warehouse team’s changes do not ship sooner, the service is not paying for itself.
  • Checkout’s answer when each dependency is slow or down: the time to an answer, and which answer. The recorded Inventory service had none within a minute.
  • Changes that needed two releases, from merged pull requests: the cost of a boundary in the wrong place, as in How big should a module or service be?
  • Product-page latency at the flash sale’s load, with more copies of the shop: the evidence that traffic did not need a service. Before that, check that two copies can run at all; section 08 shows why.

The shop runs on a laptop in these lessons. There are no production release times or latencies here, and the lesson gives none. Baseline before you change is how to take them.

08 / Ask for it

Two agents, one shop, the same verdict, and the same gap.

We copied the relay’s latest build into two folders and sent two agents running Claude Sonnet the question from section 01, with its four facts, at the same time: decide module by module, write DECISION.md, make the changes the decision needs, and keep every endpoint working. The topology prompt added this lesson’s rule, including “every caller gets an answer in bounded time when it is slow or down, and that answer is tested”. A script then ran each result with its own card and mail providers and a proxy in front of Inventory, made Inventory and the card provider hang, started two copies of the shop on one crashed checkout, and ran this lesson’s desk over the code.

What the checker found, run 2026-09-23
What the checker didPlain promptTopology prompt
Files it changedDECISION.md, tests/decision.test.tsDECISION.md, tests/inventory-outage.test.ts
Modules that run apartinventory-service.tsinventory-service.ts
A checkout, Inventory as a service201 in 0.1 s201 in 0.0 s
A checkout while Inventory hangsno answer in 60 sno answer in 60 s
Product pages while Inventory hangs503 in 5.0 s503 in 5.0 s
A checkout while the card provider hangsno answer in 60 sno answer in 60 s
Two copies recover one crashed checkout: orders per trial1, 1, 1, 1, 11, 1, 1, 1, 1
The desk over the resultcatalog: keep; inventory: fix first; orders: keep; payments: keep; mailer: keepcatalog: keep; inventory: fix first; orders: keep; payments: keep; mailer: keep
Its own tests104 of 104 pass105 of 105 pass

Both agents reached the desk’s topology on their own: Inventory stays the warehouse team’s service, and catalog, orders, payments, and mailer stay in the shop. Neither split anything for the flash sale. Neither changed a line of the shop’s code; each wrote its decision and a test that pins it.

Neither fixed what the desk says to fix first. When Inventory hangs, a checkout still gets no answer within a minute in both builds. The plain agent never looked. The topology agent looked, tested that product pages answer 503 in five seconds, and then decided checkout should stay unbounded, citing the relay’s own consistency rule that an unknown outcome must never be guessed:

topology build · DECISION.md
  - **Writes** (`reserveStock`/`releaseStock`, the checkout path):
    deliberately **not** bounded — `orders/index.ts`'s
    `resolveReservation`/`resolveRelease` retry until the service
    actually answers, because guessing "declined" or "reserved" on an
    unknown outcome risks releasing stock that was actually held, or
    the reverse (`CONSISTENCY.md`). This is not a gap in the bounded-
    time rule; it's the one case where a wrong fast answer is worse than
    a slow correct one, and it was already tested before this change
    (`tests/service-move.test.ts`'s "service going down mid-checkout"
    case).

Both instructions were right, and together they meant a customer staring at a spinner. A bounded answer does not have to be a guess: “we are confirming your order, and we will email you” is an answer in two seconds, while the recovery the consistency lesson built finishes the checkout in the background. Both DECISION.md files also call the payment client timeout-bounded; with the card provider holding the request, checkout gave no answer in a minute either.

The plain agent found something the desk does not ask. Its answer to the flash sale is the desk’s answer, more copies of the shop, and it warned that the checkout recovery is not safe to run in two copies at once:

plain build · DECISION.md
Handling the flash sale's 20x product-page traffic in practice will mean
running more than one instance of the shop process for that day. That is
a deployment/replica-count question, not a module-boundary one, so it is
not decided here — but it is worth flagging: `orders/store.ts`'s
`_completeCheckout` checks a checkout's status and then writes its
completion in a second step; within one process this is safe (the check
and the write happen in the same synchronous call, so nothing else in
that process can interleave), but if two *separate* shop processes ever
raced to resume the exact same crash-orphaned checkout at once (e.g. two
replicas both running `recoverCheckouts()` against the same `SHOP_DB` at
startup), nothing today stops both from committing an order for it. This
was already true before this decision and isn't introduced by it; it
would need its own fix (e.g. a uniqueness constraint on
`orders_orders.checkout_key`) the day multiple shop replicas against one
`SHOP_DB` actually becomes the plan.

In the completed run, each of five recovery trials produced one order in both builds. That did not make recovery safe to run in parallel: in the plain build, one copy failed to start in one trial, and another trial logged a database-lock error during recovery. An earlier topology run also stopped this check when a copy could not start because the database was locked. More copies require coordination as well as the same code.

The line both prompts lacked: when a caller cannot know an outcome in time, it answers within N seconds with “pending” and a way to check, and finishing the work is someone else’s job. The desk can say a module is not ready. Only a prompt can say what “ready” answers.

How the runs were made and checkedTwo decisions, one checker
  • Both folders started as the Extracting a service lesson’s recorded extraction build, restored from its evidence and checked against its checksums (40 of 40 files). The copy is kept in evidence/base.
  • Both agents received the prompts word for word, in fresh contexts, at the same time; the only differences were the topology block, the folder, and the ports. The files each wrote are kept byte for byte with checksums, and each result’s diff against the base.
  • The run notes were not written: the authoring session was cut off by an API limit after the checker finished, and the run transcripts were cleared before the notes could be taken, so this page cannot show a transcript audit. What follows is the authoring session’s account, and only its first part is written down in the tree (evidence/prompts.md): the prompt files lived two folders above the run folders, and no transcript mentions them. It also reported that both agents stopped their processes by process id, and that the plain agent’s checks read spilled output of its own tools, outside its folder, which the harness wrote there; no notes back those two.
  • The first checker run was interrupted. The second recorded a database-lock failure during the two-copy check; the final checker records failed starts and recovery errors per trial so the remaining trials can finish. Earlier outputs are kept alongside the completed report. The final run reports 104 of 104 tests passing for the plain build and 105 of 105 for the topology build. Those passing tests do not remove the outage and recovery findings above.
  • One run of each prompt is a sample, not a measurement of a model.

09 / Hold it there

Keep the modules ready, and the decision written down.

A modular monolith stays a choice only while each module could still leave. Three checks keep it that way.

  1. The platform’s door: a package’s exports

    Node’s package exports field allows “multiple entry points to be defined, conditional entry resolution support between environments, and preventing any other entry points besides those defined in "exports". This encapsulation allows module authors to clearly define the public interface for their package” (Node.js packages, v26.10.0, fetched 23 September 2026). Make each module a workspace package that exports only its index.ts, and a deep import stops resolving. On a scratch fixture with a workspace link and Node 22.21.1, the front-door import ran and the deep one failed with ERR_PACKAGE_PATH_NOT_EXPORTED.

    catalog/package.json and orders/index.ts
    // catalog/package.json
    {
      "name": "@shop/catalog",
      "type": "module",
      "exports": { ".": "./index.ts" }
    }
    
    // orders/index.ts
    import { getProduct } from '@shop/catalog';           // resolves
    import { getRow } from '@shop/catalog/store.ts';      // ERR_PACKAGE_PATH_NOT_EXPORTED
  2. A rule a check enforces

    Run the desk in CI over the repository and a pressures file the team keeps next to the code. A deep import, a table named outside its owner, or a module that runs apart with no pressure fails the build, and the pressures file is where a new reason to split has to be written down first. Architecture as rules covers turning sentences into checks like this one.

    decision.spec.ts
    // decision.spec.ts, run on every pull request
    import { readFileSync } from 'node:fs';
    import { expect, it } from 'vitest';
    import { desk } from './desk';
    import { sourceFiles } from './source-files';
    
    const situation = JSON.parse(readFileSync('PRESSURES.json', 'utf8'));
    
    it('runs apart only what a written pressure needs apart', () => {
    	const verdicts = desk(sourceFiles('.'), situation.modules, situation);
    	expect(verdicts.filter((v) => v.verdict !== 'keep' && v.verdict !== 'stay a service')).toEqual([]);
    });
  3. A check on what actually happens

    For every module that runs apart, make it hang behind a proxy and time the caller’s answer, as the checker in section 08 does to Inventory. Readiness is not a document; it is an answer measured in seconds.

The frontend version of this choice is whether two teams ship parts of one page separately, which Micro-frontends covers. Nothing here runs in a browser, so this lesson has no frontend row.

10 / Make the call

One deployment, one service, and a written reason for each.

For this shop I would keep catalog, orders, payments, and mailer in one deployment, and keep Inventory as the warehouse team’s service, after two fixes: checkout answers within a few seconds when Inventory is slow or down, and the move scripts move to the warehouse team’s repository with the tables they touch. I accept one network hop in every checkout and a weekly release for everything else. For the flash sale, the shop needs a database that several copies can share before it runs as several copies. I would reconsider when a second team takes a module, when marketing must change the catalog faster than weekly, or when a module’s failures start taking checkout down with them.

Keep a note of the decision

Why
The warehouse team owns Inventory and releases on its own schedule; one team and a weekly release cover the rest; a flash sale brings twenty times the traffic.
What
A modular monolith for catalog, orders, payments, and mailer; Inventory as its own service, with a bounded answer at checkout.
Constraint
Only another team, its own releases, or isolated failures justify a service; a module leaves only with a clean front door, its own tables, and a tested answer when it is down.
Fallback
The flash sale runs on more copies of the shop, after its data moves to a database they can share; checkout answers “pending” in seconds when Inventory is down; everything else still ships weekly.
Reconsider when
A second team takes a module, the catalog must release more often than weekly, or one module’s failures keep reaching checkout.

Take it with you

Explain it without saying “microservices”: “A part runs on its own when someone needs it to, another team, another release schedule, a failure we must contain, and when the rest can live without it for a minute. Until then it is a folder with a front door.” Then list the services you run, and write one pressure next to each.

Paste into your next prompt, and fill in the blanks

Decide module by module. A module becomes, or stays, a service only for a
pressure one deployment cannot meet: <another team owns it | it releases on
its own schedule | its failures must not reach <the critical path>>. More
traffic alone is met by running more copies. A module with no data that
one other module calls is never a service. Before a module runs apart,
every caller has a tested answer in <N> seconds when it is slow or down,
nothing outside it names its tables, and nothing imports past its front
door. Write each verdict in DECISION.md with what would change it.
Connections to follow nextRelated lessons

Take the shop into your editor. Marketing now wants to change prices every hour during the flash sale; decide whether that is a pressure on the catalog or a data change, and write the verdict before you write the code.

Back to architecture →