javierbrea / eslint-plugin-boundaries

Eslint plugin checking architecture boundaries between elements
MIT License
555 stars 10 forks source link
architectural-patterns architecture dependencies eslint eslint-plugin eslint-rules import quality

Build status Coverage Status Quality Gate

Renovate Last commit Last release

NPM downloads License

eslint-plugin-boundaries

In words of Robert C. Martin, "Software architecture is the art of drawing lines that I call boundaries. Those boundaries separate software elements from one another, and restrict those on one side from knowing about those on the other." (*acknowledgements)

This plugin ensures that your architecture boundaries are respected by the elements in your project checking the folders and files structure and the dependencies between them. It is not a replacement for eslint-plugin-import, on the contrary, the combination of both plugins is recommended.

By default, the plugin works by checking import statements, but it is also able to analyze "require", "exports" and dynamic imports, and can be configured to check any other AST nodes. (Read the main rules overview and configuration chapters for better comprehension)

Table of Contents

Details - [Installation](#installation) - [Overview](#overview) - [Main rules overview](#main-rules-overview) * [Allowed element types](#allowed-element-types) * [Allowed external modules](#allowed-external-modules) * [Private elements](#private-elements) * [Entry point](#entry-point) - [Rules](#rules) - [Configuration](#configuration) * [Global settings](#global-settings) * [Predefined configurations](#predefined-configurations) * [Rules configuration](#rules-configuration) * [Main format of rules options](#main-format-of-rules-options) * [Elements matchers](#elements-matchers) * [Error messages](#error-messages) * [Advanced example](#advanced-example) - [Resolvers](#resolvers) - [Usage with TypeScript](#usage-with-typescript) - [Migration guides](#migration-guides) - [Debug mode](#debug-mode) - [Acknowledgements](#acknowledgements) - [Contributing](#contributing) - [License](#license)

Installation

This module is distributed via npm which is bundled with node and should be installed as one of your project's devDependencies:

npm install --save-dev eslint eslint-plugin-boundaries

eslint-plugin-boundaries does not install eslint for you. You must install it yourself.

Activate the plugin and one of the canned configs in your eslint.config.js file:

import boundaries from "eslint-plugin-boundaries";

export default [
  {
    plugins: {
      boundaries,
    },
    rules: {
      ...boundaries.configs.recommended.rules,
    }
  }
];

[!NOTE]
From version 5.0.0, this plugin is compatible with eslint v9 and above. It may be also compatible with previous eslint versions, but you might read the documentation of the 4.2.2 version to know how to configure it properly using the legacy configuration format.

Overview

All of the plugin rules need to be able to identify the elements in the project, so, first of all you have to define your project element types by using the boundaries/elements setting.

The plugin will use the provided patterns to identify each file as one of the element types. It will also assign a type to each dependency detected in the dependency nodes (import or other statements), and it will check if the relationship between the dependent element and the dependency is allowed or not.

export default [{
  settings: {
    "boundaries/elements": [
      {
        type: "helpers",
        pattern: "helpers/*"
      },
      {
        type: "components",
        pattern: "components/*"
      },
      {
        type: "modules",
        pattern: "modules/*"
      }
    ]
  }
}]

This is only a basic example of configuration. The plugin can be configured to identify elements being a file, or elements being a folder containing files. It also supports capturing path fragments to be used afterwards on each rule options, etc. Read the configuration chapter for further info, as configuring it properly is crucial to take advantage of all of the plugin features.

Once your project element types are defined, you can use them to configure each rule using its own options. For example, you could define which elements can be dependencies of other ones by configuring the element-types rule as in:

export default [{
  rules: {
    "boundaries/element-types": [2, {
      default: "disallow",
      rules: [
        {
          from: "components",
          allow: ["helpers", "components"]
        },
        {
          from: "modules",
          allow: ["helpers", "components", "modules"]
        }
      ]
    }]
  }
}]

The plugin won't apply rules to a file or dependency when it does not recognize its element type, but you can force all files in your project to belong to an element type by enabling the boundaries/no-unknown-files rule.

Main rules overview

Allowed element types

This rule ensures that dependencies between your project element types are allowed.

Examples of usage:

Read the docs of the boundaries/element-types rule for further info.

Allowed external modules

External dependencies used by each type of element in your project can be checked using this rule. For example, you can define that "helpers" can't import react, or "components" can't import react-router-dom, or modules can't import { Link } from react-router-dom.

Read the docs of the boundaries/external rule for further info.

Private elements

This rule ensures that elements can't require other element's children. So, when an element B is children of A, B becomes a "private" element of A, and only A can use it.

Read the docs of the boundaries/no-private rule for further info.

Entry point

This rule ensures that elements can't import another file from other element than the defined entry point for that type (index.js by default)

Read the docs of the boundaries/entry-point rule for further info.

Rules

Configuration

Global settings

boundaries/element-types

Define patterns to recognize each file in the project as one of this element types. All rules need this setting to be configured properly to work. The plugin tries to identify each file being analyzed or import statement in rules as one of the defined element types. The assigned element type will be that with the first matching pattern, in the same order that elements are defined in the array, so you should sort them from the most accurate patterns to the less ones. Properties of each element:

export default [{
  settings: {
    "boundaries/elements": [
      {
        type: "helpers",
        pattern: "helpers/*/*.js",
        mode: "file",
        capture: ["category", "elementName"]
      },
      {
        type: "components",
        pattern: "components/*/*",
        capture: ["family", "elementName"]
      },
      {
        type: "modules",
        pattern: "module/*",
        capture: ["elementName"]
      }
    ]
  }
}]

Tip: You can enable the debug mode when configuring the plugin, and you will get information about the type assigned to each file in the project, as well as captured properties and values.

boundaries/dependency-nodes

This setting allows to modify built-in default dependency nodes. By default, the plugin will analyze only the import statements. All the rules defined for the plugin will be applicable to the nodes defined in this setting.

The setting should be an array of the following strings:

If you want to define custom dependency nodes, such as jest.mock(...), use additional-dependency-nodes setting.

For example, if you want to analyze the import and dynamic-import statements, you should use the following value:

"boundaries/dependency-nodes": ["import", "dynamic-import"],

boundaries/additional-dependency-nodes

This setting allows to define custom dependency nodes to analyze. All the rules defined for the plugin will be applicable to the nodes defined in this setting.

The setting should be an array of objects with the following structure:

Example of usage:

export default [{
  settings: {
    "boundaries/additional-dependency-nodes": [
      // jest.requireActual('source')
      {
        selector: "CallExpression[callee.object.name=jest][callee.property.name=requireActual] > Literal",
        kind: "value",
      },
      // jest.mock('source', ...)
      {
        selector: "CallExpression[callee.object.name=jest][callee.property.name=mock] > Literal:first-child",
        kind: "value",
      },
    ],
  }
}]

boundaries/include

Files or dependencies not matching these micromatch patterns will be ignored by the plugin. If this option is not provided, all files will be included.

export default [{
  settings: {
    "boundaries/include": ["src/**/*.js"]
  }
}]

boundaries/ignore

Files or dependencies matching these micromatch patterns will be ignored by the plugin.

export default [{
  settings: {
    "boundaries/ignore": ["**/*.spec.js", "src/legacy-code/**/*"]
  }
}]

Note: The boundaries/ignore option has precedence over boundaries/include. If you define boundaries/include, use boundaries/ignore to ignore subsets of included files.

boundaries/root-path

Use this setting only if you are facing issues with the plugin when executing the lint command from a different path than the project root.

How to define the root path of the project By default, the plugin uses the current working directory (`process.cwd()`) as root path of the project. This path is used as the base path when resolving file matchers from rules and `boundaries/elements` settings. This is specially important when using the `basePattern` option or the `full` mode in the `boundaries/elements` setting. This may produce unexpected results [when the lint command is executed from a different path than the project root](https://github.com/javierbrea/eslint-plugin-boundaries/issues/296). To fix this, you can define a different root path by using this option. For example, supposing that the `eslint.config.js` file is located in the project root, you could define the root path as in: ```js import { resolve } from "node:path"; export default [{ settings: { "boundaries/root-path": resolve(import.meta.dirname) } }] ``` Note that the path should be absolute and resolved before passing it to the plugin. Otherwise, it will be resolved using the current working directory, and the problem will persist. You can also use the next environment variable to define the root path when executing the lint command: ```bash ESLINT_PLUGIN_BOUNDARIES_ROOT_PATH=../../project-root npm run lint ``` You can also provide an absolute path in the environment variable, but it may be more useful to use a relative path to the project root. Remember that it will be resolved from the path where the lint command is executed.

Predefined configurations

The plugin is distributed with two different predefined configurations: "recommended" and "strict".

Recommended

We recommend to use this setting if you are applying the plugin to an already existing project. Rules boundaries/no-unknown, boundaries/no-unknown-files and boundaries/no-ignored are disabled, so it allows to have parts of the project non-compliant with your element types, allowing to refactor the code progressively.

import boundaries from "eslint-plugin-boundaries";

export default [{
  rules: {
    ...boundaries.configs.recommended.rules,
  }
}]

Strict

All rules are enabled, so all elements in the project will be compliant with your architecture boundaries. 😃

import boundaries from "eslint-plugin-boundaries";

export default [{
  rules: {
    ...boundaries.configs.strict.rules,
  }
}]

Rules configuration

Some rules require extra configuration, and it has to be defined in each specific rule property of the eslint.config.js file. For example, allowed element types relationships has to be provided as an option to the boundaries/element-types rule. Rules requiring extra configuration will print a warning in case they are enabled without the needed options.

Main format of rules options

The docs of each rule contains an specification of their own options, but the main rules share the format in which the options have to be defined. The format described here is valid for options of element-types, external and entry-point rules.

Options set an allow or disallow value by default, and provide an array of rules. Each matching rule will override the default value and the value returned by previous matching rules. So, the final result of the options, once processed for each case, will be allow or disallow, and this value will be applied by the plugin rule in the correspondent way, making it to produce an eslint error or not.

export default [{
  rules: {
    "boundaries/element-types": [2, {
      // Allow or disallow any dependency by default
      default: "allow",
      // Define a custom message for this rule
      message: "${file.type} is not allowed to import ${dependency.type}",
      rules: [
        {
          // In this type of files...
          from: ["helpers"],
          // ...disallow importing this type of elements
          disallow: ["modules", "components"],
          // ..for this kind of imports (applies only when using TypeScript)
          importKind: "value",
          // ...and return this custom error message
          message: "Helpers must not import other thing than helpers"
        },
        {
          from: ["components"],
          disallow: ["modules"]
          // As this rule has not "message" property, it will use the message defined at first level
        }
      ]
    }]
  }
}]

Remember that:

Rules options properties

Tip: Properties from/target and disallow/allow can receive a single matcher, or an array of matchers.

Elements matchers

Elements matchers used in the rules options can have the next formats:

Templating

When defining Element matchers, the values captured both from the element importing ("from") and from the imported element ("target") are available to be replaced. They are replaced both in the main string and in the <capturedValuesObject>.

Templates must be defined with the format ${from.CAPTURED_PROPERTY} or ${target.CAPTURED_PROPERTY}.

Error messages

The plugin returns a different default message for each rule, check the documentation of each one for further info. But some rules support defining custom messages in their configuration, as seen in "Main format of rules options".

When defining custom messages, it is possible to provide information about the current file or dependency. Use ${file.PROPERTY} or ${dependency.PROPERTY}, and it will be replaced by the correspondent captured value from the file or the dependency:

{
  "message": "${file.type}s of category ${file.category} are not allowed to import ${dependency.category}s"
  // If the error was produced by a file with type "component" and captured value "category" being "atom", trying to import a dependency with category "molecule", the message would be:
  // "components of category atom are not allowed to import molecules"
}

Available properties in error templates both from file or dependency are:

Tip: Read "Global settings" for further info about how to capture values from elements.

Some rules also provide extra information about the reported error. For example, no-external rules provides information about detected forbidden specifiers. This information is available using ${report.PROPERTY}. Check each rule's documentation to know which report properties it provides:

{
  "message": "Do not import ${report.specifiers} from ${dependency.source} in helpers"
}
Advanced example of a rule configuration

Just to illustrate the high level of customization that the plugin supports, here is an example of advanced options for the boundaries/element-types rule based on the previous global elements settings example:

export default [{
  rules: {
    "boundaries/element-types": [2, {
      // disallow importing any element by default
      default: "disallow",
      rules: [
        {
          // allow importing helpers files from helpers files
          from: ["helpers"],
          allow: ["helpers"]
        },
        {
          // when file is inside an element of type "components"
          from: ["components"],
          allow: [
            // allow importing components of the same family
            ["components", { family: "${from.family}" }],
            // allow importing helpers with captured category "data"
            ["helpers", { category: "data" }],
          ]
        },
        {
          // when component has captured family "molecule"
          from: [["components", { family: "molecule" }]],
          allow: [
            // allow importing components with captured family "atom"
            ["components", { family: "atom" }],
          ],
        },
        {
          // when component has captured family "atom"
          from: [["components", { family: "atom" }]],
          disallow: [
            // disallow importing helpers with captured category "data"
            ["helpers", { category: "data" }]
          ],
          // Custom message only for this specific error
          message: "Atom components can't import data helpers"
        },
        {
          // when file is inside a module
          from: ["modules"],
          allow: [
            // allow importing any type of component or helper
            "helpers",
            "components"
          ]
        },
        {
          // when module name starts by "page-"
          from: [["modules", { elementName: "page-*" }]],
          disallow: [
            // disallow importing any type of component not being of family layout
            ["components", { family: "!layout" }],
          ],
          // Custom message only for this specific error
          message: "Modules with name starting by 'page-' can't import not layout components. You tried to import a component of family ${target.family} from a module with name ${from.elementName}"
        }
      ]
    }]
  }
}]

Resolvers

"With the advent of module bundlers and the current state of modules and module syntax specs, it's not always obvious where import x from 'module' should look to find the file behind module." (**Quote from the eslint-plugin-import docs)

This plugin uses eslint-module-utils/resolve module under the hood, which is a part of the eslint-plugin-import plugin. So the import/resolver setting can be used to use custom resolvers for this plugin too.

Read the resolvers chapter of the eslint-plugin-import plugin for further info.

export default [{
  settings: {
    "import/resolver": {
      "eslint-import-resolver-node": {},
      "some-other-custom-resolver": { someConfig: "value" }
    }
  }
}]

Usage with TypeScript

This plugin can be used also in TypeScript projects using @typescript-eslint/eslint-plugin. Follow next steps to configure it:

Install dependencies:

npm i --save-dev @typescript-eslint/eslint-plugin @typescript-eslint/parser eslint-import-resolver-typescript

Configure @typescript-eslint/parser as parser, load the @typescript-eslint plugin, and setup the eslint-import-resolver-typescript resolver in the eslint.config.js config file:

import boundaries from "eslint-plugin-boundaries";
import typescriptParser from "@typescript-eslint/parser";
import typescriptEslintPlugin from "@typescript-eslint/eslint-plugin";

export default [{
  languageOptions: {
    parser: typescriptParser,
  },
  plugins: {
    "@typescript-eslint": typescriptEslintPlugin,
    boundaries,
  },
  settings: {
    "import/resolver": {
      typescript: {
        alwaysTryTypes: true,
      },
    },
  },
}];

Note that eslint-import-resolver-typescript detects even custom paths defined in the tsconfig.json file, so its usage is also compatible with this plugin.

In case you face any issue configuring it, you can also use this repository as a guide. It contains a fully working and tested example.

Migration guides

Migrating from v4.x

v5.0.0 release is compatible with eslint v9 and above. It may be also compatible with previous eslint versions, but you might read the documentation of the 4.2.2 version to know how to configure it properly using the legacy configuration format. You may also be interested on reading the eslint guide to migrate to v9.

Migrating from v3.x

v4.0.0 release introduced breaking changes. If you were using v3.x, you should read the "how to migrate from v3 to v4" guide.

Migrating from v1.x

v2.0.0 release introduced many breaking changes. If you were using v1.x, you should read the "how to migrate from v1 to v2" guide.

Debug mode

In order to help during the configuration process, the plugin can trace information about the files and imports being analyzed. The information includes the file path, the assigned element type, the captured values, etc. So, it can help you to check that your elements setting works as expected. You can enable it using the ESLINT_PLUGIN_BOUNDARIES_DEBUG environment variable.

ESLINT_PLUGIN_BOUNDARIES_DEBUG=1 npm run lint

Acknowledgements

* Quote from Robert C. Martin's book "Clean Architecture: A Craftsman's Guide to Software Structure and Design".

** This plugin uses internally the eslint-module-utils/resolve module, which is a part of the eslint-plugin-import plugin. Thanks to the maintainers of that plugin for their awesome work.

Contributing

Contributors are welcome. Please read the contributing guidelines and code of conduct.

License

MIT, see LICENSE for details.