Sertaç Yıldırım field notes

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.

In short
  • 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 of git blame is 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 usWith 14 peopleWith 3 people
Independent review4 teams do not wait for each otherThe same 3 people review everything anyway
Separate accessThe agency sees one repoNo agency
Small, fast CI4 min4 min — but 8 times
Separate release rhythmPayments 3/week, reporting 1/monthThe same person ships all of them
The price: coordinating shared codeSpread over 4 teamsCarried by the whole team
The repo boundary reflects the team boundary. If there is one team, there can be one repo.

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.

TimeWhat happenedCost
Wednesday 10:15Enum value added in common, PR opened20 min
Wednesday 11:00Review, merge, tag 1.14.0, package published45 min
Wednesday 11:30–17:00Bump PRs in 8 services (1.13 → 1.14). Two fail to compile: an old switch does not know the new value8 PRs, 2 fixes
Thursday8 CI runs (one flaky, two retries), 8 reviews, 5 deploysOne person's whole day
Friday 16:30The remaining 3 deploys. The reporting service is still on 1.13: nobody touched it that week, the bump was forgotten7 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.

A boundary that blocks nobody has one remaining function: charging a price.

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.

What changes
  • One repo: 8 services under services/, shared code under libs/.
  • common is 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.
What does not change
  • 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.

A monorepo does not fix a bad shared-library boundary; it shows it.

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.

One workflow per service, with a path filter
# .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.

Before: package, version matrix
<!-- services/payments/Payments.csproj -->
<PackageReference Include="Company.Common" Version="1.13.0" />
<!-- 8 services, 8 different lines; nobody knows which one is current -->
After: source, always HEAD
<!-- 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:

For each old repo, with its history
# 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.

Last 6 months: which service pairs changed in the same commit?
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.

Eight services for three people means eight alert sets. Merging repos does not change that.

Migration order

In this order
  1. Set up CI in an empty monorepo. Path filter, one workflow per service, independent deploy. Try it with one service.
  2. Move the shared libraries. That is where the pain is, and where the fastest win is.
  3. Move the services, least dependent first. With each move, package reference → project reference. One PR per service.
  4. Run the co-change analysis. List the merge candidates; merging is a separate job.
  5. Archive the old repos. Do not delete them.
Do not do these
  • 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/common to 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

Before you move
  • 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? Does git log --follow see 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.