← Architecture
Decompose a system How small a unit should be

How big should a module or service be?

Size it by what changes together, and by what each call costs.

Plenty of service diagrams have a box with one arrow in and one arrow out: a service whose only job is to be called by one other service and call a third. Nobody drew it on purpose. Let’s put a laundry pickup service under three sizes and count what each one costs.

TypeScriptGoOne laundry service, three layouts, a size review, and four recorded builds.

01 / The prompt

“Build me the backend for a laundry pickup service, as microservices.”

A customer books a pickup in a two-hour slot. A driver collects the bags, the laundry weighs them, and weighing prices the order by the kilo, charges the card, and emails a receipt. The customer can look the order up. Ask an agent for that “as microservices”, and any of three answers is plausible, and all three work:

  • A service per noun. Customers, slots, pickups, weighing, pricing, rates, payments, receipts: eight services, each small and easy to name.
  • A service per capability. Booking, the plant, and billing: three services, each holding what one part of the business changes.
  • One service with a module per capability, and the word “microservices” politely set aside.

The prompt said how the pieces should talk. It never said how big each piece should be, and what would tell you it is the wrong size. Martin Fowler, arguing that most systems should start as one application, put the gap plainly: “I’ve seen microservice systems vary from a team of 60 with 20 services to a team of 4 with 200 services. It’s not clear to what degree service size affects the premium.”

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

When we asked, the plain build came back as four services, and nothing in it says why four. This lesson makes the size something you can count, so the choice between the three answers is yours, with a reason, before the agent makes it for you.

02 / Name the choice

Size it by change and by call.

A unit is anything with a boundary: a module inside one deploy, or a service with its own. Granularity is how much each unit holds. A boundary is not free: between modules it costs a function call and a folder; between services it costs a network hop, a deploy, and someone on call. The question is where those costs buy something.

A unit is the right size when a typical change stays inside it, a typical request crosses few of its boundaries, it has a reason of its own to exist, and mostly one team changes it. Merge a unit that only one other unit calls and that keeps no data. Split a unit that several teams change for different reasons.

The same laundry service and twelve changes over a quarter, three ways
A service per nounA service per capabilityOne service
Units831
Network calls to weigh one order630
Changes that needed two deploys7 of 122 of 120 of 12
A unit that exists to call anotherpricing: only weighing calls it, and it keeps no dataNoneNone
Whose changes share a deployMostly one team’s; weighing gets two teams’One team’s eachAll three teams’: the plant made 4 of 12

Words to put in a prompt or a review

Granularity
How much one module or service holds. Too fine and too coarse both cost something.
Network hop
A call that leaves one service for another: latency, a timeout, a way to fail.
Lockstep change
One change that needs two units to ship together, or in the right order.
Pass-through service
A service with one caller and no data of its own: a function with a deploy.
Nanoservice
A service so small that what it costs to run outweighs what it does.
Capability
A part of the business that changes for its own reasons: booking, the plant, billing.
Where the laundry’s numbers come fromAn authored system, measured by code

The laundry is written down as data in laundry.json: eight components and which of them keep data, the calls each of three public requests makes between them, and twelve changes a quarter might bring, each with the team that would ask for it and the components it would touch. The three layouts put the same components into different units. The components, calls, and changes are authored to be the kinds a laundry service gets, not taken from a real company. Every number on this page is the lesson’s code measuring them.

03 / Follow one order

Watch one order cross eight services, then three, then one.

The same order is weighed and charged under each layout, the same change lands in each, and the review reads what it cost. In Try it, change who makes the changes and what the review accepts.

Decompose a system

How many services should the laundry be?

A service per noun · 8 units

customersslotspickupsweighingpricingno dataratespaymentsreceipts

customers, slots, pickups, weighing, pricing, rates, payments, receipts

01/ 05
Eight services

Eight services, one per noun.

The answer an agent gives “as microservices”: customers, slots, pickups, weighing, pricing, rates, payments, receipts. Each box is its own deploy.

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

Read this scene

The answer an agent gives “as microservices”: customers, slots, pickups, weighing, pricing, rates, payments, receipts. Each box is its own deploy.

A service per noun.

Watch restarts the story when you come back. Step through keeps your step. Try it starts from three teams and the lesson’s limits each time you open it.

04 / Read the shape

The size is a count, not a feeling.

Basic form puts components in units and counts the calls that cross. In the wild adds the changes, the pass-through, and who makes the changes. At the call site it becomes a review with limits you can argue about, which picks the smallest number of units that passes.

A layout puts each component in a unit. A call between components in different units is a network hop; the same call inside one unit is a function call.

TypeScriptReading
granularity.ts
export type Component = { name: string; data: boolean };
export type Call = { from: string; to: string };
export type Request = { name: string; entry: string; calls: Call[] };
export type Change = { title: string; team: string; touches: string[] };
export type System = { components: Component[]; requests: Request[]; changes: Change[] };
export type Unit = { name: string; components: string[] };
export type Layout = { name: string; units: Unit[] };

/** The unit a component lives in under this layout. */
export function unitOf(layout: Layout, component: string): string {
	const unit = layout.units.find((u) => u.components.includes(component));
	if (!unit) throw new Error(`${layout.name}: no unit holds ${component}`);
	return unit.name;
}

/** Calls in one request that leave one unit for another: each is a network hop between services. */
export function hops(request: Request, layout: Layout): number {
	return request.calls.filter((c) => unitOf(layout, c.from) !== unitOf(layout, c.to)).length;
}
GoAlongside
main.go
type Component struct {
	Name string `json:"name"`
	Data bool   `json:"data"`
}
type Call struct {
	From string `json:"from"`
	To   string `json:"to"`
}
type Request struct {
	Name  string `json:"name"`
	Entry string `json:"entry"`
	Calls []Call `json:"calls"`
}
type Change struct {
	Title   string   `json:"title"`
	Team    string   `json:"team"`
	Touches []string `json:"touches"`
}
type System struct {
	Components []Component `json:"components"`
	Requests   []Request   `json:"requests"`
	Changes    []Change    `json:"changes"`
}
type Unit struct {
	Name       string   `json:"name"`
	Components []string `json:"components"`
}
type Layout struct {
	Name  string `json:"name"`
	Units []Unit `json:"units"`
}

// UnitOf names the unit a component lives in under this layout.
func UnitOf(layout Layout, component string) (string, error) {
	for _, u := range layout.Units {
		if slices.Contains(u.Components, component) {
			return u.Name, nil
		}
	}
	return "", fmt.Errorf("%s: no unit holds %s", layout.Name, component)
}

// Hops counts the calls in one request that leave one unit for another:
// each is a network hop between services.
func Hops(request Request, layout Layout) (int, error) {
	n := 0
	for _, c := range request.Calls {
		from, err := UnitOf(layout, c.From)
		if err != nil {
			return 0, err
		}
		to, err := UnitOf(layout, c.To)
		if err != nil {
			return 0, err
		}
		if from != to {
			n++
		}
	}
	return n, nil
}
The behavior these examples promiseChecked by shared cases from a separate model
  • Every component belongs to exactly one unit; a layout that leaves one out is an error.
  • A call is a hop when its two components are in different units. A request’s hops count every such call, repeats included.
  • A change is lockstep when the components it touches are in more than one unit; its units are listed in the layout’s order.
  • A unit is a pass-through when exactly one other unit calls it, no request enters there, and none of its components keeps data.
  • For each unit that any change touched, the main team is the one with the most changes there (the first seen, on a tie), and its share is rounded to two places.
  • The review flags a request past the hop limit, lockstep changes past a share of all changes, every pass-through, and a unit whose main team’s share is under the limit. The defaults are 3 hops, 25%, and 75%. The pick is the flag-free layout with the fewest units, the earlier one on a tie, or none.

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

Reading the TypeScriptSets for units, a Map for callers

A change’s units go through a Set so a change that touches pricing and rates in one unit counts that unit once, then back through the layout’s own order so the output does not depend on which file was edited first. Callers are a Map from unit to a Set of units, because a pass-through is about how many different units call it, not how many calls.

Reading the GoErrors instead of throws, and a nil pick

UnitOf returns an error for a component no unit holds, and every function that reads a layout passes it on. The pick is a *string so that “no layout passes” is nil, which marshals to the same null the shared cases expect. The main team is chosen by walking teams in the order they first appeared, because Go’s map order is not stable.

Run it yourselfNo dependencies

Copy the complete TypeScript file and run node --experimental-strip-types granularity.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/service-granularity

go 1.23
Four services: 2 of 4 changes touched more than one unit (limit 25%)
Four services: pricing: one caller (weighing), no data of its own
Two services: no flags
Pick: Two services

05 / Review the agent’s diff

“Pricing is now its own service.”

The laundry runs as three services. Booking wants to show a price before pickup, and an agent took the ticket by moving pricing out of the plant. Before you merge, ask what the next pricing change will need.

The agent’s pull request

“Booking needs to show a price before pickup, so pricing is now its own service that both booking and the plant call. Pricing can scale on its own. All tests pass.”

// services.json
			(added){ "name": "pricing", "routes": [] },
			
			(added)// services/pricing/server.ts (new)
			(added)route('POST /quote', async ({ service, kg }) => {
			(added)  const rate = RATES[service];
			(added)  return { price: Math.round(Math.max(kg, 3) * rate) };
			(added)});
			
			// services/plant/server.ts
			(removed)const price = priceFor(pickup.service, kg);
			(added)const { price } = await call(PRICING_URL, '/quote', { service, kg });
			
			// services/booking/server.ts
			(added)const quote = await call(PRICING_URL, '/quote', { service, kg: 3 });
			
You are reviewing this change. What do you do?

06 / How it fails

Too small fails at runtime. Too big fails at deploy time.

A service per noun fails the way a network fails, one hop at a time. One service fails the way a shared calendar fails: nobody is broken, everybody waits.

How the wrong size fails the laundry
What goes wrongWhat people seeWhere it comes from
Slow: too many hopsWeighing an order waits on 6 round trips, each with its own timeout.Shared case, a service per noun.
Down: one small service stopsA bag is on the scale and cannot be charged because the mailer is down.Recorded: in the plain build, stopping any of its four services stops weighing. The laundry fixture does not model outages.
Half-done: a failure between two hopsThe card is charged, the receipt is not sent, and the pickup still says “booked”.Recorded: the plain build with the mailer stopped, read from its code.
Half-done: a lockstep change ships in the wrong orderDuvets appear at booking before the plant can price them, or the reverse.Shared case: 7 of 12 changes need two services per noun, 2 per capability. Which order breaks is authored.
Duplicated: a retry across a hopThe charge call times out after it succeeded, weighing retries, the card is charged twice.Authored. Every hop is one more place to need an idempotency key: see Retry, backoff & idempotency.
Stuck: one deploy carries three teamsThe plant’s price change waits for billing’s refund fix to pass review.Shared case, one service: its main team made 33% of its changes.
Wasted: a service that only forwardsA deploy, a dashboard, and an on-call page for a multiplication.Shared case: pricing, a service per noun.

07 / Is it worth it?

Three services cost three network calls more than one. Here is what they buy.

Run a service per capability and one service against the same four kinds of change. The service per noun has already lost on every count, so it is left out.

The same four changes, in each layout
ChangeA service per capabilityOne service
A second client: a drivers’ app that marks bags collectedIt calls booking. Authored.It calls the one service. Authored; no real difference.
Replace a dependency: a new card providerA change in billing.A change in the billing module. No difference to the code; one deploy either way.
Change a rule: duvets priced per itemOne service, the plant’s.One service. Shared case: no difference in how many units it touches.
A second team takes over billingThey deploy billing when they are ready.They share every deploy with booking and the plant. Shared case: this is the row where the choice is made.

Three of the four rows are the same. The fourth is the reason to split at all, and it only exists if there is a second team. Before you split or merge anything, decide what you will measure:

  • Calls between services per request, from your traces, for the busiest requests: the baseline. A merge that works brings the number down and the latency with it.
  • The share of changes that needed more than one deploy, from the last quarter’s merged pull requests. A split that works keeps it low for the changes it was meant to separate.
  • Services with one caller and no data, from traces and the code.
  • How long a finished change waits to ship, per team: the cost of a shared deploy that no other count sees.

The laundry’s numbers are the lesson’s authored system measured by its code. There are no before-and-after latencies or lead times from a real service here, and the lesson gives none. Baseline before you change is how to take them.

08 / Ask for it

Two prompts, two sizes, one count.

We sent two agents running Claude Sonnet the request from section 01, word for word, at the same time. It fixed how services talk (HTTP, a URL per service in the environment, an x-caller header for tracing) and said nothing about how many there should be. The sizing prompt added a block: three teams and what each changes, no service that only one other calls and keeps no data, and as few boundaries per request as possible. Each build was then copied to a fresh agent with five changes to commit one at a time. A script started every service behind its own counting proxy, sent the public requests, stopped each service in turn, and ran this lesson’s measure over both histories.

What the checker found after the five changes, run 2026-09-23
What the checker didPlain promptSizing prompt
Servicespickups, weighing, billing, mailerbooking, plant, billing
Book a pickup: calls between services00
Weigh and charge: calls between services42
Track a pickup: calls between services01
List receipts: calls between services00
Services whose outage breaks weighing4 of 42 of 3
Commit: Express pickupspickups, weighingbooking, plant
Commit: Minimum chargeweighingplant
Commit: Duvetspickups, weighingbooking, plant
Commit: Weight on receiptsmailer, weighingbilling, plant
Commit: Saturday slotspickupsbooking
Behavior checks passed7 of 77 of 7

Neither agent made a service per noun. The plain build has four services: pickups, weighing, billing, and a mailer, split along the sentence that described weighing (“prices the order, charges the customer, and emails them a receipt”). The sizing build has the three the block named. The difference that survived is on the busiest request: weighing an order makes 4 calls between services in the plain build and 2 in the sizing build, which paid for it with one call on tracking.

The five changes landed the same way in both: 3 of 5 needed two services. Express orders and duvets are chosen at booking and priced at the plant, so in both builds the booking service changed for the plant team’s price rules. Fewer services did not make those changes local. Only saying who owns the fields would have.

Stopping each service in turn found the cost of every boundary. In the plain build, weighing fails if any of the four is down, and it says so as 400 malformed body, because a refused connection lands in the handler’s catch-all. With the mailer down, the charge has already been recorded and the pickup is still “booked”: a retry charges twice. In the sizing build, weighing survives billing being down by treating the charge as best effort, and answers 200 with no charge recorded. Both agents had tried their builds by hand, and every behavior check passed.

plain build · services.json
[
  { "name": "pickups", "routes": ["POST /pickups", "GET /pickups/:id"] },
  { "name": "weighing", "routes": ["POST /pickups/:id/weigh"] },
  { "name": "billing", "routes": [] },
  { "name": "mailer", "routes": ["GET /receipts"] }
]
sizing build · services.json
[
  { "name": "booking", "routes": ["POST /pickups", "GET /pickups/:id"] },
  { "name": "plant", "routes": ["POST /pickups/:id/weigh"] },
  { "name": "billing", "routes": ["GET /receipts"] }
]
sizing build · SERVICES.md
Public route:
- `POST /pickups/:id/weigh` — weighs and prices a pickup. **2 calls**: one
  to `booking` (`GET /internal/pickups/:id`) to learn the service type and
  the customer's email/name and to confirm the id exists (404 otherwise),
  and one to `billing` (`POST /internal/charges`) to record the charge and
  send the receipt.

The sizing block got the count right and the boundaries right, and it did not say what a boundary costs. The line both prompts lacked is about each hop, not about size: for every call from one service to another, say what the caller answers when it fails; never report a charge that was not recorded, and never answer 400 for another service’s outage. Fewer services means fewer of those answers to write. It does not write them for you.

How the runs were made and checkedFour builds, two histories
  • Both round-one agents received the prompts word for word, in fresh contexts, at the same time; the only differences were the sizing block, the folder, and the ports each could use. Each build was copied with its git history to a fresh agent with the same five changes.
  • The files each agent wrote are kept byte for byte, with checksums. The ticket histories are kept as git log --name-only, recorded after each run.
  • Which services keep data was read from the code by hand, because a proxy cannot see a process’s memory; it is written down in data-flags.json.
  • Every agent stopped its processes by process id. Two logged to paths at the filesystem root (/tmp_start.log), which macOS refuses; one wrote and removed files in /tmp; one ticket agent wrote a pid file in the folder above its own, which we removed after recording. While checking its ports, the sizing agent listed the plain agent’s running services; it did not touch them.
  • The checker’s first two runs hung while stopping services and wrote no results; the fixes are in the run notes. The table is the third run.
  • One run of each prompt is a sample, not a measurement of a model.

09 / Hold it there

Make a small unit cheap, and a new service a decision.

The pressure toward too many services is that a service is the easiest private thing to make. Give the code a cheaper private boundary, and put a count in front of every new service.

  1. The language’s door: a private package, not a service

    Go keeps a small unit private without a network: “Code in or below a directory named "internal" is importable only by code that shares the same import path above the internal directory” (go command, Internal packages, fetched 23 September 2026). Pricing and rates can be packages under the plant, as small as you like, and nothing outside the plant can reach them. TypeScript has no equivalent in the language; a package’s exports map or an import rule does the job.

    plant/ as one Go module
    plant/
      go.mod                 module example.com/laundry/plant
      main.go                the plant service: weigh, price, charge
      internal/
        pricing/pricing.go   importable only from inside plant/
        rates/rates.go
  2. A rule a check enforces

    Run the lesson’s review over the list of services, a day of traces, and the last quarter’s history, and fail the build on a new flag. A new pass-through or a request that gained a hop then gets a conversation before it ships. Architecture as rules covers turning a sentence like this into a check; the helpers in the sample are yours to write against your tracing and git.

    size-review.spec.ts
    // size-review.spec.ts, run after every deploy to staging
    import { expect, it } from 'vitest';
    import { limits, measure, review } from './granularity';
    import { layoutFromServicesJson, requestsFromTraces, changesFromGitLog } from './sources';
    
    it('keeps every service worth its deploy', async () => {
    	const layout = layoutFromServicesJson('services.json');
    	const system = {
    		components: layout.units.map((u) => ({ name: u.name, data: u.name !== 'gateway' })),
    		requests: await requestsFromTraces({ since: '1d' }),
    		changes: changesFromGitLog({ since: '90.days' })
    	};
    	expect(review(measure(system, layout), system.changes.length, limits)).toEqual([]);
    });
  3. A check on what actually happens

    Code says what a service may call; traffic says what it does. Put a counting proxy, or your tracing, in front of every service in staging, send the public requests, and count the calls between services per request. Then stop each service in turn and see which requests still answer. That is what the checker in section 08 does to the recorded builds.

Frontend code has the same question at a different scale: how many packages or components to split a feature into. The call there is a function call, so the cost is the lockstep change and not the hop; the frontend version of a team boundary is Micro-frontends. There is nothing browser-specific to own here, so this lesson has no frontend row.

10 / Make the call

As many services as teams that ship on their own, and modules below that.

For the laundry with three teams I would run three services, one per capability: booking, the plant, and billing, with pricing and rates as modules inside the plant. I accept 3 network calls to weigh an order, and that two changes a quarter, express orders and weight on receipts, need two services to ship together. I would reconsider when a unit’s changes start coming from a second team, when a request crosses more boundaries than its latency allows, or when one part needs to scale, fail, or deploy on its own while the rest does not.

With one team, one service with those same three modules wins: the review picks it (One service), and the modules keep the split cheap to make later. Section 6 ends on that choice, with a real shop: Modular monolith vs. services.

Keep a note of the decision

Why
Three teams change the laundry, each for its own reasons, and every service costs a hop, a deploy, and an on-call rota.
What
A service per capability, booking, the plant, and billing, with smaller modules inside each; no service that only forwards.
Constraint
At most 3 calls between services per request, at most 25% of changes needing two, and three-quarters of each service’s changes from its own team.
Fallback
Two changes a quarter ship in two services; weighing an order takes 3 network calls, and billing being down stops a charge.
Reconsider when
A unit’s changes come from a second team, a request crosses more boundaries than its latency allows, or one part must scale or deploy alone. With one team, merge to one service.

Take it with you

Explain it without saying “microservice”: “Each piece we run on its own should be something one team changes on its own, and asking it for something should be worth a trip over the network.” Then list your own services, and for each one write down its callers, its data, and who changed it last quarter.

Paste into your next prompt, and fill in the blanks

Size each service by what changes together and who changes it:
<teams and what each changes>. A change one team usually makes needs only
that team's service. Do not make a service that only one other service
calls and that keeps no data; keep that code inside its caller, as a
module. Serving <the busiest request> crosses at most <N> service
boundaries. Write in SERVICES.md why each service exists, its owner, and
how many calls each public request makes to other services, and tell me
which services each later change touched.
Connections to follow nextRelated lessons

Take the laundry into your editor. Add stain treatment, chosen at booking and priced per item at the plant, and before you write it, say which services it will touch in each layout.

Back to architecture →