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:

TagMeaning
scope:featureRoute-level page components
scope:data-accessServices, stores, API calls
scope:uiPresentational components only
scope:utilPure functions, pipes, validators
domain:catalogProduct catalog business context
domain:cartShopping cart business context
domain:checkoutCheckout and payment
domain:ordersOrder management
domain:sharedCross-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 SeriesBack to main series overview

Export for reading

Comments