How do you know a shared Nx library should be split?

In an Nx workspace, splitting a library means taking one project, such as libs/shared, and redistributing its files into several smaller projects. Each new project has its own project configuration, its own public entry point, and its own place in the project graph. The code itself barely changes. What changes is how precisely Nx can reason about it.

That precision is the reason to split. Nx calculates affected scope at the project level. If a date-formatting helper and a database retry policy live in the same library, editing either one marks every consumer of that library as affected. A one-line change then triggers tests, lint, and builds across services that never import the code you touched.

Two diagnostics expose the problem. First, run nx affected against a branch that edits one obscure file in the shared lib. If most of the workspace lights up, the library is acting as a choke point. Second, open nx graph, focus on the library, and compare consumers with the files they actually import. Oversized libs usually contain clusters of files with distinct audiences: HTTP clients used only by gateways, queue utilities used only by workers, and validation schemas used everywhere.

Backend and platform teams that own widely consumed code benefit most, because their changes carry the largest blast radius. Typical signals that it is time to split:

  • CI time on shared-lib changes is close to a full run.
  • Unrelated teams wait on reviews for one library.
  • The barrel file has grown so large that imports pull in heavy, unrelated dependencies.
git switch -c diag/shared-blast-radius

echo '// diagnostic edit' >> libs/shared/src/lib/date/format-date.ts
git commit -am 'diag: touch one shared helper'

total=$(npx nx show projects | wc -l)

affected=$(npx nx show projects --affected --base=main --head=HEAD | wc -l)

echo "Affected: ${affected} of ${total} projects"
npx nx graph --focus=shared

git switch main
git branch -D diag/shared-blast-radius

Measures how many projects a one-file edit to libs/shared marks as affected, then opens the project graph focused on the shared library to review its consumers.

Where should you draw the boundaries in a shared lib refactor?

Split along the lines that already show up in your git history and import graph, not along file types. A shared-models, shared-utils, shared-services split by folder looks tidy but leaves every library touched by every feature, so the affected graph barely changes. Seams that hold up are ones where the code on each side changes for different reasons and is imported by different consumers.

Seam Typical contents Why it is a good boundary
Domain models Types, enums, validation schemas for one domain Changes with the domain, imported widely, should stay dependency-free
Data-access Repositories, query builders for one domain Changes with schema; only that domain's services consume it
Infrastructure clients Queue, cache, HTTP, storage wrappers Changes with vendor or config; consumed across domains
Utilities Pure functions with no domain knowledge Rarely changes; safe to depend on from anywhere

Two failure modes follow from getting this wrong. First, a library named common or core becomes the new grab-bag within months. If you cannot describe a library's purpose in one sentence without "and", it is too broad. Second, cycles appear when two new libraries need each other's types. Plan for this up front: put shared interfaces and DTOs in a lower-level library that imports nothing internal, and have both higher libraries depend on it.

Do not overcorrect. Every library carries its own project configuration, tsconfig, lint config, and test setup. Each one also adds a node to the project graph and a cache entry to maintain. A dozen libraries with three files each cost more in configuration drift than they save in affected scope. Aim for libraries that hold a coherent unit of change, even if that unit is a few thousand lines.

Arrows point from importer to imported. Nothing imports upward.

 +-----------------+       +-----------------+   consumers
 |   orders-app    |       |   billing-app   |
 +--------+--------+       +--------+--------+
          |                         |
          v                         v
 +-----------------+       +-----------------+   +----------------+
 | orders-         |  no   | billing-        |   | infra-clients  |
 | data-access     |<--X-->| data-access     |   | queue, cache,  |
 | (schema-driven) |import | (schema-driven) |   | http, storage  |
 +--------+--------+       +--------+--------+   +-------+--------+
          |                         |                    |
          +------------+------------+                    |
                       v                                 |
          +-------------------------+                    |
          | shared-models           |                    |
          | types, DTOs, enums,     |                    |
          | schemas; no internal    |                    |
          | imports                 |                    |
          +-------------------------+                    |
                                                         v
                                            +--------------------------+
                                            | utils: pure functions,   |
                                            | no domain knowledge      |
                                            +--------------------------+

Each library is split by consumer, domain and change rate. The two data-access libraries never import each other; both depend on a lower-level shared-models library that has no internal imports, so no cycle can form, and the infrastructure clients depend only on domain-free utilities.

What does the Nx move generator do, and what does it not do?

Nx ships a move generator, invoked as nx g move or @nx/workspace:move. Its scope is narrower than the name suggests. It relocates or renames an entire project. It moves the folder, updates the project configuration, rewrites the path alias in the root TypeScript config, and updates import statements across the workspace that point at the old alias. For a rename such as shared-utils to platform/utils, this is exactly what you want.

It does not split a library. The generator has no concept of "these five files go to a new project and the rest stay." It operates on a project as a whole.

Moving individual files between libraries is a manual job, done in two steps:

  1. Create the target library and move the files into it.
  2. Rewrite every import that reached those files through the old library's barrel export.

The generator's import rewriting is built around static TypeScript import specifiers. Treat the following as things to check by hand after any move, whether automated or manual:

  • Dynamic imports with computed paths. A specifier assembled at runtime cannot be matched against an alias.
  • String-based paths. Examples include Jest moduleNameMapper entries, module paths in config files, and paths referenced from build scripts.
  • Non-TypeScript references. These include shell scripts, Dockerfiles, CI pipelines, and documentation that point at the old directory.

A workspace-wide text search for the old alias and the old folder path is the cheapest safety net.

Move generator Library generator
Purpose Relocate or rename an existing project Scaffold a new, empty project
Input One whole project A name and directory
Updates path aliases Rewrites the existing alias Adds a new alias
Updates consumer imports Yes, for static imports No
Splits code across projects No No, it only creates the destination
nx g @nx/workspace:move --project=shared-utils --destination=platform/utils --dry-run
nx g @nx/workspace:move --project=shared-utils --destination=platform/utils

nx g @nx/js:library --name=date-utils --directory=libs/platform/date-utils

git mv libs/platform/utils/src/lib/date libs/platform/date-utils/src/lib/date

git grep -n "from '@myorg/shared-utils'" -- '*.ts' '*.tsx'

git grep -n "import(" -- '*.ts' '*.tsx' | grep -i 'utils'

git grep -n -e 'shared-utils' -e 'libs/shared/utils' -e 'libs/platform/utils/src/lib/date'

nx graph
nx affected -t lint test build

The move generator relocates a whole project in one command. Splitting a library means scaffolding a new library, moving the files by hand, rewriting imports and searching the workspace for references the rewrite missed.

How do you split an Nx library incrementally without breaking consumers?

Treat each slice as its own short-lived change, not one big refactor:

  1. Create the target library with the library generator, giving it the tags and import alias you settled on when drawing boundaries.
  2. Move one cohesive slice, such as a single module and its tests. Don't move a scattered set of files.
  3. Update imports in consumers you can reach easily so they point at the new alias.
  4. Add a temporary re-export in the old library's barrel for the moved symbols, so consumers you haven't touched still compile.
  5. Run lint, test and build for affected projects, then merge.
  6. Start the next slice from a fresh main.

Small sequential merges matter because a busy monorepo moves under you. A long-running branch that relocates dozens of files will conflict with every teammate who edits those files. Each rebase becomes a manual three-way merge across moved paths. One slice per merge keeps the conflict surface small, and each review stays readable.

Default to non-buildable libraries unless you have a concrete reason to compile a project independently, such as publishing it or using incremental builds that depend on per-library outputs. Buildable libraries add build targets and configuration to maintain. They also make you resolve dependency ordering early, which slows the split.

Schedule the barrel removal when you create it, for example as a follow-up task tied to the last slice. While the re-export exists, consumers still import from the old library. The project graph then shows them depending on it rather than on the new one, so affected scope does not shrink. Remove it once all imports point at the new libraries.

git switch main && git pull && git switch -c split/orders-pricing

nx g @nx/js:library --directory=libs/orders/pricing \
  --importPath=@acme/orders-pricing \
  --tags=scope:orders,type:data-access \
  --bundler=none

git mv libs/shared/src/lib/pricing libs/orders/pricing/src/lib/pricing

cat >> libs/shared/src/index.ts <<'EOF'
// TEMPORARY: re-export moved symbols. Hides the real dependency edge; delete when unused.
export * from '@acme/orders-pricing';
EOF

nx affected -t lint test build --base=origin/main

grep -rn "from '@acme/shared'" apps libs --include='*.ts' | grep -i pricing

One slice of an incremental split: generate a tagged non-buildable library, move a single module, add a marked temporary re-export in the old barrel, run lint, test and build for affected projects, and check for remaining old-path imports before the barrel is removed.

How do you stop the split from eroding back into a monolith?

A split only holds if something rejects the imports that would undo it. In Nx, that is the @nx/enforce-module-boundaries lint rule combined with project tags. Give each new library two tags: a scope tag for the domain it serves (for example scope:billing) and a type tag for its layer (type:feature, type:data-access, type:util). The rule's dependency constraints then state which tags may depend on which. Billing code may import from billing and shared scopes but not from another domain, and a util library may import only other utils. A merge that quietly reconnects two libraries you just separated then fails lint in CI.

The same rule reports circular dependencies, and a split often surfaces them. Code that lived in one library could reference itself freely. Once it is divided, two of the new libraries may each need something from the other, usually a type or interface. Do not merge them back. Move the shared types into a lower-level library that both depend on. That gives the graph a clear direction again.

Strict constraints carry a cost. When a deadline collides with a rule, engineers add disable comments, and those accumulate. Assign an owner to the tag scheme. Review the constraints and the existing exceptions on a regular schedule, and either fix each exception or change the rule it works around.

Finally, consumers must import from the narrowest library that provides what they use. A broad re-export library brings the old dependency fan-out back into the graph.

How do you verify that splitting reduced affected scope and saved time?

Measure against fixed reference points, not impressions. Before the split, pick five to ten real commits from history that represent typical changes: a validation tweak, a logging change, a DTO addition. Record the affected project set for each, using the same base and head SHAs. After the split, replay the same logical changes against the new layout and compare. If a logging change still affects every API service, a dependency edge is still too broad.

Then check that targets behave. Run build and test twice with no changes. The second run should be a full cache hit. A cache miss usually means target inputs still reference the old library's files or a shared config that changed.

Illustrative estimate (assumed numbers, not a benchmark):

  • Refactor effort: 60 engineer-hours
  • CI runs per week: 200
  • Average CI minutes saved per run: 6

Payback in weeks = refactor hours ÷ (runs per week × minutes saved ÷ 60) = 60 ÷ (200 × 6 ÷ 60) = 60 ÷ 20 = 3 weeks.

This counts only CI time. Faster local test feedback adds to the benefit. Ongoing boundary maintenance subtracts from it.

Cleanup

Shrink the old library to a deliberate core, or delete it. Do not leave it as a dumping ground with a familiar name. Update CODEOWNERS so each new project has a clear owner. Remove CI steps or path filters that still mention the old directory. Delete stale path aliases from the base tsconfig. A leftover alias lets someone import the old path, and the build may still resolve it.

When should you avoid splitting, and what are the alternatives?

Splitting has ongoing costs. Each new project adds configuration, another node in the project graph, and a boundary someone has to defend in code review. Skip the split in these cases:

  • The workspace is small. If a full test run already fits comfortably inside your CI budget, shrinking the affected scope saves little time.
  • The library has one consumer. Move the code into that consumer or leave it alone. A split adds indirection without any reuse.
  • The code is tightly coupled. If the candidate pieces import each other in both directions, separating them produces circular dependencies between projects. Untangle the code inside the library first.
  • Nobody can own the boundaries. Libraries without owners drift back together, because no one rejects imports that cross the line.
Alternative Cost Risk Payoff
Tighten boundaries inside the existing library (folder-level lint rules, one public barrel) Low Low Clearer internal structure. Affected scope stays the same.
Extract a deliberate core library for stable primitives Medium Medium, because core can turn into the new dumping ground Rarely changing code stops triggering rebuilds
Secondary entry points Low to medium Low Narrower import paths for consumers. Nx still treats it as one project, so affected scope does not shrink.
Accept the current affected scope None Cost grows as the library grows Team effort goes to higher-value work

If you choose not to split, write the reason down and record a threshold for revisiting it, such as consumer count or CI duration. Then the next person to raise the question has something to measure against.

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 nx split 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 .env by 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.