Monorepo: Team Structure, Not Code Structure
Wednesday, 10:15. One line in the shared library: a new value in the error code enum. Writing it took 20 minutes. Then, in order: tag a new library version, open a bump PR in 8 service repos, and for each one wait for CI, get a review, deploy. The seventh went live on Friday at 16:30. The eighth PR was never opened; it was forgotten. The team is 3 people. Nobody is waiting for anybody; the repos are waiting for each other.
- The repo boundary is the team boundary. With 4 teams and 14 people, 9 repos was right: nobody should wait for anybody's PR. When the team shrank to 3, the same boundary started charging a price for protecting nobody. This is Conway's law, applied to repos.
- 20 minutes of work in the shared library, 3 days of delivery. 7 bump PRs, 7 CI runs, 7 reviews, 7 deploys; the eighth service was forgotten. Not a single event:
commonships 19 versions a year, and in 4 of those rounds at least one service was left behind. In a monorepo the same work is one PR, one review, one commit. - Monorepo ≠ monolith. One repo, but every service deploys on its own; the CI trigger and the deploy trigger are separate. If a shared library change forces you to ship all 8 services at once, the problem is not the repo; it is the library boundary.
- Split the CI first, move the code second. Without a path filter, every commit triggers everything; on the second day the pipeline is 31 minutes. The path filter is not free either: it is a dependency graph maintained by hand, and combined with a required check it locks a PR forever.
- Move the history, do not copy it. With
git filter-repo, renaming the tags; otherwise two years ofgit blameis gone andv1.0.0from 8 repos collide. - The real question is the number of services. 8 services for 3 people means 8 pipelines, 8 dashboards, 8 alert sets. Merging repos does not reduce that; 12 months of co-change data told us which two services are really one.
What does a repo boundary solve?
We started the project three years ago with 4 teams and 14 people. The payments team wrote
the payments and notifications services; the accounts team wrote
accounts, limits and reconciliation; the reporting team
wrote reporting and admin; the integrations team wrote
integrations. Each service had its own repo, and a common library
sat in the middle. 9 repos. It was the right decision for that day, and the reasons were
concrete:
- The payments team's PR did not wait for the reporting team's unfinished work.
- CI was small and fast: 4 minutes per repo.
- An outside agency wrote the integrations repo; they saw only that repo.
- Release rhythms were different: payments shipped 3 times a week, reporting once a month.
Over three years the team shrank. Today we are 3 people running the same 8 services. The repos stayed where they were; the reasons did not. Conway's law says that a system copies the communication structure of the organization that builds it. 9 repos were a copy of how 4 teams talked to each other. The organization changed; the copy stayed.
| What the poly repo gave us | With 14 people | With 3 people |
|---|---|---|
| Independent review | 4 teams do not wait for each other | The same 3 people review everything anyway |
| Separate access | The agency sees one repo | No agency |
| Small, fast CI | 4 min | Still 4 min; but when shared code changes, the same person opens and watches 8 PRs in 8 repos. Machines run in parallel; a person does not |
| Separate release rhythm | Payments 3/week, reporting 1/month | The same person ships all of them |
| The price: coordinating shared code | Spread over 4 teams | Carried by the whole team |
From the field: one enum value, three days
Here is the story from the opening, step by step; Wednesday, 11 February 2026. A new limit
rule needs one value added to the ErrorCode enum: LimitExceeded.
The change is in common; 8 services consume it, and that morning all 8 are on
1.13.
| Time | What happened | Cost |
|---|---|---|
| Wednesday 10:15 | Enum value added in common, PR opened | 20 min |
| Wednesday 11:00 | Review, merge, tag 1.14.0, package published | 45 min |
| Wednesday 11:30–17:00 | Bump PRs in 7 services (1.13 → 1.14). Two fail to compile: a switch expression does not cover the new value, and with TreatWarningsAsErrors on, warning CS8509 is an error for us | 7 PRs, 2 fixes |
| Thursday | 7 CI runs; one was flaky and was re-run twice. 7 reviews, 5 deploys | 9 CI runs, one person's whole day |
| Friday 16:30 | The remaining 2 deploys. The eighth PR was never opened: reporting is still on 1.13; nobody touched it that week, the bump was forgotten | 7 of 8 |
Nobody made a mistake. The process was designed so that 4 teams would not block each other.
In a team of 3 there was nobody left to block, so only the process remained. On top of that,
a version matrix: on Friday evening the table said 7 services on 1.14,
reporting on 1.13. One row of difference; but two weeks later nobody
would remember that row, and the question “which service is on which
common?” would again need a spreadsheet.
If this were a single event, there would be no post. In the 12 months before the migration,
common shipped 19 versions (minor and patch). 19 × 8 = 152
bump PRs should have been opened; 131 were. The missing 21 were either forgotten or postponed
to “the next round”; in 4 rounds at least one service was left behind, and twice we
found out through a bug in production. The job of 130 PRs a year was to change one version
number.
The cheap alternative: give the bump to a bot
Before the decision meeting, the first objection came from inside the team: “Set up Renovate, let the bot open the bump PRs, auto-merge when CI is green; done, without touching the repo structure.” A fair objection. We wrote down what it solves and what it does not, side by side:
- Opening 7 PRs by hand: drops to zero.
- The forgotten service: a bot does not forget; it opens 8 PRs minutes after the package is published.
- The messy version matrix: a service falls behind by at most one CI run.
- 7 CI runs, 7 deploys: unchanged.
- 7 reviews: if you trust auto-merge there is no review; the round with two compile errors taught us not to trust it.
- The real one: the change in shared code and the change in the service that uses it are still two PRs, two repos, two moments in time. To change an interface you first add the old and the new signature, ship a version, migrate 8 services, remove the old one, ship again. Expand/contract, for every small refactor, in a team of 3.
The math: if common changed twice a year, Renovate would be enough and we would
not migrate. It changes 19 times a year, and most of those changes are “add a field,
update the callers”: one piece of work split across two repos. The bot takes over
opening PRs; it does not take over the split.
The decision: monorepo — but not a monolith
The decision was one repo. We wrote down what changes and what does not, because the word “monorepo” meant two different things to two people on the team.
- One repo: 8 services under
services/, shared code underlibs/. commonis referenced as source, not as a package. The version matrix disappears at the source level: every service compiles against the samecommonin every commit.- A shared code change plus the services that use it: one PR, one review, one commit. Atomic refactors become possible: renames, interface changes.
- A new person sees everything with one
git clone.
- Every service keeps its own Dockerfile, its own pipeline, its own deploy.
- 8 services changed in the same PR are still deployed one by one. During the deploy window two versions of
commonare live at the same time; backward compatibility is still required. - Services keep calling each other at runtime exactly as before.
- There is no “deploy everything” button. There will not be one.
The second item on the right means this in the enum example: limits, which
produces LimitExceeded, and accounts and notifications,
which handle it, run on different versions for a few minutes during the deploy;
accounts must map an error code it does not know to Unknown. That
requirement existed in the poly repo too, for two whole days between Wednesday and Friday;
nobody had written it down.
The right list matters more than the left one. If you move to a monorepo and a change in
common still forces you to deploy all 8 services, you have gained nothing; the
problem was never the repo, it was the library boundary. The poly repo hid that behind the
version matrix. The monorepo does not hide it: every PR shows how many service directories
it touches. If that number is always 8 of 8, the shared library knows too much.
Where it goes wrong: five traps
1. Every commit triggers everything
Our first attempt was a single pipeline: on push, build and test all 8 services. The
4-minute CI became 31 minutes on the second day. The fix is to trigger by changed directory:
each service has its own workflow, and it runs only when its own directory or the shared code
it depends on changes. We did not need Bazel or dotnet-affected; the
paths filter in GitHub Actions was enough. The second rule: the CI trigger and
the deploy trigger live in separate files, with separate conditions.
# .github/workflows/payments-ci.yml
name: payments-ci
on:
push:
branches: [main]
paths:
- "services/payments/**"
- "libs/common/**" # shared code changed -> this service is built and tested too
- ".github/workflows/payments-ci.yml"
pull_request: # note: no "paths" here; the reason is in trap 2
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1 # a 4-second checkout instead of 470 MB of history
- uses: dorny/paths-filter@v3
id: chg
with:
filters: |
payments:
- "services/payments/**"
- "libs/common/**"
- if: steps.chg.outputs.payments == 'true'
run: dotnet test services/payments # only this service; 4 min, same as before
# if nothing changed, the job is green in 20 s
# .github/workflows/payments-deploy.yml
name: payments-deploy
on:
push:
branches: [main]
paths:
- "services/payments/**" # NO libs/common: shared code alone never triggers a deploy
workflow_dispatch: # by hand, when a shared-code fix must ship now; 3 times in 6 months
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 1 }
- run: docker build -t registry/payments:${{ github.sha }} services/payments
- run: ./deploy.sh payments ${{ github.sha }} # image tag = commit; rollback uses this tag
When libs/common changes, 8 CI runs start and zero deploys are triggered. A
service that needs the shared-code change has already changed in the same PR, so its own
deploy runs; a service that does not need it picks up the new common on its next
deploy. That is what “no deploy-everything button” means.
2. A path filter is a dependency graph maintained by hand
The paths list above is the simplest form of a dependency graph, and it breaks
in two directions. The easy one: somebody makes the filter too wide and CI climbs back to 30
minutes; p95 shows it. The dangerous one is the reverse: the notifications
service adds a dependency on libs/templates and nobody updates the
paths list in its workflow. From that day on, when libs/templates
changes, notifications is not built, not tested, and nobody notices. The graph is
written in one place in the csproj and in another place in YAML, and the two do not stay in
sync by themselves. Our fix is a 9-line guard:
for wf in .github/workflows/*-ci.yml; do
svc=$(basename "$wf" -ci.yml)
grep -oh 'libs/[a-z]*' services/$svc/*.csproj | sort -u | while read -r lib; do
grep -q "$lib/\*\*" "$wf" || echo "MISSING: $svc -> $lib ($wf)"
done
done
# if it prints even one line: exit 1, the PR goes red
The second trap is specific to GitHub and is the classic monorepo mistake: you make the
workflow a required check in branch protection, then you put paths on the
pull_request trigger. In a PR that touches only reporting,
payments-ci never starts; a check that never starts does not even say
“skipped”, it reports nothing, and the PR stays at “waiting for status”
forever. It happened to us on the pilot service, on the first day. There are two ways out: a second workflow with the
same name, bound to the opposite condition with paths-ignore, that runs
an empty job (the way GitHub's own docs describe), or what we did: remove paths on
the PR side and move the filter inside the job. The check reports on every PR; it does the work
only when needed. If you use a merge queue, the same problem exists there; check that setting
too.
As the number of services grows, these lists stop fitting in one person's head, and that is
the day you talk about dotnet-affected or Bazel. We do not know where that limit
is; we have 8 services, 2 libs and a 10-line list, and 3 people know it by heart.
3. Still publishing packages
If you move the code but still publish common as an internal NuGet package, you
have gained nothing: the bump PRs are now opened in the same repo, that is all. The reference
has to be source.
<!-- services/payments/Payments.csproj -->
<PackageReference Include="Company.Common" Version="1.13.0" />
<!-- 8 services, 8 different lines; nobody knows which one is current -->
<!-- services/payments/Payments.csproj -->
<ProjectReference Include="../../libs/common/Common.csproj" />
<!-- a service that no longer compiles goes red in the PR; it does not wait for Friday -->
The two compile errors from the opening story would appear here: inside the PR, on Wednesday
at 10:35. One detail: what catches them is not the compiler but
TreatWarningsAsErrors. Adding a value to an enum is not a compile error in C#; a
missing arm in a switch expression is warning CS8509. With that setting
off, the two services compile quietly and LimitExceeded falls into the
default arm at runtime. As for the forgotten reporting service: there
is no step to forget; it compiles against the new common in the same PR, and its
CI runs.
A source reference has a price too; here are four items:
- You lose the right to stay on an old version. A service cannot say “I cannot take this change now”; every change in
commonis either backward compatible or migrates all 8 services in the same PR. That is a cost; for us it was also the goal. - Transitive package versions must line up. When
commonmoves to Newtonsoft 13, all 8 services move. We manage that centrally withDirectory.Packages.props; the version lives in one file. - One target framework.
adminwas still onnet6.0; moving it tonet8.0became a precondition of the migration, 2 days. - Build time. Every service build also compiles
common: +25 seconds per service. Accepted.
The exception to the rule: if somebody outside the repo consumes common (an
agency, a mobile app, another team), you keep publishing the package; the library then has
two customers, the inside ones from source and the outside ones from the package. We have no
outside consumer. If we did, that would be the exception, and the version matrix would live on
for that customer.
4. Moving history with copy-paste
The cheapest-looking way: clone the old repo, copy the files into the new directory, one
commit. You pay for it three months later: git blame shows the same commit for
every line, and two years of answers to “why was this written this way” are gone.
History is moved by rewriting the paths:
# 1) fresh clone of the old repo (filter-repo refuses a clone that has been worked in)
git clone git@github.com:company/payments-service.git /tmp/payments
cd /tmp/payments
# 2) move all paths under services/payments/ across the whole history;
# prefix the tags with the service name: v1.4.2 -> payments/v1.4.2
# (all 8 repos have a v1.0.0; without the prefix they collide on merge)
git filter-repo --to-subdirectory-filter services/payments --tag-rename :payments/
# 3) pull into the monorepo; the two histories share no ancestor, allow it
cd ~/monorepo
git remote add payments /tmp/payments
git fetch payments --tags
git merge --allow-unrelated-histories payments/main -m "Move payments service (with history)"
git remote remove payments
# check: are the old commits visible on the new path? (paths were rewritten; --follow is not needed)
git log --oneline -- services/payments/Payments.csproj | tail -3
About 15 minutes of commands per service; the handover list takes longer. Before archiving the old repo: open PRs (6 in total for us; merge or close, reopen in the new repo), issues (GitHub transfer), webhooks (3 Slack, 1 deploy), deploy credentials (into the new repo's secrets), README badges, links in the wiki and in external documents. Then the repo is archived, and the first line of its README says “Moved to monorepo/services/payments”. It is not deleted; a link or an old document may still point there.
Size: the 9 repos added up to 470 MB, and the monorepo is 470 MB; no surprise. The surprise
was in CI: a full clone took 55 seconds, and 8 workflows did it 8 times. With
fetch-depth: 1 the checkout takes 4 seconds. Sparse checkout was not needed. We
do not use LFS; if you do, filter-repo does not move LFS objects, so plan for them
separately first.
5. One door, one key
The quiet benefit of the poly repo was access control: the agency saw only the integrations
repo. In a monorepo everyone sees everything; GitHub has no directory-level read permission.
CODEOWNERS limits who reviews, and only when “require review from Code
Owners” is enabled in branch protection; no setting limits who reads. Today we are 3
people, so this is not a problem. If an outside person joins tomorrow, there are two options,
and we wrote both down: split the service the agency will work on into a separate repo with
git subtree split and keep it in two-way sync; or never bring that service into
the monorepo at all. The first candidate is integrations: it almost never
changes together with anybody (the number is below). The day it must be solved is clear: the day the
first external account is created.
Versions, rollback and hotfixes in one repo
The sentence “one service's failure does not roll back another service's deploy” is empty until you write down the mechanism. Ours is three rules:
- Tags per service, with a prefix.
payments/v1.4.2,accounts/v2.1.0. Version numbers continue from where the old repo left them. Release notes:git log payments/v1.4.1..payments/v1.4.2 -- services/payments libs/; only the commits that concern that service. - Rollback happens at the image level, not in git. Every deploy is an image tagged with the commit SHA; rolling back means deploying the previous image again. Git is not touched, and the other 7 services are not affected. If you revert a bug in
common, 8 CI runs start but still zero deploys are triggered; you ship whichever service needs the new build. - Hotfixes go to main. The fix lands on main, and the path filter triggers only that service's deploy. No release branches; zero cherry-picks in 6 months. The condition: main must be deployable at any moment, and unfinished work in that service must sit behind a feature flag. We had that rule in the poly repo too; the monorepo made it mandatory.
The real question: how many services for 3 people?
While we talked about the number of repos, we skipped the real number. 3 people, 8 services. That means 8 pipelines, 8 dashboards, 8 alert sets, and 8 dependency updates every time a security patch comes out. A service boundary, like a repo boundary, is a team boundary; 8 boundaries drawn for 4 teams are too many for 1 team.
We decide which ones to merge by looking at history, not by gut feeling. The monorepo gave us something we did not expect here: with all history in one place, the question “which services always change together?” became a single command. In the poly repo, asking that question meant merging the history of 8 repos; nobody had asked it. The command has two traps, and we fell into both:
- “Same commit” does not work on migrated history. Every old commit that came in through
filter-repotouches exactly one service directory;merge --allow-unrelated-historiesdoes not join commits, it only puts them in the same graph. For the time before the migration the answer is zero by definition; our first run returned exactly that. The right measure is closeness in time:filter-repokeeps the dates, so counting service pairs that changed on the same day works on migrated history too. - Mechanical commits inflate every pair. A bulk format, an
.editorconfigchange, a global package upgrade: one commit touches 8 directories and gives a point to all 28 pairs. If more than 4 service directories were touched on one day, we do not count that day. The bump commits of the poly period are the same kind of noise: a bump PR that lands in 3 services on the daycommonships is not coupling; we do not count commits that change only.csprojor lock files.
cochange() { # $1 = from, $2 = to
git log --since="$1" --until="$2" --date=short --pretty=format:'DAY %ad' --name-only \
| awk '
# %ad = author date: in history moved with rebase/cherry-pick, committer dates collapse
# onto the migration day; author dates survive. (--since/--until use the committer date,
# so the window edge can shift by a day or two.)
/^DAY / { day = $2; next }
/^services\// && !/\.csproj$/ && !/packages\.lock\.json$/ {
# a commit that changes only csproj/lock = a bump, not coupling; a source file is required
split($0, p, "/"); k = day SUBSEP p[2]
if (!(k in seen)) { seen[k] = 1; list[day] = list[day] " " p[2] } }
END {
for (day in list) {
n = split(list[day], s, " ")
if (n > 4) { skipped++; continue } # bulk format / global upgrade day: skip
for (i = 1; i <= n; i++)
for (j = i + 1; j <= n; j++) {
a = s[i]; b = s[j]
if (a > b) { t = a; a = b; b = t } # order within a day is random; fix the pair alphabetically
pair[a " + " b]++
}
}
for (k in pair) print pair[k], k
print skipped + 0, "(days skipped)"
}
' | sort -rn | head -5
}
cochange 2025-09-20 2026-03-20 # poly repo period, 6 months
cochange 2026-03-20 2026-09-18 # monorepo period, 6 months
# output (ours): poly 6 mo mono 6 mo
# accounts + limits 29 38
# accounts + payments 14 24
# notifications + payments 6 9
# accounts + reporting 1 3
# (days skipped) 4 5
# highest pair that includes integrations: 1 / 2 (same day by chance)
The two halves do not measure the same thing, and we read them knowing that: in the poly
period, work that changes two services together is two commits in two repos, and with the
bump delay it often lands on two different days, so it counts low; in the mono period it is
one PR, one day, so it counts high. That is why we do not add the two halves. What we trust is
not the number but the order: the same four pairs, in the same order, in both periods.
accounts and limits changed together on 29 days in the poly half and
38 days in the mono half; these are not two services, they are one service talking over the
network. Merge candidate number one. integrations almost never changed together
with anybody; its highest pair is 2 days. It stays separate, untouched. Merging is not done in
the same PR as the migration: move first, measure, then merge. The accounts +
limits merge is planned for October; that will be a separate post.
Migration order
The decision was made at the end of February; the migration ran from 2 to 20 March 2026, three weeks, one PR per service. The order:
- Write the skeleton CI in an empty monorepo. A CI and a deploy workflow template per service, with the path filter. There is no service to try it on yet; it is a template.
- Close the preconditions. Target framework alignment,
Directory.Packages.props. 2 days for us. - Move the shared libraries. That is where the pain is, and where the fastest win is; services can switch to project references only after this.
- Pilot: move the single least dependent service. Package reference → project reference, tags prefixed, handover list closed. The skeleton CI runs on a real service for the first time; the required-check trap hit us here.
- Move the remaining services the same way. One PR per service.
- Verify the handover, then archive. Check that no webhook, deploy credential, open PR or external link is left on the old repo; run one deploy end to end from the new repo. An archive can be undone; a broken deploy chain found on Monday morning cannot. Then archive; do not delete.
- Run the co-change analysis. The day-based measure works on migrated history too; there is no need to wait. List the merge candidates; merging is a separate job.
- Move everything in one weekend. On Monday no CI runs.
- “We will fix the CI later.” Later means a 31-minute pipeline.
- Put
pull_request.pathson a required check. The first unrelated PR waits forever. - Keep publishing packages.
- Merge without prefixing the tags. Eight copies of
v1.0.0. - Merge services in the same PR as the move. Two changes, one rollback point.
- Delete the old repos.
What to watch, and what happened after 6 months
I said “measure” all through this post; ending without measuring is not an option. From 20 March to 18 September, 6 months:
- Shared-change lead time. From the PR opening in
libs/commonto the deploy of the last service that needs that change. Before: 3 days. After: across 11 PRs, a median of 5 hours, the longest 1 day (it waited for review). Forgotten services: 0, because there is no step to forget. - Service directories touched per PR. If it is always 8 of 8, the shared library knows too much; fix the boundary. For us, 2 of the 11
commonPRs touched 8 of 8 (both were log format changes); the rest touched 1–3 services. - CI time per service, p95. If the path filter works, it stays at 4 minutes. Ours was 4 min 40 s in the first week (the clone got bigger), and 4 min 10 s after
fetch-depth: 1. Above 10, somebody has widened the filter; a service that drops below 4 means somebody has narrowed it, which is just as bad. - Deploys per week. If it drops, the monorepo is scaring people. Ours went from an average of 9 before the migration to 14 after; most of the increase comes from
commonchanges shipping the same day. - Version matrix. It should have zero rows. Even one row means somebody is publishing a package. Ours: zero.
Checklist
- Which team does the repo boundary protect from which team today? If the answer is “nobody”, move.
- How many versions does the shared library ship per year? In the last year, how many bump PRs were opened, and how many were not?
- Would Renovate/Dependabot with auto-merge be enough? Which part of the work would it not solve?
- Does every service have its own CI and deploy workflow? When
libs/changes, which CI runs start, and how many deploys are triggered? - Is there a
pull_request.pathson any required check? Can an unrelated PR still be merged? - Do the lib references in the csproj match the
pathslist in the workflow? Who checks that? - Is shared code referenced as a package or as source? Does anyone outside the repo consume it?
- Was history moved with
filter-repo? Were the tags prefixed? Doesgit logsee the old commits? - What was handed over from the old repo: open PRs, issues, webhooks, secrets, badges, links?
- How is a single service rolled back: by image or by git? Where does a hotfix land?
- How many people run how many services? Which pairs changed on the same day; did you look at the poly and mono periods separately? Did you remove the noisy days and the bump commits?
- If an outside person joins the repo, what do they see? Which option is written down for that day?
- Were the old repos archived, or deleted?
Conclusion
Three years ago, 9 repos was the right decision. Today we are not reversing a right decision; we are removing the boundary because its reason has disappeared. The repo boundary existed so that 4 teams would not wait for each other. In a team of 3 there is nobody to wait for; the boundary stayed where it was, one value added to an enum cost three days, and one service was forgotten on the way. Nineteen times a year.
The monorepo removed that price: 3 days became 5 hours, and the forgotten-service count is
zero. It hides nothing else either: if the shared library knows too much, it is now visible in
every PR; if 8 services are too many for 3 people, git log says so. Fixing both is
separate work; but we could only measure both in a single repo.
The sentence to remember is Conway's sentence, applied to repos: repo structure is not an architectural choice; it is a reflection of team structure. If the team changed, the repos change too. If they do not, look at who is paying the price.