What Nx adds to an Angular workspace and how it works
Nx is a build system that sits on top of an Angular workspace. It does not replace the Angular compiler or the builders you already use. It reads your workspace configuration and source files, including the TypeScript imports between projects, and builds a project graph: a directed graph of which apps and libraries depend on which. When you ask it to run a target such as build, test or lint, it derives a task graph from that project graph. Each task waits for its dependencies to finish. Tasks that don't depend on each other run in parallel.
On top of that ordering, Nx keeps a local computation cache. Before running a task, it hashes the task's inputs: source files, relevant configuration, and the outputs of the tasks it depends on. If an identical hash already exists, Nx restores the stored terminal output and artifacts and skips the work. Projects you haven't touched are not rebuilt or retested.
The plain Angular CLI is effective for a single application. However, it has no model of how many projects relate to each other. Nx fills that gap, and it is most useful for multi-app or multi-team frontends, where the same shared code feeds several deployables. It also suits platform engineers who own CI and need build times to grow with the size of a change rather than the size of the repository (Nx introduction).
Remote caching and distributed task execution are separate capabilities. Each needs its own setup. Sharing a cache across machines also raises trust questions you must decide on deliberately: who can write artifacts, and whether CI should consume cache entries produced on developer laptops.
Thin apps, many libraries: a layered architecture for an Angular monorepo
Treat an Angular app the way you treat a service's main package. The binary parses config, wires dependencies and starts the server, while the real logic lives in internal packages that can be tested and reused on their own. In an Nx workspace, apps/ holds deployable shells: bootstrap, top-level routes and environment config. Nearly everything else goes in libs/. This matters because Nx caches, tests and computes "affected" status per project. One large app is one large cache key. Twenty small libraries let a change to billing skip the tests for orders.
Classify each library on two axes. Scope is the business domain it belongs to, such as shared, orders or billing. Type is the role it plays. Use the scope as the folder and prefix the library name with its type, for example libs/orders/feature-checkout, libs/billing/data-access or libs/shared/ui-forms.
| Type | Contains | May depend on |
|---|---|---|
| feature | Routed pages, smart components | feature, ui, data-access, util |
| ui | Presentational components, no injected services | ui, util |
| data-access | HTTP clients, state, models | data-access, util |
| util | Pure functions, pipes, helpers | util |
A domain library may import from its own scope and from shared, but never from a sibling domain.
apps/storefront apps/admin
│ │
┌────────┴────────┬────────────┘
▼ ▼
orders/feature billing/feature (no orders ↔ billing)
│ │
▼ ▼
ui ──► data-access ──► util (scoped or shared)
Dependencies only point downward. That keeps the low-level layers stable and cheap to rebuild.
┌─────────────────────┐ ┌─────────────────────┐
│ apps/storefront │ │ apps/admin │ APPS
└──────────┬──────────┘ └──────────┬──────────┘
├─────────────────────────────────────┤
▼ ▼
┌─────────────────────┐ ┌─────────────────────┐
│ orders/feature-* │ ◄────╳────► │ billing/feature-* │ FEATURE
└──────────┬──────────┘ no imports └──────────┬──────────┘
└──────────────────┬──────────────────┘
│
┌──────────────────┼──────────────────┐
▼ ▼ ▼
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ ui │─►│ util │◄─│ data-access │ LOWER
└───────────────┘ └───────────────┘ └───────────────┘
ui and data-access never import each other.
Each lower layer exists per scope (orders, billing) and in shared.
A domain imports only its own scope and shared. Arrows point down.
Thin apps import domain feature libraries, which depend downward on ui, data-access and util libraries, with no imports between sibling domains.
Generating apps and libraries and defining public APIs
Create projects with generators rather than by copying folders. nx g @nx/angular:application and nx g @nx/angular:library produce the project configuration, lint and test setup, and a TypeScript entry point that Nx already understands. A hand-made folder lacks these until you wire them up yourself. Pass --tags at creation time, for example scope:orders,type:feature. Tags added later tend to be forgotten, and an untagged project slips past the boundary rules covered in the next section.
When the generator creates a library, it registers a path alias in compilerOptions.paths of tsconfig.base.json. The alias maps a name such as @acme/orders/data-access to that library's entry file. Consumers then import by name instead of by relative path. That lets you move a library on disk by updating a single alias, and the import text tells you which project a dependency belongs to. (Depending on the Nx version and setup, some workspaces link packages through TypeScript project references and package manager workspaces instead. The principle of importing by package name stays the same.)
The entry file, usually src/index.ts, is the library's public API. Anything it does not re-export is private. A deep import such as @acme/orders/data-access/src/lib/internal/mapper skips the public API. It couples the consumer to a file layout the owning team assumes it can change freely. Renaming or splitting that file then breaks code in another project, and type checks in the owning library alone won't catch it. Keep the barrel deliberately small, and treat every new export as a contract.
# Generate the app and libraries with tags set at creation time,
# so the boundary rules in the next section can recognize them.
nx g @nx/angular:application apps/shop \
--tags=scope:shop,type:app
nx g @nx/angular:library libs/orders/data-access \
--importPath=@acme/orders/data-access \
--tags=scope:orders,type:data-access
nx g @nx/angular:library libs/orders/feature-list \
--importPath=@acme/orders/feature-list \
--tags=scope:orders,type:feature
# The library generator registers the alias in tsconfig.base.json, e.g.:
# "paths": {
# "@acme/orders/data-access": ["libs/orders/data-access/src/index.ts"]
# }
# Some workspaces link packages through TS project references and package
# manager workspaces instead; either way, consumers import by name.
grep -n '@acme/orders' tsconfig.base.json
# src/index.ts is the public API. Export only what consumers need:
cat > libs/orders/data-access/src/index.ts <<'EOF'
export * from './lib/orders.service';
export type { Order } from './lib/order.model';
// lib/internal/mapper.ts is intentionally NOT exported.
EOF
# Consumers in other projects:
# OK: import { OrdersService } from '@acme/orders/data-access';
# AVOID: import { toOrder } from '@acme/orders/data-access/src/lib/internal/mapper';
# Rough check for deep imports that bypass the barrel:
grep -rn "@acme/[^'\"]*/src/" apps libs --include='*.ts' || echo 'no deep imports found'
Generating an Angular app and two tagged libraries with Nx, checking the path alias the generator registers, keeping the library's index.ts barrel small, and searching for deep imports that go around it.
Enforcing module boundaries with tags and lint rules
Every Nx project can carry a tags array in its project.json. Two dimensions cover most Angular workspaces. type:* describes the layer: type:feature, type:ui, type:data-access, type:util. scope:* describes the domain: scope:orders, scope:billing, scope:shared. A library such as orders-data-access gets both type:data-access and scope:orders.
Tags do nothing by themselves. The @nx/enforce-module-boundaries ESLint rule reads them through its depConstraints option. Each constraint pairs a sourceTag with onlyDependOnLibsWithTags. A type:ui source might allow only type:ui and type:util, so a presentational component cannot inject an HTTP service. A scope:orders source might allow only scope:orders and scope:shared, so the orders domain cannot import billing internals. The rule checks the import's resolved project against the project graph, not file paths. That is why deep imports past a library's barrel are also flagged.
Run lint in CI and let a non-zero exit fail the build. Either nx run-many -t lint or nx affected -t lint works. If the check only runs locally, a rushed merge will bypass it.
Trade-offs
- Untagged and mis-tagged projects. An untagged project matches no
sourceTag, and a mis-tagged one matches the wrong constraints. In both cases enforcement weakens without any error. Add a constraint for the*source tag that allows nothing, so untagged projects fail loudly. - Rules that are too strict. Teams respond by copying code or by pushing it into
scope:shareduntil that scope becomes a dumping ground. Review what lands insharedthe same way you review public API changes.
// eslint.config.mjs (workspace root, flat config)
import nx from '@nx/eslint-plugin';
export default [
...nx.configs['flat/base'],
...nx.configs['flat/typescript'],
{
files: ['**/*.ts'],
rules: {
'@nx/enforce-module-boundaries': ['error', {
depConstraints: [
// Layer rules: presentational code cannot reach HTTP/state services.
{ sourceTag: 'type:feature',
onlyDependOnLibsWithTags: ['type:feature', 'type:ui', 'type:data-access', 'type:util'] },
{ sourceTag: 'type:ui',
onlyDependOnLibsWithTags: ['type:ui', 'type:util'] },
{ sourceTag: 'type:data-access',
onlyDependOnLibsWithTags: ['type:data-access', 'type:util'] },
{ sourceTag: 'type:util',
onlyDependOnLibsWithTags: ['type:util'] },
// Domain rules: orders and billing meet only through scope:shared.
{ sourceTag: 'scope:orders',
onlyDependOnLibsWithTags: ['scope:orders', 'scope:shared'] },
{ sourceTag: 'scope:billing',
onlyDependOnLibsWithTags: ['scope:billing', 'scope:shared'] },
{ sourceTag: 'scope:shared',
onlyDependOnLibsWithTags: ['scope:shared'] },
// Do not add a { sourceTag: '*', onlyDependOnLibsWithTags: ['*'] } catch-all:
// without it, a project whose tags match no constraint cannot depend on
// any library, so untagged projects fail lint instead of passing silently.
],
}],
},
},
];
// Matching libs/orders/data-access/project.json:
// "tags": ["type:data-access", "scope:orders"]
// CI step (non-zero exit fails the build):
// npx nx affected -t lint
A root ESLint flat config that wires type: and scope:* tags into @nx/enforce-module-boundaries, so ui libraries cannot import data-access and the orders and billing domains cannot import each other, with the matching project.json tags and the CI lint command shown in comments.*
Inspecting the project graph and running affected tasks in CI
Run nx graph to open the project graph in a browser. Its edges come from the imports Nx actually finds in your code, so it shows what the dependencies really are, not what the team believes they are. Look for three things: a feature library that points at another feature library, a ui library that reaches into data-access, and cycles. A cycle means Nx cannot order those projects cleanly, so it usually needs to be untangled first. In pull requests, nx graph --affected limits the view to what the change touches. Reviewers can then see when a new edge appears, which is easy to miss in a plain diff.
In CI, compare against the base branch: nx affected -t lint test build --base=origin/main --head=HEAD. Nx works out which projects changed files belong to, follows their dependents through the graph, and runs only those targets. This is where fine-grained libraries help. If a change touches one small data-access library, only that library and its consumers are rebuilt. The same change inside a large app-level library invalidates everything in it. Smaller projects also give stable cache keys, so unchanged neighbours are restored from cache instead of being recomputed.
There are two caveats. First, the cache is only as correct as the inputs and outputs you declare for each target. If you leave out an environment file or a generated asset, Nx can replay a stale result and report success. Second, a widely imported shared/util library sits under most of the graph. Any edit to it marks nearly every project as affected, so split it by concern before it turns every pull request into a full build.
# .github/workflows/ci.yml
# Run lint, test and build only for projects affected by this PR.
name: CI
on:
pull_request:
branches: [main]
jobs:
affected:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
# Full history so origin/main exists and Nx can diff against it.
# A shallow clone leaves no base to compare with.
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
# Nx maps changed files to projects, walks their dependents in the
# project graph, and runs these targets only for that set.
# Unchanged projects whose task inputs match a previous run are
# restored from cache rather than recomputed.
- run: npx nx affected -t lint test build --base=origin/main --head=HEAD
# Optional: write the affected graph to a file and upload it for
# reviewers to check for new or unexpected edges.
- run: npx nx graph --affected --base=origin/main --head=HEAD --file=affected-graph.html
- uses: actions/upload-artifact@v4
with:
name: affected-graph
path: affected-graph.html
A GitHub Actions pull request workflow that fetches full git history, runs nx affected for lint, test and build against origin/main, and publishes the affected project graph for reviewers to inspect.
Impact on cost and engineering time
Illustrative estimate (assumed numbers, not a benchmark)
The benefit of fine-grained libraries is that the affected set for a typical change shrinks. A rough model:
tasks per PR = (projects touched + downstream dependents) × tasks per project
Assumed inputs: one app, three targets per project (lint, test, build), a PR that edits one library, and code spread evenly across libraries. In the coarse layout, each of 3 libraries has only the app as a dependent. In the fine layout, each of 40 libraries has an average fan-out of 4 dependents, including the app.
| Layout | Libraries | Affected projects per PR | Tasks per PR | Library code re-checked |
|---|---|---|---|---|
| Coarse | 3 | 1 + 1 = 2 | 2 × 3 = 6 | ~1/3 |
| Fine-grained | 40 | 1 + 4 = 5 | 5 × 3 = 15 | ~5/40 (12.5%) |
The fine layout runs more tasks, but each task is smaller, so it re-checks less code overall. Raw task count is the wrong metric. What matters is the code under test per change and how often the cache hits. If your fan-out is high, the advantage disappears.
What it costs
- Granularity overhead. On a small team, generating libraries, writing barrel files and assigning tags can take more time than the CI it saves.
- A second configuration layer.
nx.json, project configuration, targets and lint rules are in addition to Angular's own configuration. Someone has to understand both. - Coupled upgrades. Nx plugin versions are tied to Angular versions, so you upgrade them together with
nx migrateand then review the generated changes. - Ownership. Tags limit which projects can import each other. They do not say who owns a project or who must review changes to it. Your Git host's code owners file covers that.
Migrating an existing Angular app, when to avoid Nx, and alternatives
Migrating incrementally
Running nx init in an existing Angular CLI workspace adds Nx without restructuring the code. Leave the app as a single project at first and extract libraries from the bottom of the dependency graph upward.
- Shared utilities. Pure functions, pipes and types have no dependencies, so moving them is low risk. Tag them
type:utiland allow them to import only other utils. - Data access. Move services and state next. Tag them
type:data-accessand allow imports from utils. - Features. Extract features last. They depend on everything below them, and by this point those dependencies already sit behind public APIs.
Add each boundary rule when its layer exists. If you turn on every constraint at once, the lint output fills with violations nobody can fix in one pull request, and the team learns to ignore it.
When to skip Nx
Nx earns its overhead through caching, affected runs and boundaries across many projects. A single small app, a small team or a short CI pipeline gets little of that benefit. You still pay for it: another tool to learn, another plugin set to keep in step with Angular upgrades, and another layer to look through when a build fails.
| Option | Strength | Trade-off |
|---|---|---|
| Angular CLI multi-project workspace | No extra tooling | No affected detection, caching or boundary rules |
| Package-manager workspaces, published packages | Explicit versioned contracts | Release overhead; consumers lag behind changes |
| Polyrepo | Independent ownership and pipelines | Cross-repo changes need coordinated releases |
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 angular, 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.