Enforce Architectural Policies in JavaScript with ESLint

ptvty

ptvty

Posted on June 12, 2024

Enforce Architectural Policies in JavaScript with ESLint

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.

  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
Enter fullscreen mode Exit fullscreen mode
// internal/constants.js
export const Pi = 3.14;
Enter fullscreen mode Exit fullscreen mode
// internal/api.js
import { Pi } from "./constants.js";

export function getPi() {
    return Pi;
}
Enter fullscreen mode Exit fullscreen mode

Setting Up ESLint

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

npm i eslint -D
Enter fullscreen mode Exit fullscreen mode

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

"lint": "eslint"
Enter fullscreen mode Exit fullscreen mode

Enforcing Architectural Policies

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

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.'
            }
          ]
        }
      ]
    }
  }
];
Enter fullscreen mode Exit fullscreen mode

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.

// main.js
import { Pi } from "./internal/constants.js"; // ❌ Lint should fail
import { getPi } from "./internal/api.js";    // ✅ Lint should pass
Enter fullscreen mode Exit fullscreen mode

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

// 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
Enter fullscreen mode Exit fullscreen mode

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

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
    └── ...
Enter fullscreen mode Exit fullscreen mode
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'
            },
          ]
        }
      ]
    }, 
  },
];

Enter fullscreen mode Exit fullscreen mode

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!

💖 💪 🙅 🚩
ptvty
ptvty

Posted on June 12, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related