Modularity for TypeScript Projects
Sheriff enforces module boundaries and dependency rules in TypeScript.
It is easy to use and has zero dependencies. The only peer dependency is TypeScript itself.
Some examples are located in ./test-projects/.
excludeRoot
Examples are available at https://github.com/softarc-consulting/sheriff/tree/main/test-projects
In order to get the best developer experience, we recommend to use Sheriff with the ESLint plugin.
npm install -D @softarc/sheriff-core @softarc/eslint-plugin-sheriff
In your eslintrc.json, insert the rules:
{
"files": ["*.ts"],
"extends": ["plugin:@softarc/sheriff/default"]
}
You can also use Sheriff without ESLint. In this case, you have to run the Sheriff CLI manually.
npm install -D @softarc/sheriff-core
The CLI provides you with commands to list modules, check the rules and export the dependency graph in JSON format.
For more details, see the CLI.
Every directory with an index.ts is a module. index.ts exports those files that should be accessible from the outside, i.e. it exposes the public API of the module.
In the screenshot below, you see an index.ts, which exposes the holidays-facade.service.ts, but encapsulates the internal.service.ts.
Every file outside of that directory (module) now gets a linting error when it imports the internal.service.ts.
Sheriff provides access rules.
To define access rules, run npx sheriff init
in your project's root folder. This creates a sheriff.config.ts file, where you can define the tags and dependency rules.
The initial sheriff.config.ts
doesn't have any restrictions in terms of dependency rules.
By default, an untagged module has the tag "noTag". All files which are not part of a module are assigned to the "root" module and therefore have the tag "root".
Dependency rules operate on those tags.
Here's an example of a sheriff.config.ts file with auto-tagged modules:
import { SheriffConfig } from '@softarc/sheriff-core';
export const sheriffConfig: SheriffConfig = {
depRules: {
root: 'noTag',
noTag: ['noTag', 'root'],
},
};
The configuration allows every module with tag "noTag" to access any other module with tag "noTag" and "root".
This is the recommendation for existing projects and allows an incremental introduction of Sheriff.
If you start from scratch, you should go with manual tagging.
To disable automatic tagging, set autoTagging
to false
:
import { SheriffConfig } from '@softarc/sheriff-core';
export const sheriffConfig: SheriffConfig = {
autoTagging: false,
tagging: {
// see below...
},
};
root
TagLet's say we have the following directory structure:
src/app ├── main.ts ├── app.config.ts ├── app.component.ts ├── holidays │ ├── data │ │ ├── index.ts │ │ ├── internal.service.ts │ │ └── holidays-data.service.ts │ ├── feature │ │ ├── index.ts │ │ └── holidays-facade.service.ts │── core │ ├── header.component.ts │ ├── footer.component.ts
src/app/holidays/data and src/app/holidays/feature are modules. All other files are part of the root module which is tagged with "root". Sheriff assigns the tag "root" automatically. You cannot change it and "root" doesn't have an index.ts. By default, it is not possible to import from the root module.
flowchart LR
app.config.ts --> holidays/feature/index.ts
holidays/feature/holidays.component.ts --> holidays/data/index.ts
subgraph "noTag (holidays/data)"
holidays/data/index.ts
holidays/data/internal.service.ts
holidays/data/holidays-data.service.ts
end
subgraph "noTag (holidays/feature)"
holidays/feature/index.ts
holidays/feature/holidays.component.ts
end
subgraph root
main.ts
app.config.ts
app.component.ts
core/header.component.ts
core/footer.component.ts
end
style holidays/feature/index.ts stroke: #333, stroke-width: 4px
style holidays/data/index.ts stroke: #333, stroke-width: 4px
style root fill: #f9f9f9
The following snippet shows a configuration where four directories are assigned to a domain and to a module type:
import { SheriffConfig } from '@softarc/sheriff-core';
export const sheriffConfig: SheriffConfig = {
tagging: {
'src/app/holidays/feature': ['domain:holidays', 'type:feature'],
'src/app/holidays/data': ['domain:holidays', 'type:data'],
'src/app/customers/feature': ['domain:customers', 'type:feature'],
'src/app/customers/data': ['domain:customers', 'type:data'],
},
depRules: {},
};
With "domain:" and "type:", we have two dimensions which allows us to define the following rules:
import { SheriffConfig } from '@softarc/sheriff-core';
export const sheriffConfig: SheriffConfig = {
version: 1,
tagging: {
'src/app/holidays/feature': ['domain:holidays', 'type:feature'],
'src/app/holidays/data': ['domain:holidays', 'type:data'],
'src/app/customers/feature': ['domain:customers', 'type:feature'],
'src/app/customers/data': ['domain:customers', 'type:data'],
},
depRules: {
'domain:holidays': ['domain:holidays'], // Rule 1
'domain:customers': ['domain:customers'], // Rule 1
'type:feature': 'type:data', // Rule 2
root: ['type:feature', 'domain:holidays', 'domain:customers'], // Rule 3
},
};
If those roles are violated, a linting error is thrown:
For existing projects, you want to tag modules and define dependency rules incrementally.
If you only want to tag modules from "holidays" and leave the rest auto-tagged, you can do so:
import { SheriffConfig } from '@softarc/sheriff-core';
export const sheriffConfig: SheriffConfig = {
tagging: {
'src/app/holidays/feature': ['domain:holidays', 'type:feature'],
'src/app/holidays/data': ['domain:holidays', 'type:data'],
},
depRules: {
'domain:holidays': ['domain:holidays', 'noTag'],
'type:feature': ['type:data', 'noTag'],
root: ['type:feature', 'domain:holidays', 'noTag'],
noTag: ['noTag', 'root'],
},
};
All modules in the directory "customers" have the tag "noTag". Be aware, that every module from "domain:holidays" can now depend on any module from directory "customers" but not vice versa.
Nested paths simplify the configuration. Multiple levels are allowed.
import { SheriffConfig } from '@softarc/sheriff-core';
export const sheriffConfig: SheriffConfig = {
tagging: {
'src/app': {
holidays: {
feature: ['domain:holidays', 'type:feature'],
data: ['domain:holidays', 'type:data'],
},
customers: {
feature: ['domain:customers', 'type:feature'],
data: ['domain:customers', 'type:data'],
},
},
},
depRules: {
'domain:holidays': ['domain:holidays'],
'domain:customers': ['domain:customers'],
'type:feature': 'type:data',
root: ['type:feature', 'domain:holidays', 'domain:customers'],
},
};
Placeholders help with repeating patterns. They have the snippet <name>
.
import { SheriffConfig } from '@softarc/sheriff-core';
export const sheriffConfig: SheriffConfig = {
tagging: {
'src/app': {
holidays: {
'<type>': ['domain:holidays', 'type:<type>'],
},
customers: {
'<type>': ['domain:customers', 'type:<type>'],
},
},
},
depRules: {
'domain:holidays': ['domain:holidays'],
'domain:customers': ['domain:customers'],
'type:feature': 'type:data',
root: ['type:feature', 'domain:holidays', 'domain:customers'],
},
};
We can use placeholders on all levels. Our configuration is now more concise.
import { SheriffConfig } from '@softarc/sheriff-core';
export const sheriffConfig: SheriffConfig = {
version: 1,
tagging: {
'src/app/<domain>/<type>': ['domain:<domain>', 'type:<type>'],
},
depRules: {
'domain:holidays': ['domain:holidays'],
'domain:customers': ['domain:customers'],
'type:feature': 'type:data',
root: ['type:feature', 'domain:holidays', 'domain:customers'],
},
};
depRules
Functions & WildcardsWe could use functions for depRules
instead of static values. The names of the tags can include wildcards:
import { SheriffConfig } from '@softarc/sheriff-core';
export const sheriffConfig: SheriffConfig = {
version: 1,
tagging: {
'src/app/<domain>/<type>': ['domain:<domain>', 'type:<type>'],
},
depRules: {
'domain:*': ({ from, to }) => from === to,
'type:feature': 'type:data',
root: ['type:feature', ({ to }) => to.startsWith('domain:')],
},
};
or
import { sameTag, SheriffConfig } from '@softarc/sheriff-core';
export const sheriffConfig: SheriffConfig = {
version: 1,
tagging: {
'src/app/<domain>/<type>': ['domain:<domain>', 'type:<type>'],
},
depRules: {
'domain:*': [sameTag, 'shared'],
'type:feature': 'type:data',
root: ['type:feature', ({ to }) => to.startsWith('domain:')],
},
};
The core package (@softarc/sheriff-core) comes with a CLI to initialize the configuration file, list modules, check the rules and export the dependency graph in JSON format.
Run npx sheriff init
to create a sheriff.config.ts
. Its configuration runs with automatic tagging, meaning no dependency rules are in place, and it only checks for the module boundaries.
Run npx sheriff verify main.ts
to check if your project violates any of your rules. main.ts
is the entry file where Sheriff should traverse the imports.
Depending on your project, you will likely have a different entry file. For example, with an Angular CLI-based project, it would be npx sheriff verify src/main.ts
.
You can omit the entry file if you set a value to the property entryFile
in the sheriff.config.ts
.
In that case, you only run npx sheriff verify
.
Run npx sheriff list main.ts
to print out all your modules along their tags. As explained above, you can alternatively use the entryFile
property in sheriff.config.ts
.
Run npx sheriff export main.ts > export.json
and the dependency graph will be stored in export.json
in JSON format. The dependency graph starts from the entry file and includes all reachable files. For every file, it will include the assigned module as well as the tags.
excludeRoot
It is usually not possible to modularize an existing codebase at once. Instead, we have to integrate Sheriff incrementally.
Next to automatic tagging, we introduce manual tagged modules step by step.
The recommended approach is start with only one module. For example holidays/feature. All files from the outside have to import from the module's index.ts, and it has the tags "type:feature".
It is very likely that holidays/feature depends on files in the "root" module. Since "root" doesn't have an index.ts, no other module can depend on it:
flowchart LR
holidays/feature/holidays.component.ts -- fails -->holidays/data/holidays-data.service.ts
app.config.ts -- succeeds -->holidays/feature/holidays.component.ts
subgraph root
holidays/data/holidays-data.service.ts
app.config.ts
main.ts
app.component.ts
core/header.component.ts
core/footer.component.ts
holidays/data/internal.service.ts
end
subgraph "type:feature (holidays/feature)"
holidays/feature/index.ts
holidays/feature/holidays.component.ts
end
style holidays/feature/index.ts stroke: #333, stroke-width: 4px
style root fill: #f9f9f9
style holidays/data/holidays-data.service.ts fill:coral
style app.config.ts fill:lightgreen
We can disable the deep import checks for the root module by setting excludeRoot
in sheriff.config.ts to true
:
export const config: SheriffConfig = {
excludeRoot: true, // <-- set this
tagging: {
'src/shared': 'shared',
},
depRules: {
root: 'noTag',
noTag: ['noTag', 'root'],
shared: anyTag,
},
};
flowchart LR
holidays/feature/holidays.component.ts --> holidays/data/holidays-data.service.ts
app.config.ts --> holidays/feature/holidays.component.ts
subgraph root
holidays/data/holidays-data.service.ts
app.config.ts
main.ts
app.component.ts
core/header.component.ts
core/footer.component.ts
holidays/data/internal.service.ts
end
subgraph "type:feature (holidays/feature)"
holidays/feature/index.ts
holidays/feature/holidays.component.ts
end
style holidays/feature/index.ts stroke: #333, stroke-width: 4px
style root fill: #f9f9f9
style holidays/data/holidays-data.service.ts fill:lightgreen
style app.config.ts fill:lightgreen
Once all files from "root" import form shared's index.ts, create another module and do the same.
For feature requests, please add an issue at https://github.com/softarc-consulting/sheriff.