Catching cross-package breaking changes Jump to heading

One of the main arguments for a monorepo is that a change to a shared library and the updates to its consumers can land together, atomically. The flip side is that a change to a shared library can break its consumers immediately, in the same commit, with no version boundary to absorb it. A team renames a function in libs/money; three services call it; if those services’ tests do not run on that pull request, main is broken the moment it merges. Catching this needs three things working together: run every dependent’s tests when a library changes, make changes to a library’s public surface visible to reviewers, and give consuming teams a say before their code breaks. This page sets up all three, within monorepo branch topology.

When to use this approach Jump to heading

  • Shared libraries in your monorepo have several consuming projects.
  • Library changes have broken consumers after merging.
  • CI runs only the tests of directly changed projects.
  • Teams own different consumers and want to know before a library they depend on changes shape.

Step 1 — Test every dependent when a library changes Jump to heading

The first line of defence is running the tests of every project that depends on the changed library, directly or transitively. That is what affected-project detection is for.

# libs/money changed → test it and everything that depends on it
sh ci/affected.sh
# money
# billing-api
# export-api

The detection itself is described in detecting affected projects from git diff. Without the reverse-dependency step, a library change runs only the library’s own tests, which by definition still pass.

What CI runs when a library changesRunning only directly changed projects tests the library against itself, which passes even when consumers break. Running the library and all its dependents tests the change where it matters. Running everything works too but costs far more on unrelated changes.Catches consumer breakage?Costchanged project onlynolowlibrary + dependentsyesproportionaleverythingyeshigh, alwaysdependents' tests are the cheapest complete answer

Step 2 — Make public API changes visible in review Jump to heading

Tests catch breakage that is exercised; reviewers catch intent. Extract each library’s public surface — exported functions and types — into a checked-in file, regenerate it in CI, and fail if it changed without the file being updated. Reviewers then see API changes as a dedicated diff.

# Python example: record a library's public names and signatures
python3 - <<'EOF' > libs/money/API.txt
import inspect, money
for name in sorted(getattr(money, "__all__", dir(money))):
    obj = getattr(money, name)
    if name.startswith("_"): continue
    sig = str(inspect.signature(obj)) if callable(obj) else ""
    print(f"{name}{sig}")
EOF
git diff --exit-code libs/money/API.txt || echo "public API of libs/money changed — update API.txt and request consumer review"

TypeScript projects can do the same with an API extractor that writes a report; Go and Rust have similar tools. The format matters less than the habit: API changes are a reviewed diff, not a surprise.

Step 3 — Require consumer approval for API changes Jump to heading

When API.txt changes, the owners of consuming projects should approve. Code owners on the API file, listing every consuming team, does exactly that.

# CODEOWNERS
/libs/money/            @acme/payments-platform
/libs/money/API.txt     @acme/payments-platform @acme/billing @acme/data-platform
A library change with an API impactA pull request changes the money library. CI runs the tests of the library and every dependent. The regenerated API file differs, so code owners of every consuming team are requested. The change merges only when dependents pass and consumers approve.Library PRlibs/moneyDependents' testsbilling, exportAPI.txt diffsurface changedConsumer reviewCODEOWNERSMergeall greeninternal changes skip the consumer review — only surface changes trigger it

Because the extra owners are attached only to the API file, purely internal library changes do not trigger consumer reviews.

Step 4 — Change APIs in steps when consumers cannot update at once Jump to heading

Monorepos make atomic changes possible, but not always practical: a consumer may belong to a team that cannot review this week, or the change may be too large for one pull request. Use expand–contract: add the new API alongside the old, migrate consumers, then remove the old.

# Step 1 (expand): new name alongside the old, old one delegating and warning
def round_half_even(amount): ...
def round_amount(amount):  # deprecated
    warnings.warn("round_amount is deprecated; use round_half_even", DeprecationWarning, stacklevel=2)
    return round_half_even(amount)
# Step 3 (contract): remove the old name only when no caller remains
git grep -n 'round_amount(' -- 'services/**' 'apps/**' || echo "no callers left — safe to remove"

The same pattern at larger scale is branch by abstraction for large changes.

Step 5 — Catch what tests miss with type checks across packages Jump to heading

Tests exercise behaviour; type checkers exercise every call site. Running the type checker for every dependent on library changes catches signature changes in code paths no test covers.

for p in $(sh ci/affected.sh); do
  root=$(yq -r ".projects.\"$p\".root" projects.yml)
  (cd "$root" && mypy . ) || echo "type errors in $p"
done
Defences against cross-package breakageDependents' tests catch exercised behaviour. Type checks across dependents catch every call site with a changed signature. The API file diff makes surface changes visible to reviewers. Consumer approval gives affected teams a decision. Expand-contract removes the need to break anything at once.each layer catches something the others missDependents' testsexercised behaviourType checks across dependentsevery call siteAPI surface diffvisible to reviewersConsumer approvalaffected teams decideExpand-contractno big-bang breakagetests alone miss untested call sites; types alone miss behaviour changes

Validation checklist Jump to heading

Frequently Asked Questions Jump to heading

Doesn’t a monorepo make breaking changes safe by updating consumers in the same commit? Jump to heading

When one person can update every consumer in one pull request and every owner approves, yes — that is the ideal. The techniques here are for when that is not practical, and for catching the cases where a consumer was missed.

Should libraries in a monorepo be versioned? Jump to heading

Only if something outside the repository consumes them. Internally, consumers use the current source, and the API file plus consumer review take the place of version numbers.

How do we find consumers of a library? Jump to heading

From the dependency graph used for affected detection, or by searching imports with git grep. Keep the graph accurate and both answers agree.