Skip to main content

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:

  1. util – generic helpers, pure functions, no framework coupling.
  2. domain – app/business types and rules, minimal deps (ideally only util).
  3. ui – presentational components (Angular), no business logic.
  4. feature – smart components, orchestration, routing, state.
  5. 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 on swe-demo/*. swe-demo/* may depend on shared/*.
  • Layer: lower layers never import higher layers. E.g., ui can depend on domain, but domain cannot depend on ui.

Concrete examples

  1. App β†’ Feature (βœ… allowed)
// apps/swe-demo/src/app/app.ts
import { NavbarContainer } from "@swe-monorepo/swe-demo-feature"; // OK
  1. Feature β†’ UI (βœ… allowed)
// libs/swe-demo/feature/src/lib/navbar-container.ts
import { Navbar } from "@swe-monorepo/swe-demo-ui"; // OK
  1. Feature β†’ Domain (βœ… allowed)
// libs/swe-demo/feature/src/lib/navbar-container.ts
import { NavItem } from "@swe-monorepo/shared-domain"; // OK (domain below feature)
  1. UI β†’ Domain (βœ… allowed)
// libs/swe-demo/ui/src/lib/navbar.ts
// presentational component typing its @Input()
import { NavItem } from "@swe-monorepo/shared-domain"; // OK
  1. Domain β†’ UI (❌ forbidden)
// libs/shared/domain/...  (DON'T)
import { Navbar } from "@swe-monorepo/swe-demo-ui"; // ❌ violates layer rule
  1. shared/* β†’ swe-demo/* (❌ forbidden)
// libs/shared/util/...  (DON'T)
import { something } from "@swe-monorepo/swe-demo-domain"; // ❌ violates scope rule
  1. UI β†’ Feature (❌ forbidden)
// libs/swe-demo/ui/... (DON'T)
import { FeatureX } from "@swe-monorepo/swe-demo-feature"; // ❌ UI must not depend on Feature
  1. Domain β†’ Util (βœ… allowed)
// libs/shared/domain/...
import { deepFreeze } from "@swe-monorepo/shared-util"; // OK

Sanity checks

  • Run nx graph and verify arrows only point up the layers and from swe-demo to shared, 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 on swe-demo/*. swe-demo/* may depend on shared/*.
  • 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.