01 / The prompt
“Build me a booking app for my pottery studio.”
You ask for classes, bookings with a waitlist, class packs, reminder texts, and invoices.
What comes back works. When we asked, the plain request returned one 387-line server.ts that lists classes in Chicago time. Two later tickets built on that file,
and a script found no wrong answer in either.
One file is fine for a week. The next step a growing codebase usually takes is folders: one
per feature, and a utils/ folder for what the features share. It is the layout most
of us have worked in, and it feels tidy. This lesson starts from that step, written out as a second
version of the same app. It passes the same behavior test as the version it is compared with.
Then the owner opens a second studio in Denver. Nobody ever decided that the studio’s time zone was a utility, but that is where it went, and every feature that shows a time learned a piece of it. The question the prompt never answered is which parts of this app are likely to change, and which one place knows each of them. Decomposition is answering that question on purpose.
02 / Name the shape
Split along what might change.
Decomposing a system means deciding what its modules are: which code lives together, and what each part keeps from the rest. Most layouts split by the kind of code, routes here and helpers there, or by the steps a request goes through. David Parnas argued in 1972 for a different line:
“We propose instead that one begins with a list of difficult design decisions or design decisions which are likely to change. Each module is then designed to hide such a decision from the others.”
D. L. Parnas, “On the criteria to be used in decomposing systems into modules”, Communications of the ACM, 1972. The quote is from the conclusion.
So the rule, in the booking app’s terms:
List the decisions likely to change. Give each one a module that keeps it secret. Everyone else asks that module a question.
The same paper describes its second layout this way: “Every module in the second decomposition is characterized by its knowledge of a design decision which it hides from all others.” That sentence is testable. For each decision, find the files that know it. One folder is a module keeping a secret; five folders is a secret everyone shares.
| Decision likely to change | Owner | What everyone else asks it |
|---|---|---|
| Where the studio is, and how class times read | calendar/ | label(classId), isTomorrow(classId, now) |
| How members are contacted, and their numbers | messaging/ | notify(memberId, text) |
| How a class pack is counted | passes/ | canSpend, spend, refund for a member |
| Tax and how money is written | billing/ | invoiceFor(passId) |
| Seats and the waitlist | bookings/ | book, cancel, bookedInto(classId) |
Words to put in a prompt or a review
- Decomposition
- Choosing the modules: what lives together and what each keeps to itself.
- Information hiding
- A module keeps one decision secret, so changing it changes only that module.
- Knows a decision
- A file that would have to be edited if the decision changed.
- Front door
- The functions a module offers others. Here, each folder’s
index.ts. - Cohesion
- Things that change for the same reason live in the same place.
- Fan-in
- How many other modules import this one. Everything depends on a folder with high fan-in.
Three ways to split the same appBy kind of code, by feature, by decision
- By kind of code:
routes/,services/,utils/. Every feature crosses every folder, so every change does too. Parnas’s first layout followed the processing steps, and of one ordinary change he wrote that it “would result in changes in every module for the first decomposition.” A folder per layer has the same property. - By feature:
bookings/,reminders/,invoices/. Closer, and where most agents start. It stays honest until features share a decision, like how times read. Then that decision moves into a shared folder, and the features are joined again underneath. - By decision: one module per thing likely to change. Features become small modules that ask the owners. This is the layout the rest of the lesson argues for, and section 10 says when it is not worth it.
Not every helper is a secret. A clamp, a sleep, or a class-name
joiner hides no decision about your product, and a folder of those is harmless. The
trouble starts when a helper carries a product decision, such as the studio’s time zone,
and gets filed as a utility.
03 / Follow one change
Watch one request open five folders, then one.
The studio opens a second location in Denver, and class times must read in each studio’s own time. First in the layout split by kind of code, where an agent edits the two obvious files and the scan finds the rest. Then in the layout split by decision. Then an agent tidies that layout up. In Try it, pick the request yourself.
Where does one change land?
By kind of code, with utils/
- bookings/
- book.ts
- cancel.ts
- classes/
- schedule.ts
- invoices/
- invoice.ts
- passes/
- buy.ts
- reminders/
- daily.ts
- utils/
- dates.ts
- db.ts
- index.ts
- money.ts
- sms.ts
- waitlist/
- join.ts
- promote.ts
- root
- app.ts
14 files, 6 features, one utils/
The layout that came back.
A folder per feature, and a utils/ folder with dates, texts, money, and every table. Every one of the 6 feature folders imports it.
Reduced motion: choose a scene to see its completed state.
Read this scene
A folder per feature, and a utils/ folder with dates, texts, money, and every table. Every one of the 6 feature folders imports it.
By kind of code, with utils/. 14 files, 6 features, one utils/.
Watch restarts the story when you come back. Step through keeps your step. Try it starts from the layout you pick each time you open it.
04 / Read the shape
A decision is known by the files that mention it.
Basic form is Parnas’s test: which files know a decision. In the wild adds the import graph, so a change has a cost: the files to edit and everything that depends on them. At the call site the analysis becomes a check a repository runs on every change.
It reads source as text. It is not a type checker, so a decision counts as known wherever
one of its marks appears outside a comment. Choosing the marks is the one judgment the
analysis needs, and it is the same judgment as writing DECISIONS.md.
Parnas’s test as code: a file knows a decision when one of the decision’s marks, such as toStudioTime or sendSms, appears in its code outside comments. Every file that knows it is a file a change to it must edit.
/** Remove // and /* *\/ comments, leaving strings (including template strings) intact. */
export function stripComments(source: string): string {
let out = '';
let i = 0;
while (i < source.length) {
const ch = source[i];
const next = source[i + 1];
if (ch === '/' && next === '/') {
while (i < source.length && source[i] !== '\n') i++;
} else if (ch === '/' && next === '*') {
const end = source.indexOf('*/', i + 2);
const stop = end < 0 ? source.length : end + 2;
out += source.slice(i, stop).replace(/[^\n]/g, ' ');
i = stop;
} else if (ch === "'" || ch === '"' || ch === '`') {
let j = i + 1;
while (j < source.length && source[j] !== ch) j += source[j] === '\\' ? 2 : 1;
out += source.slice(i, j + 1);
i = j + 1;
} else {
out += ch;
i++;
}
}
return out;
}
const IDENT = /[A-Za-z0-9_$]/;
/** Does a mark appear as a whole token? `sendSms` does not match `sendSmsLater`. */
export function mentions(code: string, mark: string): boolean {
for (let at = code.indexOf(mark); at >= 0; at = code.indexOf(mark, at + 1)) {
const before = code[at - 1] ?? ' ';
const after = code[at + mark.length] ?? ' ';
if (!IDENT.test(before) && !IDENT.test(after)) return true;
}
return false;
}
/** The files that know a decision: each is a place a change to it must be made. */
export function knownBy(files: SourceFile[], decision: Decision): string[] {
return files
.filter((file) => {
const code = stripComments(file.source);
return decision.marks.some((mark) => mentions(code, mark));
})
.map((file) => file.path)
.sort();
} // StripComments removes // and /* */ comments and keeps strings intact.
func StripComments(source string) string {
var out strings.Builder
for i := 0; i < len(source); {
ch := source[i]
switch {
case strings.HasPrefix(source[i:], "//"):
for i < len(source) && source[i] != '\n' {
i++
}
case strings.HasPrefix(source[i:], "/*"):
stop := len(source)
if end := strings.Index(source[i+2:], "*/"); end >= 0 {
stop = i + 2 + end + 2
}
for _, c := range source[i:stop] {
if c == '\n' {
out.WriteRune('\n')
} else {
out.WriteByte(' ')
}
}
i = stop
case ch == '\'' || ch == '"' || ch == '`':
j := i + 1
for j < len(source) && source[j] != ch {
if source[j] == '\\' {
j++
}
j++
}
end := min(j+1, len(source))
out.WriteString(source[i:end])
i = end
default:
out.WriteByte(ch)
i++
}
}
return out.String()
}
func isIdent(c byte) bool {
return c == '_' || c == '$' || c >= '0' && c <= '9' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z'
}
// Mentions reports whether a mark appears as a whole token.
func Mentions(code, mark string) bool {
for from := 0; ; {
at := strings.Index(code[from:], mark)
if at < 0 {
return false
}
at += from
before, after := byte(' '), byte(' ')
if at > 0 {
before = code[at-1]
}
if at+len(mark) < len(code) {
after = code[at+len(mark)]
}
if !isIdent(before) && !isIdent(after) {
return true
}
from = at + 1
}
}
// KnownBy lists the files that know a decision, sorted.
func KnownBy(files []SourceFile, d Decision) []string {
known := []string{}
for _, f := range files {
code := StripComments(f.Source)
if slices.ContainsFunc(d.Marks, func(m string) bool { return Mentions(code, m) }) {
known = append(known, f.Path)
}
}
slices.Sort(known)
return known
} The two layouts, where the zone livesutils/dates.ts and the barrel, against calendar’s front door
By kind, the zone sits in utils/dates.ts, and utils/index.ts re-exports it with everything else. Every feature imports the barrel, so every feature can reach
it, and 4 of them call toStudioTime themselves.
// Every class time is stored in UTC and shown in the studio's own time zone.
export const STUDIO_ZONE = 'America/Chicago';
const clock = new Intl.DateTimeFormat('en-US', {
timeZone: STUDIO_ZONE,
weekday: 'short',
hour: 'numeric',
minute: '2-digit'
});
const calendarDay = new Intl.DateTimeFormat('en-CA', { timeZone: STUDIO_ZONE });
/** "Thu 6:30 PM", in studio time. */
export function toStudioTime(iso: string): string {
return clock.format(new Date(iso));
}
/** "2026-10-01", the studio's calendar date for an instant. */
export function studioDay(iso: string): string {
return calendarDay.format(new Date(iso));
}
export * from './dates';
export * from './sms';
export * from './money';
export * from './db';
By decision, calendar/index.ts is the only way in, and it answers in the caller’s
terms. Reminders never sees a time.
import { studioDay, toStudioTime } from './zone';
// Calendar's front door. Callers get labels and answers, never a stored time.
type ClassRow = { id: string; title: string; startsAt: string; seats: number };
const classes: ClassRow[] = [];
export function addClass(row: ClassRow): void {
classes.push({ ...row });
}
export function classIds(): string[] {
return classes.map((c) => c.id);
}
export function seats(classId: string): number {
return find(classId).seats;
}
/** "Wheel throwing, Thu 6:30 PM" */
export function label(classId: string): string {
const cls = find(classId);
return `${cls.title}, ${toStudioTime(cls.startsAt)}`;
}
/** Does the class fall on the calendar day after `now`, where the class is held? */
export function isTomorrow(classId: string, now: string): boolean {
const next = new Date(Date.parse(now) + 86_400_000).toISOString();
return studioDay(find(classId).startsAt) === studioDay(next);
}
function find(classId: string): ClassRow {
const found = classes.find((c) => c.id === classId);
if (!found) throw new Error(`no class ${classId}`);
return found;
}
import { classIds, isTomorrow, label } from '../calendar';
import { bookedInto } from '../bookings';
import { notify } from '../messaging';
export function remindTomorrow(now: string): number {
let sent = 0;
for (const classId of classIds().filter((id) => isTomorrow(id, now))) {
for (const memberId of bookedInto(classId)) {
notify(memberId, `Tomorrow: ${label(classId)}`);
sent++;
}
}
return sent;
}
The behavior these examples promiseChecked by shared cases from a separate model
- Comments do not count.
//and/* */comments are removed first; strings, including template strings, are kept, so a mark inside a string still counts. - A mark counts only as a whole token:
sendSmsdoes not matchsendSmsLateror$sendSms. - Imports are the relative specifiers of
import … from,export … from, and bareimport '…'statements. A specifier resolves to the file itself, then with.ts, then to its folder’sindex.ts. Packages and unresolved paths are left out. - A file’s module is its top-level folder; files at the root belong to
(root). Retesting a change means every file that imports a changed file, directly or through others, plus the changed files. - The ownership check reports, per decision, every file that knows it outside its owning module, sorted by path.
Every expectation in cases.json was produced by a small Python model written
from these rules. It reads the same repositories and lives beside the examples in model/cases.py, so a wrong expectation cannot be copied from either
implementation.
Reading the TypeScriptA tiny scanner, and import.meta.glob
stripComments walks the source once, copying strings whole so a // inside a URL is not taken for a comment, and blanking block comments so
line numbers survive. retest is a breadth-first walk over the import graph,
backwards: from a file to everything that imports it. The lesson loads the booking app’s
files with Vite’s import.meta.glob, which is how the lab runs the same scan
in your browser.
Reading the Gofs.FS, and one regexp per statement form
ReadRepo takes an fs.FS, so a test can hand it os.DirFS("../repo/by-kind") and a tool can hand it the working directory.
Go’s regexp has no lookbehind, so Mentions checks the
characters on either side of each match by hand. slices.Sort keeps every list
in the same order the TypeScript produces, which the shared cases depend on.
Run it yourselfNo dependencies
Copy the complete TypeScript file and run node --experimental-strip-types decompose.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/decomposing-a-system
go 1.23
by kind: 3 of 5 files know the time zone, in bookings, reminders, utils changing it retests 5 of 5 files bookings/book.ts knows the time zone, which belongs to calendar reminders/send.ts knows the time zone, which belongs to calendar utils/index.ts knows the time zone, which belongs to calendar by decision: 1 of 5 files know the time zone, in calendar changing it retests 4 of 5 files
05 / Review the agent’s diff
“Deduplicated the time logic.”
This one arrives weeks after the layout was split by decision. It deletes more than it adds, and it reads like good hygiene. Before you decide, count the folders that know the studio’s time zone after it merges.
06 / How it fails
A shared decision fails by being changed in some of its places.
A wrong split does not crash. It fails when the next change comes: slowly, halfway, or twice. Here is each way it shows up in the booking app, and what backs the row.
| What goes wrong | What a member or the team sees | Where it comes from |
|---|---|---|
| Half-done | The schedule shows a Denver class at Denver time; the booking text and the reminder for it say Chicago time. | Editing utils/dates.ts and classes/schedule.ts leaves 3 files that know the zone untouched: bookings/book.ts, reminders/daily.ts, waitlist/promote.ts. Shared case. |
| Slow | A one-sentence request needs review in 5 folders. | By kind, the zone is known by 5 files in 5 folders; by decision, by 2 files in calendar/. Shared case. |
| Wide | A tax-rate fix reruns the tests for bookings, reminders, and the waitlist. | A change to utils/money.ts reaches 11 of 14
files through utils/index.ts. Shared case. |
| Duplicated | Two copies of the zone disagree the day one of them changes. | The copied layout: the import rule passes, the ownership check reports 1 file. Shared case. |
| Eroded | Nothing, until the next zone change. | The agent’s shared/time.ts refactor: 2 files outside calendar
know the zone. Shared case. |
| Split on the wrong line | Two modules that always change together; every pull request touches both. | Authored. Measured by change history, not by this scan: see Signs a boundary is wrong. |
The first row is the one to remember. Nothing in the half-done build is broken in a way a test of the schedule page would notice. It is correct where the agent looked and wrong where it did not, and the only thing that knew where to look was the list of files that knew the decision. Coupling and cohesion names the forces; Module boundaries covers what a front door should expose.
07 / Is it worth it?
You pay in front-door functions. Here is what they buy.
The layout by kind is quicker to write: a feature grabs what it needs from utils/. The layout by decision makes calendar, messaging, passes, and billing
answer questions, which is more functions to name. Run both against the same four kinds of
change. Counts come from the scan.
| Change | By kind, with utils/ | By decision |
|---|---|---|
| A second entry point, such as a front-desk app | It imports utils and can write any table in db. | It calls the same front doors the server does. Authored. |
| Replace a dependency: email instead of texts | 5 files in 4 folders | 2 files in messaging/ |
| Change a rule: packs expire after 90 days | 5 files in 4 folders | 1 file in passes/ |
| A second team takes over messaging | They share 4 folders with everyone who texts a member. | They own messaging/ and its front door. |
| Change the tax rate | 2 files in 2 folders | 1 file in billing/. Almost no difference: tax was nearly hidden already. |
Before you re-split a real codebase, decide what you will measure and what result you would accept, so the move is judged by the next changes and not by how the tree looks:
- Folders touched per change, from the last few months of merged pull requests, grouped by the kind of request. This is the baseline. A re-split that works lowers it for the requests it was aimed at.
- Folders that know each listed decision, from the scan in section 04, run on every pull request. The target is one per decision; a rise is erosion.
- Fan-in of any shared folder. If
utils/is imported by every feature, every change to it retests everything. Watch that it shrinks as helpers move to their owners.
This lesson measured two small layouts and six recorded builds, not a team over months, so it has no before-and-after numbers for a real codebase. The point is to have chosen the numbers before anyone moves a folder.
08 / Ask for it
Two prompts, two tickets, one scan.
We sent two agents the booking app request at the same time, both running Claude Sonnet. One
prompt described the app. The other added an Architecture block: list the
decisions likely to change in DECISIONS.md, one folder per decision, callers
ask the owner, no shared folder, front doors only. Then each build got the same
second-studio ticket, and then an email ticket, from fresh agents. A script ran the lesson’s
own scan on every build and asked every server the same questions.
| Question | Plain prompt | Architecture prompt |
|---|---|---|
| What came back | one file, server.ts | server.ts and 7 folders, one index.ts each |
| Where the time zone is known | server.ts, 2 lines | time: one folder, 4 lines |
| Where how members are contacted is known | server.ts, 8 lines | bookings, members, notify: 3 folders, 12 lines |
| Second studio: code changed | server.ts +42 −15 | classes/index.ts +19 −9, time/index.ts +27 −14 |
| Second studio: Denver times in the list, both texts, and two reminder runs | all 5 correct | all 5 correct |
| Email instead of texts: code changed | server.ts +53 −6 | bookings/index.ts +13 −8, members/index.ts +27 −7, notify/index.ts +36 −3, server.ts +8 −1 |
| Email: each member on their channel, and a seat-opened email | both correct | both correct |
The plain prompt did not build the layout this lesson warns about. It returned one file with
no utils/, and inside it the time zone already sat behind a constant and two
helpers. Its second-studio change was one file. The architecture build’s was two folders, time and classes, which owns where each class is held. Both got
every Denver time right, including the reminder run where Chicago and Denver disagree about
the date. At this size, splitting by decision did not make the change smaller.
The scan found something else. The architecture build’s DECISIONS.md says notify owns how a member is contacted. The scan found that decision in 3 folders, bookings, members, notify, because bookings fetched each
member’s phone number and handed it to notify.
2. **How a member is contacted, and what actually happens to a text.**
Today it's a fake SMS provider that just records messages. Tomorrow it
could be a real SMS API, email, or push notifications. Owned by
`notify/`, which exposes "send this text" and "what has been sent" and
hides the outbox data structure from everyone else. sendText(getMemberPhone(memberId) as string, `Booked: ${getClassLabel(classId)}`); So we asked for email. The architecture build’s change landed in exactly the folders the scan had named, plus the server for the new route. And the new decision, which channel each member gets, went into bookings, the module that runs the booking workflow:
/** Send a message to a member on whichever channel they've chosen. */
function notifyMember(memberId: string, body: string): void {
const channel = getMemberContact(memberId) ?? "sms";
const address =
channel === "email" ? (getMemberEmail(memberId) as string) : (getMemberPhone(memberId) as string);
sendMessage(channel, address, body);
} A written list of decisions is a claim; the scan is a measurement. The line the architecture prompt lacked was about data: the module that owns a decision also owns the data it needs. Messaging keeps members’ contact details and picks the channel; everyone else calls notify(memberId, text). We did not run a third prompt with that line, so this page does not claim what it would have changed.
How the runs were made and checkedSix runs, recorded as written
- Both round-one agents received the request word for word, in fresh contexts, at the same time. Each ticket went to a fresh agent working on a copy of the previous round’s build. No agent was told about another, this lesson, or the checker.
- The files each agent wrote are kept byte for byte, with checksums, beside this lesson’s examples. The checker restores them into temporary folders, runs the scan, counts each ticket’s diff, and starts a fresh server for every behavioral question.
- The scan’s marks for each decision were chosen after reading all six builds: a zone
name, an Intl
timeZoneoption, or a zone constant; a send function or a phone number. Calling an owner’s formatting function counts as asking, not knowing. - The checker’s first run printed garbled file names in the diffs, because git shortened two temporary folders to their shared prefix. It was fixed, and both runs are kept. The email round was added after that first run, to test what the scan had found.
- Five of the six agents wrote a log or scratch file under
/tmp, outside their folder, against the prompt. None collided, and every agent stopped its server by process id. - This is one sample of each prompt, not a measurement of a model. What it shows is that a scan of who knows a decision said where the next change would land.
09 / Hold it there
Check who knows the decision, not only who imports the file.
A layout drifts one reasonable-looking diff at a time, and section 05’s diff was one. Three kinds of check keep it where you put it.
The language’s own door
Go has one. A package under
calendar/internal/“can be imported only by code in the directory tree rooted at”calendar/(Go 1.4 release notes), so the compiler refuses a reminders import of the zone helpers. TypeScript inside one package has no such door: any file can import any other. That gap is why the next check exists.An import rule an agent cannot argue with
Two rules in the shape Enforcement layer runs: no shared folder, and other modules import only a module’s
index.ts. We ran them with dependency-cruiser 18.3.0 over the layouts in this lesson. The layout split by decision passes. The agent’sshared/time.tsrefactor fails with four errors. The layout by kind fails with eleven..dependency-cruiser.cjs // The booking app's module rules, in the shape Enforcement layer runs. // Run from a layout's root: repo/by-decision, or a copy with the agent's patch applied. module.exports = { forbidden: [ { name: 'no-shared-folder', comment: 'A shared folder has no owner. Put each helper in the module whose decision it serves.', severity: 'error', from: { pathNot: '^(utils|shared|helpers|common|lib)/' }, to: { path: '^(utils|shared|helpers|common|lib)/' } }, { name: 'front-door-only', comment: "Other modules import a module's index.ts, never a file inside it.", severity: 'error', from: { path: '^([^/]+)/' }, to: { path: '^[^/]+/(?!index\\.ts$)', pathNot: '^$1/' } } ], options: { tsPreCompilationDeps: true } };A check on who knows the decision
Import rules see imports. When the agent copies the zone and the day helper into reminders instead of importing them, dependency-cruiser reports no violations, and the zone now lives in two folders. The scan from section 04, run as a test, catches it: reminders/index.ts knows the time zone, which belongs to calendar. Architecture as rules shows how to keep a list like
studioDecisionsas the one declaration both checks read.decisions.spec.ts // decisions.spec.ts import { expect, it } from 'vitest'; import { checkOwnership, studioDecisions } from './decompose'; import { readRepo } from './read-repo'; it('keeps every decision inside the module that owns it', () => { expect(checkOwnership(readRepo('src'), studioDecisions)).toEqual([]); });
Build UIs?Your components already share a utils file. The second studio is where it matters.
Where it already is in your components
Most React and Svelte projects have a lib/utils.ts. shadcn/ui’s setup adds
one for its cn helper, “so your own code has a single place to import helpers
from” (shadcn/ui, manual installation). For cn that is fine: joining class names hides no decision about your
product. The same file is where formatDate usually lands next, and that one does
carry a decision: how times read to your users. Every component that calls it with its own options
knows a piece of it.
When you have to own it
The booking app’s screens show class times in the schedule, the booking dialog, and the
reminder settings. With the second studio, the zone depends on the class. If each
component formats its own time, each one changes. If the calendar feature owns a ClassTime component and a classLabel function, the Denver change is
one file, and the schedule never learns that zones exist.
ClassTime: one component that turns a start time and a location into the words a member reads. The comment shows the inline formatting it replaces.
// Before: each component formats class times itself, so each one knows the studio's zone.
// <p>{new Date(c.startsAt).toLocaleString('en-US', { timeZone: 'America/Chicago' })}</p>
// After: the calendar feature owns that decision behind one component.
const zones = { chicago: 'America/Chicago', denver: 'America/Denver' } as const;
export type Location = keyof typeof zones;
export default function ClassTime({
startsAt,
location
}: {
startsAt: string;
location: Location;
}) {
const label = new Intl.DateTimeFormat('en-US', {
timeZone: zones[location],
weekday: 'short',
hour: 'numeric',
minute: '2-digit'
}).format(new Date(startsAt));
return <time dateTime={startsAt}>{label}</time>;
}
10 / Make the call
Split by decision when you can name the decision.
A folder per feature and a small utils/ are a fine start, and often the right one.
A decision you cannot yet name is not one you can hide, and guessing produces modules with nothing
inside them. Keep the simple layout while the app is small, one person changes it, and no request
has yet opened more than a couple of folders.
Reopen it when the same kind of request keeps opening the same set of folders, or when a
helper in utils/ starts carrying a product decision like a time zone, a price, or
a channel. That is the moment the decision has a name, and it can have an owner.
Take it with you
Explain it without saying “decomposition”: “I list what I expect to change, give each thing
one folder that keeps it, and make everyone else ask that folder. Then a change to it is a
change to one place.” Then open your own utils/ and find one function that carries
a decision about your product. Which folder should own it?
Paste into your next prompt, and fill in the blanks
Before writing code, list in DECISIONS.md the decisions in this app most likely to change: <the time zone, how members are contacted, how packs are counted, how tax is worked out>. Name the one module that owns each. Split the code into one folder per decision. Other modules ask the owner questions in their own terms (<a label for a class>, <whether a class is tomorrow>) and never import its constants or helpers. No utils/, helpers/, common/, or shared/ folder for product logic. Add a test that fails when a file outside the owner mentions <the zone constant, the SMS client>, and run it in CI.
Connections to follow nextRelated lessons
- Coupling and cohesion names the two forces this lesson counts.
- Bounded contexts splits by meaning: when one word, like “customer”, means different things to different parts.
- Signs a boundary is wrong measures a split from the change history instead of the source.
- Modular monolith puts modules like these in one deployment, with a front door each.
- Enforcement layer keeps rules like section 09’s running as agents make changes.