What a monorepo changes about where cost lives
A monorepo keeps many services, libraries and tools in one version-controlled repository, with one commit history and usually one dependency graph. A multi-repo setup gives each service or library its own repository, release cycle and pipeline. Neither model is cheaper by default. The difference is where the spend lands and who pays it.
In a multi-repo setup, cost is spread across teams as coordination work. A shared library change means publishing a new version, opening a pull request in every consumer, and waiting for each one to merge. Consumers that fall behind drift onto old versions. Each repository also carries its own copy of pipeline config, which has to be patched separately when something changes. A monorepo turns that scattered work into a central platform bill. You pay for build-graph tooling, CI that only builds what changed, a repository that stays fast at scale, and ownership rules that replace the boundaries separate repos gave you for free.
| Cost line item | Multi-repo | Monorepo |
|---|---|---|
| Shared library changes | Version bump, publish, PR per consumer | One atomic commit across callers |
| Dependency drift | Consumers lag on old versions | Single version per dependency, enforced |
| Pipeline config | Duplicated per repo | Centralised, but more complex |
| Build tooling | Standard per-language tools | Build graph, caching, affected-target detection |
| Repository scale | Small repos, cheap clones | Large history, needs Git tuning |
| Access and ownership | Repo permissions | Path-level ownership rules |
Teams usually adopt a monorepo when the coordination column has become the bigger cost. A common trigger is that cross-repo changes take longer than writing the code itself.
How it works: build graphs, affected-target detection and caching
A monorepo only pays off if CI cost tracks the size of the change, not the size of the repository. Three mechanisms make that possible.
The dependency graph. Every buildable unit (a library, a service, a test suite) declares what it depends on. The build tool turns those declarations into a directed graph. Because the graph is explicit, the tool can work out which targets a change could affect.
Affected-target detection. CI diffs the commit against its base, maps the changed file paths to the targets that own them, and then walks the graph in reverse to find every downstream dependent. Only those targets get built and tested. A change to one service's handler touches a few targets. A change to a shared logging library touches many, which is correct.
Caching. Each target's output is keyed by a hash of its inputs: sources, dependencies, toolchain and flags. If the key already exists in the local or shared remote cache, the result is reused instead of rebuilt.
commit ─► changed paths ─► graph: owners + reverse deps ─► affected set
│
cache hit? ◄─────────────┘
├─ yes ─► reuse artifact
└─ no ──► build/test ─► write to cache
This tooling is the dominant new line item. Someone has to keep dependency declarations accurate, keep the cache healthy and keep build hermetic. Give it a named owner.
When that ownership is missing, CI minutes often go up after migration. Undeclared dependencies force teams to fall back to "test everything." Non-deterministic inputs such as timestamps or unpinned tools change the hash keys, so the cache never hits. The result is that every commit pays for the whole repository.
+-----------+ +-------------------+ +---------------------+
| commit |---->| changed paths |---->| dependency graph |
| vs base | | (git diff) | | owners + reverse |
+-----------+ +-------------------+ | dependents |
+----------+----------+
|
v
+---------------------+
| affected set |
| (targets to run) |
+----------+----------+
|
v
+---------------------+
| hash inputs: |
| src, deps, tools, |
| flags -> cache key |
+----------+----------+
|
+----------------------------+
v
+----------------------+
| local / remote cache |
+----+------------+----+
| |
hit | | miss
v v
+-------------+ +--------------------+ +---------------+
| reuse | | build + test only |-->| write result |
| artifact | | affected targets | | to cache |
+-------------+ +--------------------+ +---------------+
A commit's changed paths go through the dependency graph to find the affected targets. Each target is hashed into a cache key, and only cache misses get built and tested.
Refactoring ease and the engineering time it saves
The main payoff is the atomic change. In a monorepo, you can rename a function, change its signature and update every caller in one commit. That gives you one code review and one CI run. If the change breaks something, you have one revert path. The type checker and test suite check the producer and every consumer against the same snapshot, so a broken caller fails before merge rather than after release.
Across many repositories, the same change becomes a coordinated release. You publish the new API next to the old one and add a compatibility shim. You announce a deprecation window, then bump versions in each consumer repo in sequence. You keep the shim alive until the last consumer moves. Each step is a separate review, and someone has to track the stragglers.
Single-version dependency policy
Most monorepos also allow only one version of each third-party library. This has two benefits:
- Faster security patches. A vulnerable library is patched in one place instead of in every repository that pins it.
- No diamond conflicts. Two internal libraries can never require incompatible versions of the same transitive dependency.
The cost is that every upgrade must land everywhere at once. One team with brittle tests or an unmaintained service can block an upgrade for the whole organization.
Illustrative estimate (assumed numbers, not a benchmark):
Time saved per cross-cutting change = (consumer repos × hours per coordinated bump) − hours for one atomic change
With assumed inputs of 12 repos × 3 hours, minus 8 hours, this gives 28 engineer-hours saved per change. Substitute your own repo count and hours.
A cost model you can fill in with your own numbers
To turn per-change savings into a monthly decision, compare coordination time saved against the new recurring costs, and spread the one-time migration across the period you expect to benefit from it:
net monthly hours = (cross-repo changes × hours saved per change) − tooling ownership hours − (migration hours ÷ N months)
CI minutes saved = builds per month × minutes per build × cache hit rate
Keep CI minutes separate from engineer hours. They usually come from different budgets, and combining them can hide a loss in one behind a gain in the other.
Illustrative estimate (assumed numbers, not a benchmark)
| Input / result | Assumed value |
|---|---|
| Services | 30 |
| Cross-repo changes per month | 4 |
| Hours saved per change | 28 |
| Tooling ownership hours per month | 60 |
| Migration hours, amortized over N months | 1,200 over 24 = 50/month |
| Builds per month × minutes per build | 3,000 × 25 = 75,000 |
| Cache hit rate | 60% |
| Net engineer hours per month | 112 − 60 − 50 = +2 |
| CI minutes saved per month | 45,000 |
With these inputs, the monorepo barely pays for itself in engineer time, and almost all of the gain comes from CI caching. The cache hit rate matters because a cold monorepo build can cost more than a single small repo's build. That means a low hit rate can turn the CI line negative.
Sensitivity: Coordination time is the input teams estimate least reliably. Some forget review delays and release scheduling and guess too low. Others remember one bad migration and guess too high. If the real saving is 14 hours per change instead of 28, net time falls to −54 hours per month. Before you commit, measure hours from your last several cross-repo changes.
# Monorepo cost model: illustrative estimate with assumed inputs, not a benchmark.
# Engineer hours and CI minutes stay separate because they come from different budgets.
def net_monthly_hours(changes, hours_saved_per_change, tooling_hours,
migration_hours, amortize_months):
saved = changes * hours_saved_per_change
migration_per_month = migration_hours / amortize_months
return saved - tooling_hours - migration_per_month
def ci_minutes_saved(builds, minutes_per_build, cache_hit_rate):
# Simple model: each cache hit avoids one full build's minutes.
return builds * minutes_per_build * cache_hit_rate
assumed = dict(
changes=4, # cross-repo changes per month
hours_saved_per_change=28,
tooling_hours=60, # monthly tooling ownership
migration_hours=1200, # one-time migration cost
amortize_months=24, # N months to spread it over
)
print("Net engineer hours/month:", net_monthly_hours(**assumed)) # +2.0
print("CI minutes saved/month:", ci_minutes_saved(3000, 25, 0.60)) # 45000.0
# Sensitivity: coordination time is the least reliable input, so sweep it.
for hours in (14, 21, 28, 35):
result = net_monthly_hours(**{**assumed, "hours_saved_per_change": hours})
print(f"{hours:>2} h saved/change -> {result:+.0f} h/month") # 14 h -> -54
A short Python script that implements the section's formulas, reproduces the illustrative estimate (+2 engineer hours and 45,000 CI minutes per month), and varies hours saved per change to show how sensitive the result is to that one input.
Implementing it: Git scale, workstation cost and governance
Git and workstation scale
The first cost a developer feels is a slow clone. Full history, binaries and every team's code all arrive on each laptop. Three Git techniques reduce this:
- Shallow clones (
git clone --depth=1) skip history. They suit CI jobs that only build the current commit. - Partial clones (
git clone --filter=blob:none) fetch file contents only when a checkout needs them. Developers keep full history without downloading every blob up front. - Sparse checkout (
git sparse-checkout set services/billing libs/auth) limits the working tree to the directories you work in.
Sparse checkout also reduces IDE indexing time, which slows down well before Git does. Scope editor projects to the same directories.
Governance in tooling
Shared ownership only works when rules are enforced by tooling. A CODEOWNERS-style file maps directories to required reviewers, so a change to libs/auth cannot merge without that team's approval. Declare visibility and dependency constraints in the build tool and fail the build when a service imports another team's internals. Rules kept only in a wiki stop being followed.
A merge queue tests each change against the latest main before it lands, which keeps main green when hundreds of engineers share it. Test quarantine moves a known-flaky test out of the blocking path and assigns an owner, so one bad test does not stop every merge.
Migration as a one-time cost
Budget for migration separately. Import each repository with its history rewritten into a subdirectory so git log and blame still work. Then rewrite pipelines around affected-target builds and re-point deployments to new artifact paths. Run the old and new setups in parallel until their outputs match. Expect to pay for both setups during that period.
#!/usr/bin/env bash
# Developer setup: keep full history, but download file contents lazily
# and only materialize the directories this team works in.
set -euo pipefail
REPO_URL="${REPO_URL:?set REPO_URL to your monorepo remote}"
# Partial clone: commits and trees now, blobs on demand.
# --sparse starts with only top-level files in the working tree.
git clone --filter=blob:none --sparse "$REPO_URL" monorepo
cd monorepo
# Limit the working tree to the paths you own or depend on.
# Missing blobs for these paths are fetched now; others never are.
git sparse-checkout set services/billing libs/auth
# Widen later without re-cloning when you pick up new work.
git sparse-checkout add libs/payments-client
# Confirm what is checked out; point the IDE at these same paths.
git sparse-checkout list
# History and blame still work, fetching old blobs as needed.
git log --oneline -5 -- libs/auth
# CI variant: a job that only builds the current commit needs no history.
# git clone --depth=1 --single-branch --branch main "$REPO_URL" ci-checkout
Combining a partial clone with sparse checkout gives developers full history while downloading and indexing only the directories they work in, with a shallow clone shown as the CI alternative.
Who should use a monorepo and when to avoid it
That double-running cost only pays off if the monorepo fits how your code actually changes. The strongest signal is how often one change touches several components. Say a shared library or internal API change usually forces edits in several services. Across separate repositories, each of those is a coordinated release. In a monorepo it becomes a single atomic commit. The second signal is ownership. Someone has to keep the build graph correct, the cache healthy and CI fast. If no platform team treats the build system as its own product, you end up with one large repository and one slow pipeline.
| Signal | Favors monorepo | Favors multi-repo |
|---|---|---|
| Cross-component changes | Frequent shared-library and API edits | Rare. Contracts are stable and versioned |
| Build tooling ownership | A platform team can staff it | No one has time to own it |
| Release cadence | Services ship together or continuously | Each service ships on its own schedule |
| Access and compliance | Most engineers may read most code | Strict per-team or regulatory boundaries |
| Team size | Many teams sharing code | A few small teams with little overlap |
If several of these signals point to multi-repo, the monorepo's known risks become likely outcomes:
- Slow CI: without remote caching and affected-target detection, CI rebuilds far more than each change needs.
- Hidden coupling: imports are so easy that services pick up dependencies no one meant to add.
- Stalled upgrades: a framework or language upgrade can't land until every consumer is fixed, so it blocks the whole organization.
- Coarse access control: fine-grained permissions are harder to enforce in one repository than separate repositories make them.
Alternatives and middle-ground options
If the cost you're carrying is one or two of those line items rather than all of them, a full migration may be the wrong fix. Three narrower options each target a different part of the coordination bill.
Polyrepo with dependency update bots and shared pipeline templates. Bots open pull requests when a shared library releases, so version drift gets surfaced instead of discovered. Templates keep CI definitions consistent across repositories. Neither gives you atomic cross-repo changes. A breaking API change still requires a release, a wave of bot PRs, and waiting for each owner to merge.
A few domain monorepos. Group services that change together, such as payments or ingestion, into one repository per domain. Refactors inside a domain become atomic, and each repository stays small enough for stock Git and simpler build tooling. The trade-off is that cross-domain changes still pay polyrepo costs, so draw boundaries along actual change frequency rather than the org chart.
Main monorepo plus separate sensitive repositories. Keep cryptographic material, regulated code or acquired codebases outside, with their own access rules. This addresses coarse permissions directly. The price is that anything crossing that boundary goes back to versioned interfaces.
| Option | Reduces | Leaves in place |
|---|---|---|
| Polyrepo + bots + templates | Version drift, CI duplication | Non-atomic cross-repo refactors, release coordination |
| Domain monorepos | Intra-domain refactor cost, Git scale pressure | Cross-domain coordination |
| Monorepo + sensitive repos | Access-control complexity | Interface versioning at the boundary |
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to monorepo cost benefit, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.