What nx enforce-module-boundaries checks and what it cannot see

The @nx/enforce-module-boundaries rule is an ESLint rule that reads each import statement in a file and maps it to a project in the Nx project graph. It does not look at file paths alone. It checks which project owns the importing file, which project owns the target, and whether that edge between the two is allowed.

With that graph in hand, the rule flags four classes of problem:

  • Tag-constraint violations. A project tagged scope:billing imports from a project the depConstraints don't permit.
  • Deep or relative imports into another project. The import bypasses the project's public entry point, usually its index.ts. These imports couple callers to internal file layout, so a refactor inside one library breaks code in another.
  • Circular dependencies between projects. Cycles block independent builds and make the affected-project calculation less precise.
  • Libraries importing from apps. An app is a deployment unit, so dependencies should only point from apps to libraries.

The rule runs only at lint time. Anything ESLint doesn't lint, or can't resolve statically, falls outside its protection.

Coupling mechanism Detected?
Static import across projects Yes
Dynamic require() with computed path No
HTTP or gRPC calls between services No
Message queue producers/consumers No
Runtime DI container wiring No
Files excluded from lint config No

Package and option names have changed across Nx releases. Check the documentation for your installed version before copying configuration.

Why backend monorepos need two tag axes: scope and type

A single flat tag list per library mixes two different questions: which business area does this code belong to, and which layer is it? Tag a library orders and another data-access, and you can no longer express a rule like "the orders API may use orders domain logic but never billing's database layer." Every rule ends up either too loose or listing projects one by one, and that list decays as the repo grows.

Splitting tags into two independent axes fixes this.

  • Scope maps to bounded contexts: scope:orders, scope:billing, scope:shared. Orders may depend on shared but not on billing internals.
  • Type maps to layers: type:api → type:domain → type:data-access → type:util. Dependencies point only downward. A domain library never imports an API library.

Every library carries exactly one tag from each axis, such as scope:billing plus type:domain. Nx checks every constraint whose source tag matches the importing project, so an import passes only if it satisfies both the scope rule and the type rule. The two axes combine, and you get a full matrix of allowed edges from a handful of constraints.

The scope: and type: prefixes also need to stay consistent. With a fixed prefix you can grep project configs to find untagged or mis-tagged libraries, review tag changes in diffs, and script audits in CI. Free-form names like billing-core or infra defeat all of that.

This setup pays off most for backend and platform teams that run several bounded contexts in one repository. In that situation, an accidental cross-context import can quietly couple deploy cycles, schemas, and on-call ownership.

                scope:orders          scope:billing
            +------------------+   +------------------+
type:api    | orders-api       |   | billing-api      |
            +--------+---------+   +--------+---------+
                     |                      |
                     v                      v
            +------------------+   +------------------+
type:domain | orders-domain    |   | billing-domain   |
            +--------+---------+   +--------+---------+
                     |                      |
                     v                      v
            +------------------+   +------------------+
type:data-  | orders-db        |-X>| billing-db       |
 access     +--------+---------+   +--------+---------+
                     |                      |
                     v                      v
            +-----------------------------------------+
type:util   | shared-util   [scope:shared, type:util] |
            +-----------------------------------------+

  --->  allowed: the scope rule AND the type rule both pass
  -X>   blocked: scope:orders may not depend on scope:billing
  No upward edges: type:domain never imports type:api

Each library carries one scope tag (column) and one type tag (row), so an import is allowed only if it flows downward through the layers and stays within its own bounded context or targets scope:shared.

How to tag projects and write depConstraints

Tags live in each project's configuration. In project.json, add a top-level tags array, for example ["scope:orders", "type:data-access"]. For package-based projects without a project.json, put the same array under the nx key in package.json, as "nx": { "tags": [...] }. Nx reads both locations into the project graph, so the lint rule sees them the same way.

A worked constraint set

Take a workspace with scope:orders, scope:billing, and scope:shared, plus type tags. Deployable services are tagged type:app. In the depConstraints array of the @nx/enforce-module-boundaries rule, write one entry per tag:

  • sourceTag: "scope:orders" gets onlyDependOnLibsWithTags: ["scope:orders", "scope:shared"].
  • scope:billing gets the same rule with its own scope in place of scope:orders.
  • scope:shared may depend only on scope:shared.
  • type:app may depend on type:feature, type:data-access, and type:util.

A project tagged scope:orders and type:app matches two entries, and Nx applies both. An import must pass every matching constraint. The scope axis and the type axis therefore combine without you having to list every pairing.

Exclusions

Allow-lists get long when the real rule is a single prohibition. To keep domain code away from database clients and queue adapters, add an entry with sourceTag: "type:feature" and notDependOnLibsWithTags: ["type:infra", "type:platform-adapter"]. Use an explicit exclusion when the forbidden set is small and stable. It records why the rule exists, and it does not break when someone adds a new legitimate tag. Use allow-lists when the permitted set is the smaller one.

Option names and the rule's package prefix have changed across Nx releases. Check them against your installed version.

// eslint.config.mjs at the workspace root (ESLint flat config)
import nx from '@nx/eslint-plugin';

export default [
  // Registers the @nx plugin so its rules can be referenced below
  ...nx.configs['flat/base'],
  {
    files: ['**/*.ts', '**/*.js'],
    rules: {
      '@nx/enforce-module-boundaries': [
        'error',
        {
          depConstraints: [
            // Scope axis: each domain may use itself plus shared code
            { sourceTag: 'scope:orders', onlyDependOnLibsWithTags: ['scope:orders', 'scope:shared'] },
            { sourceTag: 'scope:billing', onlyDependOnLibsWithTags: ['scope:billing', 'scope:shared'] },
            // Shared code must not reach back into any domain
            { sourceTag: 'scope:shared', onlyDependOnLibsWithTags: ['scope:shared'] },

            // Type axis: deployable services compose libraries, never other apps
            { sourceTag: 'type:app', onlyDependOnLibsWithTags: ['type:feature', 'type:data-access', 'type:util'] },

            // Exclusion: domain code stays away from DB clients and queue adapters.
            // A short deny-list keeps working when new legitimate tags are added.
            { sourceTag: 'type:feature', notDependOnLibsWithTags: ['type:infra', 'type:platform-adapter'] },
          ],
          // A project tagged ["scope:orders", "type:app"] matches two entries;
          // every import must satisfy both of them.
        },
      ],
    },
  },
];

An ESLint flat config for a hypothetical orders/billing/shared workspace that combines scope and type allow-lists with a notDependOnLibsWithTags exclusion for infra and platform-adapter libraries; check the plugin export and option names against your installed Nx version.

Cross-context communication and public API discipline

When the orders context genuinely needs to know what a payment event looks like, the fix is not to let scope:orders depend on scope:payments. Extract the shared shape into a contracts library tagged scope:shared and type:contracts, and keep it limited to DTOs, event schemas, and interfaces, with no implementations. Add a constraint so that type:contracts can only depend on other type:contracts projects. Then each feature or data-access library imports something like @acme/payments-contracts instead of reaching into the payments domain. The orders team gets a stable type, and the payments implementation stays private.

Public API discipline makes this hold up. Each library's index.ts should export only the symbols you intend consumers to use. The rule already rejects deep imports that bypass a project's entry point, so do not work around that with path aliases into src/. Any symbol that is not exported from the index is free to change without breaking anyone.

Use the rule's allow option sparingly. A test-utilities library that every spec imports is a reasonable exemption. "It was easier" is not. Leave a comment next to each entry explaining why it exists.

Contracts libraries have two costs:

  • Versioning pressure. In a single-version monorepo, a schema change reaches every consumer in the same commit. Breaking changes need expand-then-contract steps, such as adding optional fields first and removing old ones later.
  • Dumping-ground drift. scope:shared attracts anything without an obvious home. Require code-owner review on shared projects, and add sub-tags such as shared:events and shared:http with their own constraints so the shared scope stays organized.
// eslint.config.js (workspace root)
const nx = require('@nx/eslint-plugin');

module.exports = [
  {
    files: ['**/*.ts'],
    plugins: { '@nx': nx },
    rules: {
      '@nx/enforce-module-boundaries': ['error', {
        // Keep this list short. Every entry needs a reason next to it.
        allow: [
          '@acme/test-utils', // spec helpers imported by every context's tests
        ],
        depConstraints: [
          // Contexts never import each other. They meet only through shared code.
          { sourceTag: 'scope:orders', onlyDependOnLibsWithTags: ['scope:orders', 'scope:shared'] },
          { sourceTag: 'scope:payments', onlyDependOnLibsWithTags: ['scope:payments', 'scope:shared'] },
          { sourceTag: 'scope:shared', onlyDependOnLibsWithTags: ['scope:shared'] },
          // Contracts hold types and schemas only, so they can depend only on other contracts.
          { sourceTag: 'type:contracts', onlyDependOnLibsWithTags: ['type:contracts'] },
          // Sub-tags keep the shared scope organized: event schemas stay free of HTTP concerns.
          { sourceTag: 'shared:events', onlyDependOnLibsWithTags: ['shared:events'] },
        ],
      }],
    },
  },
];

// libs/payments/contracts/project.json -> "tags": ["scope:shared", "type:contracts", "shared:events"]
//
// libs/payments/contracts/src/index.ts is the only public surface:
//   export type { PaymentCapturedEvent } from './lib/payment-captured.event';
//
// libs/orders/data-access/src/lib/on-payment.ts
//   import type { PaymentCapturedEvent } from '@acme/payments-contracts';            // OK: public entry point
//   import { PaymentCapturedEvent } from '@acme/payments-contracts/src/lib/...';     // rejected: deep import
//   import { PaymentService } from '@acme/payments-domain';                         // rejected: scope:payments

A flat ESLint config that isolates the orders and payments contexts, lets them share only through a contracts library tagged scope:shared and type:contracts, adds a shared:events sub-constraint, allows one justified test-utilities exemption, and shows which imports the rule accepts and rejects.

Rolling out and verifying boundary rules on an existing repo

Start by tagging every project before writing a single restrictive rule. Untagged projects behave badly under constraints: only a constraint with the wildcard sourceTag of * matches them, and any constraint that requires tags on a dependency rejects them. Tagging first means the first strict rule you add reports real violations, not missing metadata.

With tags in place, add one constraint where * may depend on *. Nothing fails yet, but the rule is wired into lint and you can confirm it runs. Next, open nx graph and look for edges that will break the rules you intend to add, such as a type:data-access project importing a type:feature, or one scope: reaching into another. Fix or plan each of these before you tighten anything.

Tighten one bounded context at a time. Replace the wildcard for scope:billing, fix what breaks, merge, then move to the next context. Small steps keep each pull request reviewable and make it clear which rule caused a failure. When the baseline is clean, make CI fail on lint errors for affected projects.

To verify, add a forbidden import on a throwaway branch and confirm the error fires. Also check that no project has an empty tag list.

Two policies matter after rollout. First, an eslint-disable comment for this rule should require a linked ticket and code-owner approval. Second, run a full lint on a schedule. Affected-only CI checks only changed projects, so it will not catch violations that slipped in while a constraint was still a wildcard.

// eslint.config.mjs (root) - mid-rollout: billing is tightened, other contexts still open
import nx from '@nx/eslint-plugin';

export default [
  ...nx.configs['flat/base'],
  {
    files: ['**/*.ts', '**/*.js'],
    rules: {
      '@nx/enforce-module-boundaries': [
        'error',
        {
          enforceBuildableLibDependency: true,
          allow: [],
          depConstraints: [
            // Step 2: tightened context. A billing project may depend only on
            // billing or shared projects. Fix what breaks, merge, then repeat
            // for the next scope.
            {
              sourceTag: 'scope:billing',
              onlyDependOnLibsWithTags: ['scope:billing', 'scope:shared'],
            },
            // Step 1: permissive catch-all. It keeps the rule wired into lint
            // and is the only constraint that matches an untagged project.
            // Every constraint that matches a project's tags must pass, so this
            // does not loosen the billing rule. Remove it once every scope
            // has its own constraint.
            {
              sourceTag: '*',
              onlyDependOnLibsWithTags: ['*'],
            },
          ],
        },
      ],
    },
  },
];

// CI (once the baseline is clean): npx nx affected -t lint
// Scheduled full sweep:            npx nx run-many -t lint

A root ESLint flat config partway through the rollout: the scope:billing constraint is enforced, and a wildcard constraint still covers every other project until its own scope is tightened.

When to avoid strict boundaries and what the alternatives are

Strict boundaries cost more than they return in some repos. A repo with a handful of projects, or one that holds a single bounded context, has no cross-context imports to police. Tags there mostly document a structure people can already see. The rule also fails when no one owns the tag metadata. Untagged new projects and stale scopes produce false positives, and engineers learn to ignore the lint error.

Granularity has its own failure mode. With six scopes and five types, the constraint list grows until no one can tell which edges are allowed. Teams then add wildcard entries or per-file disable comments, and the rule stops meaning anything. Strict layering creates a similar problem: if every layer must be its own library, a single feature can span four tiny projects, and the overhead of the graph outweighs the clarity it gives.

The alternatives enforce different things:

Approach Enforces Misses
ESLint import-restriction rules Path or pattern bans per file No project graph or tags; patterns drift as folders move
TypeScript project references Compile-time dependency direction No scope or type semantics; needs build-mode setup
Separate repositories or packages Hard isolation through versioned APIs Atomic cross-cutting changes; adds release overhead
Runtime architecture tests Rules on the loaded module or class structure Only runs in tests; slower feedback than lint
Code review conventions Judgment on intent and design Consistency; depends on reviewer attention

A common middle ground is to tag only scope, leave out type, and add type constraints once the layering has settled.

Impact on development cost and engineering time

Boundary rules front-load effort and pay it back later. Most of the cost lands in the first weeks. The savings build up over time and are easy to miss, because they show up as problems that never happen.

Cost Time saved
Initial tagging of every project Coupling caught at review instead of during a later service extraction
Refactoring the violations that surface when constraints are switched on Lint config doubles as a readable map of allowed dependencies
More libraries and more project.json entries to maintain New engineers learn the architecture from lint errors, not tribal knowledge
A larger, more complex project graph Smaller, more accurate affected sets in CI, because fewer projects depend on everything

Illustrative estimate (assumed numbers, not a benchmark). Assume 80 projects at 5 minutes each to tag, 40 existing violations at 1.5 hours each to fix, and 2 hours per month of configuration upkeep.

  • First-year cost = (80 × 5 min) + (40 × 1.5 h) + (12 × 2 h) ≈ 7 + 60 + 24 = 91 hours
  • Savings = avoided extractions × hours per extraction. One avoided 120-hour untangling of a payments module already covers the cost. Faster reviews and shorter CI runs add to that.

Plug in your own counts. The violation count drives the upfront cost most, so run the rule in warning mode first to measure it.

Keep the scheme small. Two axes, scope and type, each with a handful of values, are easier to reason about than dozens of bespoke tags. The constraint list grows roughly with the number of tag values, and so does the maintenance cost.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to nx enforce-module-boundaries, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.