How nx enforce-module-boundaries works
enforce-module-boundaries is an ESLint rule in Nx's ESLint plugin. It checks every import in your workspace against an allowlist you write. It is aimed at backend and platform engineers running Nx monorepos with many libraries and several teams, where the layering in the architecture diagram tends to drift from the layering in the code.
The rule uses three inputs:
- The project graph. Nx statically analyzes source files and records an edge whenever one project imports another, for example when an
orders-apiapp importsorders-data-access. - Tags. Each project declares string tags in its project configuration, such as
type:data-accessorscope:orders. Tags carry no meaning on their own. They are labels you attach so that rules can refer to groups of projects. depConstraints. This array lives in the rule's options. Each entry names asourceTagand lists the tags that a project carrying it may depend on, usually throughonlyDependOnLibsWithTags.
During linting, the rule checks each import edge. It resolves the importing and imported projects, reads their tags, and confirms that some matching constraint allows the dependency. If none does, the import is reported.
The report is a lint error, not a build failure. TypeScript compiles a forbidden import without complaint, and the bundler includes it. The rule only takes effect where lint runs: in the editor, in pre-commit hooks, and in CI. If lint is optional or skipped in your pipeline, the rule is advisory.
The payoff is that "controllers call services, services call repositories, nothing calls upward" stops depending on code review. It becomes a rule that a machine checks on every change.
Designing a two-axis tag scheme: type and scope
Each project gets two tags: one for its layer, one for its business area. Layer tags (type:app, type:feature, type:domain, type:data-access, type:util) control which direction dependencies may point. Scope tags (scope:billing, scope:orders, scope:shared) control which business areas may depend on each other.
One dimension can't express both. Tag a library only billing-domain and you need a separate rule for every layer and scope pair, and the rule count grows multiplicatively each time a team adds a domain. With two axes, each rule covers one concern. A project's layer and scope are checked independently, and an import must pass both checks.
| Question | Answered by | Example tag |
|---|---|---|
| Can this import point in this direction? | type:* |
type:data-access |
| Can this import cross this business area? | scope:* |
scope:orders |
| Is this safe for everyone to use? | scope:shared |
scope:shared |
The intended layering points downward only:
type:app
│
▼
type:feature
│
▼
type:domain
│
▼
type:data-access
│
▼
type:util (reachable from every layer above)
Skipping a layer, such as a feature reading a repository directly, is a policy decision you encode later. It is not implied by the diagram.
You attach tags in each project's configuration as a tags array of strings. A billing repository library would be tagged type:data-access and scope:billing. Tags are plain strings with no built-in meaning. The type: and scope: prefixes are a naming convention, and the lint rule matches on them only through the constraints you write.
{
"name": "billing-data-access",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/billing/data-access/src",
"projectType": "library",
"tags": ["type:data-access", "scope:billing"]
}
A billing repository library's project.json (at libs/billing/data-access/project.json) with one layer tag and one scope tag, which the enforce-module-boundaries rule checks independently.
Writing depConstraints for downward-only layering
The rule lives in your root ESLint config, usually scoped to *.ts and *.js files so every project inherits it. Its core option is depConstraints, an array of objects. Each object names a sourceTag and describes which tags projects carrying that tag may import.
For downward-only layering, use onlyDependOnLibsWithTags, which works as an allowlist:
type:util: may depend only ontype:util.type:domain: may depend ontype:domainandtype:util.type:data-access: may depend ontype:data-access,type:domainandtype:util.type:app: may depend on all three library types.
Each layer lists itself because a layer is usually split into several libraries that need to import one another. An allowlist fits layering better than a denylist because a newly added layer tag is forbidden by default until someone decides where it belongs.
For prohibitions that apply everywhere, add a constraint with sourceTag: '*', which matches every project. Give it notDependOnLibsWithTags: ['type:app'] so that no project can import an application.
Scope rules use the same mechanism on the second axis. A scope:billing constraint with onlyDependOnLibsWithTags: ['scope:billing', 'scope:shared'] blocks direct imports of scope:orders. Any logic that both scopes need has to move into a shared library, which makes that coupling visible in review.
The configuration format has changed across Nx versions, most notably with the move from .eslintrc.json to flat eslint.config files. Check the documentation for your installed version before you copy any structure.
// eslint.config.mjs (flat config); check the Nx docs for your installed version
import nx from '@nx/eslint-plugin';
export default [
...nx.configs['flat/base'],
{
files: ['**/*.ts', '**/*.js'],
rules: {
'@nx/enforce-module-boundaries': [
'error',
{
depConstraints: [
// Layer axis: allowlists so each layer can only point downward.
// Each layer lists itself so its own libraries can import each other.
{ sourceTag: 'type:util', onlyDependOnLibsWithTags: ['type:util'] },
{ sourceTag: 'type:domain', onlyDependOnLibsWithTags: ['type:domain', 'type:util'] },
{
sourceTag: 'type:data-access',
onlyDependOnLibsWithTags: ['type:data-access', 'type:domain', 'type:util'],
},
{
sourceTag: 'type:app',
onlyDependOnLibsWithTags: ['type:data-access', 'type:domain', 'type:util'],
},
// Global prohibition: no project may import an application.
{ sourceTag: '*', notDependOnLibsWithTags: ['type:app'] },
// Scope axis: billing cannot import orders directly; shared code
// must be moved into a scope:shared library.
{ sourceTag: 'scope:billing', onlyDependOnLibsWithTags: ['scope:billing', 'scope:shared'] },
{ sourceTag: 'scope:orders', onlyDependOnLibsWithTags: ['scope:orders', 'scope:shared'] },
{ sourceTag: 'scope:shared', onlyDependOnLibsWithTags: ['scope:shared'] },
],
},
],
},
},
];
A flat ESLint config that applies @nx/enforce-module-boundaries, using onlyDependOnLibsWithTags allowlists for downward-only layering and scope isolation, plus a '' sourceTag constraint that stops any project from importing a type:app project.*
How constraints combine when a project has multiple tags
Nx evaluates each depConstraints entry independently. A project must satisfy every constraint whose sourceTag it carries. In practice, the set of allowed dependencies is the intersection of what each matching constraint permits, not the union. This is deliberate: a tag is a promise about what a project is, and adding a tag should never quietly loosen the rules.
When a second tag tightens the rules
Suppose orders-data-access is tagged type:data-access and scope:orders, and imports orders-domain (tagged type:domain). Someone notices it exports a few formatting helpers and adds type:util so other teams can depend on it. The project now also matches the type:util constraint, which only permits type:util dependencies. Linting the existing import of orders-domain produces an error along the lines of: a project tagged with "type:util" can only depend on libs tagged with "type:util".
Nothing about the import changed. The project's identity did. The fix is not a broader constraint. Remove type:util and move the helpers into a separate orders-util library. If a project seems to need two type tags, it usually has two responsibilities.
Protections beyond tags
The rule also does work that has nothing to do with tags:
- Deep imports. It blocks imports that reach past a library's public entry point into its internal files.
- Circular dependencies. It flags cycles between projects.
- Static and lazy imports. It treats a lazy, dynamic
import()differently from a static import when it checks those boundaries.
The option names that control these behaviours differ between versions, so confirm them in your version's documentation.
// eslint.config.js (ESLint flat config at the workspace root)
const nx = require('@nx/eslint-plugin');
module.exports = [
{
files: ['**/*.ts'],
plugins: { '@nx': nx },
rules: {
'@nx/enforce-module-boundaries': [
'error',
{
depConstraints: [
// Each entry is checked on its own. A project must pass
// every entry whose sourceTag it carries.
{
sourceTag: 'type:data-access',
onlyDependOnLibsWithTags: ['type:data-access', 'type:domain', 'type:util'],
},
{ sourceTag: 'type:domain', onlyDependOnLibsWithTags: ['type:domain', 'type:util'] },
// Utils are leaves: they may only import other utils.
{ sourceTag: 'type:util', onlyDependOnLibsWithTags: ['type:util'] },
{ sourceTag: 'scope:orders', onlyDependOnLibsWithTags: ['scope:orders', 'scope:shared'] },
],
},
],
},
},
];
// libs/orders-data-access/project.json
// Before: "tags": ["type:data-access", "scope:orders"] -> importing orders-domain is allowed
// After: "tags": ["type:data-access", "type:util", "scope:orders"]
// The type:util entry now applies too, so the existing import of
// orders-domain (type:domain) fails lint.
// Fix: drop type:util here and move the helpers into a new orders-util
// library tagged ["type:util", "scope:orders"].
A depConstraints setup where adding a type:util tag to orders-data-access makes the type:util constraint apply as well, which breaks its existing import of orders-domain until the helpers move to their own library.
Rolling the rule out to an existing monorepo and CI
Retrofitting the rule onto an existing monorepo rarely survives a big-bang approach. If you turn on strict constraints across every project at once, the first lint run reports hundreds of violations. Under that pressure, the rule tends to get disabled or loosened until it no longer means anything. A phased rollout keeps each step small enough to finish.
Phase 1: Inventory
Run nx graph and record the dependencies that actually exist. Look for edges that contradict your intended layering, such as a data-access library importing from a feature library. This list becomes your backlog.
Phase 2: Tag incrementally and start permissive
Add type and scope tags one domain at a time. At first, include a catch-all constraint that allows any tag to depend on any tag. The rule is then active and validating tags, but nothing fails yet.
Phase 3: Fix or explicitly allow
Replace the catch-all with real constraints for one layer at a time. Then handle each violation in one of two ways:
- Fix it by moving the code.
- Record it as a named, deliberate exception.
Do not suppress violations silently.
Phase 4: Enforce in CI
Make the lint target a required check. For pull requests, an affected-based run (nx affected with the lint target) keeps CI time manageable. Schedule a periodic full lint run as well. Affected detection depends on how your workspace marks changes to shared configuration, so a full run catches anything the affected run misses.
Also add a CI step that reads every project's configuration and fails when a project lacks a type or scope tag. Without that check, a new untagged project can bypass the constraints you intended to apply to it.
#!/usr/bin/env bash
# CI guard: fail when any project is missing a type:* or scope:* tag.
# Without this, a new untagged project skips the boundary constraints entirely.
set -euo pipefail
missing=0
# 'nx show projects' prints one project name per line.
while IFS= read -r project; do
# Read the resolved project configuration and extract its tags.
tags=$(npx nx show project "$project" --json | jq -r '(.tags // [])[]')
if ! grep -q '^type:' <<<"$tags"; then
echo "ERROR: $project has no type:* tag"
missing=1
fi
if ! grep -q '^scope:' <<<"$tags"; then
echo "ERROR: $project has no scope:* tag"
missing=1
fi
done < <(npx nx show projects)
if [ "$missing" -ne 0 ]; then
echo "Add the missing tags in each project's project.json before merging."
exit 1
fi
echo "All projects have type and scope tags."
# Run separately in the same pipeline:
# PRs: npx nx affected -t lint (the base/head commits depend on your CI setup)
# Nightly: npx nx run-many -t lint (full run catches anything affected detection misses)
A CI script that uses nx show projects and jq to fail the build when any project lacks a type:* or scope:* tag, with the affected-based and full lint commands it runs alongside.
Handling exceptions without eroding the rules
Sooner or later the lint rule will flag an import that a developer believes is legitimate, and the quickest response is an eslint-disable comment. Each disable removes one edge from the dependency graph the rule checks, and those removals are rarely revisited. Before accepting one, look for a structural fix.
Extract the shared code
If a type:data-access project in scope:orders needs a helper that lives in scope:billing, the helper is probably not billing-specific. Move it into a type:util library or a scope:shared library. Both projects can then depend on it without violating any constraint. The graph now shows the dependency honestly, instead of hiding it behind a comment.
Invert the dependency
If domain code needs to call infrastructure, such as a payment gateway client or a repository, define an interface in the domain library and implement it in the infrastructure library. The application layer wires the implementation in at startup. The import then points downward, which is what the rule expects. The domain layer also becomes easier to test, because you can substitute a fake for the interface.
When a disable is unavoidable
Sometimes neither fix fits, for example during a staged migration. In that case, require a comment on the same line that explains the reason and names a follow-up ticket. Reviewers should treat new disables as design decisions that need approval, not as noise.
Keep in mind that this rule is a guardrail, not a security boundary. Anyone can bypass it with a disable comment, by skipping lint locally, or through a CI pipeline that is misconfigured to lint only affected projects incorrectly. Do not depend on it to isolate sensitive code.
When to avoid it, alternatives, and impact on cost and engineering time
The rule sees only static imports that resolve to projects in the Nx graph. It cannot see runtime configuration, dynamic string-based require calls, messages on a queue, HTTP calls between services, or two services writing to the same database tables. A domain library can pass lint while its service writes directly into another team's schema. A clean lint run tells you about import structure. It does not tell you the architecture is clean.
Tags also need upkeep. A new project with a missing or copy-pasted tag either fails constraints or slips past them, depending on how your rules are written. Over-granular schemes, such as one tag per subdomain per layer, make legitimate changes fail lint, and every false failure pushes someone toward an exception. Strict layering also produces more, smaller libraries. Each one adds project config, a public API to maintain, more cache entries and a busier graph. For a small service owned by two people, that is often ceremony with little payoff.
| Approach | Enforced automatically | Typical gap |
|---|---|---|
| Review and convention only | No | Depends on reviewer attention |
| Separate repos or packages | Yes, via published versions | Slow cross-cutting changes, version drift |
| Generic import-restriction lint rules | Yes, by path pattern | No project graph, brittle path globs |
enforce-module-boundaries |
Yes, by tag | Runtime coupling, tag hygiene |
The time saving comes from fixing a violation when it is a one-line import in a pull request. Left alone, the same violation becomes a dependency tangle that someone has to unpick during a later refactor.
Illustrative estimate (assumed numbers, not a benchmark): saved hours = violations × (late fix hours − review fix hours) − upkeep hours. With assumed inputs of 6 violations per quarter, 6 h per late fix, 0.5 h per fix at review and 8 h of tag upkeep: 6 × (6 − 0.5) − 8 = 25 hours per quarter.
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.