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.
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 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 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.
Related Jump to heading
- Monorepo Branch Topology — the parent topic.
- Catching Semantic Conflicts in CI — breakage that appears only when changes combine.
- Enforcing CODEOWNERS Review on Sensitive Paths — making owner review required.
- One Branching Strategy for Many Teams — where shared-library rules fit.