Nx boundaries
October 1, 2025About 2 min
Nx boundaries
Nx boundaries are rules that keep your architecture clean by controlling which libraries can import which other libraries. You declare intent with tags (like type:ui or scope:shared) and Nx enforces it via the ESLint rule @nx/enforce-module-boundaries.
We'll use these libraries:
- App:
apps/swe-demo - Feature libs:
libs/swe-demo/feature,libs/shared/feature - UI libs:
libs/swe-demo/ui,libs/shared/ui - Domain libs:
libs/swe-demo/domain,libs/shared/domain - Util libs:
libs/swe-demo/util,libs/shared/util
The idea: shared/* sits below everything (generic, reusable), while swe-demo/* composes features of our specific app.
Layering model (mental model)
From lowest to highest:
- util β generic helpers, pure functions, no framework coupling.
- domain β app/business types and rules, minimal deps (ideally only util).
- ui β presentational components (Angular), no business logic.
- feature β smart components, orchestration, routing, state.
- app β composition root (wires features together).
Dependency direction: only upwards (lower layers never import higher layers).
Tagging each project
Add tags in each project.json so Nx "knows" what the type, scope and optionally framework of a lib is.
// libs/shared/util/project.json
{ "name": "shared-util", "tags": ["scope:shared", "type:util"] }
// libs/shared/domain/project.json
{ "name": "shared-domain", "tags": ["scope:shared", "type:domain"] }
// libs/shared/ui/project.json
{ "name": "shared-ui", "tags": ["scope:shared", "type:ui", "framework:angular"] }
// libs/shared/feature/project.json
{ "name": "shared-feature", "tags": ["scope:shared", "type:feature", "framework:angular"] }
// libs/swe-demo/util/project.json
{ "name": "swe-demo-util", "tags": ["scope:swe-demo", "type:util"] }
// libs/swe-demo/domain/project.json
{ "name": "swe-demo-domain", "tags": ["scope:swe-demo", "type:domain"] }
// libs/swe-demo/ui/project.json
{ "name": "swe-demo-ui", "tags": ["scope:swe-demo", "type:ui", "framework:angular"] }
// libs/swe-demo/feature/project.json
{ "name": "swe-demo-feature", "tags": ["scope:swe-demo", "type:feature", "framework:angular"] }
Enforcing boundaries (ESLint rule)
Find the eslint.config.mjs in the root of the monorepo and add the depConstraints:
depConstraints: [
{
sourceTag: 'scope:shared',
onlyDependOnLibsWithTags: ['scope:shared'],
},
{
sourceTag: 'scope:swe-demo',
onlyDependOnLibsWithTags: ['scope:swe-demo','scope:shared'],
},
{
sourceTag: 'type:util',
onlyDependOnLibsWithTags: ['type:util'],
},
{
sourceTag: 'type:domain',
onlyDependOnLibsWithTags: ['type:domain','type:util'],
},
{
sourceTag: 'type:ui',
onlyDependOnLibsWithTags: ['type:ui','type:domain','type:util'],
},
{
sourceTag: 'type:feature',
onlyDependOnLibsWithTags: ['type:feature','type:ui','type:domain','type:util'],
},
],
How to read this:
- Scope:
shared/*can't depend onswe-demo/*.swe-demo/*may depend onshared/*. - Layer: lower layers never import higher layers. E.g.,
uican depend ondomain, butdomaincannot depend onui.
Concrete examples
- App β Feature (β allowed)
// apps/swe-demo/src/app/app.ts
import { NavbarContainer } from "@swe-monorepo/swe-demo-feature"; // OK
- Feature β UI (β allowed)
// libs/swe-demo/feature/src/lib/navbar-container.ts
import { Navbar } from "@swe-monorepo/swe-demo-ui"; // OK
- Feature β Domain (β allowed)
// libs/swe-demo/feature/src/lib/navbar-container.ts
import { NavItem } from "@swe-monorepo/shared-domain"; // OK (domain below feature)
- UI β Domain (β allowed)
// libs/swe-demo/ui/src/lib/navbar.ts
// presentational component typing its @Input()
import { NavItem } from "@swe-monorepo/shared-domain"; // OK
- Domain β UI (β forbidden)
// libs/shared/domain/... (DON'T)
import { Navbar } from "@swe-monorepo/swe-demo-ui"; // β violates layer rule
shared/*βswe-demo/*(β forbidden)
// libs/shared/util/... (DON'T)
import { something } from "@swe-monorepo/swe-demo-domain"; // β violates scope rule
- UI β Feature (β forbidden)
// libs/swe-demo/ui/... (DON'T)
import { FeatureX } from "@swe-monorepo/swe-demo-feature"; // β UI must not depend on Feature
- Domain β Util (β allowed)
// libs/shared/domain/...
import { deepFreeze } from "@swe-monorepo/shared-util"; // OK
Sanity checks
- Run
nx graphand verify arrows only point up the layers and fromswe-demotoshared, never the other way around. - Run
nx affected:lint(or your lint task) to catch boundary violations early. - Keep libraries small and focused; boundaries are simplest when each lib has a single responsibility.
Short summary
- Scope:
shared/*is reusable and must not depend onswe-demo/*.swe-demo/*may depend onshared/*. - Layer:
utilβdomainβuiβfeatureβapp(imports only go from right to left). - Domain stays pure, UI is presentational, Feature orchestrates, App composes.
- Keep domain framework-free (pure TS). Itβs your bottom layer alongside util.