- Notifications
You must be signed in to change notification settings - Fork13
Eslint plugin checking architecture boundaries between elements
License
javierbrea/eslint-plugin-boundaries
Folders and files
Name | Name | Last commit message | Last commit date | |
---|---|---|---|---|
Repository files navigation
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 foreslint-plugin-import, on the contrary, the combination of both plugins is recommended.
By default,the plugin works by checkingimport
statements, but it is also able to analyze "require", "exports" and dynamic imports, and can be configured to check any otherAST nodes. (Read themain rules overview andconfiguration chapters for better comprehension)
Details
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 installeslint
for you. You must install it yourself.
Activate the plugin and one of the canned configs in youreslint.config.js
file:
importboundariesfrom"eslint-plugin-boundaries";exportdefault[{plugins:{ boundaries,},rules:{ ...boundaries.configs.recommended.rules,}}];
Note
From version5.0.0
, this plugin is compatible with eslint v9 and above. It may be also compatible with previous eslint versions, but you might read thedocumentation of the4.2.2
version to know how to configure it properly using the legacy configuration format.
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 theboundaries/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 thedependency nodes (import
or other statements), and it will check if the relationship between the dependent element and the dependency is allowed or not.
exportdefault[{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 theconfiguration 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 theelement-types
rule as in:
exportdefault[{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 theboundaries/no-unknown-files rule.
This rule ensures that dependencies between your project element types are allowed.
Examples of usage:
- Define types in your project as "models", "views" and "controllers". Then ensure that "views" and "models" can be imported only by "controllers", and "controllers" will never be used by "views" or "models".
- Define types in your project as "components", "views", "layouts", "pages", "helpers". Then ensure that "components" can only import "helpers", that "views" can only import "components" or "helpers", that "layouts" can only import "views", "components" or "helpers", and that "pages" can import any other element type.
Read thedocs of theboundaries/element-types
rule for further info.
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 importreact
, or "components" can't importreact-router-dom
, or modules can't import{ Link } from react-router-dom
.
Read thedocs of theboundaries/external
rule for further info.
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 thedocs of theboundaries/no-private
rule for further info.
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 thedocs of theboundaries/entry-point
rule for further info.
- boundaries/element-types: Check allowed dependencies between element types
- boundaries/external: Check allowed external dependencies by element type
- boundaries/entry-point: Check entry point used for each element type
- boundaries/no-private: Prevent importing private elements of another element
- boundaries/no-unknown: Prevent importing unknown elements from the known ones
- boundaries/no-ignored: Prevent importing ignored files from recognized elements
- boundaries/no-unknown-files: Prevent creating files not recognized as any of the 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 orimport
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 youshould sort them from the most accurate patterns to the less ones. Properties of eachelement
:
type
:<string>
Element type to be assigned to files or imports matching thepattern
. This type will be used afterwards in the rules configuration.pattern
:<string>|<array>
micromatch
pattern.By default the plugin will try to match this pattern progressively starting from the right side of each file path. This means that you don't have to define patterns matching from the base project path, but only the last part of the path that you want to be matched. This is made because the plugin supports elements being children of other elements, and otherwise it could wrongly recognize children elements as a part of the parent one.
For example, given a pathsrc/helpers/awesome-helper/index.js
, it will try to assign the element to a pattern matchingindex.js
, thenawesome-helper/index.js
, thenhelpers/awesome-helper/index.js
, etc. Once a pattern matches, it assign the correspondent element type, and continues searching for parents elements with the same logic until the full path has been analyzed.This behavior can be disabled setting themode
option tofull
, then the provided pattern will try to match the full path.basePattern
:<string>
Optionalmicromatch
pattern. If provided, the left side of the element path must match also with this pattern from the root of the project (like if pattern is[basePattern]/**/[pattern]
). This option is useful when using the optionmode
withfile
orfolder
values, but capturing fragments from the rest of the full path is also needed (seebaseCapture
option below).mode
:<string> file|folder|full
Optional.- When it is set to
folder
(default value), the element type will be assigned to the first file's parent folder matching the pattern. In the practice, it is like adding**/*
to the given pattern, but the plugin makes it by itself because it needs to know exactly which parent folder has to be considered the element. - If it is set to
file
, the given pattern will not be modified, but the plugin will still try to match the last part of the path. So, a pattern like*.model.js
would match with pathssrc/foo.model.js
,src/modules/foo/foo.model.js
,src/modules/foo/models/foo.model.js
, etc. - If it is set to
full
, the given pattern will only match with patterns matching the full path. This means that you will have to provide patterns matching from the base project path. So, in order to matchsrc/modules/foo/foo.model.js
you'll have to provide patterns like**/*.model.js
,**/*/*.model.js
,src/*/*/*.model.js
, etc.(the chosen pattern will depend on what do you want to capture from the path)
- When it is set to
capture
:<array>
Optional. This is a very powerful feature of the plugin. It allows to capture values of some fragments in the matching path to use them later in the rules configuration. It usesmicromatch
capture feature under the hood, and stores each value in an object with the givencapture
key being in the same index of the captured array.
For example, givenpattern: "helpers/*/*.js"
,capture: ["category", "elementName"]
, and a pathhelpers/data/parsers.js
, it will result in{ category: "data", elementName: "parsers" }
.baseCapture
:<array>
Optional.micromatch
pattern. It allows capturing values frombasePattern
ascapture
does withpattern
. All keys fromcapture
andbaseCapture
can be used in the rules configuration.
exportdefault[{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 thedebug 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.
This setting allows to modify built-in default dependency nodes. By default, the plugin will analyze only theimport
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:
'require'
: analyzerequire
statements.'import'
: analyzeimport
statements.'export'
: analyzeexport
statements.'dynamic-import'
: analyzedynamic import statements.
If you want to define custom dependency nodes, such asjest.mock(...)
, useadditional-dependency-nodes setting.
For example, if you want to analyze theimport
anddynamic-import
statements, you should use the following value:
"boundaries/dependency-nodes": ["import","dynamic-import"],
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:
selector
: Theesquery selector for theLiteral
node in which dependency source are defined. For example, to analyzejest.mock(...)
calls you could use thisAST selector:CallExpression[callee.object.name=jest][callee.property.name=mock] > Literal:first-child
.kind
: The kind of dependency, possible values are:"value"
or"type"
. It is available only when using TypeScript.
Example of usage:
exportdefault[{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",},],}}]
Files or dependencies not matching thesemicromatch
patterns will be ignored by the plugin. If this option is not provided, all files will be included.
exportdefault[{settings:{"boundaries/include":["src/**/*.js"]}}]
Files or dependencies matching thesemicromatch
patterns will be ignored by the plugin.
exportdefault[{settings:{"boundaries/ignore":["**/*.spec.js","src/legacy-code/**/*"]}}]
Note: The
boundaries/ignore
option has precedence overboundaries/include
. If you defineboundaries/include
, useboundaries/ignore
to ignore subsets of included files.
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 andboundaries/elements
settings. This is specially important when using thebasePattern
option or thefull
mode in theboundaries/elements
setting. This may produce unexpected resultswhen the lint command is executed from a different path than the project root. To fix this, you can define a different root path by using this option.
For example, supposing that theeslint.config.js
file is located in the project root, you could define the root path as in:
import{resolve}from"node:path";exportdefault[{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:
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.
The plugin is distributed with two different predefined configurations: "recommended" and "strict".
We recommend to use this setting if you are applying the plugin to an already existing project. Rulesboundaries/no-unknown
,boundaries/no-unknown-files
andboundaries/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.
importboundariesfrom"eslint-plugin-boundaries";exportdefault[{rules:{ ...boundaries.configs.recommended.rules,}}]
All rules are enabled, so all elements in the project will be compliant with your architecture boundaries. 😃
importboundariesfrom"eslint-plugin-boundaries";exportdefault[{rules:{ ...boundaries.configs.strict.rules,}}]
Some rules require extra configuration, and it has to be defined in each specificrule
property of theeslint.config.js
file. For example, allowed element types relationships has to be provided as an option to theboundaries/element-types
rule. Rules requiring extra configuration will print a warning in case they are enabled without the needed options.
The docs of each rule contains an specification of their own options, butthe main rules share the format in which the options have to be defined. The format described here is valid for options ofelement-types
,external
andentry-point
rules.
Options set anallow
ordisallow
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 beallow
ordisallow
, and this value will be applied by the plugin rule in the correspondent way, making it to produce an eslint error or not.
exportdefault[{rules:{"boundaries/element-types":[2,{// Allow or disallow any dependency by defaultdefault:"allow",// Define a custom message for this rulemessage:"${file.type} is not allowed to import ${dependency.type}",rules:[{// In this type of files...from:["helpers"],// ...disallow importing this type of elementsdisallow:["modules","components"],// ..for this kind of imports (applies only when using TypeScript)importKind:"value",// ...and return this custom error messagemessage:"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:
- All rules are executed, and the resultant value will be the one returned by the last matching one.
- If one rule contains both
allow
anddisallow
properties, thedisallow
one has priority. It will not try to match theallow
one ifdisallow
matches. The result for that rule will bedisallow
in that case.
from/target
:<element matchers>
Depending of the rule to which the options are for, the rule will be applied only if the file being analyzed matches with this element matcher (from
), or the dependency being imported matches with this element matcher (target
).disallow/allow
:<value matchers>
If the plugin rule target matches with this, then the result of the rule will be "disallow/allow". Each rule will require a type of value here depending of what it is checking. In the case of theelement-types
rule, for example, another<element matcher>
has to be provided in order to check the type of the local dependency.importKind
:<string>
Optional. It is useful only when using TypeScript, because it allows to define if the rule applies when the dependency is being imported as a value or as a type. It can be also defined as an array of strings, or a micromatch pattern. Note that possible values to match with are"value"
,"type"
or"typeof"
. For example, you could define that "components" can import "helpers" as a value, but not as a type. So,import { helper } from "helpers/helper-a"
would be allowed, butimport type { Helper } from "helpers/helper-a"
would be disallowed.message
:<string>
Optional. If the rule results in an error, the plugin will return this message instead of the default one. Readerror messages for further info.
Tip: Properties
from/target
anddisallow/allow
can receive a single matcher, or an array of matchers.
Elements matchers used in the rules options can have the next formats:
<string>
: Will returntrue
when the element type matches with thismicromatch
pattern. Itsupports templating for using values from captured values.[<string>, <capturedValuesObject>]
: Will returntrue
whe when the element type matches with the first element in the array, and all of the captured values also match.
The<capturedValuesObject>
has to be an object containingcapture
keys from theboundaries/element-types
setting of the element as keys, andmicromatch
patterns as values. (values also supporttemplating)
For example, for an element of type "helpers" with settings as{ type: "helpers", pattern": "helpers/*/*.js", "capture": ["category", "elementName"]}
, you could write element matchers as:["helpers", { category: "data", elementName: "parsers"}]
: Will only match with helpers with category "data" and elementName "parsers" (helpers/data/parsers.js
).["helpers", { category: "data" }]
: Will match with all helpers with category "data" (helpers/data/*.js
)["data-${from.elementName}", { category: "${from.category}" }]
: Will only match with helpers with the type equals to theelementName
of the file importing plus adata-
prefix, and the category being equal to thecategory
of the file importing the dependency.
When definingElement 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}
.
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 fromfile
ordependency
are:
type
: Element's type.internalPath
: File path being analyzed or imported. Relative to the element's root path.source
: Available only fordependency
. The source of theimport
statement as it is in the code.parent
: If the element is child of another element, it is also available in this property, which contains correspondenttype
,internalPath
and captured properties as well.importKind
: Available only fordependency
when using TypeScript. It contains the kind of import being analyzed. Possible values are"value"
,"type"
or"typeof"
.- ...All captured properties are also available
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"}
Just to illustrate the high level of customization that the plugin supports, here is an example of advanced options for theboundaries/element-types
rule based on the previous globalelements
settings example:
exportdefault[{rules:{"boundaries/element-types":[2,{// disallow importing any element by defaultdefault:"disallow",rules:[{// allow importing helpers files from helpers filesfrom:["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 errormessage:"Atom components can't import data helpers"},{// when file is inside a modulefrom:["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 errormessage:"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}"}]}]}}]
"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 theeslint-plugin-import
docs)
This plugin useseslint-module-utils/resolve
module under the hood, which is a part of theeslint-plugin-import
plugin. Sotheimport/resolver
setting can be used to use custom resolvers for this plugin too.
Read theresolvers
chapter of theeslint-plugin-import
plugin for further info.
exportdefault[{settings:{"import/resolver":{"eslint-import-resolver-node":{},"some-other-custom-resolver":{someConfig:"value"}}}}]
This plugin can be used also inTypeScript 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 theeslint-import-resolver-typescript
resolver in theeslint.config.js
config file:
importboundariesfrom"eslint-plugin-boundaries";importtypescriptParserfrom"@typescript-eslint/parser";importtypescriptEslintPluginfrom"@typescript-eslint/eslint-plugin";exportdefault[{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 thetsconfig.json
file, so its usage is also compatible with this plugin.
In case you face any issue configuring it, you can alsouse this repository as a guide. It contains a fully working and tested example.
v5.0.0 release is compatible with eslint v9 and above. It may be also compatible with previous eslint versions, but you might read thedocumentation of the4.2.2
version to know how to configure it properly using the legacy configuration format. You may also be interested on reading theeslint guide to migrate to v9.
v4.0.0 release introduced breaking changes. If you were using v3.x, you shouldread the "how to migrate from v3 to v4" guide.
v2.0.0 release introduced many breaking changes. If you were using v1.x, you shouldread the "how to migrate from v1 to v2" guide.
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 yourelements
setting works as expected. You can enable it using theESLINT_PLUGIN_BOUNDARIES_DEBUG
environment variable.
ESLINT_PLUGIN_BOUNDARIES_DEBUG=1 npm run lint
* Quote from Robert C. Martin's book"Clean Architecture: A Craftsman's Guide to Software Structure and Design".
** This plugin uses internally theeslint-module-utils/resolve
module, which is a part of theeslint-plugin-import
plugin. Thanks to the maintainers of that plugin for their awesome work.
Contributors are welcome.Please read thecontributing guidelines andcode of conduct.
MIT, seeLICENSE for details.
About
Eslint plugin checking architecture boundaries between elements
Topics
Resources
License
Code of conduct
Uh oh!
There was an error while loading.Please reload this page.
Stars
Watchers
Forks
Packages0
Uh oh!
There was an error while loading.Please reload this page.
Uh oh!
There was an error while loading.Please reload this page.