What Nx module boundaries enforce and how the rule works

A module boundary in Nx is a rule about which projects may depend on which. Nx does not infer these rules. You declare them by attaching tags to projects and writing constraints over those tags. The @nx/enforce-module-boundaries ESLint rule then reports any import that breaks a constraint.

How the rule decides

Nx builds a project graph by analyzing source code, installed dependencies and TypeScript configuration. Each project is a node, and each detected dependency is an edge. When ESLint lints a file, the rule resolves each import to its target project in that graph. It then checks the importing project's tags against the target project's tags under your depConstraints. If no constraint permits the dependency, the import fails lint. The rule also flags a few structural problems that tags do not cover, such as circular dependencies between projects and relative imports that reach into another project's files.

Why teams adopt it

In a small monorepo, people review dependency direction by eye. As the number of projects and teams grows, that review stops scaling. A convenient import from a domain library into an app, or from one bounded context into another, merges without anyone noticing. Over time those imports turn layering into a tangle. The rule makes dependency direction a checked property instead of a convention people have to remember.

What it cannot see

The rule only knows about edges that exist in the graph. If two projects are coupled through something Nx does not analyze, the rule will not report it. Examples include HTTP calls, message queues, shared database tables, and imports built from strings at runtime. Treat a passing lint run as proof about static imports only, not about runtime coupling.

        +-----------+      +--------------+   +---------------+
        | source    |      | installed    |   | TypeScript    |
        | imports   |      | dependencies |   | configuration |
        +-----+-----+      +-------+------+   +-------+-------+
              |                    |                  |
              +--------------------+------------------+
                                   |
                                   v
                         +-------------------+
                         | Project graph     |
                         | nodes = projects  |
                         | edges = deps      |
                         +---------+---------+
                                   |
                                   v
 +--------------+  +-------------------------------+  +----------------+
 | project tags |->| @nx/enforce-module-boundaries |<-| depConstraints |
 +--------------+  +---------------+---------------+  +----------------+
                                   |
                        +----------+----------+
                        v                     v
                 +-------------+      +---------------+
                 | permitted:  |      | violation:    |
                 | lint passes |      | lint fails    |
                 +-------------+      +---------------+

 Not in the graph, so never checked: HTTP calls, message queues,
 shared database tables, imports built from strings at runtime

Nx builds a project graph from source imports, installed dependencies and TypeScript config, and @nx/enforce-module-boundaries checks each edge against project tags and depConstraints. Coupling outside the graph is never checked.

Who should use it and when it is the wrong tool

The rule pays off when a workspace has grown past the point where any one person holds the dependency graph in their head. Typical cases are several teams sharing one repository, or a layered backend where domain logic must not reach into infrastructure adapters. In those setups the expensive mistakes are quiet ones. A convenient import from a feature library into a shared utility looks harmless in review, but each one tightens coupling and widens the set of projects that nx affected has to rebuild and retest.

When to avoid or defer it

  • Very small workspaces. With a handful of projects and one team, a tagging scheme costs more to maintain than it prevents. Add it when a second team or a second layer appears.
  • Mostly runtime coupling. If services talk over HTTP or queues and share little source code, the import graph says little about your real architecture. Contract tests and schema checks will catch more.
  • Dynamic or string-built imports. Code that resolves modules from computed paths never becomes a static edge, so the rule cannot see it.
  • Generated code outside the graph. If generators emit code that is not part of a project, or that is wired in at build time, its dependencies never show up as graph edges.

Even where the rule fits, it does not replace architectural review. It checks that imports follow the constraints you wrote. It cannot tell you whether those constraints are correct. Tags also describe the allowed direction of dependencies, not who owns a project or who may change it. Use CODEOWNERS and repository permissions for ownership and access control.

Designing tag dimensions on purpose

Tags are set per project. With a project.json, they go in its top-level tags array. For package-based projects without a project.json, put the same array under the nx key in package.json. Because Nx merges configuration from plugins, nx.json, and the project files, check what a project actually resolves to with nx show project <name> (source) rather than assuming the file you edited was the one that took effect.

Start with two dimensions. type: describes the layer: type:app, type:feature, type:data-access, type:util. scope: describes the domain or team, such as scope:billing or scope:shared. Together they answer the two architectural questions most workspaces care about: "can this layer depend on that layer?" and "can this domain reach into that domain?" Add a third dimension, such as platform:node versus platform:browser, only when there is a real constraint you need to enforce.

Granularity Example Effect
Too fine scope:billing-invoices-pdf Every refactor needs a constraint change, and eslint-disable comments start to pile up
Balanced type:feature, scope:billing Layering and domain isolation are enforced, and the config stays stable
Too coarse scope:backend only Nearly every import is allowed, so the rule enforces almost nothing

Tags are free-form strings. A typo such as type:data-acess matches no constraint and produces no error. Close that gap in one of two ways. You can add a CI check that reads each project's resolved tags and rejects values outside an allowed list. Or you can make a custom generator the only way to create projects, so tags are chosen from fixed options instead of typed by hand.

// scripts/check-tags.mjs
// Run in CI. Fails if any project has a tag outside the allowed list.
import { execSync } from 'node:child_process';

// The allowed values for each dimension. Add new values in code review.
const allowed = {
  type: ['app', 'feature', 'data-access', 'util'],
  scope: ['billing', 'orders', 'shared'],
};

const run = (cmd) => JSON.parse(execSync(cmd, { encoding: 'utf8' }));

// Read the resolved config, after plugins and nx.json have been merged in,
// rather than parsing project.json or package.json directly.
const projects = run('npx nx show projects --json');
const errors = [];

for (const name of projects) {
  const { tags = [] } = run(`npx nx show project ${name} --json`);

  // Catch typos such as type:data-acess. Nx would ignore them silently.
  for (const tag of tags) {
    const [dim, value] = tag.split(':');
    if (!allowed[dim]?.includes(value)) errors.push(`${name}: unknown tag "${tag}"`);
  }

  // Every project needs exactly one tag per dimension, so constraints always apply.
  for (const dim of Object.keys(allowed)) {
    const count = tags.filter((t) => t.startsWith(`${dim}:`)).length;
    if (count !== 1) errors.push(`${name}: expected one ${dim}: tag, found ${count}`);
  }
}

if (errors.length) {
  console.error(errors.join('\n'));
  process.exit(1);
}
console.log(`Tags OK for ${projects.length} projects`);

A CI script that reads each project's resolved tags with nx show project --json and fails when a tag is misspelled, not on the allowed list, or missing for the type or scope dimension.

Writing depConstraints and combining dimensions

Once tag values are stable, encode the rules in the depConstraints array of the @nx/enforce-module-boundaries ESLint rule. Each entry names a sourceTag and lists what projects carrying that tag may import. For example, onlyDependOnLibsWithTags: ["type:util"] on sourceTag: "type:util" keeps utilities free of framework and data-access code. A second entry with sourceTag: "scope:billing" and onlyDependOnLibsWithTags: ["scope:billing", "scope:shared"] keeps billing isolated from other domains.

Constraints are not alternatives. Every constraint whose sourceTag matches a project applies to it, and an import must satisfy all of them. Take a library tagged scope:billing and type:util that imports billing-data-access, tagged scope:billing and type:data-access. The scope constraint allows the import because both projects are billing. The type constraint rejects it because a util may only depend on other utils. This is why separate dimensions work: each rule stays short, and their intersection carries the full architecture.

When a ban is easier to state than an allow list, use notDependOnLibsWithTags. For example, you can forbid type:ui everywhere in backend code.

The rule also reports relative imports that cross into another project's folder. Those paths skip the tag check entirely, so any project must be imported through its workspace path alias. Deep imports into another library's internal files have a similar problem because they bypass its public entry point. Export the symbol from the library's index instead.

Give scope:shared its own constraint, typically allowing only scope:shared. Without one, shared becomes a dumping ground that quietly imports from every domain.

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

export default [
  ...nx.configs['flat/base'],
  {
    files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'],
    rules: {
      '@nx/enforce-module-boundaries': [
        'error',
        {
          depConstraints: [
            // Type dimension: utils stay free of UI, data-access and framework code.
            { sourceTag: 'type:util', onlyDependOnLibsWithTags: ['type:util'] },

            // Scope dimension: billing may only reach its own domain and shared code.
            {
              sourceTag: 'scope:billing',
              onlyDependOnLibsWithTags: ['scope:billing', 'scope:shared'],
            },

            // Shared gets its own rule so it cannot pull in any domain.
            { sourceTag: 'scope:shared', onlyDependOnLibsWithTags: ['scope:shared'] },

            // A ban is simpler than an allow list here: no UI in server code.
            { sourceTag: 'platform:server', notDependOnLibsWithTags: ['type:ui'] },
          ],
        },
      ],
    },
  },
];

// A project tagged scope:billing + type:util matches BOTH the type:util and
// scope:billing entries, so each import must satisfy both of them:
//   import { loadInvoices } from '@acme/billing-data-access';
//   scope rule: allowed (billing -> billing)
//   type rule:  rejected (util -> data-access) => lint error

An ESLint flat config for @nx/enforce-module-boundaries that defines independent type and scope constraints, a dedicated rule for scope:shared, and a notDependOnLibsWithTags ban, with a comment tracing an import that the scope rule allows but the type rule rejects.

Rolling the rule out to an existing workspace

Start by seeing which dependencies already exist, not the ones you think exist. Run nx graph to browse the project graph, or nx graph --file=graph.json to save it for scripting. Nx builds this graph from imports, installed packages and TypeScript configuration. That means it shows the edges the lint rule will actually check, including any undocumented ones.

Tag first, constrain later

Give every project a tag in each dimension before you add any real constraint. A project that matches no constraint can't depend on anything, so one missing tag can make a lint run fail across the workspace. Next, add a single catch-all constraint where any source tag (*) can depend on any tag (*). Now the rule runs in CI while blocking nothing, and you can confirm that tagging and linting work before you enforce any architecture.

Tighten one dimension at a time

Add the real constraints for one dimension first. type is usually the easier place to start. Run lint across the workspace and handle every violation in one of two ways:

  • Fix it. Move the code, or extract it into a correctly tagged library.
  • Record it. Add an inline suppression for that one import, with a ticket reference. This keeps every exception visible and searchable instead of silently allowed.

Once that dimension is clean, move on to scope. If you tighten both at once, you get a mixed list of failures that's hard to divide between teams.

The wildcard needs an owner

While the catch-all constraint is in place, every import that no other constraint covers is allowed, so it quietly cancels out the work above. Assign one person to own it and set the condition for removing it. A good condition: when every dimension is enforced, delete the constraint in a single, reviewed change. Without an owner, it tends to stay in place for good.

// eslint.config.mjs (root) - stage 2 of the rollout: `type` is enforced, `scope` is not yet
import nx from '@nx/eslint-plugin';

export default [
  ...nx.configs['flat/base'],
  {
    files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'],
    rules: {
      '@nx/enforce-module-boundaries': [
        'error',
        {
          allow: [],
          depConstraints: [
            // Stage 2: real constraints for the `type` dimension only.
            { sourceTag: 'type:app', onlyDependOnLibsWithTags: ['type:feature', 'type:ui', 'type:data-access', 'type:util'] },
            { sourceTag: 'type:feature', onlyDependOnLibsWithTags: ['type:ui', 'type:data-access', 'type:util'] },
            { sourceTag: 'type:ui', onlyDependOnLibsWithTags: ['type:ui', 'type:util'] },
            { sourceTag: 'type:data-access', onlyDependOnLibsWithTags: ['type:data-access', 'type:util'] },
            { sourceTag: 'type:util', onlyDependOnLibsWithTags: ['type:util'] },

            // Stage 3 (not yet enabled): `scope` constraints go here, e.g.
            // { sourceTag: 'scope:billing', onlyDependOnLibsWithTags: ['scope:billing', 'scope:shared'] },

            // TEMPORARY catch-all from stage 1. While present it allows anything
            // the constraints above don't cover, including cross-scope imports.
            // Owner: platform-team (jane.doe). Remove when: `scope` constraints
            // are enforced and lint is clean. Tracked in ARCH-142.
            { sourceTag: '*', onlyDependOnLibsWithTags: ['*'] },
          ],
        },
      ],
    },
  },
];

// Recording a known violation instead of fixing it now, in the importing file:
// // eslint-disable-next-line @nx/enforce-module-boundaries -- ARCH-157: move to data-access
// import { formatInvoice } from '@acme/billing-feature-invoices';

A root ESLint flat config partway through the rollout. It enforces the type dimension, leaves a placeholder for scope, and keeps the stage-1 wildcard constraint with a named owner and removal condition, plus an example of recording one exception with an inline suppression.

Making the rule a blocking CI gate and fixing violations

Enforcement only happens when lint runs

The boundary rule is an ESLint rule. It does nothing at build time and nothing at runtime. A bundler will happily compile a forbidden import, and the application will run with it. Violations can merge without anyone noticing in three cases: the lint target is skipped, it is misconfigured (for example, the rule is off or set to warn), or it runs in CI but is not a required check. Mark the lint job as required on your protected branches. Set the rule to error.

To keep the job fast, run lint through nx affected -t lint against your main branch as the base. Only projects touched by the change, and the projects that depend on them, get re-linted. That makes the gate cheap enough that nobody has a reason to bypass it.

Require tags on every new project. An untagged project either matches no constraint, which usually blocks all of its imports in confusing ways, or falls through to a * wildcard that allows everything. Neither tells you anything useful. Set tags in your generators, and check for empty tag arrays in review.

Fixing violations properly

A violation message names the importing project's tag and lists the tags it is allowed to depend on. Read it as a statement about where the code belongs. The usual fix is one of two moves:

  • Move the shared code down into a lower-layer library that both projects can depend on.
  • Split a library that has grown two responsibilities.

Sometimes neither fix is practical, and you need to loosen a constraint or add an eslint-disable comment. Treat that as an architectural decision. Record the reason, name an owner, and link it from the pull request.

Alternatives and impact on cost and engineering time

Tag-based lint is one of several ways to protect architecture. It works best layered on top of the others, not as a replacement for them.

Approach What enforces it When violations surface Main cost
Code review and ADRs alone Reviewer attention In review, if someone notices Reviewer time; inconsistent results
Code-ownership rules Required approvals per path In review Who approves, not what may import what
Separate repos or published packages Versioning and publishing At release or upgrade Version drift; slower cross-cutting changes
Convention-based folder layout Habit Rarely, or never Erodes silently
Nx tags with enforce-module-boundaries Lint on the project graph Locally and in CI Tagging, migration, config upkeep

The cost of lint enforcement comes early. You tag every project, fix or waive the existing violations, and keep depConstraints current as teams and domains change. Strict layering can also force extra shared libraries, or small duplications where two domains need the same helper but are not allowed to depend on each other.

The savings repeat over time. Boundary violations fail in CI before a reviewer has to spot them. Allowed dependencies become predictable, so review focuses on design. The project graph also stays shallower, which keeps affected builds and tests small.

Illustrative estimate (assumed numbers, not a benchmark). Review time saved per month = PRs × share touching imports × minutes saved per PR. With assumed inputs of 400 PRs, 25%, and 6 minutes, that gives 400 × 0.25 × 6 = 600 minutes, or 10 hours per month. Compare that figure with your own one-time tagging effort plus ongoing maintenance.

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 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.