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.
| Sign | Where to read it | In the homework platform |
|---|---|---|
| They change together | git log --name-only | assignments and grading shared 9 commits |
| They talk too much | Traces, or a count of calls per request | grading called assignments 11 times to grade one submission |
| They deploy together | Release notes and deploy logs | Not 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() { # 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
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.
Which boundary is in the wrong place?
Rubric in assignments/
| assign | gradin | submis | roster | notifi | |
|---|---|---|---|---|---|
| assignments 14 | · | 9 | 1 | 1 | |
| grading 13 | 9 | · | 1 | ||
| submissions 4 | · | ||||
| roster 3 | 1 | · | |||
| notifications 2 | 1 | 1 | · |
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.
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 };
} 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:
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.
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.
| What goes wrong | What people see | Where it comes from |
|---|---|---|
| Slow: every rubric change is two changes | Two folders, often two reviewers, for one idea. | 9 shared commits, 69% of grading’s. Shared case. |
| Slow at runtime: a chatty request | Grading 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 changed | A 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 split | Grading cannot become a service without taking assignments along. | Authored. See Extracting a service. |
| False alarm: limits set too low | Every pair looks wrong, so nobody looks. | Shared case: at 1 commit, 30%, and 1 call, the review raises 11 flags. |
| Missed: limits set too high | The 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.
| Change | Rubric in assignments | Rubric in grading |
|---|---|---|
| A second client: a parent’s view of grades | It needs both folders to explain a score. | It asks grading. Authored. |
| Replace a dependency: a new file store for uploads | A change in submissions. | A change in submissions. No difference. |
| Change a rule: rubric levels | Rubric and score in two folders. | One folder. Shared case (the “Rubric levels” commit). |
| A second team takes over grading | They 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.
| Commit | Plain prompt | Architecture prompt |
|---|---|---|
| Weighted criteria | assignments, grading | assignments, grading |
| Late penalty | grading | grading |
| Rubric levels | assignments, grading | assignments, grading |
| Resubmission | grading, submissions | grading, submissions |
| Feedback per criterion | grading | grading |
| Check: weighted criteria | 8 of 12 | 8 of 12 |
| Check: late penalty | 6 of 10 | 6 of 10 |
| Check: rubric levels | 6 of 8 | 6 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:
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:
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.
The tool’s own record
Git already keeps what this needs.
git log --name-onlylists the files each commit touched, and--sincebounds the window. No new instrumentation is needed for the first sign; the second needs traces, which Observability across a request covers.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([]); });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
- Conway’s law and team boundaries reads the same history for teams instead of folders.
- Decomposing a system predicts where a change will land; this lesson checks where they did.
- How big should a module or service be? is the same question at the size of a service.
- Coupling and cohesion names what the count measures.
- Extracting a service is what a wrong boundary makes hard.