Lesson 12 / 12
Monorepo and Polyrepo
The same work under two schemes: total objects are 22600 in both, but the cost per repository is 22600 versus 5650, bisect is 11 versus 36 steps, and of four cross-component questions the monorepo answers 4 while the polyrepo answers 1.
Contents
This topic’s four lessons made four separate interventions on the same repository: they embedded a dependent repository, moved a binary body outside, duplicated the working tree, and configured the transformation layer. All four held one thing fixed — the repository’s boundary.
This lesson makes that boundary a variable. The same work, the same commits, and the same objects; the only difference is whether all of it stands inside one repository or inside four separate ones. The institutional discussion of scale and ownership trade-offs was established in Enterprise Context and Integration in the Software Architecture course and in the DevOps and Platform Engineering course, and it is not repeated here. What is measured here is only this: objects, steps, and answered questions.
Two Schemes, the Same Work
Monorepo is the scheme where more than one component stands in a single repository and a single history. Polyrepo is the scheme where each component stands in its own repository and its own history.
The side of the distinction that enters the measurement is built in one sentence: in a monorepo, components share a common order; in a polyrepo, they do not. In a monorepo, two components can be touched in the same commit, and history records this; in a polyrepo, every repository has its own order, and there is no comparable common point between two orders.
The measurement therefore models splitting not as a rewrite but as the same commit sequence distributed across separate histories. The objects are the same, the commits are the same; the only thing that changes is the boundary.
The Measurement’s Assumptions
- LR27 — The same work is the shared setup’s 4000-commit scale. The polyrepo scheme splits this sequence into 4 equal parts and renumbers each part on its own; no commit is added, removed, or changed.
- LR28 — The object count in each part is unaffected by the split; the total objects of the two schemes are equal by definition. What is measured is not the total, it is the cost paid per repository.
- LR29 — The oracle works with its own rule in every history: the commit that introduced the defect is at the three-quarters point of that history. The split scheme has four separate oracles.
- LR30 — The “repository unknown” row is the case where which component the defect is in is not known beforehand; the search runs separately in each repository and the steps are summed.
- LR31 — A question is answered if all the components it requires stand in a single history. In a monorepo, four components stand in one history; in a polyrepo, one.
- LR32 — The first part’s unit is touched object, the second part’s unit is step. The third part does not measure cost, it counts answerability.
- LR33 — The measurement does not include axes like team size, ownership, permissions, or release scheme; those are the subject of other courses, and no organizational conclusion can be drawn from this table.
Measurement
"""Monorepo and polyrepo: the same work under two schemes. Part 1 - clone cost, unit touched object. Part 2 - bisect and blame, unit step. Part 3 - how many cross-component questions get answered. """ SEED = 20260814 FILES = ("metrics.py", "report.py", "identity.py", "config.py", "document.md") TOTAL = 4000 COMPONENTS = 4 def rng(seed): d = seed % 2147483646 + 1 def draw(n): nonlocal d d = (d * 48271) % 2147483647 return d % n return draw def history(n, seed=SEED): """n-commit linear history; each commit touches one file.""" draw, commits = rng(seed), [] for i in range(n): file = FILES[draw(5)] binary = draw(11) == 0 commits.append({"no": i + 1, "file": file, "binary": binary, "objects": 2 + (40 if binary else 0)}) return commits def faulty_commit(t): """Oracle: the commit that introduced the defect is known because we wrote the setup.""" return len(t) * 3 // 4 def linear_search(t): target, step = faulty_commit(t), 0 for x in t: step += 1 if x["no"] >= target: return step return step def bisect(t): target, low, high, step = faulty_commit(t), 1, len(t), 0 while low < high: mid = (low + high) // 2 step += 1 if mid >= target: high = mid else: low = mid + 1 return step def blame(t, file): return sum(1 for x in t if x["file"] == file) def clone_cost(t, include_binary=True): return sum(x["objects"] for x in t if include_binary or not x["binary"]) def split(t, count): """The same work, split into `count` separate repositories: objects are the same, histories are separate.""" size = len(t) // count return [[{**x, "no": i + 1} for i, x in enumerate(t[p * size:(p + 1) * size])] for p in range(count)] QUESTIONS = (("isolating a defect in one component", 1), ("two components changing together", 2), ("a change spreading to three components", 3), ("the shared state of all components", 4)) def answerable(needed, components_in_repo): """A question is answerable if all the components it needs stand in a single history.""" return needed <= components_in_repo mono = history(TOTAL) poly = split(mono, COMPONENTS) print("scheme repos objects per repo 1 component 2 components all components") print(f" monorepo 1 {clone_cost(mono):18d} {clone_cost(mono):11d} " f"{clone_cost(mono):13d} {clone_cost(mono):14d}") one = clone_cost(poly[0]) two = sum(clone_cost(p) for p in poly[:2]) total = sum(clone_cost(p) for p in poly) print(f" polyrepo {COMPONENTS:3d} {total // COMPONENTS:18d} {one:11d} " f"{two:13d} {total:14d}") print() print("scheme bisect linear search" " blame (config.py)") SEARCH = (("monorepo, 4000 commits", bisect(mono), linear_search(mono), blame(mono, "config.py")), ("polyrepo, defect's repo known", bisect(poly[0]), linear_search(poly[0]), blame(poly[0], "config.py")), ("polyrepo, repo unknown", sum(bisect(p) for p in poly), sum(linear_search(p) for p in poly), sum(blame(p, "config.py") for p in poly))) for name, bi, li, bl in SEARCH: print(f" {name:34s} {bi:7d} {li:14d} {bl:18d}") print() print("question needed components monorepo polyrepo") for name, needed in QUESTIONS: m = "yes" if answerable(needed, COMPONENTS) else "no" p = "yes" if answerable(needed, 1) else "no" print(f" {name:39s} {needed:16d} {m:>9s} {p:>9s}") print() print(f"monorepo answered {sum(answerable(n, COMPONENTS) for _, n in QUESTIONS)}/" f"{len(QUESTIONS)}, polyrepo answered " f"{sum(answerable(n, 1) for _, n in QUESTIONS)}/{len(QUESTIONS)}") print(f"total objects the same in both schemes {total}; what changes is per-repository " f"cost: {clone_cost(mono)} and {total // COMPONENTS}")
scheme repos objects per repo 1 component 2 components all components monorepo 1 22600 22600 22600 22600 polyrepo 4 5650 5640 11280 22600 scheme bisect linear search blame (config.py) monorepo, 4000 commits 11 3000 787 polyrepo, defect's repo known 9 750 209 polyrepo, repo unknown 36 3000 787 question needed components monorepo polyrepo isolating a defect in one component 1 yes yes two components changing together 2 yes no a change spreading to three components 3 yes no the shared state of all components 4 yes no monorepo answered 4/4, polyrepo answered 1/4 total objects the same in both schemes 22600; what changes is per-repository cost: 22600 and 5650
Same Total, Different per Repository
The first table’s unit is touched object, and the right column gives 22600 under both schemes. This is not a coincidence, it is the setup’s rule: splitting produces no object and destroys none.
The difference is in the columns on the left. In a monorepo, someone working on a single component also clones 22600 objects, because a clone has no smaller unit. In a polyrepo, the same person clones 5640 objects — a quarter. If two components are needed, 11280; if all four, 22600 again.
The crossover point reads directly from the table: a polyrepo is only cheap if most workers touch a single component. For someone touching all four, the two schemes pay the same cost, and under a polyrepo that cost is paid through four separate operations. Partial and sparse clone options achieve the same narrowing in a monorepo without splitting the repository; this measurement does not use them.
Splitting Breaks Search
The second table’s unit is step, and the difference between the rows is the lesson’s sharpest result.
If the component the defect is in is known, the polyrepo wins: bisect drops from 11 steps to 9, linear search from 3000 to 750, blame from 787 to 209. The search runs over a smaller history, and the cost shrinks.
If it is not known, the table flips. Bisect climbs to 36 steps — more than three times the monorepo’s. The reason is clear: bisect operates on a single ordered set, four separate histories are four separate sets, and each one runs its own 9-step search. Splitting destroys bisect’s one strength — growing slowly as the set grows — by fragmenting it.
The same row’s other two columns give 3000 and 787 — identical to the monorepo’s values. Linear scan and blame are unaffected by the split, because both already touched every commit, and total commit count did not change. The only search form the split damages is the one that works by halving the set.
The Question Left Unanswered
The third table counts not cost but answerability. A monorepo answers 4 of four questions, a polyrepo answers 1.
The one question answered is the one that stays within a single component. The remaining three look at two, three, and four components at once, and there is no history in a polyrepo that can do that. This is not a tooling gap, it is the definition of the boundary: if no commit records two components changing together, there is no query that can later ask that question.
There is a way to close the gap, and its cost was measured in this topic’s first lesson: setting up a record repository that pins the four components together. This binds the four components as submodules and makes “the shared state of all components” answerable again. In exchange, the identities needed to build a single commit’s tree climb from 1 to 5, and a recursive clone returns to 22600 objects. When a polyrepo scheme wants to take back the question it lost, it starts paying back the monorepo’s cost.
Summary
- Splitting produces no object: total objects are 22600 in both schemes. What changes is the cost paid per repository — 22600 versus 5650.
- Someone touching a single component clones 5640 objects in a polyrepo, someone touching all four clones 22600; a polyrepo is cheap only if most workers stay within a single component.
- If the defect’s repository is known, bisect drops from 11 to 9 steps; if it is not, it climbs to 36. Splitting breaks the search that works by halving the set.
- Linear scan stays at 3000 steps and blame at 787: splitting does not affect either form, because both already touched every commit.
- Of four cross-component questions, a monorepo answers 4, a polyrepo 1; the loss is not a tooling gap but the definition of the boundary, and taking it back recovers the cost of a submodule.
Course Wrap-Up
The course ran on a single measure: the cost of an operation is not the length of history but the number of objects it touches — and cost is counted in two units, step or touched object. Twelve lessons applied this measure to separate places.
| lesson | operation measured | how cost grows |
|---|---|---|
| Interactive Rebase | objects touched by rewriting from the middle | 252 / 362 / 3002 / 11322 objects; ratio from 0.520 to 0.500 — the ratio stays fixed while scale grows eightyfold |
| Bulk History Transformation | objects touched by a transformation from the first commit | 380 / 920 / 5640 / 22600 objects, ratio 1.000 at every scale; the shared prefix drops to 0 and the cost is the entire history |
| Force Push Discipline | what rewriting makes the copies pay | the writer pays between 2 and 5640 objects, copies between 14 and 39480 — always seven times as much; scales with copy count |
| Reflog | what stays reachable after a rewrite | reachable fixed at 1000, unreachable 3 / 500 / 701 / 1000; recovery depends on the horizon and closes at 3 / 50 / 500 / 1000 |
| Bug Hunting with Bisect | the step count of finding a defect | linear 37 / 150 / 750 / 3000 steps, bisect 6 / 7 / 9 / 11; while history grows eightyfold, bisect does not even double |
| Blame and Line History | the step count of a file’s line history | 8 / 36 / 209 / 787 steps; grows with the number of commits touching the file and the answer settles at 40 commits |
| Hooks | the cost at the moment the block is placed | 0 objects before commit, 140 before push, 3522 objects and seven copies six hundred commits after acceptance; a late gate distributes the cost |
| Submodules | the history the pinned version binds | 76 objects bind 378 commits; the clone climbs from 920 to 4540 (a 4.93 ratio) and totals with submodule count |
| Large File Storage | object type’s share of the total | 91 commits (0.0910) produce 0.6777 of objects; the clone drops from 5640 to 2131 — cost does not track commit count |
| Multiple Working Trees | the objects an extra working area copies | an extra tree is 0 objects at every scale; 5640 instead of 22560 for four branches — cost never grows with tree count |
| Repository Attributes | the unnecessary object the absence of settings writes | line ending 162, merge driver 86, total 248 objects and a ratio of 0.0421; does not decrease as scale grows |
| Monorepo and Polyrepo | the cost of the same work under two schemes | total 22600 objects unchanged, per repository 22600 and 5650; bisect 11 and 36 steps, answered questions 4/4 and 1/4 |
Three readings come out of the table. First: costs diverge within the same history. While scale grows eightyfold, linear search grows eightyfold, blame grows a hundredfold, cloning grows sixtyfold; bisect does not even double, and an extra working tree does not grow at all. A repository being “large” does not by itself say anything — what says something is which operation touches which set.
Second: the number that determines cost is, most of the time, not commit count. Object type produces two-thirds of objects with fewer than a tenth of the commits; copy count forces resynchronization independent of the size of the change; where the boundary is drawn triples the search’s step count. In all three cases, looking at the wrong variable gives the wrong result.
Third: most of this course’s tools cannot be undone, and the one who writes does not pay the cost. Bulk transformation zeroes the shared prefix, force push deletes without anyone’s knowledge, retroactive cleanup forces every copy to be re-cloned. A recovery path was counted alongside each one — a backup branch, the reflog, a separate clone, a dry run — and what they all share is that they have to be set up before the operation.
From here, the next course — Code Review and Team Process — takes up the same tools with a different question. Everything measured across this course was the work the tool did: how many objects it touched, how many steps it spent, which question it left unanswered. No measurement said whether the operation should have been done. Knowing that rewriting history touches 5640 objects does not decide whether that history should be rewritten; knowing at which moment a hook places a block does not say which change should be blocked. The distinction has to be set from the start: the tool changes history, the human makes the decision. The next course’s subject is how that decision gets made — who looks, what they look at, and where the decision gets recorded. The numbers this course leaves behind are the unit that will be used there to compute the cost of the decisions to be made.
To keep your progress and take notes, Log in
My notes
Log in to take notes.