Imposing Constraints on the Dependency Graph

If you partition your code into well-defined cohesive units, even a small organization will end up with a dozen apps and dozens or hundreds of libs. If all of them can depend on each other freely, the chaos will ensue, and the workspace will become unmanageable.

To help with that Nx uses code analyses to make sure projects can only depend on each other's well-defined public API. It also allows you to declaratively impose constraints on how projects can depend on each other.

Tags

Nx comes with a generic mechanism for expressing constraints: tags.

First, use your project configuration (within project.json or workspace.json) to annotate your projects with tags. In this example, we will use three tags: scope:client. scope:admin, scope:shared.

1// project "client"
2{
3  // ... more project configuration here
4
5  "tags": ["scope:client"]
6}
7
8// project "client-e2e"
9{
10  // ... more project configuration here
11
12  "tags": ["scope:client"],
13  "implicitDependencies": ["client"]
14}
15
16// project "admin"
17{
18  // ... more project configuration here
19
20  "tags": ["scope:admin"]
21}
22
23// project "admin-e2e"
24{
25  // ... more project configuration here
26
27  "tags": ["scope:admin"],
28  "implicitDependencies": ["admin"]
29}
30
31// project "client-feature-main"
32{
33  // ... more project configuration here
34
35  "tags": ["scope:client"]
36},
37
38// project "admin-feature-permissions"
39{
40  // ... more project configuration here
41
42  "tags": ["scope:admin"]
43}
44
45// project "components-shared"
46{
47  // ... more project configuration here
48
49  "tags": ["scope:shared"]
50}
51
52// project "utils"
53{
54  // ... more project configuration here
55
56  "tags": ["scope:shared"]
57}

Next you should update your root lint configuration:

  • If you are using ESLint you should look for an existing rule entry in your root .eslintrc.json called "@nrwl/nx/enforce-module-boundaries" and you should update the "depConstraints":
1{
2  // ... more ESLint config here
3
4  // @nrwl/nx/enforce-module-boundaries should already exist within an "overrides" block using `"files": ["*.ts", "*.tsx", "*.js", "*.jsx",]`
5  "@nrwl/nx/enforce-module-boundaries": [
6    "error",
7    {
8      "allow": [],
9      // update depConstraints based on your tags
10      "depConstraints": [
11        {
12          "sourceTag": "scope:shared",
13          "onlyDependOnLibsWithTags": ["scope:shared"]
14        },
15        {
16          "sourceTag": "scope:admin",
17          "onlyDependOnLibsWithTags": ["scope:shared", "scope:admin"]
18        },
19        {
20          "sourceTag": "scope:client",
21          "onlyDependOnLibsWithTags": ["scope:shared", "scope:client"]
22        }
23      ]
24    }
25  ]
26
27  // ... more ESLint config here
28}
  • If you are using TSLint you should look for an existing rule entry in your root tslint.json called "nx-enforce-module-boundaries" and you should update the "depConstraints":
1{
2  // ... more TSLint config here
3
4  // nx-enforce-module-boundaries should already exist at the top-level of your config
5  "nx-enforce-module-boundaries": [
6    true,
7    {
8      "allow": [],
9      // update depConstraints based on your tags
10      "depConstraints": [
11        {
12          "sourceTag": "scope:shared",
13          "onlyDependOnLibsWithTags": ["scope:shared"]
14        },
15        {
16          "sourceTag": "scope:admin",
17          "onlyDependOnLibsWithTags": ["scope:shared", "scope:admin"]
18        },
19        {
20          "sourceTag": "scope:client",
21          "onlyDependOnLibsWithTags": ["scope:shared", "scope:client"]
22        }
23      ]
24    }
25  ]
26
27  // ... more TSLint config here
28}

With these constraints in place, scope:client projects can only depend on other scope:client projects or on scope:shared projects. And scope:admin projects can only depend on other scope:admin projects or on scope:shared projects. So scope:client and scope:admin cannot depend on each other.

Projects without any tags cannot depend on any other projects. If you add the following, projects without any tags will be able to depend on any other project.

1{
2  "sourceTag": "*",
3  "onlyDependOnLibsWithTags": ["*"]
4}

If you try to violate the constrains, you will get an error:

A project tagged with "scope:admin" can only depend on projects tagged with "scoped:shared" or "scope:admin".

Exceptions

The "allow": [] are the list of imports that won't fail linting.

  • "allow": ['@myorg/mylib/testing'] allows importing '@myorg/mylib/testing'.
  • "allow": ['@myorg/mylib/*'] allows importing '@myorg/mylib/a' but not '@myorg/mylib/a/b'.
  • "allow": ['@myorg/mylib/**'] allows importing '@myorg/mylib/a' and '@myorg/mylib/a/b'.
  • "allow": ['@myorg/**/testing'] allows importing '@myorg/mylib/testing' and '@myorg/nested/lib/testing'.

Multiple Dimensions

The example above shows using a single dimension: scope. It's the most commonly used one. But you can find other dimensions useful. You can define which projects contain components, state management code, and features, so you, for instance, can disallow projects containing dumb UI components to depend on state management code. You can define which projects are experimental and which are stable, so stable applications cannot depend on experimental projects etc. You can define which projects have server-side code and which have client-side code to make sure your node app doesn't bundle in your frontend framework.

Let's consider our previous three scopes - scope:client. scope:admin, scope:shared. By using just a single dimension, our client-e2e application would be able to import client application or client-feature-main. This is likely not something we want to allow as it's using framework that our E2E project doesn't have.

Let's add another dimension - type. Some of our projects are applications, some are UI features and some are just plain helper libraries. Let's define three new tags: type:app, type:feature, type:ui and type:util.

Our project configurations might now look like this:

1// project "client"
2{
3  // ... more project configuration here
4
5  "tags": ["scope:client", "type:app"]
6}
7
8// project "client-e2e"
9{
10  // ... more project configuration here
11
12  "tags": ["scope:client", "type:app"],
13  "implicitDependencies": ["client"]
14}
15
16// project "admin"
17{
18  // ... more project configuration here
19
20  "tags": ["scope:admin", "type:app"]
21}
22
23// project "admin-e2e"
24{
25  // ... more project configuration here
26
27  "tags": ["scope:admin", "type:app"],
28  "implicitDependencies": ["admin"]
29}
30
31// project "client-feature-main"
32{
33  // ... more project configuration here
34
35  "tags": ["scope:client", "type:feature"]
36},
37
38// project "admin-feature-permissions"
39{
40  // ... more project configuration here
41
42  "tags": ["scope:admin", "type:feature"]
43}
44
45// project "components-shared"
46{
47  // ... more project configuration here
48
49  "tags": ["scope:shared", "type:ui"]
50}
51
52// project "utils"
53{
54  // ... more project configuration here
55
56  "tags": ["scope:shared", "type:util"]
57}

We can now restrict projects within the same group to depend on each other based on the type:

  • app can only depend on feature, ui or util, but not other apps
  • feature cannot depend on app or another feature
  • ui can only depend on other ui
  • everyone can depend on util including util itself
1{
2  // ... more ESLint config here
3
4  // nx-enforce-module-boundaries should already exist at the top-level of your config
5  "nx-enforce-module-boundaries": [
6    true,
7    {
8      "allow": [],
9      // update depConstraints based on your tags
10      "depConstraints": [
11        {
12          "sourceTag": "scope:shared",
13          "onlyDependOnLibsWithTags": ["scope:shared"]
14        },
15        {
16          "sourceTag": "scope:admin",
17          "onlyDependOnLibsWithTags": ["scope:shared", "scope:admin"]
18        },
19        {
20          "sourceTag": "scope:client",
21          "onlyDependOnLibsWithTags": ["scope:shared", "scope:client"]
22        },
23        {
24          "sourceTag": "type:app",
25          "onlyDependOnLibsWithTags": ["type:feature", "type:ui", "type:util"]
26        },
27        {
28          "sourceTag": "type:feature",
29          "onlyDependOnLibsWithTags": ["type:ui", "type:util"]
30        },
31        {
32          "sourceTag": "type:ui",
33          "onlyDependOnLibsWithTags": ["type:ui", "type:util"]
34        },
35        {
36          "sourceTag": "type:util",
37          "onlyDependOnLibsWithTags": ["type:util"]
38        }
39      ]
40    }
41  ]
42
43  // ... more ESLint config here
44}

There are no limits to number of tags, but as you add more tags the complexity of your dependency constraints rises exponentially. It's always good to draw a diagram and carefully plan the boundaries.

Banning external imports

This constraint is only available for projects using ESLint.

You may want to constrain what external packages a project may import. For example, you may want to prevent backend projects from importing packages related to your frontend framework. You can ban these imports using bannedExternalImports property in your dependency constraints configuration.

A common example of this is for backend projects that use NestJS and frontend projects that use Angular. Both frameworks contain a class named Injectable. It's very easy for a developer to import the wrong one by mistake, especially when using auto-import in an IDE. To prevent this, add tags to define the type of project to distinguish between backend and frontend projects. Each tag should define its own list of banned external imports.

1{
2  // ... more ESLint config here
3
4  // nx-enforce-module-boundaries should already exist at the top-level of your config
5  "nx-enforce-module-boundaries": [
6    true,
7    {
8      "allow": [],
9      // update depConstraints based on your tags
10      "depConstraints": [
11        // projects tagged with "frontend" can't import from "@nestjs/common"
12        {
13          "sourceTag": "frontend",
14          "bannedExternalImports": ["@nestjs/common"]
15        },
16        // projects tagged with "backend" can't import from "@angular/core"
17        {
18          "sourceTag": "backend",
19          "bannedExternalImports": ["@angular/core"]
20        }
21      ]
22    }
23  ]
24
25  // ... more ESLint config here
26}

Another common example is ensuring that util libraries stay framework-free by banning imports from these frameworks. You can use wildcard * to match multiple projects e.g. react* would match react, but also react-dom, react-native etc. You can also have multiple wildcards e.g. *react* would match any package with word react in it's name. A workspace using React would have a configuration like this.

1{
2  // ... more ESLint config here
3  // nx-enforce-module-boundaries should already exist at the top-level of your config
4  "nx-enforce-module-boundaries": [
5    true,
6    {
7      "allow": [],
8      // update depConstraints based on your tags
9      "depConstraints": [
10        // projects tagged with "type:ui" can't import from "react" or related projects
11        {
12          "sourceTag": "type:ui",
13          "bannedExternalImports": ["*react*"]
14        }
15      ]
16    }
17  ]
18
19  // ... more ESLint config here
20}