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, 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.

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. 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: common ships 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 of git blame is gone and v1.0.0 from 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 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 minStill 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 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; 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.

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 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 us7 PRs, 2 fixes
Thursday7 CI runs; one was flaky and was re-run twice. 7 reviews, 5 deploys9 CI runs, one person's whole day
Friday 16:30The remaining 2 deploys. The eighth PR was never opened: reporting 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 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.

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

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:

The bot solves
  • 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.
The bot does not solve
  • 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.

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 at the source level: every service compiles against the same common in 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.
What does not change
  • 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 common are 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.

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

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.

One CI workflow per service, with a path filter
# .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
Deploy is a separate workflow with a separate trigger
# .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:

On every PR: is each lib reference in the csproj also in the workflow's paths list?
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.

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. 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 common is 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 common moves to Newtonsoft 13, all 8 services move. We manage that centrally with Directory.Packages.props; the version lives in one file.
  • One target framework. admin was still on net6.0; moving it to net8.0 became 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:

For each old repo, with its history
# 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-repo touches exactly one service directory; merge --allow-unrelated-histories does 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-repo keeps 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 .editorconfig change, 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 day common ships is not coupling; we do not count commits that change only .csproj or lock files.
The two periods separately: which service pairs changed on the same day? (a day with more than 4 services touched is noise; a commit that changes only csproj/lock files is a bump)
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.

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

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:

In this order
  1. 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.
  2. Close the preconditions. Target framework alignment, Directory.Packages.props. 2 days for us.
  3. 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.
  4. 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.
  5. Move the remaining services the same way. One PR per service.
  6. 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.
  7. 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.
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.
  • Put pull_request.paths on 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/common to 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 common PRs 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 common changes shipping the same day.
  • Version matrix. It should have zero rows. Even one row means somebody is publishing a package. Ours: zero.

Checklist

Before you move
  • 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.paths on any required check? Can an unrelated PR still be merged?
  • Do the lib references in the csproj match the paths list 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? Does git log see 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.