/sheriff

Lightweight Modularity for TypeScript Projects

Primary LanguageTypeScriptMIT LicenseMIT

build status

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

npm install -D @softarc/sheriff-core @softarc/eslint-plugin-sheriff

In your eslintrc.json, insert the rules:

{
  "files": ["*.ts"],
  "extends": ["plugin:@softarc/sheriff/default"]
}
Show Example for Angular (CLI)
{
  "root": true,
  "ignorePatterns": ["**/*"],
  "overrides": [
    // existing rules...
    {
      "files": ["*.ts"],
      "extends": ["plugin:@softarc/sheriff/default"]
    }
  ]
}
Show Example for Angular (NX)
{
  "root": true,
  "ignorePatterns": ["**/*"],
  "plugins": ["@nrwl/nx"],
  "overrides": [
    // existing rules...
    {
      "files": ["*.ts"],
      "extends": ["plugin:@softarc/sheriff/default"]
    }
  ]
}

2. Video Introduction

3. Module Boundaries

Every directory with an index.ts is a module. The index.ts exports those files that should be accessible from the outside.

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 allows the configuration of access rules between modules.

For that, there has to be a sheriff.config.ts in the project's root folder. The config assigns tags to every directory that represents a module, i.e. it contains an index.ts.

The dependency rules operate on these tags.

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 = {
  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: {},
};

If modules of the same domains can access each other and if a module of type feature can access type data but not the other way around, the depRules in the config would have these values.

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', 'shared'],
    'domain:customers': ['domain:customers', 'shared'],
    'type:feature': 'type:data',
  },
};

If those roles are broken, a linting error is raised:

Screenshot 2023-06-13 at 17 50 41

4.1. Nested Paths

The configuration can be simplified by nesting the paths. Multiple levels are allowed.

import { SheriffConfig } from '@softarc/sheriff-core';

export const sheriffConfig: SheriffConfig = {
  version: 1,
  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', 'shared'],
    'domain:customers': ['domain:customers', 'shared'],
    'type:feature': 'type:data',
  },
};

4.2. Placeholders

For repeating patterns, one can also use placeholders with the syntax <name>:

import { SheriffConfig } from '@softarc/sheriff-core';

export const sheriffConfig: SheriffConfig = {
  version: 1,
  tagging: {
    'src/app': {
      holidays: {
        '<type>': ['domain:holidays', 'type:<type>'],
      },
      customers: {
        '<type>': ['domain:customers', 'type:<type>'],
      },
    },
  },
  depRules: {
    'domain:holidays': ['domain:holidays', 'shared'],
    'domain:customers': ['domain:customers', 'shared'],
    'type:feature': 'type:data',
  },
};

Since placeholders are allowed on all levels, we could have the following improved version:

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', 'shared'],
    'domain:customers': ['domain:customers', 'shared'],
    'type:feature': 'type:data',
  },
};

4.3. DepRules Functions & Wildcards

The values of the dependency rules can also be implemented as functions. The names of the tags can include wildcards.

So an optimised version would look like this:

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, 'shared'],
    'type:feature': 'type:data',
  },
};

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

5. Integrating Sheriff into large Projects

It is usually not possible to modularise an existing codebase at once. Instead, we have to integrate Sheriff incrementally.

The recommended approach is to pick one directory and set it as a module. Let's call that module shared. All files from the outside have to import now from the module's index.ts.

Once a single module does exist, Sheriff automatically assigned the root module to the rest. If files from shared need to import from root, an index.ts in root is required as well.

We can disable the deep import checks for the root module by setting excludeRoot in sheriff.config.ts to true.

Example:

export const config: SheriffConfig = {
  excludeRoot: true,
  tagging: {
    'src/shared': 'shared',
  },
  depRules: {
    '*': anyTag,
  },
};

Note, that dependency rules are also disabled.

  1. Once shared is complete, create a new module and do the same.
  2. You can wait to configure the dependency rules at the end or together with the modules incrementally.

6. Planned Features

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

  • Editor
  • Print modules with their tags
  • Testing Dependency Rules
  • Angular Schematic
  • Feature Shell: It shouldn't be necessary to create a feature subdirectory for a domain, since feature has access to everything
  • Dependency rules for node_modules
  • CLI as alternative to eslint
  • Find cyclic dependencies
  • Find unused files
  • TestCoverage 100%
  • UI for Configuration
  • Migration from Nx (automatic)
  • Cache