A monorepo is a single git repository that holds several projects: for example the shop, the admin panel and the libraries they share. It is not a monolith: each application is built and deployed separately. Only the code lives together, with one version of Angular and one commit history.
The gain: a change that touches a library and the two applications that use it lands in a single PR, tested together. The cost: without tooling, each PR rebuilds and retests everything; without rules, anyone imports anything. This page covers both halves: the workspace with the Angular CLI and ng-packagr, then what Nx adds on top of it — the project graph, affected, the cache, and boundaries checked at lint time.
- A monorepo keeps several apps and libraries in one repository, with a single set of dependency versions.
- Angular CLI supports several projects in one workspace out of the box (
ng generate library). Nx adds the dependency graph, boundaries and caching. - A library has a public API: whatever is not exported from its entry file stays internal.
- “Affected” runs the build and the tests only for what a change touched.
- Most of the time a folder is enough. A library pays off when it is published or used by several apps.
"Folder, library or npm package" is an architecture decision, not a style one. It determines how quickly you can ship a cross-cutting change and how easily you can break something without noticing. The Angular documentation says it directly: a library forces you to decouple code from the application's logic, but it costs maintenance time — and that pays off when several applications use it.
Monorepo, polyrepo, monolith
The three models differ mainly in the cost of a cross-cutting change. Say the Product model gets a new field, used by the UI library and by both applications:
| Model | What it looks like | Changing "Product" | The price |
|---|---|---|---|
| Monolith | One application, one deploy, folders by domain | 1 PR, trivial | Does not serve two applications with different release rhythms |
| Polyrepo + npm | Each library in its own repo, published with a version | 3+ PRs: library → publish → bump in each application | Versions drift; a fix reaches production when each team "remembers" to |
| Monorepo | Applications + libraries in the same repo, consumed from the workspace | 1 atomic PR, tested with all its consumers | CI has to be smart (affected, cache) and the boundaries have to be enforced |
An Angular CLI workspace has a single package.json at the root, so all projects use the same version of Angular, RxJS or CDK — what Nx calls the single version policy. An ng update updates the whole repo once. The price is coordination: no team falls behind, but none can run ahead. If teams really must ship independently, with different rhythms and versions, the next step is micro frontends (which, ironically, often live inside a monorepo too).
Multi-project workspace with the Angular CLI
ng new creates an application at the root by default. For a monorepo you start from an empty workspace (--no-create-application), and each ng generate application or ng generate library adds a project under projects/ and an entry in angular.json. A scoped library (@acme/ui) ends up in projects/acme/ui, and the project name is the package name.
# 1. Empty workspace: no application at the root (createApplication: false)
ng new acme --no-create-application
cd acme
# 2. Projects end up in projects/ (newProjectRoot)
ng generate application shop
ng generate application admin
ng generate library @acme/ui # → projects/acme/ui, default prefix "lib"
# 3. Each command gets the project name
ng serve shop
ng build admin
ng test @acme/ui
ng build @acme/ui --watch # incremental rebuild while you work on the libraryThe important difference is in angular.json: applications use the @angular/build:application builder (esbuild), libraries use @angular/build:ng-packagr, which produces a package in the Angular Package Format and always compiles AOT. For libraries, the default configuration is production.
{
"newProjectRoot": "projects",
"projects": {
"shop": {
"projectType": "application",
"root": "projects/shop",
"sourceRoot": "projects/shop/src",
"architect": {
"build": { "builder": "@angular/build:application" },
"serve": { "builder": "@angular/build:dev-server" }
}
},
"@acme/ui": {
"projectType": "library",
"root": "projects/acme/ui",
"sourceRoot": "projects/acme/ui/src",
"prefix": "lib",
"architect": {
"build": {
"builder": "@angular/build:ng-packagr",
"defaultConfiguration": "production",
"configurations": {
"production": { "tsConfig": "projects/acme/ui/tsconfig.lib.prod.json" },
"development": { "tsConfig": "projects/acme/ui/tsconfig.lib.json" }
}
}
}
}
}
}Avoid the ng- prefix (it is reserved for the framework). For public packages the convention is ngx-; for internal ones, a company scope (@acme/…) is the clearest and fits a private registry.
Anatomy of a library
Four files define a library. Each one answers a specific question:
ng-package.json— where the build goes (dest) and which file holds the public API (lib.entryFile).package.json— the name, version and dependencies.@angular/*go inpeerDependencies: if they were independencies, the application could receive a second copy of Angular and break at runtime."sideEffects": falsetells the bundler it can remove everything you don't import.public-api.ts— the contract. What is not exported here is private, so you can refactor it without telling anyone.tsconfig.lib.prod.json—compilationMode: "partial": the partial-Ivy format is stable across Angular versions, and compilation finishes in the application's build, with its own compiler version. Hence the rule: the application must use the same Angular version as the library was built with, or a newer one.
{
"$schema": "../../../node_modules/ng-packagr/ng-package.schema.json",
"dest": "../../../dist/acme/ui",
"lib": {
"entryFile": "src/public-api.ts"
}
}Secondary entry points
Angular Package Format mainly produces one flat ES module per entry point. A bundler cannot split a module into several chunks, so the entry point is the smallest granularity of lazy loading. That is why Angular Material has one entry point per component (@angular/material/button, …/tabs). The second reason: you isolate special APIs, such as @acme/ui/testing, so they don't reach production by accident.
projects/acme/ui/
├── ng-package.json → @acme/ui (primary entry point)
├── src/public-api.ts
├── button/
│ ├── ng-package.json → @acme/ui/button (secondary entry point)
│ └── src/public-api.ts
└── testing/
├── ng-package.json → @acme/ui/testing (harnesses, mocks for tests)
└── src/public-api.ts
ng-packagr discovers every ng-package.json on its own; you register nothing elsewhere.A library with a single purpose (an HTTP client, a set of utilities) works well with a single entry point — and @angular/core has just one for the runtime.
Consumption, build and publishing
Inside the same workspace you don't have to publish anything, but you do have to build the library first. ng generate library adds an alias to tsconfig.json that points to dist/, not to the source. The reason: applications and libraries have different builds (esbuild vs ng-packagr), and the same TypeScript can come out as different JavaScript. The documentation explicitly asks that aliases point to the built library. While you work, ng build @acme/ui --watch rebuilds incrementally.
{
"compilerOptions": {
"paths": {
// added automatically by ng generate library — points to the BUILD, not the source
"@acme/ui": ["./dist/acme/ui"],
// added by you, for the secondary entry points
"@acme/ui/*": ["./dist/acme/ui/*"]
}
}
}Publishing means a build in the production configuration (the default) and npm publish from dist — you publish the generated package, not the source folder.
# Distribution build: the production configuration (default for libraries) → partial-Ivy
ng build @acme/ui
# You publish the CONTENTS of dist, not the source folder
cd dist/acme/ui
npm publish # or: npm publish --registry https://npm.company.com
# The consumer (another repo) installs it like any other package
npm install @acme/ui@^1.4.0| Consumer | How it gets the code | Versions |
|---|---|---|
| Application in the same repo | An alias in tsconfig, always the latest version on the branch | Not needed: everyone is on HEAD |
| Another repo / another client | npm install @acme/ui from the registry | Strict semver, a changelog, correct peerDependencies |
The @nx/angular:lib generator puts the alias in tsconfig.base.json pointing to the source (libs/…/src/index.ts): the library is compiled together with the application, with no separate build step. You add its own build (--buildable / --publishable) only for libraries you publish or for incremental builds. Don't mix the two styles in the same workspace without knowing why.
Nx: the graph and the boundaries
Nx uses the same Angular builders, but keeps the configuration of each project in its own project.json and builds a project graph from analyzing the imports. Everything else rests on that graph: nx graph shows it to you, affected walks it in reverse, and the lint rule checks it on every import. In an existing Angular CLI workspace you start with npx nx@latest init.
# Existing Angular CLI workspace → Nx (keeps the structure)
npx nx@latest init
# …or move the projects into apps/ and libs/
npx nx@latest init --integrated
# Libraries with tags from day one
nx g @nx/angular:lib libs/shop/feature-cart --tags=scope:shop,type:feature
nx g @nx/angular:lib libs/shared/ui --tags=scope:shared,type:ui
nx g @nx/angular:lib libs/shared/ui --publishable --importPath=@acme/ui # only if you publish it
# The project graph, interactive in the browser
nx graph
nx graph --focus shop-feature-cart
# Run only what is affected by the changes compared to main
nx affected -t lint test build
nx affected -t build --graph # see the task graph before you run it
nx run-many -t test # everything, no filterLibrary types
The Nx guide proposes four types — the same roles as in smart vs dumb and in the folder structure, except that now the boundary is a library with a public API:
| Type | What it contains | May depend on |
|---|---|---|
feature | Pages and container components for a use case; usually lazy | any type |
ui | Presentational components; the data arrives through inputs, no injected services | ui, util |
data-access | Communication with the backend and state (stores) | data-access, util |
util | Pure functions, often with no framework code | util |
acme/
├── nx.json ← cache, targetDefaults, defaultBase
├── tsconfig.base.json ← "@acme/shop/feature-cart": ["libs/shop/feature-cart/src/index.ts"]
├── apps/
│ ├── shop/project.json tags: scope:shop, type:app
│ └── admin/project.json tags: scope:admin, type:app
└── libs/
├── shop/
│ ├── feature-catalog/ tags: scope:shop, type:feature (smart pages, lazy)
│ ├── feature-cart/ tags: scope:shop, type:feature
│ └── data-access/ tags: scope:shop, type:data-access (API + store)
├── admin/
│ ├── feature-users/ tags: scope:admin, type:feature
│ └── data-access/ tags: scope:admin, type:data-access
└── shared/
├── ui/ tags: scope:shared, type:ui (dumb components)
├── data-access/ tags: scope:shared, type:data-access (auth, HTTP config)
└── util/ tags: scope:shared, type:util (pure functions)Tags on two dimensions
Each project gets tags in project.json. The combination that works in practice is scope (the domain: scope:shop, scope:admin, scope:shared) × type (the layer: type:feature, type:ui…). The scope answers "who talks to whom", the type answers "which way dependencies flow".
{
"name": "shop-feature-cart",
"sourceRoot": "libs/shop/feature-cart/src",
"projectType": "library",
"tags": ["scope:shop", "type:feature"]
}The @nx/enforce-module-boundaries rule
The lint rule takes a list of depConstraints. For an import, Nx takes all the constraints whose sourceTag the source project has, and applies them with AND: shop/feature-cart must satisfy both the scope rule and the type rule. onlyDependOnLibsWithTags requires the target to have at least one of the tags. A project with no matching tag at all is not allowed to depend on anything.
import nx from '@nx/eslint-plugin';
export default [
...nx.configs['flat/base'],
...nx.configs['flat/typescript'],
...nx.configs['flat/javascript'],
{
files: ['**/*.ts'],
rules: {
'@nx/enforce-module-boundaries': [
'error',
{
allow: [],
depConstraints: [
// Dimension 1: the domain (who may talk to whom)
{ sourceTag: 'scope:shop', onlyDependOnLibsWithTags: ['scope:shop', 'scope:shared'] },
{ sourceTag: 'scope:admin', onlyDependOnLibsWithTags: ['scope:admin', 'scope:shared'] },
{ sourceTag: 'scope:shared', onlyDependOnLibsWithTags: ['scope:shared'] },
// Dimension 2: the type (the direction in which dependencies flow)
{ sourceTag: 'type:app', onlyDependOnLibsWithTags: ['type:feature', 'type:ui', 'type:data-access', 'type:util'] },
{ 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'] },
],
},
],
},
},
];Besides the tags, the rule has fixed checks that run before them. Three matter every day, in this order: circular dependencies, importing an application (forbidden), and a static import of a lazy-loaded library (it would pull the chunk into the main bundle). You can see all of them in the demo:
scope:shopscope:sharedscope:adminlazy (loadChildren)
The same rule also accepts finer forms:
// Other useful forms of constraints (same rule, same depConstraints):
[
// "ui" must not reach data-access even TRANSITIVELY (checks the whole chain)
{ sourceTag: 'type:ui', notDependOnLibsWithTags: ['type:data-access'] },
// the rule applies only to projects that have BOTH tags
{ allSourceTags: ['scope:shop', 'type:ui'], onlyDependOnLibsWithTags: ['scope:shared'] },
// npm packages banned for one project type
{ sourceTag: 'type:util', bannedExternalImports: ['@angular/*'] },
// glob and regex in tags
{ sourceTag: 'scope:*', onlyDependOnLibsWithTags: ['/^scope:(shared|core)$/'] },
]Mind the difference: notDependOnLibsWithTags checks the whole chain of the target's dependencies, not just the direct one. It is the right tool for "the UI never reaches data-access, not even through detours". For boundaries between folders in a single application, without Nx, see Boundaries.
Affected and cache
nx affected -t test starts from the files changed between --base (by default defaultBase, that is main) and --head, maps them to projects, then walks up the graph along the reverse edges: everything that depends, directly or transitively, on a changed project is affected. In the demo workspace, a change in shop/data-access affects 4 projects (the library, the two shop features and the shop application); one in shared/util affects all 10.
On top of that comes the cache: before running a task, Nx computes a hash of its inputs — the project's sources, its dependencies' (per inputs), the configuration, the command. If the hash has been seen before, Nx replays the result (the terminal output and the files) instead of running it. With a remote cache, a task run once in CI no longer runs on anyone's laptop.
changed ✎affectedran ✓from cache
Click a project in the graph: you change one of its files.
- The CI terminal shows up here.
{
"defaultBase": "main",
"namedInputs": {
"default": ["{projectRoot}/**/*"],
"production": ["default", "!{projectRoot}/**/*.spec.ts"]
},
"targetDefaults": {
"build": {
"dependsOn": ["^build"],
"inputs": ["production", "^production"],
"cache": true
},
"test": {
"inputs": ["default", "^production"],
"cache": true
},
"lint": { "cache": true }
}
}If every application depends on one huge shared library, any change in it affects the whole repo, and affected stops saving anything. Smaller libraries, split by domain and type, mean narrower graphs and more cache hits.
The cache assumes tasks without side effects: the same inputs give the same result. An e2e test that hits a real backend is not cacheable — its result depends on something Nx cannot see.
Folder or library?
The Nx guide on project size sums up the trade-off well. You split into libraries when tests slow you down (affected and cache start to skip pieces), when the code has a different owner (a team, a CODEOWNERS file), or when you want verified boundaries. You don't split what always changes together — the boundary costs navigation and configuration without buying anything. And while the design is still being explored, one project is cheaper; you extract the library once the API has settled.
A folder in the feature (or in shared/) with an index.ts as its public API gives you 80% of the benefits with no configuration. Extract the library when a second consumer appears.
src/app/features/cart/ui/ + lint rule on importsFor the "folder" option, the rules from Shared vs feature and an index.ts as the public API give you most of the benefits, with no extra configuration.
When YES, when NO
- Two or more applications share domain code or an internal design system.
- Frequent cross-cutting changes (API models, auth, theme) that you want to ship atomically, in one PR.
- Several teams in the same code: tags and the lint rule make the boundaries explicit.
- A slow CI:
affected+ cache cut the time in proportion to what you touched. - A design system also consumed from outside the repo: a publishable library built with ng-packagr.
- One application, one team: domain folders (Folder structure) and a
no-restricted-importsrule give you the same boundaries. - Teams that must ship completely independently, even with different Angular versions: polyrepo or micro frontends.
- A library per component: configuration with no benefit. A library per domain and type, not per file.
- Publishing to npm for code used only inside the repo: versions add ceremony with no value.
Common pitfalls
"@acme/ui": ["projects/acme/ui/src/public-api.ts"] looks convenient (you no longer build the library), but the application compiles the code with esbuild, while external consumers receive what ng-packagr produces. The bugs appear only for them. Fix: an alias to dist and ng build @acme/ui --watch — or switch to Nx, which is designed to compile from source.
The library brings its own copy of Angular, and the application ends up with two instances: DI and change detection break in ways that are hard to diagnose. Fix: everything that is @angular/* goes in peerDependencies.
- Deep imports (
@acme/ui/src/lib/button/button) that bypasspublic-api.ts: the first internal refactor breaks the consumers. Fix: export from the public API or create a secondary entry point; forbid the pattern withno-restricted-imports. - Relative imports between entry points of the same library: ng-packagr builds each entry point separately and the build fails. Fix: import by the package name (
@acme/ui). - Projects without tags, "fixed" with
sourceTag: '*'→'*': the rule no longer checks anything. Fix:--tagsat generation time, and the tags checked at review. - A shared library as a junk drawer that everything depends on: affected = the whole repo on every PR. Fix: split it by domain and type.
- Caching non-deterministic tasks (e2e against a real backend, tests that read the time): Nx replays a result that is no longer true. Fix: no
cache: trueon those targets, or complete inputs. - A static import over a
loadChildren: the lazy chunk disappears into the initial bundle. The Nx rule catches it; without Nx, check the bundle (see Performance).
Cheat sheet
ANGULAR CLI (multi-project)
ng new acme --no-create-application empty workspace
ng g application shop | ng g library @acme/ui
ng build @acme/ui [--watch] libraries are built BEFORE they are consumed
tsconfig paths → ./dist/acme/ui (+ "@acme/ui/*" for secondary entry points)
cd dist/acme/ui && npm publish only what leaves the repo gets published
LIBRARY
ng-package.json dest + lib.entryFile (secondary: only "lib")
public-api.ts the public API — the rest is private
package.json peerDependencies for @angular/*, "sideEffects": false
compilationMode "partial" for npm
NX
nx graph the project graph
nx affected -t lint test build only the affected projects (transitive dependents included)
"cache": true in targetDefaults hash over inputs → replay on the second run
tags + @nx/enforce-module-boundaries → boundaries enforced at lint time
types: feature → ui, data-access, util · ui → ui, util · data-access → data-access, util · util → util