DEV Community

Przemyslaw Jan Beigert
Przemyslaw Jan Beigert

Posted on

Modular monolith with the eslint-boundries

Intro

If your monolith JS/TS application is growing and you're afraid of losing control, this article might be for you.

Problem

Everyone saw something like this:

src/
  components/
  hooks/
  storage/
Enter fullscreen mode Exit fullscreen mode

In smaller projects, it works but with bigger one, it might be annoying. Let's separate the first module from that:

src/
  components/
  hooks/
  storage/
  modules/
    moduleA/
        components/
        hooks/
        storage/
Enter fullscreen mode Exit fullscreen mode

Nice.

All of the logic responsible for moduleA is in one place and the rest of the categories are little bit smaller. So the natural next step will be moduleB:

src/
  ...
  modules/
    moduleA/
    moduleB/
Enter fullscreen mode Exit fullscreen mode

Nice?

Not really, modular monolith is not about the arrangement of files, it's about hard separation. ModuleA should be independent of moduleB. It this case: (one repo and one app) files from moduleA may import stuff from moduleB (and otherwise).

Boundries

Let's apply eslint-boundries plugin.

yarn add eslint-plugin-boundaries @typescript-eslint/parser @typescript-eslint/parser --dev
Enter fullscreen mode Exit fullscreen mode
// eslintrc
{
 "plugins": [
  "boundaries"
  ]
}
Enter fullscreen mode Exit fullscreen mode

Then we have to define elements:

 // eslintrc
{
  "settings": {
    "boundaries/elements": [
     {
        "type": "module-a",
        "pattern": "src/modules/moduleA/**",
        "mode": "file"
      },
       {
        "type": "module-b",
        "pattern": "src/modules/moduleB/**",
        "mode": "file"
      },
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

All of the files from moduleA are tagged as module-a and moduleB => moduleA

Rules

Now we're able to prevent moduleA from importing from moduleB:

// eslintrc
{
  "rules": {
     "boundaries/element-types": [2, {
        "default": "allow",
        "rules": [
          {
            "from": "module-a",
            "disallow": ["module-b"]
          },
          {
            "from": "module-b",
            "disallow": ["module-a"]
          },
       ]
    }]
  }
}
Enter fullscreen mode Exit fullscreen mode

Now when you try to an illegal import eslint will throw an error: Importing elements of type 'moduleA' is not allowed in elements of type 'module-b'. Disallowed in rule 1

import { } from '../moduleA/fileA';
//  Importing elements of type 'moduleA' is not allowed in elements of type 'module-b'. Disallowed in rule 1

export const bFn = () => {}
Enter fullscreen mode Exit fullscreen mode

Exceptions

What about the case when moduleA needs to import some constant value, like name or URL? Eslint will throw an error, however, this behavior is not against the modular monolith approach. Creating copy of these constants also doesn't sound like a good idea.

In my projects always exists one exception:

{
    "boundaries/ignore": ["**/*.const.ts"]
}
Enter fullscreen mode Exit fullscreen mode

*.const.ts files contain only constant values (no classes of functions). Also, not-primitive, exported objects have forced immutability by as const syntax.

Full example on my sandboxes repo link

Conclusion

Eslint-boundaries plugin is a great way to start the migration to modular-monolith approach. But don't rush. Migration from the monolith is not an easy task. You may make a lot of mistakes and that's fine. Don't try to create many elements, and rules at once. Instead, separate one and wait some time. Wrong boundaries are much worse than too few.

Don't fix problems that should not appear. Of course, utils should not import hooks, and hooks shouldn't import components. But there's no need to declare boundaries for that, focus on more important rules like forcing already-defined borders between modules.

Top comments (0)