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.
| A service per noun | A service per capability | One service | |
|---|---|---|---|
| Units | 8 | 3 | 1 |
| Network calls to weigh one order | 6 | 3 | 0 |
| Changes that needed two deploys | 7 of 12 | 2 of 12 | 0 of 12 |
| A unit that exists to call another | pricing: only weighing calls it, and it keeps no data | None | None |
| Whose changes share a deploy | Mostly one team’s; weighing gets two teams’ | One team’s each | All 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.
How many services should the laundry be?
A service per noun · 8 units
customers, slots, pickups, weighing, pricing, rates, payments, receipts
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.
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;
} 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:
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.
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.
| What goes wrong | What people see | Where it comes from |
|---|---|---|
| Slow: too many hops | Weighing an order waits on 6 round trips, each with its own timeout. | Shared case, a service per noun. |
| Down: one small service stops | A 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 hops | The 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 order | Duvets 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 hop | The 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 teams | The 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 forwards | A 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.
| Change | A service per capability | One service |
|---|---|---|
| A second client: a drivers’ app that marks bags collected | It calls booking. Authored. | It calls the one service. Authored; no real difference. |
| Replace a dependency: a new card provider | A change in billing. | A change in the billing module. No difference to the code; one deploy either way. |
| Change a rule: duvets priced per item | One service, the plant’s. | One service. Shared case: no difference in how many units it touches. |
| A second team takes over billing | They 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 did | Plain prompt | Sizing prompt |
|---|---|---|
| Services | pickups, weighing, billing, mailer | booking, plant, billing |
| Book a pickup: calls between services | 0 | 0 |
| Weigh and charge: calls between services | 4 | 2 |
| Track a pickup: calls between services | 0 | 1 |
| List receipts: calls between services | 0 | 0 |
| Services whose outage breaks weighing | 4 of 4 | 2 of 3 |
| Commit: Express pickups | pickups, weighing | booking, plant |
| Commit: Minimum charge | weighing | plant |
| Commit: Duvets | pickups, weighing | booking, plant |
| Commit: Weight on receipts | mailer, weighing | billing, plant |
| Commit: Saturday slots | pickups | booking |
| Behavior checks passed | 7 of 7 | 7 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.
[
{ "name": "pickups", "routes": ["POST /pickups", "GET /pickups/:id"] },
{ "name": "weighing", "routes": ["POST /pickups/:id/weigh"] },
{ "name": "billing", "routes": [] },
{ "name": "mailer", "routes": ["GET /receipts"] }
] [
{ "name": "booking", "routes": ["POST /pickups", "GET /pickups/:id"] },
{ "name": "plant", "routes": ["POST /pickups/:id/weigh"] },
{ "name": "billing", "routes": ["GET /receipts"] }
] 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.
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
exportsmap 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.goA 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([]); });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
- Signs a boundary is wrong reads the same counts from git and traces to find a split in the wrong place.
- Conway’s law and team boundaries is why the team column decides the answer.
- Decomposing a system chooses where the lines go; this lesson chooses how many.
- Modular monolith vs. services decides, for a shop, which units deserve their own deploy.
- Containing failure is what each extra hop asks of you.