Home → Engineering
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, wait for 8 CI runs, 8 reviews, 8 deploys. The last one went live on Friday at 16:30. 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.
- 20 minutes of work in the shared library, 3 days of delivery. 8 bump PRs, 8 CI runs, 8 reviews; one service was forgotten. In a monorepo the same work is one PR, one review, one commit.
- Monorepo ≠ monolith. One repo, but every service deploys on its own. 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; six months later the pipeline is 25 minutes and you are talking about splitting repos again.
- Move the history, do not copy it. With
git filter-repo; otherwise two years ofgit blameis gone. - 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; the migration is a chance to merge the services that always change together.
What does a repo boundary solve?
We started the project three years ago with 4 teams and 14 people: payments, accounts,
reporting, integrations. Each team had its own services, each service its own repo, and a
common library in the middle. 9 repos. It was the right decision for that day,
and the reasons were concrete:
- The payment 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.
| 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 | 4 min — but 8 times |
| 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. A new limit rule needs one value added to
the ErrorCode enum: LimitExceeded. The change is in
common; 8 services consume it.
| 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 8 services (1.13 → 1.14). Two fail to compile: an old switch does not know the new value | 8 PRs, 2 fixes |
| Thursday | 8 CI runs (one flaky, two retries), 8 reviews, 5 deploys | One person's whole day |
| Friday 16:30 | The remaining 3 deploys. The reporting service 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 question “which service is on which
common?” needed a spreadsheet. 5 services on 1.14, 2 on 1.13, 1 still on
1.11. Nobody knew it by heart.
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.- 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.
- Services keep calling each other at runtime exactly as before.
- One service's failure does not roll back another service's deploy.
- There is no “deploy everything” button. There will not be one.
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: four 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 Nx, Turborepo or Bazel; the paths filter
in GitHub Actions was enough.
# .github/workflows/payments.yml
name: payments
on:
push:
branches: [main]
paths:
- "services/payments/**"
- "libs/common/**" # shared code changed -> this service is tested too
- ".github/workflows/payments.yml"
pull_request:
paths:
- "services/payments/**"
- "libs/common/**"
jobs:
build:
runs-on: ubuntu-latest
defaults:
run:
working-directory: services/payments
steps:
- uses: actions/checkout@v4
- run: dotnet test # only this service; 4 min, same as before
A path filter is the simplest form of a dependency graph: when libs/common
changes, the 8 workflows that depend on it run; when services/payments changes,
only one runs. Past 30 services this cannot be managed by hand, and that is the day you talk
about Nx or Bazel. With 8 services you do not.
2. 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. The forgotten reporting service could not be forgotten either; it would not compile.
3. 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 wants a clean clone)
git clone --no-local git@github.com:company/payments-service.git /tmp/payments
cd /tmp/payments
# 2) move all paths under services/payments/ across the whole history
git filter-repo --to-subdirectory-filter services/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
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?
git log --oneline --follow -- services/payments/Payments.csproj | tail -3
About 15 minutes per service. The old repos are not deleted; they are archived read-only. A link, a CI reference or an old document may still point there.
4. 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. Today we are 3 people, so this is not a problem.
If an outside person joins tomorrow, CODEOWNERS limits who reviews, not who reads.
We did not solve this today; we wrote it down as a note. The day it must be solved is clear:
the day the first external account is created.
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.
git log --since="6 months ago" --pretty=format:'%H' --name-only \
| awk '
function flush() {
for (i = 1; i <= n; i++)
for (j = i + 1; j <= n; j++)
pair[s[i] " + " s[j]]++
n = 0; delete seen
}
/^[0-9a-f]{40}$/ { flush(); next } # new commit
/^services\// { split($0, p, "/"); d = p[2]
if (!(d in seen)) { seen[d] = 1; s[++n] = d } }
END { flush(); for (k in pair) print pair[k], k }
' | sort -rn | head -5
# output (ours):
# 41 accounts + limits
# 23 payments + accounts
# 9 payments + notifications
# 2 reporting + accounts
# 0 integrations + (anything)
The numbers say two things. accounts and limits changed in the same
commit 41 times in six months; these are not two services, they are one service talking over
the network. Merge candidate number one. integrations changed together with nobody
in six months; it stays separate, untouched. Merging is not done in the same PR as the
migration: move first, measure, then merge.
Migration order
- Set up CI in an empty monorepo. Path filter, one workflow per service, independent deploy. Try it with one service.
- Move the shared libraries. That is where the pain is, and where the fastest win is.
- Move the services, least dependent first. With each move, package reference → project reference. One PR per service.
- Run the co-change analysis. List the merge candidates; merging is a separate job.
- Archive the old repos. Do not delete them.
- Move everything in one weekend. On Monday no CI runs.
- “We will fix the CI later.” Later means a 31-minute pipeline.
- Keep publishing packages.
- Merge services in the same PR as the move. Two changes, one rollback point.
- Delete the old repos.
What to watch
- Shared-change lead time. From the PR opening in
libs/commonto the last service's deploy. Ours was 3 days; the target is the same day. - Service directories touched per PR. If it is always 8 of 8, the shared library knows too much; fix the boundary.
- CI time per service, p95. If the path filter works, it stays at 4 minutes. Above 10, somebody has broken the filter.
- Deploys per week. If it drops, the monorepo is scaring people; the feeling of “will I break something” has grown.
- Version matrix. It should have zero rows. Even one row means somebody is publishing a package.
Checklist
- Which team does the repo boundary protect from which team today? If the answer is “nobody”, move.
- How many PRs and how many days did the last shared-library change take? How many services were forgotten?
- Does every service have its own workflow and path filter? Which workflows run when
libs/changes? - Is shared code referenced as a package or as source?
- Was history moved with
filter-repo? Doesgit log --followsee the old commits? - How many people run how many services? Which pairs changed together in the last 6 months?
- If an outside person joins the repo, what do they see? Is there a note 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, the reason for it 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.
The monorepo removes that price, and it hides nothing else: 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: 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.