Three Ways to Enforce Module Boundaries and Dependency Rules in an Nx Monorepo

Three Ways to Enforce Module Boundaries and Dependency Rules in an Nx Monorepo

Introduction

In a previous article, I shared how you can organise your libraries in an Angular Nrwl/Nx monorepo. In this one, we’re taking it a step further. I’ll show to you how to enforce module boundaries and dependency rules for these libraries. I’ll walk you through three different ways to do this, with examples, along with the advantages and the disadvantages of each.

Before we start, let’s clarify the two terms I’m going to use throughout the article:

  • Module boundaries - This defines what parts of a module are accesible from the outside. Everything else should not be accessible. For example, in an Nx library that means that other libraries or apps can only access what’s exported in the library’s index.ts file and deep imports are not allowed.

  • Dependency rules It specifies which modules are allowed to depend on which other modules. It’s more about setting a clear architecture across your project. For example, a dependency rule can state that only specific libraries can be imported into an app.

Why you should set Module boundaries and Dependency rules

When working in a Nx monorepo, most of the code is organized in many different modules (libraries). These modules can be imported in other modules or applications and together they form a dependency graph. In a growing project, especially when you have too many different teams working on many different modules, it’s crucial to be able to enforce some rules about the architecture of the project.

For example, many projects follow a Domain Driven Design architecture or a Hexagonal Architecture or your custom Architecture defined by the team. Maintaining a clean architecture like this in a monorepo can bring a lot of benefits:

  • It enables autonomous teams that can work on their own features avoiding cross-team dependepncies and conflicts.
  • It increases the discoverability in the code and it reduces the cognitive load for the developers.
  • It promotes low coupling between the libaries which can also speed up CI pipelines times since fewer projects are affected by changes.

The challenge you’ll face eventually is that when multiple teams touch the same codebase, if there are no rules in place to enforce programmatically the chosen architectrue, it will slowly start to drift and deform over time.

In the demo project, I’ve followed an architecture which you can see in the following graph.

graph

Two things to notice in the graph are:

  1. The arrows on each domain point only downward. That means that a lower-level layer of the architecture cannot have dependencies on an upper-level layer.
  2. The domains are strictly encapsulated. The domains (users / learnings) have no dependency on each other. I deliberately chose to duplicate code rather than compromising a library’s encapsulation. Some teams prefer adding an API layer in each Domain to share Domain specific logic with other domains, but in my experience, this pattern opens the door to sharing more and more logic until it becomes unmanageble.

The project’s structure looks like this:

apps
  > my app
libs
  > learnings
      > data-access
      > feature-list
      > feature-search
      > shell
      > model
      > utils-testing
  > shared
      > data-access
      > ui
      > model
  > users
      > data-access
      > feature-list
      > feature-search
      > shell
      > model
      > utils-testing

Rules

To keep this architecture intact and prevent drift over time, I’ll be enforcing the following rules:

Module Boundaries Rules

  • Every module can be accessed only through a public API which is defined in the index.ts file under the src folder of the module. If an index file is not provided, then the module is fully encapsulated meaning it can only import other modules, but nothing can import it.

Dependency Rules

ScopeAllowed DependenciesRestrictions
ApplicationLibraries only (no applications)Cannot import from other applications
Learnings domainLibraries in the same domain
Libraries in the shared domain
Cannot depend on libraries from other domains
Cannot import from applications
Users domainLibraries in the same domain
Libraries in the shared domain
Cannot depend on libraries from other domains
Cannot import from applications

Module TypeCan Depend On
featurefeature, model, data-access, ui, util
data-accessutil, model, data-access
shellshell, model, feature
uiutil, model, ui
applicationshell, model
utilmodel, util
modelmodel

With these rules in place, I can be confident that my arhitecture can deliver the benefits I want for my team. Now, let’s take a look at the tools we can use to enforce this programmatically.

Tools

In my demo project, I have integrated three tools to enforce the aforementioned rules. There might be more tools out there but these are the ones I found more interesting to cover in this article.

1. Nx Module Boundaries

This is the official solution from NX. It’s also the simplest solution since it’s build-in and it doesn’t require any dditional packages to work. Spoiler alert, this simplicity comes with limitations, meaning it’s the least flexible solution. You can only apply rules at the library level by assigning tags in the project.json file. If your project is structured differently and you need to apply rules at the sub-folder level, this is not possible.

Setup

To enforce module and dependency rules with Nx Module Boundaries you need to:

  1. Add tags on every library. In this demo project I used two kinds of tags(scope, type). Read about how multidimensional tags can improve your rules here.
{
"name": "learnings-data-access",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"projectType": "library",
"sourceRoot": "libs/learnings/data-access/src",
"tags": ["type:data-access", "scope:learnings"],
  1. After defining the tags for each project, you can set the rules we discussed earlier in the project’s ESLint configuration.
// .eslintrc.json
...
"@nx/enforce-module-boundaries": [
  "error",
  {
  "enforceBuildableLibDependency": true,
  "allow": [],
  "depConstraints": [
      {
      "sourceTag": "scope:learning-webapp",
      "onlyDependOnLibsWithTags": [
          "scope:learning-webapp",
          "scope:shared",
          "scope:learnings",
          "scope:users"
      ]
      },
      {
      "sourceTag": "type:app",
      "onlyDependOnLibsWithTags": [
          "type:shell",
          "type:model"
      ]
      },
      {
      "sourceTag": "scope:learnings",
      "onlyDependOnLibsWithTags": [
          "scope:learnings",
          "scope:shared"
      ]
      },
      {
      "sourceTag": "scope:users",
      "onlyDependOnLibsWithTags": [
          "scope:users",
          "scope:shared"
      ]
      },
      {
      "sourceTag": "type:shell",
      "onlyDependOnLibsWithTags": [
          "type:shell",
          "type:model",
          "type:feature"
      ]
      },
      {
      "sourceTag": "type:util",
      "onlyDependOnLibsWithTags": [
          "type:model",
          "type:util"
      ]
      },
      {
      "sourceTag": "type:model",
      "onlyDependOnLibsWithTags": [
          "type:model"
      ]
      },
      {
      "sourceTag": "type:data-access",
      "onlyDependOnLibsWithTags": [
          "type:util",
          "type:model",
          "type:data-access"
      ]
      },
      {
      "sourceTag": "type:ui",
      "onlyDependOnLibsWithTags": [
          "type:util",
          "type:model",
          "type:ui"
      ]
      },
      {
      "sourceTag": "type:feature",
      "onlyDependOnLibsWithTags": [
          "type:feature",
          "type:model",
          "type:data-access",
          "type:ui",
          "type:util"
      ]
      }
  ]
  }
]
...

Pros

  • It’s a built-in solution, it doesn’t require additional external dependencies. It is maintained from the NX team.
  • It’s integrated with ESLint

Cons

  • It’s not flexible. Rules can only be applied at the project level, not at the folder level.
  • Does not support complex rules. As we’ll see with the other two tools, we can define more generic rules that reduce boilerplate.

2. Dependency cruiser

Dependency Cruiser is a framework agnostic tool which you can use to validate the dependency and module boundary rules you have configured. Using it, you can visualize all your dependecies in a graph. The violated rules can be reported either in the graph itself or in text format. This tool is the most flexible between the three I’m covering in this article and also the most powerful since apart from defining and validating dependency rules, you can also use it to identify circular dependencies, orphans, or code marked as “shared” that’s actually imported by only one other module, and more.

Setup

Once the dependency-cruiser package is installed and you have run npx depcruise --init, a dependency-cruiser.js configuration file is generated. This file includes general rules that are useful for any project, such as detecting circular dependencies and dead code. You can remove any of these default rules if they’re not relevant to your project. Then, simply add your own rules along with the messages that will be shown when a rule is violated.

{
    name: "no-cross-domains",
    comment:
      "One library should not depend on another domain's library. Dependencies from shared are allowed.",
    severity: "error",
    from: {
      path: "^libs/(?!(shared))([^/]+)"
    },
    to: {
      path: "^libs/(?!(shared))",
      pathNot: "^libs/\$1"
    }
  },
  {
    name: "no-lib-to-app-deps",
    comment:
      "One application should not depend only on shell or model libraries. ",
    severity: "error",
    from: {
      path: "^apps/"
    },
    to: {
      path: "^libs/",
      pathNot: "^libs/[^/]+/(?:shell|model)(?:/|$)"  
    }
  },
  {
    name: 'data-access-lib-restricted-deps',
    comment:
      'Libraries of type data-access should depend only on libs of type data-access, model or utils-testing',
    severity: 'error',
    from: {
      path: '^libs/[^/]+/data-access'  
    },
    to: {
      path: '^libs/',
      pathNot: '^libs/[^/]+/(?:data-access|model|utils-testing)(?:/|$)'
    }
  },
  {
    name: 'ui-lib-restricted-deps',
    comment:
      'Libraries of type ui should depend only on libs of type util, model or ui',
    severity: 'error',
    from: {
      path: '^libs/[^/]+/ui'  
    },
    to: {
      path: '^libs/',
      pathNot: '^libs/[^/]+/(?:util|model|ui)(?:/|$)'
    },
  },
  {
    name: 'model-lib-restricted-deps',
    comment:
      'Libraries of type model should depend only on libs of type model',
    severity: 'error',
    from: {
      path: '^libs/[^/]+/model'  
    },
    to: {
      path: '^libs/',
      pathNot: '^libs/[^/]+/(?:model)(?:/|$)'
    },
  },
  {
    name: 'util-lib-restricted-deps',
    comment:
      'Libraries of type util should depend only on libs of type model or util',
    severity: 'error',
    from: {
      path: '^libs/[^/]+/util'  
    },
    to: {
      path: '^libs/',
      pathNot: '^libs/[^/]+/(?:model|util)(?:/|$)'
    },
  },
  {
    name: 'shell-lib-restricted-deps',
    comment:
      'Libraries of type shell should depend only on libs of type shell, model or feature',
    severity: 'error',
    from: {
      path: '^libs/[^/]+/shell'  
    },
    to: {
      path: '^libs/',
      pathNot: '^libs/[^/]+/(?:shell|model|feature-[^/]+)(?:/|$)'
    },
  },
  {
    name: 'feature-lib-restricted-deps',
    comment:
      'Libraries of type feature should depend only on every type except shell',
    severity: 'error',
    from: {
      path: '^libs/[^/]+/feature-[^/]+'  
    },
    to: {
      path: '^libs/',
      pathNot: '^libs/[^/]+/(?:feature-[^/]+|model|data-access|ui|util)(?:/|$)'
    },
  },

The following rule helps identify code in the shared library that isn’t truly “shared”. For example, modules that are only used by a single domain and should probably be moved there instead.

{
  name: 'no-unshared-lib',
  comment: 'Each shared lib’s index.ts must be imported by at least two other first-level libs',
  severity: 'error',
  from: {
      path: '^libs/'                
  },
  module: {
      path: '^libs/shared/(?:data-access|ui|model)/src/index\.(?:ts|tsx|js|jsx)$',
      numberOfDependentsLessThan: 2
  }
},

To validate the rules in your project, run one of the following commands:

"depcruise:check": "depcruise apps/*/src libs/*/src --exclude '^node_modules'",
"depcruise:report": "depcruise apps/*/src libs/*/src --exclude '^(node_modules|apps/.+-e2e)' --output-type err-html > dependency-report.html",
"depcruise:graph": "depcruise apps/*/src libs/*/src --exclude '^(node_modules|apps/.+-e2e)' --output-type ddot | dot -Tsvg > dependency-graph.html"

Rule violations can be displayed in a text format:

violations

Rule violations can also be visualized as a graph:

violations

Pros

  • Very flexible, framework agnostic
  • You can set rules also at the folders level, it doen’t depend on the tags you set in each project.
  • Can also be used as a general code quality tool to identify dead code or circular dependencies. For example, you can ensure that a module in a shared library is imported by at least two domains otherwise, if it’s only used by one domain, it should belong to that domain instead of the shared library.
  • You can visualize the violated rules

Cons

  • Not integrated with ESLint.
  • Steeper learning curve due to the extended capabilities and richer API.

3. Sheriff

Sheriff is a tool designed to enforce module boundaries and dependency rules in TypeScript projects. It’s framework agnostic like Dependency cruiser and also more powerfull than the built-in solution that Nx offers. For example, you can define more fine-grained rules at the folder level, without relying on the project.json tags. However, unlike Dependency Cruiser, you can’t add broader code-quality rules.

Setup

Once the @softarc/sheriff-core, @softarc/eslint-plugin-sheriff packages are installed and you have run npx sheriff init, a sheriff.config.ts configuration file is generated. In the modules property of this configuration, we can define the modules and set their associated tags. As you can see in the following code snippet, to reduce boilerplate we can leverage placeholders lile scope and feature. In the depRules property we can define the dependency rules we have described earlier.

export const config: SheriffConfig = {
entryFile: 'apps/learning-webapp/src/main.ts',
enableBarrelLess: true,
modules: {
  'libs/<scope>/feature-<feature>/src': ['scope:<scope>', 'type:feature'],
  'libs/<scope>/utils-<util>/src': ['scope:<scope>', 'type:util'],
  'libs/<scope>/<type>/src': ['scope:<scope>', 'type:<type>'],
},
depRules: {
  'root': ['type:shell', 'type:model'],
  'scope:*': [sameTag, 'scope:shared'],
  'scope:shared': 'scope:shared',
  'type:feature': [
    'type:feature',
    'type:model',
    'type:data-access',
    'type:ui',
    'type:util',
  ],
  'type:data-access': ['type:util', 'type:model', 'type:data-access'],
  'type:ui': ['type:util', 'type:model', 'type:ui'],
  'type:model': 'type:model',
  'type:util': ['type:util', 'type:model'],
  'type:shell': ['type:shell', 'type:model', 'type:feature'],
},
};

Pros

  • Very flexible, framework agnostic
  • You can have fine grained rules also in the folders level, it doen’t depend on the tags you set in each project.json file.
  • It can be intgrated with eslint or not.
  • Reduced boilerplate

Cons

  • Steeper learning curve than the built-in NX module boundaries solution. For example you need to understand the automatic tagging concept or how to configure it based on bareless vs barell modes.

Conclusion

When multiple teams work in a monorepo, it’s important to enforce the chosen architecture, especially around module boundaries and dependency rules programmatically, so it doesn’t drift over time. I explored three tools for this purpose.

Personally, I find the built-in solution from Nx the least versatile, and I don’t think it scales well as the number of projects in the monorepo grows. I’d prefer using either Dependency Cruiser or Sheriff. Both are versatile and flexible. I’d lean toward Dependency Cruiser if I needed to check additional things, such as whether a module is “shared enough,” if there are circular dependencies, or if there are orphaned modules in the codebase.

Thank you for reading ♡