← Architecture
Decompose a system Where to split

Decomposing a system

Split along what might change.

Most codebases you have worked in have a utils/ folder, and every feature imports it. It feels like the tidiest folder in the repository. Let’s ask an agent for a small booking app, then make one ordinary request of it, and count how many folders open.

TypeScriptGoOne booking app, two layouts, a scan, and six recorded builds.

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.

The booking app’s decisions, and who keeps each one
Decision likely to changeOwnerWhat everyone else asks it
Where the studio is, and how class times readcalendar/label(classId), isTomorrow(classId, now)
How members are contacted, and their numbersmessaging/notify(memberId, text)
How a class pack is countedpasses/canSpend, spend, refund for a member
Tax and how money is writtenbilling/invoiceFor(passId)
Seats and the waitlistbookings/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.

Decompose a system

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/

01/ 04
Every feature imports 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.

TypeScriptReading
decompose.ts
/** 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();
}
GoAlongside
main.go
// 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.

by kind · utils/dates.ts
// 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));
}
by kind · utils/index.ts
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.

by decision · calendar/index.ts
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;
}
by decision · reminders/index.ts
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: sendSms does not match sendSmsLater or $sendSms.
  • Imports are the relative specifiers of import … from, export … from, and bare import '…' statements. A specifier resolves to the file itself, then with .ts, then to its folder’s index.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:

go.mod
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.

The agent’s pull request

“Deduplicated the time logic: the helpers now live in shared/time.ts so any module can use them, and reminders no longer needs a special calendar function. All tests pass.”

(removed)// calendar/zone.ts (deleted)
			(added)// shared/time.ts (new)
			(added)export const STUDIO_ZONE = 'America/Chicago';
			(added)export function toStudioTime(iso: string): string { … }
			(added)export function studioDay(iso: string): string { … }
			
			// calendar/index.ts
			(removed)import { studioDay, toStudioTime } from './zone';
			(added)import { studioDay, toStudioTime } from '../shared/time';
			(added)export function startsAt(classId: string): string { … }
			
			// reminders/index.ts
			(added)import { studioDay } from '../shared/time';
			(removed)classIds().filter((id) => isTomorrow(id, now))
			(added)const tomorrow = studioDay(new Date(Date.parse(now) + 86_400_000).toISOString());
			(added)classIds().filter((id) => studioDay(startsAt(id)) === tomorrow)
			
You are reviewing this change. What do you do?

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.

How a decision shared across folders fails
What goes wrongWhat a member or the team seesWhere it comes from
Half-doneThe 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.
SlowA 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.
WideA 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.
DuplicatedTwo 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.
ErodedNothing, 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 lineTwo 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.

The same four changes, made to each layout
ChangeBy kind, with utils/By decision
A second entry point, such as a front-desk appIt 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 texts5 files in 4 folders2 files in messaging/
Change a rule: packs expire after 90 days5 files in 4 folders1 file in passes/
A second team takes over messagingThey share 4 folders with everyone who texts a member.They own messaging/ and its front door.
Change the tax rate2 files in 2 folders1 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.

What the checker found, run 2026-09-23
QuestionPlain promptArchitecture prompt
What came backone file, server.tsserver.ts and 7 folders, one index.ts each
Where the time zone is knownserver.ts, 2 linestime: one folder, 4 lines
Where how members are contacted is knownserver.ts, 8 linesbookings, members, notify: 3 folders, 12 lines
Second studio: code changedserver.ts +42 −15classes/index.ts +19 −9, time/index.ts +27 −14
Second studio: Denver times in the list, both texts, and two reminder runsall 5 correctall 5 correct
Email instead of texts: code changedserver.ts +53 −6bookings/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 emailboth correctboth 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.

DECISIONS.md · architecture prompt
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.
bookings/index.ts · architecture prompt
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:

bookings/index.ts · after the email ticket
/** 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 timeZone option, 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.

  1. 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.

  2. 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’s shared/time.ts refactor 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 }
    };
    
  3. 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 studioDecisions as 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.

ReactAlready in your code
ClassTime.tsx
// 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

Take the booking app into your editor. Add a rule that members may not book two classes that overlap, and decide which module owns it before you write a line.

Back to architecture →