What monorepo anti-patterns are and why they appear

A monorepo is a single repository that holds many services and libraries. Monorepo anti-patterns are not caused by putting everything in one repository. They come from what usually goes missing along the way: dependency rules that say which modules may import which, and build discipline that limits each build to the code a change actually affects. A polyrepo setup enforces some separation by default, because crossing a repository boundary takes deliberate effort. In a monorepo, any file can import any other file, so the structure stays clean only if someone defines the rules and enforces them.

Teams adopt monorepos for good reasons. An API change and every caller of that API can land in one atomic commit. Linting, testing and release tooling are set up once and shared. Code reuse means importing a module instead of publishing a package and waiting for consumers to upgrade. Each of these benefits has a failure mode when nothing enforces the structure:

Benefit adopted for What happens without enforcement
Atomic cross-cutting changes Every commit rebuilds and retests everything
Shared tooling One overloaded pipeline that nobody clearly owns
Easy code reuse Tangled imports and a "common" library everything depends on

This guide is for backend and platform engineers who run or have inherited a monorepo and recognise these symptoms. Its main argument is that you should fix the structure before you pay for more compute. Bigger CI runners make an unbounded build finish sooner, but the build still covers the whole repository. Explicit, machine-checked boundaries make the build smaller.

When a monorepo fits, and when to apply these practices

A monorepo is a good fit when the code shares a lot: common libraries, one primary language or runtime, teams that often make changes spanning several services, and an organisation willing to fund shared build and CI tooling. It fits less well when parts of the code need hard access restrictions, release on unrelated schedules, or share almost nothing with the rest.

The practices in this guide are worth applying when you see these signals:

  • CI time grows with repository size, not with change size. A one-line fix takes about as long to verify as a large refactor.
  • Small edits to shared code trigger rebuilds across most of the repository. This usually points to a catch-all library or undeclared global inputs.
  • Refactors stall on unexpected breakage in packages that should not have depended on the changed code.
  • Nobody can say who owns a shared package, or approval for a change to it requires chasing several teams.
  • Dependency upgrades are left half-finished, so several versions of the same library coexist.

Timing matters. Boundary rules and a dependency-graph check are cheap to add while the repository is small, and expensive to retrofit once hundreds of violations exist. If you are starting a monorepo, add tagging and a graph check on day one. If you have inherited one, use the remediation order at the end of this guide.

How a monorepo build works

Most of the fixes below depend on one model, so it is worth stating explicitly.

  1. Targets. The repository is divided into units the build system knows about: packages, libraries, services, test suites. Each target declares its source files, its configuration and the other targets it depends on.
  2. The dependency graph. Those declarations form a directed graph. An edge from orders to billing-contracts means orders cannot build without it. For build ordering to work, the graph must have no cycles.
  3. Change detection. For a given commit, CI computes the changed files (for example with git diff --name-only against the merge base), maps each file to the target that owns it, and walks the graph in reverse to find every target that depends on those targets, directly or indirectly. That set is the affected set.
  4. Content-addressed caching. Each build or test action is keyed by a hash of its declared inputs: source files, dependency outputs, toolchain versions, relevant flags and environment. If the key has been seen before, the stored output is reused instead of recomputed. A remote cache shares those outputs across CI runners and developer machines.
  5. Boundary checks. Separately from building, a check reads the same graph and rejects edges that break the rules, such as a library depending on an app or a service importing another service's internals.

Every anti-pattern in this guide is a way of breaking one of these steps. A god library makes the affected set nearly everything. Cycles make the graph unorderable and the boundaries meaningless. Undeclared inputs make change detection miss things and cache keys wrong. Non-determinism makes cache keys stable while outputs vary, so cached results cannot be trusted.

The god library and circular dependencies: how the graph decays

That decay usually starts in one place: a package named common, utils or shared. It begins as a home for a date formatter and a logger. Because it is already imported everywhere, adding code to it is easier than creating a new package, so it absorbs database helpers, HTTP clients and domain types. The build system sees one input. A one-line change to a string helper invalidates the cache for every dependent package, and CI rebuilds and retests most of the repository.

Split it by domain and by rate of change. Stable primitives that rarely change belong apart from fast-moving business logic, and each piece needs a single owning team. Do not over-split. Dozens of three-file packages create a fragmented graph with high coordination cost and no real isolation.

Cycles cause the other kind of decay. Generate the dependency graph and look for strongly connected components. Common causes are shared types placed in the wrong layer and convenience imports, where one service reaches into another's internals. There are three standard fixes. You can extract a contracts package holding interfaces, invert the dependency so the low-level module defines the interface it needs, or merge packages that always change together because they are one unit.

Before                      After
orders ──► common ◄── billing    orders ──► billing-contracts ◄── billing
   ▲         │                     │                                │
   └─────────┘ (cycle)             └──► time-utils ◄────────────────┘

A contracts package can become the next catch-all. Give it an owner and keep it limited to cross-boundary types.

 BEFORE: one catch-all package plus a cycle
 ┌──────────┐    imports internals      ┌──────────┐
 │  orders  │──────────────────────────►│ billing  │
 │          │◄──────────────────────────│          │
 └────┬─────┘        (cycle)            └────┬─────┘
      │                                      │
      ▼                                      ▼
 ┌─────────────────────────────────────────────────┐
 │ common: date formatting, logger, db helpers,    │
 │ HTTP clients, domain types                      │
 │ one edit invalidates every dependent's cache    │
 └─────────────────────────────────────────────────┘

 AFTER: split by domain and rate of change; no cycle
 ┌──────────┐                           ┌──────────┐
 │  orders  │                           │ billing  │
 └──┬────┬──┘                           └──┬────┬──┘
    │    │    ┌───────────────────────┐    │    │
    │    └───►│   billing-contracts   │◄───┘    │
    │         │ cross-boundary types  │         │
    │         └───────────────────────┘         │
    │                                           │
    │         ┌───────────────────────┐         │
    └────────►│ time-utils, logger    │◄────────┘
              │ stable primitives     │
              └───────────────────────┘

Before, orders and billing import each other's internals and both depend on a catch-all common package. After, they share only a narrow, owned contracts package and a separate package of stable primitives, which removes the cycle and limits how far a change spreads.

How to enforce module boundaries mechanically

Boundaries that exist only in a wiki erode with every deadline. They hold when a machine rejects the violation before a human has to argue about it.

Start with three rules. Apps may depend on libs. Libs may not depend on apps. Services may not import each other's internals. To make these checkable, tag every package with a type (app, lib, service) and give each service a public entry point. Anything outside that entry point is private. A cross-service import that targets a private path is a violation, even when the language allows it.

You can enforce this in three ways, and they can be combined:

  • Lint rules that match import paths against allowed tag pairs. Editors surface the error immediately, which is cheap feedback.
  • Visibility settings in the build system. These declare which packages may consume a target, so a disallowed edge fails to build at all.
  • A graph check in CI. The job extracts the dependency graph, tests each edge against the rules, fails on cycles, and prints the offending path.

Ownership files assign each shared package to a team, and branch protection makes that team's approval required. Treat shared libraries as internal products. Each one needs a stated API, a changelog, and a deprecation process, because consumers depend on stability, not on implementation details.

Strict enforcement has a cost. A legitimate cross-cutting change, such as a logging migration, can need a dozen approvals. When the friction is high enough, engineers copy the code instead of importing it. Keep an exception path for these cases: a documented allowlist entry with an owner and an expiry date. Track how many entries exist. If the allowlist keeps growing, the rule is probably wrong and should be revisited.

#!/usr/bin/env python3
"""CI gate: fail the build when the package graph breaks layering rules or has cycles.
Usage: python3 check_graph.py graph.json allowlist.json
graph.json:     {"libs/log": {"type": "lib", "imports": ["services/billing/internal/db"]}}
allowlist.json: [{"from": "...", "to": "...", "owner": "team-x", "expires": "2026-12-31"}]
"""
import json, sys
from datetime import date

graph = json.load(open(sys.argv[1]))
allow = json.load(open(sys.argv[2]))
today = date.today().isoformat()
allowed = {(a["from"], a["to"]) for a in allow if a["expires"] >= today}
errors = []

def pkg_of(path):  # longest package name that prefixes the import path
    return max((p for p in graph if path == p or path.startswith(p + "/")), key=len, default=None)

for src, meta in graph.items():
    for imp in meta["imports"]:
        dst = pkg_of(imp)
        if dst in (None, src) or (src, imp) in allowed:
            continue
        s, d = meta["type"], graph[dst]["type"]
        if s == "lib" and d != "lib":
            errors.append(f"{src} -> {imp}: libs may only depend on libs")
        if s == "service" and d == "service" and imp != dst:  # only the bare package name is public
            errors.append(f"{src} -> {imp}: private internals of another service")

edges = {s: {pkg_of(i) for i in m["imports"]} - {None, s} for s, m in graph.items()}
state = {}
def visit(n, path):  # depth-first search; an edge back to an active node is a cycle
    state[n] = "active"
    for m in edges[n]:
        if state.get(m) == "active":
            errors.append("cycle: " + " -> ".join(path[path.index(m):] + [m]))
        elif m not in state:
            visit(m, path + [m])
    state[n] = "done"
for n in graph:
    if n not in state: visit(n, [n])

print(f"allowlist: {len(allowed)} active entries")  # watch this number grow over time
print("\n".join(errors) or "dependency graph OK")
sys.exit(1 if errors else 0)

A tool-neutral CI script that checks a dependency graph exported as JSON against the app/lib/service layering rules, honours an allowlist whose entries expire, detects cycles, and exits non-zero so the build fails on any violation.

Diagnosing slow CI and making builds proportional to the change

Before changing anything, measure. Split each pipeline's wall-clock time into four buckets: queueing (waiting for a runner), dependency installation, compilation, and tests. Each bucket has a different fix. Long queues need more runner capacity, not faster builds. Slow installs point to cache misses. Compilation and tests are where proportionality matters. Next, list every step that ran on a recent docs-only commit. Any step that appears there runs unconditionally, so it is a candidate for scoping.

Affected-target builds work by computing which files changed, mapping those files to their owning targets, and walking the dependency graph in reverse to find everything that depends on them. CI then builds and tests only that set. The result is only correct if the graph sees every input. Common hidden inputs include:

  • A root tsconfig or lint config that every package inherits
  • Generated code whose generator template lives elsewhere
  • Environment variables read at build time
  • The root lockfile

Declare each one as an explicit input of the targets it affects. A lockfile change should invalidate every target that resolves dependencies through it.

Illustrative estimate. Assume 400 targets, a full run of 40 minutes, and a typical change that touches 30 targets. Scoped builds would run in roughly 3–5 minutes, plus fixed setup time.

Keep a scheduled full build anyway, nightly or on merges to main. Undeclared inputs will slip past the graph eventually, and the full build is how you find them.

#!/usr/bin/env bash
set -euo pipefail

BASE_REF="${BASE_REF:-origin/main}"
changed_files=$(git diff --name-only "${BASE_REF}...HEAD")

GLOBAL_INPUTS=(
  "tsconfig.base.json"     # inherited compiler config
  ".eslintrc.json"         # inherited lint config
  "pnpm-lock.yaml"         # root lockfile: dependency resolution for every package
  "codegen/templates/"     # generator templates for code under */generated/
)

env_fingerprint=$(printf 'API_BASE_URL=%s\nFEATURE_FLAGS=%s\n' \
  "${API_BASE_URL:-}" "${FEATURE_FLAGS:-}" | sha256sum | cut -d' ' -f1)
echo "build env fingerprint: ${env_fingerprint}"

full_build=false
while IFS= read -r file; do
  [[ -z "$file" ]] && continue
  for input in "${GLOBAL_INPUTS[@]}"; do
    if [[ "$file" == "$input"* ]]; then
      echo "global input changed: $file"
      full_build=true
    fi
  done
done <<< "$changed_files"

if [[ "$full_build" == true || "${SCHEDULED_FULL_BUILD:-false}" == true ]]; then
  ./scripts/build-all.sh          # nightly safety net or global change
else
  # Map changed files to owning targets and walk reverse dependencies.
  ./scripts/build-affected.sh --env-key "$env_fingerprint" $changed_files
fi

A CI entry script that treats shared configs, the root lockfile, codegen templates and build-time env vars as explicit inputs, falling back to a full build when they change or on a schedule.

Using build caching correctly

A cache only pays off when the same inputs reliably produce the same outputs. Caching tools key each action on a hash of its declared inputs. If a step reads anything it didn't declare, or writes different bytes on each run, every downstream key changes and the cache keeps missing without reporting an error.

Common sources of non-determinism

  • Timestamps. Build dates embedded in binaries, or archive entries stamped with file modification times, make otherwise identical artifacts hash differently. Normalise them, or fix them to a constant.
  • Unpinned tools. A compiler or code generator picked up from a developer's PATH is an undeclared input. Pin toolchains and include them in the action key.
  • Network access in tests. A test that pulls live data is not reproducible, so a cached pass proves nothing. Block network access in the sandbox and treat any test that needs it as an integration test with its own lane.

Checking that the cache works

Build the same commit twice on clean machines and compare output hashes. Any difference points to a leak. Track hit rate per target over time. A sudden drop after a toolchain or CI image change usually means a new non-deterministic input, not a slower codebase.

Running a remote cache

A remote cache is production infrastructure. It needs storage with an eviction policy, monitoring, and capacity planning. It is also a supply-chain risk: anyone who can write an entry can put arbitrary artifacts into other people's builds. Give write access only to trusted CI, keep developer access read-only, and have the platform team own it with an on-call rotation.

#!/usr/bin/env bash
set -euo pipefail

COMMIT="${1:-HEAD}"
BUILD_CMD="${BUILD_CMD:-make dist}"   # placeholder for your real build entry point
OUT_DIR="dist"

export SOURCE_DATE_EPOCH="$(git log -1 --format=%ct "$COMMIT")"
export TZ=UTC LC_ALL=C

pack() {
  tar --sort=name --mtime="@${SOURCE_DATE_EPOCH}" --owner=0 --group=0 \
      --numeric-owner -C "$1" -cf - . | gzip -n > "$2"
}

WORK="$(mktemp -d)"
trap 'git worktree remove --force "$WORK/a" 2>/dev/null || true
      git worktree remove --force "$WORK/b" 2>/dev/null || true
      rm -rf "$WORK"' EXIT

build_once() {
  git worktree add --detach "$WORK/$1" "$COMMIT" >/dev/null
  (cd "$WORK/$1" && $BUILD_CMD)
  # Hash every output in a stable order so the two runs can be diffed line by line.
  (cd "$WORK/$1/$OUT_DIR" && find . -type f -print0 | sort -z | xargs -0 sha256sum) \
    > "$WORK/$1.sha256"
}

build_once a
build_once b

if diff -u "$WORK/a.sha256" "$WORK/b.sha256"; then
  echo "reproducible: outputs match for $COMMIT"
else
  echo "NON-DETERMINISTIC: the files listed above differ between runs" >&2
  exit 1
fi

A script that builds one commit twice in clean worktrees with time and locale pinned, then diffs the hashes of every output file, so any non-determinism that would break caching shows up as a named file.

Version sprawl, single-version policy and the distributed monolith

Third-party versions are the next source of drift. Letting each package pin its own versions feels flexible, but every package becomes its own compatibility matrix, and a security fix turns into dozens of separate upgrades.

Aspect Per-package versions Single-version policy
Upgrade effort Spread out, often never finished One coordinated change
Diamond conflicts Common at link time or runtime Prevented by construction
Blast radius of an upgrade One package Every consumer
Fits best Loosely related code, vendored tools Shared libraries, one runtime

A single-version policy forces the owner of an upgrade to fix every consumer. That cost is the reason the policy works. To avoid a long-lived branch, land compatibility shims first. Then temporarily allow both versions through an explicit allowlist with an expiry date, migrate consumers in small trunk commits, and delete the old version last.

A monorepo can also hide a distributed monolith: services that read each other's tables, import each other's internal types, or have to deploy in lockstep. Give each service sole ownership of its data. Have services talk only through versioned, published contracts, and verify those contracts with tests. A service should build, test and release from its own targets without anyone else's deploy.

Split the repository when the code has hard access boundaries, independent release cadences, or no shared code. A split has its own costs. A change that touches several repositories becomes a multi-step publish-and-bump sequence, and keeping versions consistent becomes manual work. Unclear ownership and teams that can't agree on upgrades are organizational problems. Tooling won't solve them.

Remediation order and impact on cost and engineering time

The technical debt, though, can be paid down in an order where each step makes the next one cheaper.

A sequence that avoids rework

  1. Measure. Record CI duration (median and slowest runs), queue time and how often main breaks. Without a baseline, you can't show that later work paid off.
  2. Map the graph. List the cycles and the modules with the most dependents.
  3. Add boundary checks in warn-only mode. The violation count becomes visible, and nobody's merge is blocked.
  4. Break the worst cycles and split the god library. Affected-build detection is only as precise as the graph. While one library still sits under everything, almost every change looks like it affects everything.
  5. Turn on affected builds and caching. They pay off right away once the graph is clean enough to narrow the set of targets.

Warn-only mode should not last forever. Add a ratchet: CI fails if the violation count rises, and the allowed maximum drops each time someone fixes a violation. Set a deadline after which the check blocks merges, and assign each remaining violation to a team.

Step Upfront cost What it saves
Boundary checks Rule setup and upkeep New coupling caught in review
Cycle and god-library fixes Refactoring effort Rebuilds of unrelated code, unclear ownership
Affected builds and caching Cache infrastructure, keeping builds hermetic CI minutes per change, broken builds on main

Illustrative estimate (assumed numbers, not a benchmark): hours saved per month = engineers × CI runs per engineer per day × minutes saved per run × working days ÷ 60. With 40 engineers, 4 runs a day, 10 minutes saved per run and 20 working days, that is 40 × 4 × 10 × 20 ÷ 60 ≈ 533 hours. Engineers often do other work while CI runs, so count only the share of that time spent blocked. Then compare the result with the one-time remediation effort.

Alternatives to fixing the monorepo in place

Restructuring the graph is not the only option. Each alternative below solves part of the problem and leaves the rest.

  • Scale CI hardware and parallelism. More or larger runners, and splitting the test suite into parallel shards, cut wall-clock time without touching the code. This is the fastest change to make and is the right answer when queueing dominates. It does not reduce the total work per change, so compute cost keeps growing with the repository, and it does nothing about cycles or unclear ownership.
  • Split into multiple repositories (polyrepo). Each repository builds and releases independently, and boundaries are enforced by the repository line itself. The cost moves elsewhere: shared code has to be published as versioned packages, cross-cutting changes become a sequence of publish-and-bump steps, and version drift between consumers returns. This fits code with independent release cadences or hard access requirements.
  • A hybrid of a few domain repositories. Group closely related services and their libraries into one repository per domain, and share only stable, versioned packages across domains. This keeps atomic changes where they matter most and limits how far a bad dependency can spread. It needs a clear rule for where new code goes, or the domain repositories drift into small monorepos with the same problems.
  • Git submodules or vendored copies. These let one repository pin a specific revision of another. They avoid a publishing pipeline but make updates manual and easy to forget, and vendored copies tend to diverge from their source.
  • Workspace features of the language's package manager only. Many ecosystems let you link local packages without adopting a graph-aware build system. This is enough for small repositories. Without affected-target detection and content-addressed caching, CI time still scales with repository size once it grows.

None of these alternatives removes the need for ownership and explicit dependency rules. A polyrepo with tangled package dependencies has the same coupling as a monorepo with tangled imports, only slower to change. Choose the repository layout based on how the code is released and owned. Enforce the boundaries either way.

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