softarc-consulting / sheriff

Lightweight Modularity for TypeScript Projects
MIT License
165 stars 11 forks source link

Sheriff

Modularity for TypeScript Projects

build status npm version

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/.

1. Installation & Setup

Examples are available at https://github.com/softarc-consulting/sheriff/tree/main/test-projects

1.1. Sheriff and ESLint (recommended)

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"]
}
Angular (CLI) Example ```jsonc { "root": true, "ignorePatterns": ["**/*"], "overrides": [ // existing rules... { "files": ["*.ts"], "extends": ["plugin:@softarc/sheriff/default"], }, ], } ```
Angular (NX) Example ```jsonc { "root": true, "ignorePatterns": ["**/*"], "plugins": ["@nrwl/nx"], "overrides": [ // existing rules... { "files": ["*.ts"], "extends": ["plugin:@softarc/sheriff/default"], }, ], } ```

1.2. Sheriff without ESLint

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.

2. Video Introduction

3. Module Boundaries

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.

Screenshot 2023-06-24 at 12 24 09

Every file outside of that directory (module) now gets a linting error when it imports the internal.service.ts.

Screenshot 2023-06-24 at 12 23 32

4. Dependency Rules

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.

4.1. Automatic Tagging

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...
  },
};

4.2. The root Tag

Let'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

4.3. Manual Tagging

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:

  1. A module can only depend on modules of the same domain
  2. A module of "type:feature" can depend on "type:data" but not the other way around
  3. "root" can depend on a module of "type:feature" and both domains.
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:

Screenshot 2023-06-13 at 17 50 41

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.

4.4. Nested Paths

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'],
  },
};

4.5. Placeholders

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'],
  },
};

4.6. depRules Functions & Wildcards

We 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:')],
  },
};

5. CLI

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.

5.1. init

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.

5.2. verify [main.ts]

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.

5.3. list [main.ts]

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.

5.4. export [main.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.

6. Integrating Sheriff into large Projects via 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.

7. Planned Features

For feature requests, please add an issue at https://github.com/softarc-consulting/sheriff.