JOURNAL

Nx Module Boundaries — The #1 Architecture Rule for Large Angular Codebases

Post A — How to enforce Nx module boundary rules in Angular 21 to prevent spaghetti imports, protect domain separation, and keep teams independently productive in a monorepo.

angular techlead nx boundaries

Read with AI

Choose content to copy and paste into your AI assistant. Nothing is sent automatically. CMS content is converted to Markdown; original Markdown is used when available.

The number one cause of architecture decay in Angular monorepos isn’t bad engineers. It’s the absence of enforced boundaries. Without them, it’s only a matter of time before libs/checkout imports from libs/catalog, libs/catalog imports from libs/cart, and every team is blocked waiting on every other team.

Module boundaries are not suggestions. They are architectural law — enforced by your linter, not your code review process.

What Nx Module Boundaries Are

Nx’s @nx/enforce-module-boundaries ESLint rule analyzes import statements across your monorepo and fails the lint check when a library imports from a library it shouldn’t.

The rule is configuration-driven. You declare which libraries can import from which, using tags you assign to each library.

The Tag Strategy for TechShop

Tags come in two flavors — scope (what kind of library it is) and domain (which business area it belongs to).

// project.json for libs/catalog/feature-product-list
{
  "tags": ["scope:feature", "domain:catalog"]
}
// project.json for libs/catalog/data-access
{
  "tags": ["scope:data-access", "domain:catalog"]
}
// project.json for libs/shared/ui
{
  "tags": ["scope:ui", "domain:shared"]
}

Full tag taxonomy for TechShop:

Tag Meaning
scope:feature Route-level page components
scope:data-access Services, stores, API calls
scope:ui Presentational components only
scope:util Pure functions, pipes, validators
domain:catalog Product catalog business context
domain:cart Shopping cart business context
domain:checkout Checkout and payment
domain:orders Order management
domain:shared Cross-domain utilities

The Boundary Rules

// eslint.config.mjs (flat config)
{
  "rules": {
    "@nx/enforce-module-boundaries": ["error", {
      "enforceBuildableLibDependency": true,
      "allow": [],
      "depConstraints": [
        // Scope rules — what each scope can import
        {
          "sourceTag": "scope:feature",
          "onlyDependOnLibsWithTags": [
            "scope:data-access",
            "scope:ui",
            "scope:util"
          ]
        },
        {
          "sourceTag": "scope:data-access",
          "onlyDependOnLibsWithTags": ["scope:util", "scope:data-access"]
        },
        {
          "sourceTag": "scope:ui",
          "onlyDependOnLibsWithTags": ["scope:ui", "scope:util"]
        },
        {
          "sourceTag": "scope:util",
          "onlyDependOnLibsWithTags": ["scope:util"]
        },

        // Domain rules — domains can only use shared, not each other
        {
          "sourceTag": "domain:catalog",
          "onlyDependOnLibsWithTags": ["domain:catalog", "domain:shared"]
        },
        {
          "sourceTag": "domain:cart",
          "onlyDependOnLibsWithTags": ["domain:cart", "domain:shared"]
        },
        {
          "sourceTag": "domain:checkout",
          "onlyDependOnLibsWithTags": [
            "domain:checkout",
            "domain:shared",
            "domain:cart"  // checkout CAN read cart, but cart cannot read checkout
          ]
        },
        {
          "sourceTag": "domain:orders",
          "onlyDependOnLibsWithTags": ["domain:orders", "domain:shared"]
        }
      ]
    }]
  }
}

What the Rules Prevent

❌ This import now fails at lint:

// libs/catalog/feature-product-list/src/lib/product-list.component.ts

// Feature importing from another feature
import { CheckoutStepComponent } from '@techshop/checkout/feature-step'; // ❌

// Data-access importing from a feature
import { ProductListComponent } from '@techshop/catalog/feature-product-list'; // ❌

// Different domain importing without permission
import { CartService } from '@techshop/cart/data-access'; // ❌ (catalog cannot use cart)

✅ These imports pass:

// Feature importing from data-access (same domain)
import { ProductStore } from '@techshop/catalog/data-access'; // ✅

// Feature importing from shared UI
import { ButtonComponent } from '@techshop/shared/ui'; // ✅

// Feature importing shared util
import { formatPrice } from '@techshop/shared/util-format'; // ✅

Visualizing the Graph

# Show the full dependency graph in-browser
npx nx graph

# Show what is affected by a change to cart/data-access
npx nx affected:graph --base=main

The graph makes it immediately obvious when rules are working. You see clean layers and isolated domains. When rules are broken, you see cross-domain arrows that shouldn’t exist.

Adding a New Library Correctly

# Generate with tags at creation time
npx nx g @nx/angular:library feature-order-history \
  --directory=libs/orders \
  --tags="scope:feature,domain:orders" \
  --standalone \
  --routing

CI Enforcement

# .github/workflows/pr.yml
- name: Lint (boundary check)
  run: pnpm nx affected --target=lint --base=$NX_BASE --head=$NX_HEAD

The lint step runs on every PR. A boundary violation is a build failure. No exceptions.

Common Boundary Violations and Fixes

Problem: Checkout needs to know the cart total.

Wrong fix: Import CartService from domain:cart into domain:checkout.

Right fix: Create libs/shared/util-cart-summary tagged domain:shared with a CartSummary interface. Cart writes to it, Checkout reads from it.

libs/
  shared/
    util-cart-summary/       ← domain:shared, scope:util
      cart-summary.model.ts  ← CartSummary interface
  cart/
    data-access/             ← writes CartSummary to signal
  checkout/
    data-access/             ← reads CartSummary (shared domain allowed)

Problem: Two features both need the same product card component.

Wrong fix: Import from each other.

Right fix: Move ProductCardComponent to libs/catalog/ui-product-card tagged scope:ui. Both features import from there.

The Tech Lead Responsibility

Boundaries are easy to add at the start. They’re expensive to retrofit after a year of violations. Your job as Tech Lead:

  1. Set the tag strategy before day 1 of feature work
  2. Add the depConstraints before the first PR
  3. Make lint a required CI check that cannot be bypassed
  4. Review nx graph monthly — spot new cross-domain edges before they harden

The rule: if you can’t explain why two libraries should communicate without looking at the code, the boundary rule is wrong, not the linter.


References


Part of the Angular Tech Lead Series — Back to main series overview

Discussion

Comments are reviewed before publication. Your email is kept private.

Add a comment

Name and email are required. Do not include confidential information.

Privacy & data

Loading anti-spam verification…

← Back to allĐọc tiếng Việt