01 / The prompt
“Build it, and three teams will maintain it.”
You ask for a language-learning backend: courses, spaced-repetition practice, and a paid plan. You also say who will look after it: a courses team, a practice team, and a subscriptions team, with CODEOWNERS to match. When we asked, what came back was a working server with one file per team, and a CODEOWNERS file giving each team its file.
That is Conway’s law, running on a prompt: the org chart you describe becomes the shape of the code. It is also the move this lesson teaches. Draw the teams on purpose, and the code follows. Draw them by accident, as most organizations do, and the code follows that too.
The question the prompt never answered is which changes can one team make alone? The team map answers it, one CODEOWNERS line at a time.
02 / Name the shape
Draw the teams and the code together.
In 1968 Melvin Conway put the observation that now carries his name this way: “organizations which design systems (in the broad sense used here) are constrained to produce designs which are copies of the communication structures of these organizations” (“How Do Committees Invent?”).
It runs in both directions, which is what makes it useful:
Where two teams meet, the code grows a boundary. Where the code has a boundary, two teams will have to talk. So draw both on purpose, around the changes you make most.
A repository already records the team map: CODEOWNERS says who approves each path. Run your merged pull requests through it and you get the conversations your org chart costs.
| Team map | Teams | What one team can change alone |
|---|---|---|
| By layer | mobile, backend, data | A screen, a service, or a query: one kind of technology. |
| By stream | courses, practice, subscriptions | Everything for one thing a learner does, top to bottom. |
| Either | platform | Whatever no other rule claims: the catch-all line. |
Words to put in a prompt or a review
- Conway’s law
- A system’s structure copies the communication structure of the teams that build it.
- Inverse Conway maneuver
- Shaping the teams first, so the architecture you want is the one they produce.
- Stream-aligned team
- A team that owns one flow of work for a user, end to end.
- CODEOWNERS
- The file that says which team must approve changes to each path.
- Coordination cost
- The number of teams a change has to wait for.
- Team API
- What a team offers others: code, docs, and how to ask it for a change.
Both team maps, as CODEOWNERSNine lines against twenty-one
# Teams by layer: whoever knows the technology owns the folder.
* @lingo/platform
/apps/mobile/ @lingo/mobile
/services/ @lingo/backend
/packages/ @lingo/backend
/db/ @lingo/data
# Teams by stream: whoever owns the learner's outcome owns the code for it.
* @lingo/platform
# Courses: what there is to learn.
/apps/mobile/screens/Course* @lingo/courses
/apps/mobile/screens/Lesson* @lingo/courses
/services/api/routes/courses.ts @lingo/courses
/db/**/*courses* @lingo/courses
/packages/content/ @lingo/courses
# Practice: reviewing what was learned.
/apps/mobile/screens/Practice* @lingo/practice
/services/api/routes/practice.ts @lingo/practice
/services/workers/reminders.ts @lingo/practice
/db/**/*attempts* @lingo/practice
/packages/practice/ @lingo/practice
# Subscriptions: who can learn how much.
/apps/mobile/screens/Paywall* @lingo/subscriptions
/services/api/routes/billing.ts @lingo/subscriptions
/services/workers/renewals.ts @lingo/subscriptions
/db/**/*subscriptions* @lingo/subscriptions
/packages/billing/ @lingo/subscriptions
03 / Follow nineteen changes
Watch the same history cost different teams different conversations.
A language app’s last nineteen merged pull requests, reviewed first under teams by layer and then under teams by stream. Every owner is resolved from CODEOWNERS the way GitHub does it. In Try it, edit the file yourself.
How many teams does a change need?
Teams by layer
- apps/mobile/
- api/client.tsmobile
- screens/CourseList.tsxmobile
- screens/LessonScreen.tsxmobile
- screens/PaywallScreen.tsxmobile
- screens/PracticeScreen.tsxmobile
- theme.tsmobile
- db/client.ts/
- data
- db/migrations/
- 004_courses_audio.sqldata
- 005_attempts_answer.sqldata
- 006_subscriptions_currency.sqldata
- 007_subscriptions_pause.sqldata
- db/queries/
- attempts.tsdata
- courses.tsdata
- subscriptions.tsdata
- packages/billing/
- plans.tsbackend
- prices.tsbackend
- packages/content/
- schema.tsbackend
- validators.tsbackend
- packages/practice/
- grading.test.tsbackend
- grading.tsbackend
- scheduler.test.tsbackend
- scheduler.tsbackend
- services/api/
- errors.tsbackend
- middleware/auth.tsbackend
- middleware/logging.tsbackend
- routes/billing.tsbackend
- routes/courses.tsbackend
- routes/practice.tsbackend
- server.tsbackend
- services/workers/
- reminders.tsbackend
- renewals.tsbackend
Three teams, one per layer.
Mobile owns the app, backend owns the services and packages, data owns the database. Platform owns whatever nobody else does.
Reduced motion: choose a scene to see its completed state.
Read this scene
Mobile owns the app, backend owns the services and packages, data owns the database. Platform owns whatever nobody else does.
Teams by layer. .
Watch restarts the story when you come back. Step through keeps your step. Try it starts from the team map you pick each time you open it.
04 / Read the shape
Who approves a change is a function of the paths it touches.
Basic form resolves owners for one path. In the wild reviews a whole history. At the call site the answer becomes the note a pull-request bot leaves, which is where teams feel the boundary every day.
CODEOWNERS as GitHub reads it: each line a pattern and its owners, patterns in the gitignore style, and the last matching line wins. ownersOf is who must approve a change to one path.
export type Rule = { pattern: string; owners: string[]; line: number };
/** CODEOWNERS lines: a pattern and zero or more owners. `#` starts a comment. */
export function parseCodeowners(text: string): Rule[] {
const rules: Rule[] = [];
text.split('\n').forEach((raw, index) => {
const line = raw.replace(/(^|\s)#.*$/, '').trim();
if (!line) return;
const [pattern, ...owners] = line.split(/\s+/);
rules.push({ pattern, owners, line: index + 1 });
});
return rules;
}
/**
* The gitignore-style subset CODEOWNERS uses. A slash at the start or in the middle
* anchors the pattern to the repository root; a trailing slash matches everything under
* a directory; `*` stays inside one path segment and `**` crosses segments.
*/
export function toRegExp(pattern: string): RegExp {
let body = pattern;
const dirOnly = body.endsWith('/');
if (dirOnly) body = body.slice(0, -1);
const anchored = body.startsWith('/') || body.includes('/');
if (body.startsWith('/')) body = body.slice(1);
let source = '';
for (let i = 0; i < body.length; i++) {
const ch = body[i];
if (body.startsWith('**/', i)) {
source += '(?:.*/)?';
i += 2;
} else if (body.startsWith('**', i)) {
source += '.*';
i += 1;
} else if (ch === '*') source += '[^/]*';
else if (ch === '?') source += '[^/]';
else source += ch.replace(/[.+^${}()|[\]\\]/g, '\\$&');
}
return new RegExp(`^${anchored ? '' : '(?:.*/)?'}${source}${dirOnly ? '/.*' : '(?:/.*)?'}$`);
}
/** The owners of one path: the last rule that matches wins, as GitHub resolves it. */
export function ownersOf(path: string, rules: Rule[]): string[] {
let owners: string[] = [];
for (const rule of rules) if (toRegExp(rule.pattern).test(path)) owners = rule.owners;
return owners;
} type Rule struct {
Pattern string `json:"pattern"`
Owners []string `json:"owners"`
Line int `json:"line"`
}
var commentRe = regexp.MustCompile(`(^|\s)#.*$`)
// ParseCodeowners reads CODEOWNERS lines: a pattern and zero or more owners.
func ParseCodeowners(text string) []Rule {
rules := []Rule{}
for i, raw := range strings.Split(text, "\n") {
fields := strings.Fields(commentRe.ReplaceAllString(raw, ""))
if len(fields) == 0 {
continue
}
rules = append(rules, Rule{fields[0], append([]string{}, fields[1:]...), i + 1})
}
return rules
}
// ToRegexp turns the gitignore-style subset CODEOWNERS uses into a regular expression.
// A slash at the start or in the middle anchors the pattern to the root; a trailing
// slash matches everything under a directory; * stays in one segment and ** crosses them.
func ToRegexp(pattern string) *regexp.Regexp {
body := pattern
dirOnly := strings.HasSuffix(body, "/")
body = strings.TrimSuffix(body, "/")
anchored := strings.HasPrefix(body, "/") || strings.Contains(body, "/")
body = strings.TrimPrefix(body, "/")
var src strings.Builder
for i := 0; i < len(body); i++ {
switch {
case strings.HasPrefix(body[i:], "**/"):
src.WriteString("(?:.*/)?")
i += 2
case strings.HasPrefix(body[i:], "**"):
src.WriteString(".*")
i++
case body[i] == '*':
src.WriteString("[^/]*")
case body[i] == '?':
src.WriteString("[^/]")
default:
src.WriteString(regexp.QuoteMeta(body[i : i+1]))
}
}
prefix, suffix := "(?:.*/)?", "(?:/.*)?"
if anchored {
prefix = ""
}
if dirOnly {
suffix = "/.*"
}
return regexp.MustCompile("^" + prefix + src.String() + suffix + "$")
}
// OwnersOf returns the owners of the last rule that matches the path.
func OwnersOf(path string, rules []Rule) []string {
owners := []string{}
for _, r := range rules {
if ToRegexp(r.Pattern).MatchString(path) {
owners = r.Owners
}
}
return owners
} The behavior these examples promiseChecked by shared cases from a separate model
- A line is a pattern followed by owners;
#at the start or after a space begins a comment; a pattern with no owners is allowed and leaves those paths unowned. - A slash at the start or in the middle anchors the pattern to the root. A trailing slash
matches only things under that directory.
*matches within one path segment,**across segments, and?one character. An unanchored pattern matches at any depth. - The owners of a path come from the last line that matches it.
- A pull request needs the sorted set of owners of its files. The history counts pull requests needing one team (or none), two, and three or more, and every pair of teams that shared a review, most shared first.
Every expectation in cases.json was produced by a Python model written from
these rules. It matches paths segment by segment rather than through a regular expression,
and it lives in model/cases.py. GitHub’s own documentation puts the key rule
in one line: “the last matching pattern takes the most precedence” (About code owners).
Reading the TypeScriptA glob, turned into one RegExp
toRegExp walks the pattern once: **/ becomes an optional run
of directories, * a run of anything but a slash, and every other character is
escaped. The anchoring and the trailing slash only change the start and the end of the expression.
Reading the Goregexp.QuoteMeta, and sorted pairs
Go builds the same expression with regexp.QuoteMeta for the literal parts. Pair
counts live in a map, which has no order, so the pairs are sorted by count and then by name
before they are returned, the order the shared cases expect.
Run it yourselfNo dependencies
Copy the complete TypeScript file and run node --experimental-strip-types conway.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/conways-law
go 1.23
by layer: #1 Show the next review: needs @backend, @mobile (2 teams) #2 Store answers: needs @backend, @data (2 teams) by stream: #1 Show the next review: @practice can merge this alone #2 Store answers: @practice can merge this alone
05 / Review the agent’s diff
“Consolidated the three route files.”
The teams here are organized by stream. The change is a fair cleanup of duplicated setup. Before you merge it, check which line of CODEOWNERS the new file falls under.
06 / How it fails
A team map fails as waiting.
Nothing in the code breaks. What breaks is time: pull requests wait for reviewers, and the boundary between two teams becomes an interface nobody can change quickly.
| What goes wrong | What people see | Where it comes from |
|---|---|---|
| Slow: every feature crosses teams | Audio-only lessons: needs @lingo/backend, @lingo/data, @lingo/mobile (3 teams). | By layer, 13 of 19 pull requests needed two or more teams. Shared case. |
| A frozen interface | The API between the app and the services changes only when two teams agree. | backend and mobile reviewed 9 pull requests together. Shared case. |
| Slow the other way: platform work crosses streams | Dark mode: needs @lingo/courses, @lingo/platform, @lingo/practice, @lingo/subscriptions (4 teams). | By stream, 3 pull requests needed four teams. Shared case. |
| A bottleneck owner | Every change to a shared file waits for the catch-all team. | The route table in section 05 falls to *. Authored. |
| Unowned code | Nobody is asked, so nobody reviews it. | A CODEOWNERS line with no owners. Shared case (/docs/private/). |
| A map nobody follows | CODEOWNERS says streams; the folders say layers. | Authored; section 08 checks it on recorded builds. |
Micro-frontends is the same law applied to a browser page two teams ship.
07 / Is it worth it?
You pay in shared work across streams. Here is what it buys.
Teams by layer are easy to staff: hire mobile developers into mobile. Teams by stream need each team to handle every layer, and give up the easy cross-cutting change. Run both against the same four kinds of change.
| Change | By layer | By stream |
|---|---|---|
| A second client: a web app beside the mobile app | A new team, or mobile learns the web; every feature adds a fourth reviewer. | Each stream adds its web screens. Authored. |
| Replace a dependency: the database driver | Upgrade the database driver: @lingo/data can merge this alone. | Upgrade the database driver: needs @lingo/courses, @lingo/platform, @lingo/practice, @lingo/subscriptions (4 teams). |
| Change a rule: review intervals from accuracy | Review intervals from accuracy: @lingo/backend can merge this alone. | Review intervals from accuracy: @lingo/practice can merge this alone. No difference: the rule sits in one package. |
| A second team takes over billing | They get a slice of every layer team’s folders to negotiate. | They get the subscriptions lines of CODEOWNERS. Authored. |
Before you reorganize teams or folders, decide what you will measure:
- Teams per merged pull request, from your own history and CODEOWNERS, with the function in section 04. That is the baseline.
- Time from opening to merging, split by the number of teams a pull request needed. If waiting is the cost, it shows up here.
- The most shared team pairs. The top pair is your busiest interface; it should be the one you meant.
The history here is nineteen authored pull requests, not a real team’s. It is chosen to include changes that favor each map, and the lesson has no before-and-after numbers for a real reorganization.
08 / Ask for it
Name the teams, and the code takes their shape.
We sent two agents running Claude Sonnet the same request, naming the three teams and asking for CODEOWNERS. One prompt added an Architecture block: a folder per team with its routes, rules, and storage, a small shared core with an owner, and front doors between teams. Then each build got a practice-only ticket, and then a new practice endpoint, from fresh agents. A script read each build’s own CODEOWNERS with the lesson’s resolver.
| Question | Plain prompt | Architecture prompt |
|---|---|---|
| TypeScript files per owning team | courses: 1, platform: 2, practice: 1, subscriptions: 1 | platform: 3, courses: 3, practice: 3, subscriptions: 3 |
| Practice ticket: files changed | practice.ts (practice) | teams/practice/store.ts (practice) |
| Practice ticket: teams to approve | practice | practice |
| New review rule works | as the ticket describes | as the ticket describes |
| Stats endpoint: files changed | practice.ts (practice), server.ts (platform) | TEAMS.md (platform), teams/practice/index.ts (practice), teams/practice/routes.ts (practice), teams/practice/store.ts (practice) |
| Stats endpoint: teams to approve | platform, practice | platform, practice |
Both builds drew the code along the team lines, because the prompt drew the teams. The plain build did it with a file per team; the architecture build with a folder per team. The practice ticket needed only the practice team in both.
The new endpoint found the difference. In the plain build, server.ts, owned by
platform, lists every endpoint, so adding one to practice also needed platform:
if (method === "GET" && pathname === "/practice/due") {
handlePracticeDue(res, url.searchParams);
return;
}
if (method === "GET" && pathname === "/practice/stats") {
handlePracticeStats(res, url.searchParams);
return;
}
The architecture build’s entry point hands each request to the teams in turn:
if (await handleCourses(req, res, url)) return true;
if (await handlePractice(req, res, url)) return true;
if (await handleSubscriptions(req, res, url)) return true; Its code change stayed inside practice. It still needed platform, for one line in TEAMS.md, the document listing every team’s endpoints, which sits at the root
and falls to the catch-all owner. A shared list of everyone’s work belongs to nobody in
particular.
The line these runs point to: each team owns its own routes; the entry point hands requests to teams instead of listing their endpoints, and every file, documents included, has a specific owner.
How the runs were made and checkedSix builds, recorded as written
- Both round-one agents got the request at the same time, in fresh contexts. Each ticket went to a fresh agent working on a copy of the previous build.
- The stats round was added after the checker’s first run, which found the first ticket needed one team in both builds and pointed at the difference in how requests are routed.
- The files each agent wrote are kept byte for byte, with checksums. The checker restores
them, resolves each file’s owners with the lesson’s own
ownersOf, counts each ticket’s diff, and runs the review rule and the stats endpoint over HTTP. - Several agents wrote logs outside their folders, to
/tmpor the filesystem root, against the prompt; two noticed and removed them. 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
Make the team map a file that reviews enforce.
A team map drifts the way code does. Three kinds of check keep them together.
The platform’s own door
GitHub’s documentation says: “Code owners are automatically requested for review when someone opens a pull request that modifies code that they own.” An administrator can also “require approval from a code owner before the author can merge” (About code owners). With that setting on, the file stops being a suggestion. This lesson quotes the setting and did not change one.
A rule a check enforces
Every path should have a specific owner, not only the catch-all. A test that runs
ownersOfover every file in the repository and fails on anything that falls to*catches the unowned route table from section 05 the day it is added. It sits beside the rules in Architecture as rules.A check on what actually happens
Run the history, not just the file. A scheduled job that counts teams per merged pull request over the last quarter shows whether the map still matches the work.
team-boundaries.spec.ts // team-boundaries.spec.ts import { expect, it } from 'vitest'; import { coordination, parseCodeowners } from './conway'; import { readFileSync } from 'node:fs'; import { mergedLastQuarter } from './history'; it('keeps most changes inside one team', () => { const rules = parseCodeowners(readFileSync('.github/CODEOWNERS', 'utf8')); const { histogram } = coordination(mergedLastQuarter(), rules); const total = histogram.one + histogram.two + histogram.more; expect(histogram.one / total).toBeGreaterThanOrEqual(0.7); });
The same law shapes a frontend: the component library a design-system team owns and the feature folders product teams own. That split has its own lesson, Micro-frontends; this one has no frontend row.
10 / Make the call
Draw teams around the changes you make most.
With one team, Conway’s law is quiet: every conversation is a hallway one. Keep the code organized by whatever reads best until a second team arrives.
Once there are several teams, look at your merged pull requests. If most features cross the same boundary, that boundary is in the wrong place, or the teams are. Moving the teams is often cheaper than moving the code, and it is the change that makes the new code shape stick. Reopen the map when the most shared team pair stops being the one you intended.
Take it with you
Explain it without saying “Conway’s law”: “The code splits where the teams split. If every feature needs three teams, either the teams or the folders are drawn against the work.” Then count the teams your last five merged pull requests needed.
Paste into your next prompt, and fill in the blanks
<Three> teams will maintain this code: <team> owns <what it does for the user>, … Draw the code boundaries where the team boundaries are: one folder per team, holding its routes, rules, and storage, so a change in one team's area needs only that team's review. Keep shared code small and give it a specific owner. Write .github/CODEOWNERS to match the folders, with no code left to the catch-all owner, and describe what the teams ask each other for in TEAMS.md.
Connections to follow nextRelated lessons
- Bounded contexts often line up with teams: one team, one meaning of each word.
- Signs a boundary is wrong reads the same history for pull requests that always touch the same two folders.
- Micro-frontends is the law applied to one page.
- Modular monolith gives each team a module with a front door inside one deployment.
- Enforcement layer keeps rules like section 09’s running as agents make changes.