What splitting an Nx library means and why granularity drives affected builds
Splitting an Nx library means taking one project, usually something named shared, common, or utils, and dividing it into several smaller projects, each with its own project configuration, public entry point, and targets. The code may barely change. What changes is the unit Nx reasons about. This matters most to backend and platform engineers who own shared libs, because their CI bills and queue times grow with every new consumer.
Nx computes affected projects at the project level. When a file changes, Nx maps it to the project that owns it, marks that project as affected, and then marks every project that depends on it, transitively. Nx does not check whether a dependent actually imports the changed file. Caching works at a similar grain. A target's hash is built from its configured inputs, which by default cover the project's files as a whole plus relevant inputs from its dependencies. Editing one date helper therefore invalidates the cache for every target whose inputs include that library.
Coarse: Split:
[shared] [shared-dates] [shared-http] [shared-auth]
/ / | \ \ | | | | |
api jobs web admin billing api billing api jobs admin
edit dates.ts -> 5 affected edit dates.ts -> 2 affected
With the coarse library, a narrow fix to date formatting reruns lint, build, and test for all five consumers, and none of those results can come from cache. After the split, the same edit reaches only the projects that import date code. The rest of the graph keeps its cached results.
COARSE: edit dates.ts -> 5 projects affected
+---------------------------------------------------+
| shared (dates.ts edited) |
+---------------------------------------------------+
| | | | |
v v v v v
+-------+ +-------+ +-------+ +-------+ +-------+
|billing| | api | | jobs | | web | | admin |
+-------+ +-------+ +-------+ +-------+ +-------+
rebuild rebuild rebuild rebuild rebuild
SPLIT: edit dates.ts -> 2 projects affected
+----------------+ +-------------------+ +-----------+
| shared-dates | | shared-http | |shared-auth|
| (edited) | | | | |
+----------------+ +-------------------+ +-----------+
| | | | | |
| |<---+ | | |
v v v v v
+-------+ +-------+ +-------+ +-------+ +-------+
|billing| | api | | jobs | | web | | admin |
+-------+ +-------+ +-------+ +-------+ +-------+
rebuild rebuild cached cached cached
Editing dates.ts in a coarse shared library marks all five dependents as affected, while after the split only the consumers of shared-dates rerun and the rest keep their cached results.
When to split: signals you can measure in your own repo
Folder structure is a poor guide to whether a library is too large. A sprawling directory can be harmless if nothing changes it, and a small one can be a bottleneck if every team edits it weekly. Decide from what your affected computation and cache already show you.
Collecting evidence
Pick ten to twenty representative merged commits: routine feature work, not dependency bumps or repo-wide refactors. For each one, run nx show projects --affected with --base set to the commit's parent and --head set to the commit. Record how many projects come back and which files in the shared library were touched. Then open nx graph and focus on the library to see its direct and transitive dependents. If small edits to one corner of the library consistently return most of the workspace, the library is acting as a single invalidation unit. Nx cannot tell which consumers actually use the changed code. It only knows that they depend on the project.
| Signal | How you observe it | What it suggests |
|---|---|---|
| Most projects affected by nearly every change | High project counts from nx show projects --affected across sampled commits |
The library sits too close to the root of the graph |
| Unrelated changes invalidate the same cached targets | Cache misses on consumers that never import the edited code | Too much code is in one project, so it has one hash |
| Several teams own the library | Commit authors and CODEOWNERS entries span teams | Boundaries follow history, not ownership |
| Index file re-exports unrelated concerns | The barrel mixes domains, such as dates, auth, and HTTP clients | Natural split points already exist |
#!/usr/bin/env bash
# Measure how often edits to one shared library mark most of the workspace
# as affected. Run from the workspace root with a clean working tree.
set -euo pipefail
LIB_PATH="libs/shared/util" # placeholder: path of the library under review
SAMPLE="${1:-commits.txt}" # one hand-picked commit SHA per line
# Total project count gives a denominator for the affected counts.
total=$(npx nx show projects | wc -l)
echo "workspace projects: $total"
printf 'commit\taffected\tlibrary files touched\n'
while read -r sha; do
[ -z "$sha" ] && continue
# Compare each commit against its first parent, so the result reflects
# only what that change did.
affected=$(npx nx show projects --affected \
--base="${sha}^" --head="$sha" | wc -l)
# Which files inside the library did this commit change?
files=$(git diff --name-only "${sha}^" "$sha" -- "$LIB_PATH" \
| sed "s|^$LIB_PATH/||" | paste -sd ',' -)
printf '%s\t%s/%s\t%s\n' \
"${sha:0:10}" "$affected" "$total" "${files:-none}"
done < "$SAMPLE"
# Then inspect the library's dependents visually (project name is a placeholder):
# npx nx graph --focus=shared-util
A script that replays sampled commits through nx show projects --affected with explicit base and head SHAs, then prints the affected count next to the library files each commit touched, so you can see whether small, localized edits still invalidate most of the workspace.
When not to split, and the alternatives to consider first
Some of those signals point at a symptom that splitting won't cure. If a change to a root tsconfig, a shared ESLint config, or the lockfile marks every project as affected, the cause is the global inputs, not the size of your library. Each new library inherits the same inputs, so after a split, the same commit still invalidates everything. Audit target inputs and the named inputs in nx.json first. Narrow production inputs so they exclude test files and docs, and list global files only for the targets that actually read them.
Splitting also carries recurring costs. Every new library adds a project configuration, more targets for the task runner to schedule and cache, another tsconfig path alias, and another node in the graph. It also brings fixed per-library overhead: test runner bootstrap, lint config, and setup files that run even when the library contains only a few modules. If the library is buildable or publishable, the cost grows further. You take on a package name, versioning, and release work that consumers will depend on. A library consumed through a source path alias can be merged back cheaply, but a published package can't.
| Option | Problem it addresses | Commitment |
|---|---|---|
| Tighten inputs and named inputs | Global files invalidating everything | Low; config only |
| Clean up the public API | Barrel exports leak internals and widen coupling | Low; no new projects |
| Tags on the existing library | Wrong consumers importing it | Low; lint rule |
| Merge small pieces | Graph noise, per-library setup overhead | Low to medium |
| Split the library | Unrelated domains forcing shared rebuilds | Medium; high if publishable |
Choosing split boundaries from the dependency graph
A good boundary groups code that changes for the same reason and is used by the same consumers. Splitting by file type alone (all services here, all helpers there) rarely reduces affected builds. A single feature change still touches every slice, and every consumer still depends on all of them.
Find clusters from real imports
Start with who imports what. Search the workspace for imports of the library's path alias, and for each consuming project record which exported symbols it uses. Clusters show up quickly: the billing apps import invoice builders and currency helpers, while the auth services import token validation and never touch billing. Those two groups are candidate libraries. Exports that nearly every consumer uses are not a cluster. They are a shared base, and you should treat them separately.
Layering by type, when it matches change patterns
Separating data-access, domain logic, utilities and types/contracts works when those layers change at different rates. Data-access often changes with schema migrations, while domain rules change with product requirements. If they always change together in the same PRs, keep them together.
Two traps
A contracts or types-only library tends to become a dependency of everything. Every edit to it then marks most of the workspace as affected, which recreates the original problem. Keep contracts scoped per domain.
Similarly, when two new slices share code, resist extracting a lower-level library by default. Do it only if that shared code is stable. Otherwise duplicate it briefly and revisit later.
#!/usr/bin/env bash
# Map which projects import which symbols from a library's path alias.
# Assumes an apps/* and libs/* layout and single-line named imports;
# multi-line import blocks are missed, so treat the output as a first pass.
set -euo pipefail
ALIAS='@acme/shared' # path alias from tsconfig.base.json
PAIRS=$(mktemp)
trap 'rm -f "$PAIRS"' EXIT
for project in apps/* libs/*; do
[ -d "$project" ] || continue
# Pull the {...} part of each import from the alias, one symbol per line.
# Strip 'type' modifiers and 'as' renames so symbols compare cleanly.
grep -rhoE --include='*.ts' --include='*.tsx' \
"import (type )?\{[^}]*\} from ['\"]${ALIAS}['\"]" "$project" \
| sed -E 's/.*\{([^}]*)\}.*/\1/' \
| tr ',' '\n' \
| sed -E 's/^[[:space:]]*(type[[:space:]]+)?//; s/[[:space:]]+as[[:space:]].*$//; s/[[:space:]]*$//' \
| grep -v '^$' \
| sort -u \
| sed "s|^|$project |" >> "$PAIRS" || true
done
echo '== Symbols used per consumer (look for clusters) =='
awk '{ a[$1] = a[$1] " " $2 } END { for (p in a) print p ":" a[p] }' "$PAIRS" | sort
echo
echo '== Consumer count per symbol (high counts = shared base, not a cluster) =='
awk '{ print $2 }' "$PAIRS" | sort | uniq -c | sort -rn
A shell pass over the workspace that lists, for each project, the symbols it imports from the library's path alias, and counts consumers per symbol, so you can spot consumer clusters and the widely used exports that form a shared base.
Pre-split cleanup and what the Nx move generator can and cannot do
Before any files move, get the library into a state where the split is mechanical. Three things matter most.
Remove deep imports. Consumers that reach past the public index.ts into internal paths will break the moment those files land in a different project. Route every import through the public entry point first. Any symbol that has to be exported to make this work is part of the real contract, and you need to know about it before you draw boundaries.
Break internal cycles. Inside one project, a cycle between two folders is untidy but harmless. Once those folders become separate libraries, the same cycle becomes a cross-project circular dependency. Nx's graph will report it, and boundary rules will reject it. Fix it now by extracting the shared piece or inverting the dependency.
Make tests pass in isolation. Tests that depend on shared fixtures or global setup elsewhere in the library will fail after extraction. Run each slice's specs on their own to find these dependencies early.
The move generator (nx g move, implemented as @nx/workspace:move) relocates or renames an entire project. It updates the project configuration, the path aliases in the base tsconfig, and import statements across the workspace. It cannot carve a subset of files out of a project. That part of the work is yours to do. Use the move generator afterward, if a new library needs a different name or location.
Always run it with --dry-run first and review the list of changed files. Option names and plugin package names have changed across Nx releases, so run the generator with --help against your installed version rather than copying flags from examples.
# 1. Find deep imports that bypass the public index.ts.
# Any match means a consumer reaches into internal paths.
grep -rnE "from '@myorg/shared-ui/" --include='*.ts' --include='*.tsx' apps libs
# 2. Run one slice's specs on their own to expose hidden reliance
# on fixtures or global setup elsewhere in the library.
# (Jest treats the positional argument as a test path pattern.)
npx jest --config libs/shared/ui/jest.config.ts libs/shared/ui/src/lib/forms
# 3. After the split, open the project graph to confirm that
# no cross-project cycles were introduced.
nx graph
# 4. Optional: rename or relocate a whole project with the move generator.
# It cannot extract a subset of files. Check the options for your
# installed Nx version first, because flag names differ between releases.
nx g @nx/workspace:move --help
# Preview the changes without writing any files. The flag names below are
# placeholders for the ones that --help reports for your version.
nx g @nx/workspace:move \
--projectName=shared-ui-forms \
--destination=libs/forms/ui \
--dry-run
Pre-split checks for deep imports, isolated test runs and graph cycles, followed by a dry run of the Nx move generator, run only after confirming its options with --help.
How to implement a staged extraction, one reversible PR at a time
Ship the extraction as a sequence of small PRs, each one revertible on its own:
- Create the empty library. This PR should only add a project and a path alias. Reviewers can confirm the tags and tsconfig paths before any code moves.
- Move one cohesive slice. In the old library's
index.ts, add a temporary re-export such asexport * from '@acme/billing-pricing'and mark it with a dated comment that names an owner. Consumers keep compiling untouched. Runnx affectedfor your build and test targets and check that the changed set matches your expectations. - Migrate consumers in batches. Rewrite imports one project or one team area per PR, changing
@acme/billingto@acme/billing-pricingonly where those symbols are used. - Delete the re-export. Do this once no consumer imports the moved symbols through the old path. A lint rule or a repo-wide search confirms it.
The re-export keeps the old dependency edge alive. Until consumers switch, a change to the new library still marks everything that depends on the old one as affected, so affected builds do not improve. A re-export without an owner and a deadline tends to become permanent.
Batch size is a trade-off. One large import-rewrite PR conflicts with every in-flight branch. Small batches avoid that, but two import paths coexist for longer, and new code may adopt the old one.
If the split creates a cycle, where the new library needs something that still lives in the old one, you have two options. If the coupling is real, merge the slice back. If it is incidental, extract the shared piece into a third library that both can depend on.
Enforcing boundaries and measuring impact on cost and engineering time
Assign every new project a scope tag (who owns it) and a type tag (what layer it is, such as type:util or type:data-access) in its project configuration. Then configure the Nx module boundary lint rule with dependency constraints, for example that type:util may depend only on other type:util projects. Run lint in CI so a forbidden import fails the build instead of quietly re-coupling the slices. The rule's package-prefixed name and its options have changed across Nx versions, so check the documentation for the version you run. Update CODEOWNERS in the same PR so review requests follow the new paths. If you skip this, the old library's owners keep getting pulled into every review.
To verify the payoff, choose a few representative historical commits. Compute the affected projects for each one with fixed --base and --head before the split, then recompute after the split by replaying equivalent changes. Compare affected counts and cache hit rates on the same inputs.
Illustrative estimate (assumed numbers, not a benchmark). Assume 40 projects depend on shared, a typical change touches only date utilities used by 6 of them, each affected project costs 3 CI minutes, and there are 20 such changes per week.
Saved minutes per week = changes × (affected before − affected after) × minutes per project = 20 × (40 − 6) × 3 = 2,040.
Set that figure against the hours spent on migration plus ongoing tag and lint maintenance. Narrower review scope is real but harder to quantify, so count it separately.
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 split nx library, 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.
- Changing a shared variable is one edit and one review instead of editing every service's
.envby hand.
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.
- A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
- 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.