← Architecture
Decompose a system When a split is in the wrong place

Signs a boundary is wrong

Read the boundary from the history.

Scroll through the last month of merged pull requests and look at the file lists. If the same two folders show up together again and again, the history is telling you something the folder names are not. Let’s read one platform’s history and let it point at a boundary.

TypeScriptGoOne platform, two git histories, a coupling count, and four recorded builds.

01 / The prompt

“Build me a homework platform.”

You ask for assignments, submissions, and grading, each in its own folder. What comes back works, and the folders are the ones you named. The first time we asked, both builds put the rubric, the list of criteria a piece of work is scored against, in assignments/, beside the title and the due date, because that is where a teacher types it in.

It is also used only by grading. So every change to how work is scored, a weight, a level, a bonus criterion, touches both folders. Nothing breaks. The pull requests just keep showing the same two folders, and nobody asks why.

The question the prompt never answered is which folder should change when the scoring rules change? The git history answers it after the fact. This lesson reads it on purpose.

02 / Name the shape

Read the boundary from the history.

A boundary is wrong when it separates things that change for the same reason. The code compiles, the folders look sensible, and every change still has to cross the line. Harald Gall, Karin Hajek, and Mehdi Jazayeri named it from release histories in 1998: “If programs change together across module or subsystem boundaries, the decomposition structure of the application should be reconsidered and possibly restructured.”

H. Gall, K. Hajek, M. Jazayeri, “Detection of logical coupling based on product release history”, International Conference on Software Maintenance, 1998.

Two folders that change in the same commits again and again, or call each other many times to serve one request, are one module split in the wrong place. Move the thing that is misplaced, then check the history again.

Signs a boundary is wrong, and where to read each
SignWhere to read itIn the homework platform
They change togethergit log --name-onlyassignments and grading shared 9 commits
They talk too muchTraces, or a count of calls per requestgrading called assignments 11 times to grade one submission
They deploy togetherRelease notes and deploy logsNot measured here; the same count works on releases

Words to put in a prompt or a review

Change coupling
Two parts that change in the same commits, whatever the imports say.
Shotgun surgery
One change that needs small edits in many places.
Chatty boundary
Many calls across a line to serve one request.
Distributed monolith
Separate services that must change and deploy together.
Misplaced concept
Data or a rule that lives in one module and is used by another.
Hotspot
A file or folder that changes far more than the rest.
How the histories were madeReal git repositories, scripted

Both histories are real: a script builds each as a git repository with fixed dates and writes git log --reverse --name-only. The two differ in one line, where the rubric file lives. The commits are authored to be the kinds of change a homework platform gets, and the lesson says so.

history/generate.sh
history() { # name rubric-folder
	name=$1
	rubric=$2
	mkdir "$work/$name"
	cd "$work/$name"
	git init -q
	n=0
	commit "Set up assignments" assignments/index.ts assignments/store.ts assignments/due-dates.ts "$rubric/rubric.ts"
	commit "Set up grading" grading/index.ts grading/score.ts grading/store.ts
	commit "Set up submissions" submissions/index.ts submissions/upload.ts submissions/store.ts
	commit "Set up roster" roster/index.ts roster/classes.ts
	commit "Weighted rubric criteria" "$rubric/rubric.ts" grading/score.ts
	commit "Partial credit per criterion" "$rubric/rubric.ts" grading/score.ts grading/feedback.ts
	commit "Late penalty" assignments/due-dates.ts grading/score.ts
	commit "Upload PDFs" submissions/upload.ts
	commit "Rubric templates" "$rubric/rubric.ts" "$rubric/templates.ts" grading/score.ts
	commit "Email when graded" notifications/email.ts grading/index.ts
	commit "Import class lists" roster/classes.ts roster/import.ts
	commit "Bonus criteria" "$rubric/rubric.ts" grading/score.ts
	commit "Feedback comments per criterion" "$rubric/rubric.ts" grading/feedback.ts
	commit "Extend a due date" assignments/due-dates.ts assignments/index.ts
	commit "Resubmissions" submissions/index.ts submissions/store.ts
	commit "Round scores to half points" grading/score.ts
	commit "Criterion descriptions in Markdown" "$rubric/rubric.ts" grading/feedback.ts
	commit "Group assignments" assignments/index.ts assignments/store.ts roster/classes.ts
	commit "Hide scores until release" grading/index.ts grading/store.ts
	commit "Rubric levels (excellent to missing)" "$rubric/rubric.ts" grading/score.ts grading/feedback.ts
	commit "Reminder before the due date" notifications/email.ts assignments/due-dates.ts
	commit "Drop the lowest criterion" "$rubric/rubric.ts" grading/score.ts
	commit "Plagiarism flag on upload" submissions/upload.ts
	commit "Copy a rubric to another assignment" "$rubric/rubric.ts" "$rubric/templates.ts" assignments/index.ts
	
history/before.log (three commits)
commit: Weighted rubric criteria

assignments/rubric.ts
grading/score.ts
commit: Partial credit per criterion

assignments/rubric.ts
grading/feedback.ts
grading/score.ts
commit: Late penalty

assignments/due-dates.ts
grading/score.ts

03 / Follow twenty-four commits

Watch two folders change together, then stop.

The homework platform’s history, read by folder pair. First with the rubric kept in assignments/, then with it moved into grading/. In Try it, set the limits yourself.

Decompose a system

Which boundary is in the wrong place?

Rubric in assignments/

Commits that touched both folders, of 24
assigngradinsubmisrosternotifi
assignments 14·911
grading 139·1
submissions 4·
roster 31·
notifications 211·
01/ 04
Moving together

Twenty-four commits to a homework platform.

Assignments changed in 14 of them and grading in 13. Submissions, roster, and notifications changed on their own.

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

Read this scene

Assignments changed in 14 of them and grading in 13. Submissions, roster, and notifications changed on their own.

Rubric in assignments/.

Watch restarts the story when you come back. Step through keeps your step. Try it starts from the default limits each time you open it.

04 / Read the shape

The history is the measurement.

Basic form counts commits per folder pair. In the wild adds calls per request. At the call site both become a review a team can run every week, and act on when a pair it did not expect shows up.

Change coupling from git: for each pair of top-level folders, the commits that touched both, and that count over the smaller folder’s commits. A value near 1 means one folder never changes without the other.

TypeScriptReading
boundaries.ts
export type Commit = { subject: string; files: string[] };

/** Parse `git log --reverse --name-only --format='commit: %s'`. */
export function parseLog(text: string): Commit[] {
	const commits: Commit[] = [];
	for (const line of text.split('\n')) {
		if (line.startsWith('commit: ')) commits.push({ subject: line.slice(8), files: [] });
		else if (line.trim() && commits.length) commits[commits.length - 1].files.push(line.trim());
	}
	return commits;
}

/** The top-level folder a file lives in; files at the root belong to `(root)`. */
export function moduleOf(path: string): string {
	return path.includes('/') ? path.slice(0, path.indexOf('/')) : '(root)';
}

export type Pair = { a: string; b: string; together: number; coupling: number };

/**
 * For each pair of modules, how many commits touched both, and that count over the
 * smaller module's commits: 1 means one never changes without the other.
 */
export function coChange(commits: Commit[]): { changes: Record<string, number>; pairs: Pair[] } {
	const changes: Record<string, number> = {};
	const together = new Map<string, number>();
	for (const commit of commits) {
		const modules = [...new Set(commit.files.map(moduleOf))].sort();
		for (const m of modules) changes[m] = (changes[m] ?? 0) + 1;
		for (let i = 0; i < modules.length; i++)
			for (let j = i + 1; j < modules.length; j++) {
				const key = `${modules[i]} ${modules[j]}`;
				together.set(key, (together.get(key) ?? 0) + 1);
			}
	}
	const pairs = [...together.entries()].map(([key, count]) => {
		const [a, b] = key.split(' ');
		const coupling = Math.round((count / Math.min(changes[a], changes[b])) * 100) / 100;
		return { a, b, together: count, coupling };
	});
	pairs.sort(
		(x, y) => y.together - x.together || y.coupling - x.coupling || (x.a + x.b < y.a + y.b ? -1 : 1)
	);
	return { changes: Object.fromEntries(Object.entries(changes).sort()), pairs };
}
GoAlongside
main.go
type Commit struct {
	Subject string   `json:"subject"`
	Files   []string `json:"files"`
}

// ParseLog reads `git log --reverse --name-only --format='commit: %s'`.
func ParseLog(text string) []Commit {
	commits := []Commit{}
	for _, line := range strings.Split(text, "\n") {
		if subject, ok := strings.CutPrefix(line, "commit: "); ok {
			commits = append(commits, Commit{subject, []string{}})
		} else if strings.TrimSpace(line) != "" && len(commits) > 0 {
			last := &commits[len(commits)-1]
			last.Files = append(last.Files, strings.TrimSpace(line))
		}
	}
	return commits
}

// ModuleOf is the top-level folder, or "(root)".
func ModuleOf(p string) string {
	if i := strings.Index(p, "/"); i >= 0 {
		return p[:i]
	}
	return "(root)"
}

type Pair struct {
	A        string  `json:"a"`
	B        string  `json:"b"`
	Together int     `json:"together"`
	Coupling float64 `json:"coupling"`
}

type CoChangeResult struct {
	Changes map[string]int `json:"changes"`
	Pairs   []Pair         `json:"pairs"`
}

// CoChange counts, for each pair of modules, the commits that touched both, and that
// count over the smaller module's commits.
func CoChange(commits []Commit) CoChangeResult {
	changes := map[string]int{}
	together := map[[2]string]int{}
	for _, c := range commits {
		mods := []string{}
		for _, f := range c.Files {
			if m := ModuleOf(f); !slices.Contains(mods, m) {
				mods = append(mods, m)
			}
		}
		slices.Sort(mods)
		for _, m := range mods {
			changes[m]++
		}
		for i := range mods {
			for j := i + 1; j < len(mods); j++ {
				together[[2]string{mods[i], mods[j]}]++
			}
		}
	}
	pairs := []Pair{}
	for key, n := range together {
		smaller := min(changes[key[0]], changes[key[1]])
		coupling := math.Round(float64(n)/float64(smaller)*100) / 100
		pairs = append(pairs, Pair{key[0], key[1], n, coupling})
	}
	slices.SortFunc(pairs, func(x, y Pair) int {
		if x.Together != y.Together {
			return y.Together - x.Together
		}
		if x.Coupling != y.Coupling {
			if y.Coupling > x.Coupling {
				return 1
			}
			return -1
		}
		return strings.Compare(x.A+x.B, y.A+y.B)
	})
	return CoChangeResult{changes, pairs}
}
The behavior these examples promiseChecked by shared cases from a separate model
  • A line starting commit: begins a commit; other non-blank lines are its files. Lines before the first commit are ignored.
  • A module is a top-level folder. For each commit, each module it touched counts once, and each pair of those modules counts once.
  • Coupling is the pair’s shared commits over the smaller of the two modules’ commits, rounded to two places. Pairs are sorted by shared commits, then coupling, then name.
  • Chatter adds up calls per request for each direction between two modules, sorted by calls and then request.
  • A pair is flagged at 4 or more shared commits and 50% or more coupling; a request at 5 or more calls across one boundary.

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

Reading the TypeScriptA Set per commit, and a pair key

Each commit’s modules go through a Set so a commit that edits three grading files counts once for grading. Pairs are keyed by the two names sorted, so assignments and grading are one pair whichever order the files came in.

Reading the GoArray keys, and a stable sort

A [2]string is a comparable value, so it works as a map key for a pair. slices.SortStableFunc keeps calls from one trace in the order they were read when their counts tie, which the shared cases depend on.

Run it yourselfNo dependencies

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

go 1.23
assignments and grading changed together in 4 commits; assignments changed in 4 (100%)
grading calls assignments 10 times to serve "Grade one submission"

05 / Review the agent’s diff

“Decoupled grading from assignments.”

The review flagged assignments and grading, and an agent took the ticket. The import between them is gone. Before you merge, ask whether the next rubric change will still touch both folders.

The agent’s pull request

“Decoupled grading from assignments: the rubric types now live in shared/, so grading no longer imports anything from assignments. All tests pass.”

(added)// shared/rubric-types.ts (new)
			(added)export type Criterion = { id: string; label: string; weight: number };
			(added)export type Rubric = { id: string; criteria: Criterion[] };
			
			// assignments/rubric.ts
			(removed)export type Criterion = { id: string; label: string; weight: number };
			(added)import type { Criterion, Rubric } from '../shared/rubric-types';
			
			// grading/score.ts
			(removed)import type { Criterion } from '../assignments/rubric';
			(added)import type { Criterion } from '../shared/rubric-types';
			
You are reviewing this change. What do you do?

06 / How it fails

A wrong boundary fails as every change being two changes.

A boundary in the wrong place does not break the build. It makes each change a little slower, each request a little chattier, and each future split a little harder.

How a wrong boundary fails the homework platform
What goes wrongWhat people seeWhere it comes from
Slow: every rubric change is two changesTwo folders, often two reviewers, for one idea.9 shared commits, 69% of grading’s. Shared case.
Slow at runtime: a chatty requestGrading one submission waits on 11 calls.Shared case. In one process that is cheap; over a network it is 11 round trips.
Half-done: one side of a pair changedA new rubric level that grading does not score yet.Authored; the history shows how often the two must move together.
Stuck: the split blocks a later splitGrading cannot become a service without taking assignments along.Authored. See Extracting a service.
False alarm: limits set too lowEvery pair looks wrong, so nobody looks.Shared case: at 1 commit, 30%, and 1 call, the review raises 11 flags.
Missed: limits set too highThe real problem stays quiet.Shared case: at 10 commits and 12 calls, the review flags nothing.

07 / Is it worth it?

You pay with a bigger module. Here is what it buys.

Moving the rubric grows grading from 13 changed commits to 15 of 24. Run both layouts against the same four kinds of change.

The same four changes, in each layout
ChangeRubric in assignmentsRubric in grading
A second client: a parent’s view of gradesIt needs both folders to explain a score.It asks grading. Authored.
Replace a dependency: a new file store for uploadsA change in submissions.A change in submissions. No difference.
Change a rule: rubric levelsRubric and score in two folders.One folder. Shared case (the “Rubric levels” commit).
A second team takes over gradingThey own scoring but not the rubric they score with.They own both. Authored.

Before you move anything, decide what you will measure:

  • Shared commits for the flagged pair over the last few months: the baseline. A move that works brings it down for the changes it was aimed at, as the lesson’s history goes from 9 to 3.
  • Calls across the boundary per request, from traces, before and after.
  • The size of the module that grew. If grading becomes the folder every commit touches, the boundary moved the hotspot instead of removing it.

The histories here are scripted from authored commits, not a real team’s, so the lesson has no before-and-after numbers for a real codebase.

08 / Ask for it

Five tickets, two histories, one count.

We sent two agents running Claude Sonnet the same request with the same three folders. The architecture prompt added a block: draw boundaries around what changes together, keep each rule with the module that uses it, and write the expectations in BOUNDARIES.md. Each build was committed, and a fresh agent then made five changes, one commit each. A script ran this lesson’s count over both git histories and checked the new rules over HTTP.

Folders each commit touched, run 2026-09-23
CommitPlain promptArchitecture prompt
Weighted criteriaassignments, gradingassignments, grading
Late penaltygradinggrading
Rubric levelsassignments, gradingassignments, grading
Resubmissiongrading, submissionsgrading, submissions
Feedback per criteriongradinggrading
Check: weighted criteria8 of 128 of 12
Check: late penalty6 of 106 of 10
Check: rubric levels6 of 86 of 8

The two histories are the same, commit for commit. Both builds kept the rubric in assignments/, and in both, the two scoring changes, weighted criteria and rubric levels, touched assignments and grading together. Every commit that touched assignments also touched grading: 2 of 2, a coupling of 1 in each. The review with this lesson’s limits flags nothing, because five commits are too few for its threshold of four; a quarter of real history would not be.

The architecture build had written its expectations down, and they disagree with each other. Its BOUNDARIES.md says weighted criteria stay inside assignments:

BOUNDARIES.md · assignments/
future change touches how assignments are described or validated (new
field, different date rules, weighted criteria, minimum rubric size), it
should stay inside this folder.

and, a few paragraphs later, that weighted totals stay inside grading:

BOUNDARIES.md · grading/
and the `Grade` / `StudentGradeSummary` types. A future change to grading
mechanics (partial credit rules, regrade history, weighted totals, letter
grades) stays here, and would only touch how `gradeSubmission` computes
`total`/`max` or how `Grade` is shaped — not the other two modules.

Both modules claimed the same change, and the history shows it took both. That is the sign this lesson teaches, written into the agent’s own design document before a line of the ticket was written. Every behavior check passed in both builds.

The line both prompts lacked is about the rubric, not about boundaries in general: the rubric is read only to score, so grading owns it; assignments stores which rubric an assignment uses. A prompt can name the rule. Only the history can say whether the split held.

How the runs were made and checkedFour builds, two git histories
  • The round-one agents got the request at the same time and committed their builds in their own git repositories. A copy of each, history included, went to a fresh agent with the five changes, to commit one at a time.
  • The files each agent wrote are kept byte for byte, with checksums. The git histories are kept as the output of git log --name-only, recorded from each repository after its run.
  • The checker’s first run sent a rubric level together with maxPoints, which the plain build rejects because the ticket says “instead of points”. The question was fixed to send levels alone, and both runs are kept.
  • Several agents wrote scratch files to /tmp; one removed its own. Every agent stopped its server by process id.
  • This is one sample of each prompt, not a measurement of a model.

09 / Hold it there

Run the review on the history, not the diagram.

Import rules check what the code says. This sign lives in what the code does over time, so the checks read the history.

  1. The tool’s own record

    Git already keeps what this needs. git log --name-only lists the files each commit touched, and --since bounds the window. No new instrumentation is needed for the first sign; the second needs traces, which Observability across a request covers.

  2. A rule a check enforces

    Schedule the review. A weekly job that runs the coupling count over the last ninety days and fails on a new pair gives the team a moment to decide, instead of a slow drift nobody notices. It belongs with the rules in Architecture as rules.

    boundary-review.spec.ts
    // boundary-review.spec.ts, run weekly
    import { execFileSync } from 'node:child_process';
    import { expect, it } from 'vitest';
    import { boundaryReport, parseLog } from './boundaries';
    import { tracesFromLastWeek } from './traces';
    
    it('finds no folders that change together or talk too much', () => {
    	const log = execFileSync('git', ['log', '--since=90.days', '--reverse', '--name-only', '--format=commit: %s'], {
    		encoding: 'utf8'
    	});
    	expect(boundaryReport(parseLog(log), tracesFromLastWeek())).toEqual([]);
    });
  3. A check on what actually happens

    After a move, re-run the count on the commits made since. A boundary is right when the next quarter’s history agrees, not when the folders look tidy.

The count works on any repository, a frontend’s included: a components/ folder that changes with every page is the same sign. There is nothing browser-specific to own, so this lesson has no frontend row.

10 / Make the call

Move the misplaced thing, not the whole boundary.

A few shared commits are normal: an assignment really does have a due date and a rubric, and a late penalty touches both. Leave a pair alone when the shared changes are rare, varied, and explainable.

Act when the same kind of change keeps crossing the same line. Find the concept that is in the wrong place, usually data read only by the other side, and move it. Merge two modules only when nothing in either changes without the other.

Take it with you

Explain it without saying “boundary”: “These two folders keep changing in the same commits and calling each other constantly. Something in one of them belongs to the other.” Then run git log --name-only over your last month and count the pairs.

Paste into your next prompt, and fill in the blanks

Draw the module boundaries around what changes together: put each rule
with the module that uses it, with the data it needs, even when <another
module> collects that data first. A module does not ask another for many small
pieces to serve one request. Commit each change separately, and tell me which
folders each commit touched; if <two folders> change together in most commits,
say which concept should move.
Connections to follow nextRelated lessons

Take the homework platform into your editor. Add peer review, where students score each other with the rubric, and decide which folder it lives in before you look at the history.

Back to architecture →