DEV Community

Sakthikumaran Navakumar
Sakthikumaran Navakumar

Posted on

Stop the Spaghetti: Enforcing Module Boundaries in an Nx Monorepo

Enforcing Module Boundaries in an Nx Monorepo

It starts with good intentions. Your team decides to adopt a monorepo. You scaffold your workspace, create a handful of libraries, and for the first few sprints everything feels clean and purposeful. Then the deadlines hit.

A developer needs a formatting utility from the payments module — so they reach in and import it directly. Another engineer needs a component from the loans feature for a quick prototype in the accounts section. Someone else, pressed for time, imports a service three layers deep from another domain's internals. Nobody reviews it too carefully. The CI pipeline is green. Ship it.

Six months later, your dependency graph looks like a bowl of spaghetti. The payments domain knows about accounts. The accounts feature imports from loans. Shared utilities carry domain-specific logic. Nobody can confidently change anything without triggering a cascade of broken imports across the workspace. Your architecture diagram, proudly mounted on the team's Confluence page, bears no resemblance to what the codebase actually does.
This is not a discipline problem. It is a tooling problem. And Nx solves it elegantly.

Why Module Boundaries Matter: The Architectural Case

Before we look at the tooling, it is worth understanding why boundary enforcement is not just a nice-to-have but an architectural necessity in any sufficiently large codebase.

Implicit Coupling Is the Silent Killer

In a monorepo without enforced boundaries, any library can import from any other library. Technically, there is nothing stopping a UI component from reaching directly into a data-access service, or a feature module from importing internal implementation details from a completely unrelated domain. These imports create implicit coupling — undocumented, invisible dependencies that accumulate quietly until refactoring becomes genuinely dangerous.

Social Enforcement Does Not Scale

With a team of three engineers and ten libraries, you can maintain architectural discipline through code review and shared understanding. With a team of fifteen engineers, forty libraries, and three concurrent feature tracks, you cannot. The cognitive overhead of manually auditing import paths in code review is enormous, the feedback loop is slow, and violations slip through. You need the tooling to carry the architectural intent forward, independent of team size, experience level, or deadline pressure.

The Architecture Diagram Should Match the Import Graph

This is the principle that underpins everything. If your architecture diagram shows that the payments domain is isolated from the loans domain, then your import graph should reflect exactly that. Nx module boundary enforcement is the mechanism that keeps these two things in sync — automatically, continuously, and without relying on human vigilance.

The Nx Mental Model: Tags and Dependency Constraints

Nx enforces boundaries through a tag-based system. Each library in your workspace is assigned one or more tags via its project.json file, and your ESLint configuration defines rules that govern which tags are allowed to depend on which other tags.

Tags typically carry two dimensions of information: scope and type.

Scope answers the question: which domain or vertical does this library belong to? In a banking application, you might have scopes like scope:payments, scope:loans, scope:accounts, scope:kyc (Know Your Customer), and scope:shared.

Type answers the question: what architectural layer or role does this library play? Rather than the generic feature/ui/data-access/util convention, a more expressive and flexible approach uses tags like type:app, type:lib, type:shared, and type:e2e. This maps more naturally to how libraries actually behave in large-scale production workspaces and gives you coarser, more durable rules.

Here is how tags are applied in a library's project.json:

//apps/banking-portal-e2e/project.json
{
  "name": "banking-portal-e2e",
  "projectType": "application",
  "tags": ["scope:banking-portal", "type:e2e"]
}
Enter fullscreen mode Exit fullscreen mode

Once your libraries are tagged, the ESLint rule @nx/enforce-module-boundaries becomes the enforcement engine.


The Sample Project Structure

Before diving into the ESLint configuration, here is the workspace structure we will be working with throughout this article. This represents a simplified but realistic banking platform monorepo.

banking-workspace/
├── apps/
│   ├── banking-portal/       # Customer-facing web app
│   │   └── project.json      # tags: scope:banking-portal, type:app
│   ├── banking-portal-e2e/   # E2E tests for banking-portal
│   │   └── project.json      # tags: scope:banking-portal, type:e2e
│   ├── admin-dashboard/      # Internal ops/admin app
│       └── project.json      # tags: scope:admin, type:app  
├── libs/
│   ├── payments/
│   │   ├── feature-transfer/ # tags: scope:payments, type:lib
│   │   ├── feature-transaction-history/ # tags: scope:payments, type:lib
│   │   └── data-access/      # tags: scope:payments, type:lib
│   ├── loans/
│   │   ├── feature-apply/    # tags: scope:loans, type:lib
│   │   ├── feature-repayment/# tags: scope:loans, type:lib
│   │   └── data-access/      # tags: scope:loans, type:lib
│   ├── accounts/
│   │   ├── feature-dashboard/# tags: scope:accounts, type:lib
│   │   ├── feature-settings/ # tags: scope:accounts, type:lib
│   │   └── data-access/      # tags: scope:accounts, type:lib
│   ├── kyc/
│   │   ├── feature-onboarding/# tags: scope:kyc, type:lib
│   │   └── data-access/       # tags: scope:kyc, type:lib
│   └── shared/
│       ├── ui-design-system/  # tags: scope:shared, type:shared
│       ├── ui-forms/          # tags: scope:shared, type:shared
│       ├── util-formatters/   # tags: scope:shared, type:shared
│       ├── util-validators/   # tags: scope:shared, type:shared
│       └── data-access-http/  # tags: scope:shared, type:shared
Enter fullscreen mode Exit fullscreen mode

Every domain (payments, loans, accounts, kyc) is a self-contained vertical. The shared scope holds anything that is genuinely cross-cutting. Apps consume libs. Libs do not reach into other domain's libs unless explicitly permitted. E2E projects only test — they never become a source of shared logic.

Enforcing Boundaries with the Nx ESLint Plugin

With the project structure established, let us look at the ESLint configuration that encodes these architectural rules. All of this lives in your root .eslintrc.json.

{
  "root": true,
  "plugins": ["@nx"],
  "overrides": [
    {
      "files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
      "rules": {
        "@nx/enforce-module-boundaries": [
          "error",
          {
            "enforceBuildableLibDependency": true,
            "allowCircularSelfDependency": true,
            "banTransitiveDependencies": true,
            "depConstraints": [

              {
                "sourceTag": "type:app",
                "onlyDependOnLibsWithTags": ["type:lib", "type:shared"]
              },

              {
                "sourceTag": "type:lib",
                "onlyDependOnLibsWithTags": ["type:lib", "type:shared"]
              },

              {
                "sourceTag": "type:shared",
                "onlyDependOnLibsWithTags": ["type:shared"]
              },

              {
                "sourceTag": "type:e2e",
                "onlyDependOnLibsWithTags": ["type:shared"]
              },

              {
                "sourceTag": "scope:payments",
                "onlyDependOnLibsWithTags": ["scope:payments", "scope:shared"]
              },
              {
                "sourceTag": "scope:loans",
                "onlyDependOnLibsWithTags": ["scope:loans", "scope:shared"]
              },
              {
                "sourceTag": "scope:accounts",
                "onlyDependOnLibsWithTags": ["scope:accounts", "scope:shared"]
              },
              {
                "sourceTag": "scope:kyc",
                "onlyDependOnLibsWithTags": ["scope:kyc", "scope:shared"]
              },
              {
                "sourceTag": "scope:shared",
                "onlyDependOnLibsWithTags": ["scope:shared"]
              }

            ]
          }
        ]
      }
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

This configuration encodes six architectural decisions simultaneously:

  1. Apps can consume domain libs and shared libs — but never other apps
  2. Domain libs can consume other libs within their own scope and anything in shared — but never reach across domain boundaries
  3. Shared libs are the foundation layer — they are self-contained and import nothing outside their own scope
  4. E2E projects can reference shared utilities if needed, but are otherwise isolated from application and domain logic
  5. banTransitiveDependencies ensures that if lib A depends on lib B which depends on lib C, lib A cannot import from lib C directly — it must go through lib B's public API
  6. enforceBuildableLibDependencyCheck ensures that if you enable buildable libs, your dependency declarations stay honest

Advanced Tag Expressions: !, *, and Combining Constraints

One of the most underused and underappreciated features of the @nx/enforce-module-boundaries rule is its support for tag expression operators. Beyond simple string matching, the rule supports negation, wildcards, and compound logic that lets you write precise, expressive constraints.

The Wildcard Operator: *

The wildcard * matches any tag. This is useful when you want to say "this library can import from literally anything" — which you should use sparingly, but it has legitimate use cases for certain shell or orchestration libraries.

{
  "sourceTag": "type:app",
  "onlyDependOnLibsWithTags": ["*"]
}
Enter fullscreen mode Exit fullscreen mode

More practically, wildcards shine when used within a tag expression for partial matching. For example, if you want to allow a shared utility to depend on any shared library regardless of a sub-classification:

json
{
  "sourceTag": "scope:shared",
  "onlyDependOnLibsWithTags": ["scope:shared", "*:shared"]
}
Enter fullscreen mode Exit fullscreen mode

You can also use ! directly within the onlyDependOnLibsWithTags array to say "must have this tag and must not have that tag simultaneously":

json{
  "sourceTag": "scope:accounts",
  "onlyDependOnLibsWithTags": ["scope:accounts", "scope:shared", "!type:e2e"]
}
Enter fullscreen mode Exit fullscreen mode

This reads as: "accounts-scoped libraries may only depend on libraries that are tagged scope:accounts or scope:shared, and in all cases, those libraries must not be tagged type:e2e." This is particularly useful when your shared scope contains a mix of test utilities and production utilities that you want to distinguish at the dependency constraint level.

Violations in Action: What This Looks Like in Your IDE
Let us see what happens when a developer violates one of these rules. This is arguably the most important section of the article because it shows the tangible developer experience of boundary enforcement.
typescript// libs/payments/feature-transfer/src/lib/transfer.component.ts


// ✅ ALLOWED — payments lib importing from its own domain
import { PaymentsApiService } from '@banking/payments/data-access';

// ✅ ALLOWED — payments lib importing from shared scope
import { CurrencyFormatterPipe } from '@banking/shared/util-formatters';
import { ButtonComponent } from '@banking/shared/ui-design-system';

// ❌ VIOLATION — crossing domain boundaries
import { LoanEligibilityService } from '@banking/loans/data-access';
// ESLint Error: "A project tagged with 'scope:payments' can only depend on 
// libs tagged with 'scope:payments' or 'scope:shared'"

// ❌ VIOLATION — lib importing from an app
import { AppConfig } from '@banking/banking-portal';
// ESLint Error: "A project tagged with 'type:lib' cannot depend on 
// libs tagged with 'type:app'"

// ❌ VIOLATION — reaching into e2e project
import { MockApiInterceptor } from '@banking/banking-portal-e2e';
// ESLint Error: "Imports of e2e projects are not permitted"

Enter fullscreen mode Exit fullscreen mode

These errors surface in real time in VS Code (with the ESLint extension installed).

The Architectural Payoff

Once module boundaries are in place and running in CI, the benefits compound over time in ways that go well beyond code organization.

  • Your architecture becomes self-documenting. A new engineer joining the banking platform team can read the ESLint configuration and understand the entire domain structure and dependency rules in under five minutes. The rules tell the same story as the architecture diagram — because they are the architecture diagram, expressed as code.
  • Code reviews become faster and more focused. Reviewers no longer need to manually audit import paths or ask questions like "should payments really know about loans?" The linter has already answered that question before the pull request was opened. Reviews can focus entirely on logic, correctness, and intent.
  • Refactoring becomes safe. Because every library exposes only what its index.ts exports, and because boundaries prevent cross-domain imports, you can refactor a library's internals with surgical confidence. The blast radius of any change is bounded by the public API surface.
  • Teams can work in parallel without stepping on each other. When domain boundaries are enforced, the payments team and the loans team genuinely cannot create accidental dependencies between their work. Conway's Law works in your favour: the organizational structure is mirrored by — and protected by — the module boundary rules.
  • The architecture survives team turnover. Senior engineers who understand the original design decisions eventually leave. Without tooling, their architectural intent leaves with them. With boundary enforcement, the decisions are encoded in the linting configuration and outlive any individual contributor.

The companion repository for this article, including the full workspace structure, ESLint configuration, sample library implementations, and intentional violations with their error output, is available at github. Clone it, break the rules, and watch the linter push back.

github: https://github.com/sakthikumaran22/nx-module-boundaries-demo

Reference:

Top comments (0)