Skip to main content

Enforce Boundaries

Once you have established the organization of your workspace and libraries, and the underlying rules, you will probably want to enforce them.

As a matter of fact, even with the best intentions, it is easy to break the rules we've defined even with a small team and workspace, let alone with a larger team and/or workspace.

The @nx/enforce-module-boundaries eslint rule

Here is where the Nx eslint plugin (@nx/eslint-plugin) comes into play. More precisely, the depConstraints option of the @nx/enforce-module-boundaries rule allows you to enforce the boundaries you have defined for your workspace. For instance, this will analyze the "imports" in your workspace and prevent you from importing a library from another library that isn't supposed to depend on it.

The depConstraints option is a list of constraints that define which projects can depend on which other projects. These constraints are tag-based, hence the need to tag your libraries first.

Defining a Constraint

Let's start with a constraint example. The following constraint will allow libraries with the type:ui tag to only depend on libraries with either the type:ui or type:model tags:

.eslintrc.json
"depConstraints": [
{
"sourceTag": "type:ui",
"onlyDependOnLibsWithTags": ["type:ui", "type:model"]
}
],

This means that given the following code (a library with type:ui depending on a library with type:infra):

libs/catalog/ui/index.ts
import { RecipeRepository } from '@marmicode/catalog/infra';

the eslint rule will produce the following error:

libs/catalog/ui/index.ts
1:1 error A project tagged with "type:ui" can only depend on libs tagged with "type:ui", "type:model" @nx/enforce-module-boundaries

✖ 1 problem (1 error, 0 warnings)
info

Note that if a project doesn't match any constraint (i.e. sourceTag), the default behavior is to produce the following error:

1:1  error  A project without tags matching at least one constraint cannot depend on any libraries  @nx/enforce-module-boundaries

✖ 1 problem (1 error, 0 warnings)

While this behavior can be overriden by adding a passthrough constraint: {"sourceTag": "*", "onlyDependOnLibsWithTags": ["*"]}, we do not recommend it as it could hide both configuration errors and constraints violations.

Circular Dependencies

In the example above, allowing type:ui to depend on type:ui might look like a circular dependency. However, it is not the case because we are defining a rule for a category of libraries, not a specific library. This means that a library with type:ui can depend on another library with type:ui.

If you end up with a circular dependency between your libraries, the eslint rule will catch it and produce an error like the following:

libs/catalog/search-ui/index.ts
1:1 error Circular dependency between "catalog-search-ui" and "catalog-recipe-ui" detected: catalog-search-ui -> catalog-recipe-ui -> catalog-search-ui

Cumulative Constraints

As presented in the previous chapter, it is possible to assign multiple tags to a library, each representing a different dimension (e.g. scope:catalog, type:ui).

As the eslint rule will check that all constraints matching the sourceTag are met, you can define multi-dimensional constraints by adding multiple constraints with the sourceTag for each dimension.

As an example, the following constraints will allow libraries with the type:ui tag to only depend on libraries with either the type:ui or type:model tags, and libraries with the scope:catalog tag to only depend on libraries with either the scope:catalog or scope:shared tags:

.eslintrc.json
"depConstraints": [
{
"sourceTag": "scope:catalog",
"onlyDependOnLibsWithTags": ["scope:catalog", "scope:shared"]
},
{
"sourceTag": "type:ui",
"onlyDependOnLibsWithTags": ["type:ui", "type:model"]
}
],

Multi-Dimensional Constraints

Sometimes, you may want to define constraints that depend on multiple dimensions (e.g. platform + type).

A typical example is when the frontend and backend have different architecture styles (e.g. layered for the frontend and hexagonal for the backend). In this case, the frontend's domain layer can depend on the infra layer while the backend's domain layer cannot.

To put this differently, the type:domain category has different rules depending on the category of the platform dimension.

Luckily, the @nx/enforce-module-boundaries rule supports multi-dimensional constraints that you can define using the allSourceTags option, and this is how you can define the previous example:

"depConstraints": [
{
"allSourceTags": ["platform:web", "type:domain"],
"onlyDependOnLibsWithTags": ["type:domain", "type:infra"]
},
{
"allSourceTags": ["platform:server", "type:domain"],
"onlyDependOnLibsWithTags": ["type:domain", "type:ports"]
}
],
note

The diagram above is simplified for the sake of clarity.

Constraining External Dependencies

After defining constraints between your apps and libraries, you may want to enforce constraints between your workspace and external dependencies (e.g. npm packages).

Here are some common use cases:

  • Preventing applications from mistakenly using an external dependency from another framework or platform (e.g. importing Injectable from @angular/core in a NestJS application).
  • Preventing applications from using framework features that you want to avoid in your workspace.
  • Preventing libraries from directly importing 3rd party libraries when there is an adapter that wraps them.
  • Preventing "non-legacy" libraries from using "legacy" external dependencies.
  • Preventing some library types from using any external dependencies (e.g. model library type).

This can be achieved using the allowedExternalImports (external dependencies whitelisting) and bannedExternalImports (external dependencies blacklisting) options.

Here is an example of how to only allow server libraries with the infra type to use Prisma and web libraries with the infra type to use Angular's HTTP Client:

"depConstraints": [
{
"allSourceTags": ["platform:server", "type:infra"],
"allowedExternalImports": ["@prisma/client"]
},
{
"allSourceTags": ["platform:web", "type:infra"],
"allowedExternalImports": ["@angular/common/http"]
},
],
Prefer allowedExternalImports

While bannedExternalImports might sound easier to adopt, it is actually more complex to maintain and more error-prone. Our observation is that using a whitelist approach is more sustainable as it will:

  • prevent unwanted external imports before they proliferate in your codebase,
  • whitelist framework features that are allowed to be used in your workspace,
  • encourage developers to think about the external dependencies they are using,
  • simplify external dependencies audit and review,
  • pinpoint library types that are using too many external dependencies,
  • be less likely to turn into a "whack-a-mole" game than you might think.

A Complete Example

Here is an example of module boundaries configuration for an crossplatform workspace using a Modular Layered Architecture:

.eslintrc.json
{
"root": true,
"plugins": ["@nx", ...],
"overrides": [
{
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
"rules": {
"@nx/enforce-module-boundaries": [
"error",
{
"enforceBuildableLibDependency": true,
"allow": [],
"depConstraints": [
{
"sourceTag": "platform:web",
"onlyDependOnLibsWithTags": ["platform:web", "platform:crossplatform"]
},
{
"sourceTag": "platform:server",
"onlyDependOnLibsWithTags": ["platform:server", "platform:crossplatform"]
},
{
"sourceTag": "platform:crossplatform",
"onlyDependOnLibsWithTags": ["platform:crossplatform"]
},
{
"sourceTag": "scope:cart",
"onlyDependOnLibsWithTags": ["scope:cart", "scope:shared"]
},
{
"sourceTag": "scope:catalog",
"onlyDependOnLibsWithTags": ["scope:catalog", "scope:shared"]
},
{
"sourceTag": "type:app",
"onlyDependOnLibsWithTags": ["type:feature", "type:ui", "type:domain", "type:infra", "type:model", "type:util"]
},
{
"sourceTag": "type:feature",
"onlyDependOnLibsWithTags": ["type:feature", "type:ui", "type:domain", "type:infra", "type:model", "type:util"]
},
{
"sourceTag": "type:ui",
"onlyDependOnLibsWithTags": ["type:ui", "type:model", "type:util"]
},
{
"sourceTag": "type:domain",
"onlyDependOnLibsWithTags": ["type:domain", "type:infra", "type:model", "type:util"]
},
{
"sourceTag": "type:infra",
"onlyDependOnLibsWithTags": ["type:infra", "type:model", "type:util"]
},
{
"sourceTag": "type:model",
"onlyDependOnLibsWithTags": ["type:model", "type:util"]
},
{
"sourceTag": "type:util",
"onlyDependOnLibsWithTags": ["type:util"]
},
{
"allSourceTags": ["platform:web", "type:app"],
"allowedExternalImports": ["@angular/*"]
},
{
"allSourceTags": ["platform:web", "type:feature"],
"allowedExternalImports": ["@angular/*"]
},
{
"allSourceTags": ["platform:web", "type:ui"],
"allowedExternalImports": ["@angular/core", "@angular/common", "@angular/material"]
},
{
"allSourceTags": ["platform:web", "type:domain"],
"allowedExternalImports": ["@angular/core"]
},
{
"allSourceTags": ["platform:web", "type:infra"],
"allowedExternalImports": ["@angular/core", "@angular/common/http"]
},
{
"allSourceTags": ["platform:web", "type:model"],
"allowedExternalImports": []
},
{
"allSourceTags": ["platform:web", "type:util"],
"allowedExternalImports": ["date-fns"]
},
{
"allSourceTags": ["platform:server", "type:app"],
"allowedExternalImports": ["@nestjs/core", "@nestjs/common", "@nestjs/testing"]
},
{
"allSourceTags": ["platform:server", "type:feature"],
"allowedExternalImports": ["@nestjs/core", "@nestjs/common", "@nestjs/testing"]
},
{
"allSourceTags": ["platform:server", "type:domain"],
"allowedExternalImports": ["@nestjs/core"]
},
{
"allSourceTags": ["platform:server", "type:infra"],
"allowedExternalImports": ["@nestjs/core", "@prisma/client"]
},
{
"allSourceTags": ["platform:server", "type:model"],
"allowedExternalImports": []
},
{
"allSourceTags": ["platform:server", "type:util"],
"allowedExternalImports": []
},
]
}
}
}
]
}