Enforce Architectural Policies in JavaScript with ESLint
Tags: javascript, typescript, architecture, webdev
Published: 2024-06-12 11:46:34

As software projects grow in size and complexity, maintaining a clean and organized codebase becomes crucial. In large JavaScript and TypeScript codebases, ensuring consistent architectural practices can reduce technical debt and improve maintainability. ESLint's `no-restricted-imports` rule offers a powerful tool for enforcing these policies. This article explores how you can leverage this rule to create a cleaner codebase.

## ESLint and `no-restricted-imports`

ESLint is a popular linter for JavaScript and TypeScript, providing a framework for identifying and reporting on patterns found in the code. Among its many rules, `no-restricted-imports` allows developers to specify import patterns that should be avoided within the codebase. By configuring this rule, you can:

1. **Prevent the use of specific modules or files**: This is useful for deprecating old utilities or avoiding problematic dependencies. Although this is not the focus of this article, you can read more about this in the [ESLint Docs](https://eslint.org/docs/latest/rules/no-restricted-imports).

2. **Enforce architectural boundaries**: By restricting imports based on directory structure, you can enforce module boundaries, ensuring that the codebase complies with your architectural policy.

Some examples of such architectural policies include:

- Feature modules should not directly import from other feature modules.
- UI components should not directly access data layer modules, but only through a DAL module.
- Modules should not import anything from a service directory except items exported from `service/index.ts` as service gateway.
- Modules inside Services, Controllers, Providers, or any other conceptual components should honor their relations within the codebase.

Let's go over two simplified examples to grasp how it can help in your projects.

## Basic Example

Given the following tree, let's say you implemented an internal service with a dozen of interconnected files including classes, functions, variables, etc. Now you do not want other services to be able to import any of the bells and whistles from the service directory. They should only import provided items from the service's API, `api.js`.

```
src
├── main.js
└── internal
    ├── api.js
    ├── constants.js
    ├── bells.js  
    └── whistles.js
```


```javascript
// internal/constants.js
export const Pi = 3.14;
```

```javascript
// internal/api.js
import { Pi } from "./constants.js";

export function getPi() {
    return Pi;
}
```


### Setting Up ESLint

To get started, you need to have ESLint installed in your project. If you haven't already:

```
npm i eslint -D
```

Add a new script to your `package.json` for convenience:

```
"lint": "eslint"
```

### Enforcing Architectural Policies

Next, configure your `eslint.config.js` file to include the `no-restricted-imports` rule:

```javascript
export default [
  {
    rules: {
      'no-restricted-imports': [
        'error',
        {
          patterns: [
            {
              group: ['*/internal/**', '!*/internal/api.js'],
              message: 'Do not import from internal service directly. Use the public API instead.'
            }
          ]
        }
      ]
    }
  }
];
```

Strings you see in the `group` array follow gitignore syntax, allowing you to include and exclude files and directories as needed.

### Results

You should expect an error if you import directly from any file in the `internal` directory except `api.js`.

```javascript
// main.js
import { Pi } from "./internal/constants.js"; // ❌ Lint should fail
import { getPi } from "./internal/api.js";    // ✅ Lint should pass
```

Given a non-compliant usage you should get and error when you run `npm run lint`:

```javascript
// main.js
import { Pi } from "./internal/constants.js";

> eslint
src\main.js
  1:1  error  './internal/constants.js' import is restricted from being used by a pattern. 
Do not import internal modules directly. Use the public API instead  no-restricted-imports
```

## Hierarchal Architecture Example 

For large codebases, it's crucial to enforce architectural boundaries. For example, you might want to ensure that a low-level module does not import from a high-level module. Additionally, you want to ensure that a high-level module does not import directly from a low-level module but through a mid-level module.


![Hierarchy policy](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/og7oopdrr5wqv8yt1l1f.png)

### Enforcing Architectural Policies

To achieve these policies, we need to use the `files` and `excludes` properties in ESLint's flat config file and combine them with group patterns in `no-restricted-imports`:

```
hierarchy
├── high
|   ├── ui.js
|   └── ...
├── mid
|   ├── api.js
|   └── ...
└── low
    ├── constants.js
    └── ...
```


```javascript
export default [
  {
    ignores: ['src/hierarchy/mid/**/*.js'],
    rules: {
      'no-restricted-imports': [
        'error',
        {
          patterns: [
            {
              group: ['**/low/**'],
              message: 'Low level modules can only be imported in mid level modules'
            },
          ]
        }
      ]
    }, 
  },

  {
    ignores: ['src/hierarchy/high/**/*.js'],
    rules: {
      'no-restricted-imports': [
        'error',
        {
          patterns: [
            {
              group: ['**/mid/**'],
              message: 'Mid level modules can only be imported in high level modules'
            },
          ]
        }
      ]
    }, 
  },

  {
    files: ['src/hierarchy/mid/**/*.js', 'src/hierarchy/low/**/*.js'],
    rules: {
      'no-restricted-imports': [
        'error',
        {
          patterns: [
            {
              group: ['**/high/**'],
              message: 'High level modules can not be imported in low or mid level modules'
            },
          ]
        }
      ]
    }, 
  },
];

```

### Results

Here we are exporting 3 instructions with similar rules. In this configuration:

1. Any attempt to import from any of the files inside the `low` directory will be flagged, except by files in the `mid` directory.

2. Any attempt to import from any of the files inside the `mid` directory will be flagged, except by files in the `high` directory.

3. Any attempt by files inside `low` or `mid` directory to import from any of the files inside the `high` directory will be flagged.

## Conclusion

Enforcing architectural policies in your codebase is essential for maintaining consistency, reducing technical debt, and ensuring long-term maintainability. ESLint's no-restricted-imports rule is an effective tool to help achieve these goals. By configuring this rule, you can:

- Prevent unwanted dependencies by restricting specific import patterns.
- Ensure that all developers adhere to the defined architectural boundaries.
- Simplify code reviews by automating the enforcement of coding standards.

Involving your development team in defining these rules ensures they are practical and aligned with best practices. Regularly review and update your ESLint configuration as your project evolves to keep your codebase robust and well-organized.

By leveraging ESLint's `no-restricted-imports` rule, you can create a cleaner, more maintainable codebase, ultimately improving the overall quality of your software projects.

### Let's Talk!

I wrote this article as my first post on DEV, to share a practical approach to maintaining a clean and organized codebase, which is something I've found crucial in my own projects. I hope you find it helpful in your development work. If you have any questions or thoughts, please leave them in the comments. I'd love to hear your feedback and continue the discussion!