DEV Community

Cover image for ESLint as an Architecture Tool, Not Just a Code Style Tool
Aleksandra Dudkina
Aleksandra Dudkina

Posted on Originally published at aleksandradudkina.hashnode.dev

ESLint as an Architecture Tool, Not Just a Code Style Tool

For a long time, I mostly thought about ESLint as something that tells me things like "You have an unused variable." or "This import is wrong."

Useful, but mostly about code quality and consistency.

Then I started using it for something much more interesting:

Preventing developers from breaking the architecture of the project.

In one of my current React projects, we use Feature-Sliced Design.

The project has layers like:

App
Pages
Widgets
Features
Entities
Shared
Enter fullscreen mode Exit fullscreen mode

And the dependency direction should basically go from top to bottom:

App
 ↓
Pages
 ↓
Widgets
 ↓
Features
 ↓
Entities
 ↓
Shared
Enter fullscreen mode Exit fullscreen mode

For example:

  • Pages can use Widgets
  • Features can use Entities
  • Entities can use Shared

But:

  • Shared shouldn't import from Features
  • Entities shouldn't import from Widgets
  • Features shouldn't import from Pages

There is another important rule: slices on the same layer shouldn't depend on each other either. For example, one feature shouldn't directly import another feature.

This sounds easy enough, until the project grows.

Architecture rules written in documentation are easy to break

You can put the architecture into a README.

You can explain it during onboarding.

You can mention it during code review.

But somebody will still eventually write:

import { SomeWidget } from "@/Widgets/SomeWidget";
Enter fullscreen mode Exit fullscreen mode

inside a feature.

Not because they intentionally decided to break the architecture. Usually just because the component exists, importing it solves the problem, and TypeScript doesn't complain. And unless someone notices it during review, it gets merged.

If a rule is important enough that we expect every developer to follow it, I would rather make the project enforce it automatically.

Teaching ESLint about FSD layers

For this, I'm currently using eslint-plugin-boundaries.

It's a general-purpose ESLint plugin for enforcing architectural boundaries. It doesn't know about FSD by default, so we need to describe our project structure ourselves.

First, I define which folders represent which architectural elements:

const FSD_ELEMENTS = [
  { type: "app", pattern: "src/App/*" },
  { type: "pages", pattern: "src/Pages/*" },
  { type: "widgets", pattern: "src/Widgets/*" },
  { type: "features", pattern: "src/Features/*" },
  { type: "entities", pattern: "src/Entities/*" },
  { type: "shared", pattern: "src/Shared/*" },
];
Enter fullscreen mode Exit fullscreen mode

At this point ESLint can understand that:

src/Features/Auth
Enter fullscreen mode Exit fullscreen mode

belongs to features, while:

src/Entities/User
Enter fullscreen mode Exit fullscreen mode

belongs to entities.

More precisely, each matching directory is recognized as an architectural element of the corresponding type.

That distinction matters because the plugin can treat imports inside one element differently from imports between separate elements.

But knowing the layers is only half of the job. We still need to describe which dependencies are allowed.

Default: disallow everything

"boundaries/dependencies": [
  "error",
  {
    default: "disallow",
    policies: [
      // ...
    ],
  },
],
Enter fullscreen mode Exit fullscreen mode

Instead of saying:

Here are some imports that are forbidden.

we say:

Every dependency between recognized architectural elements is forbidden unless we explicitly allow it.

There is an important detail here.

default: "disallow" doesn't mean absolutely every import is forbidden.

By default, boundaries/dependencies doesn't check internal imports within the same element, and it skips dependencies whose targets aren't recognized as architectural elements.

So the rule is strict about the dependencies it recognizes and evaluates, but it doesn't automatically cover every possible import in the project.

Then we describe the valid dependency direction.

For example, features can depend on entities and shared:

{
  from: {
    element: {
      type: "features",
    },
  },
  allow: {
    to: {
      element: {
        type: ["entities", "shared"],
      },
    },
  },
}
Enter fullscreen mode Exit fullscreen mode

And entities can only depend on shared:

{
  from: {
    element: {
      type: "entities",
    },
  },
  allow: {
    to: {
      element: {
        type: ["shared"],
      },
    },
  },
}
Enter fullscreen mode Exit fullscreen mode

So this is fine:

// src/Features/Profile/ui/Profile.tsx

import { UserAvatar } from "@/Entities/User";
import { Button } from "@/Shared/UI";
Enter fullscreen mode Exit fullscreen mode

But this isn't:

// src/Entities/User/ui/UserCard.tsx

import { EditProfile } from "@/Features/EditProfile";
Enter fullscreen mode Exit fullscreen mode

The second import points upward in the architecture.

And now this isn't something we have to remember during review. ESLint rejects it.

The complete dependency direction

In my project, the allowed dependencies between layers look like this:

Layer Can import from
App Pages, Widgets, Features, Entities, Shared
Pages Widgets, Features, Entities, Shared
Widgets Features, Entities, Shared
Features Entities, Shared
Entities Shared
Shared Shared

This makes the dependency graph predictable.

A lower layer doesn't suddenly depend on a higher one. But the layer direction isn't the only rule we're enforcing.

Because each slice is recognized as a separate element, dependencies between different slices on the same layer are also rejected.

For example:

// src/Features/Profile/ui/Profile.tsx

import { LoginForm } from "@/Features/Auth";
Enter fullscreen mode Exit fullscreen mode

This is forbidden. Even though both modules belong to the Features layer, they are different slices and shouldn't depend on each other directly.

On the other hand, internal imports within the same slice are allowed:

// src/Features/Profile/ui/Profile.tsx

import { useProfile } from "../model/useProfile";
Enter fullscreen mode Exit fullscreen mode

That makes sense. A slice needs to be able to use its own internal modules.

App and Shared are special in FSD because they don't follow the usual slice structure. Their segments can interact with one another, which is why the configuration explicitly allows dependencies between recognized Shared elements.

And these rules are useful for more than just keeping an FSD diagram pretty. They also help avoid situations where something that looked reusable becomes tightly coupled to one particular feature.

Enforce how imports are written

Architecture isn't only about what you import. Sometimes it's also useful to control how you import it.

The project has an alias:

{
  "paths": {
    "@/*": ["./src/*"]
  }
}
Enter fullscreen mode Exit fullscreen mode

So instead of something like:

import { Button } from "../../../Shared/UI/Button";
Enter fullscreen mode Exit fullscreen mode

we can write:

import { Button } from "@/Shared/UI/Button";
Enter fullscreen mode Exit fullscreen mode

To make this consistent, I use no-restricted-imports:

"no-restricted-imports": [
  "error",
  {
    patterns: [
      {
        regex:
          "^\\.\\./(?:\\.\\./)*(?:App|Pages|Widgets|Features|Entities|Shared)(?:/|$)",
        caseSensitive: true,
        message:
          "Для импорта из другого слоя или слайса используй alias @/.",
      },
    ],
  },
],
Enter fullscreen mode Exit fullscreen mode

This rejects relative imports that navigate directly to another named FSD layer through one or more ../ segments.

For example:

import { Button } from "../../../Shared/UI/Button";
Enter fullscreen mode Exit fullscreen mode

But it doesn't reject every possible relative import between different slices.

For example:

import { something } from "../OtherFeature";
Enter fullscreen mode Exit fullscreen mode

doesn't match this particular regex.

That doesn't necessarily mean the import is allowed. If it crosses between recognized architectural elements, boundaries/dependencies can still reject it.

So these two rules solve slightly different problems.

One rule answers:

Are these two parts of the architecture allowed to depend on each other?

The other answers:

Are we using the expected import style for paths that navigate to another FSD layer?

I like the combination, even though the alias rule isn't a complete solution for enforcing public imports.

Why not just use TypeScript for this?

Because TypeScript solves a different problem.

TypeScript is perfectly happy with:

import { SomeWidget } from "@/Widgets/SomeWidget";
Enter fullscreen mode Exit fullscreen mode

inside Shared, as long as the module exists and the types are correct. From TypeScript's point of view, there is nothing wrong with it. But architecturally, there is.

The compiler knows about:

  • types
  • modules
  • paths

It doesn't automatically know that:

Shared is a lower architectural layer and shouldn't depend on Widgets.

That's project-specific knowledge and we have to encode it somewhere. ESLint is a pretty convenient place to do that.

What about tests?

In my current configuration, tests are excluded from the architectural boundaries checks:

{
  files: ["src/**/*.{ts,tsx}"],
  ignores: ["src/**/*.test.{ts,tsx}"],
}
Enter fullscreen mode Exit fullscreen mode

I intentionally don't apply exactly the same dependency restrictions there. Tests often need to compose things differently from production code.

For example, a test might import:

  • a feature
  • an entity
  • some shared test helpers

all in the same file.

One small distinction: tests are excluded from boundaries/dependencies, but they still follow the general no-restricted-imports rule because that rule is configured separately.

So tests have more flexibility in terms of architectural dependencies, but not a complete exemption from import-style checks.

Does this enforce all FSD rules?

Not really. My current configuration mainly enforces dependencies between recognized architectural elements. But FSD has other conventions too.

One important example is public APIs.

In FSD, other slices should normally access a slice through its public API, usually an index.ts file.

For example:

// ✅ Through the public API
import { UserAvatar } from "@/Entities/User";
Enter fullscreen mode Exit fullscreen mode

Instead of:

// ❌ Directly into the slice's internals
import { UserAvatar } from "@/Entities/User/ui/UserAvatar";
Enter fullscreen mode Exit fullscreen mode

Both imports point to the correct layer, so both can pass my current boundaries/dependencies rule. But the second one bypasses the slice's public API. That means enforcing dependency direction isn't the same as enforcing every FSD convention.

eslint-plugin-boundaries has additional rules, such as boundaries/no-private, that can help protect internal modules. But those protections need to be configured separately.

There is also the question of unrecognized files. If a file doesn't match any declared architectural element, the dependency rule may not enforce the restrictions I expect. So it's important to make sure the patterns actually cover the project structure, rather than assuming that default: "disallow" makes everything safe.

For now, my primary goal is to prevent invalid dependencies between FSD layers and slices and that's what this configuration is designed to do.

This changes code review too

One unexpected benefit is that code review gets a little better. Without automated architecture rules, reviewers have to spend attention on things like:

Is this layer allowed to import from that layer?

Should this be using the alias?

Are we accidentally creating a dependency in the wrong direction?

Those are useful checks, but they're also deterministic.

Those are useful checks, but they're also deterministic. So it’s better to automate them and save code review time for more meaningful discussions.

Final takeaway

In my current project, ESLint knows:

  • which folders represent FSD architectural elements
  • which layers may depend on each other
  • that separate slices on the same layer shouldn't import each other
  • which dependency directions are forbidden
  • which relative imports should use the project alias

It doesn't enforce every FSD convention, and it doesn't automatically protect files it can't classify. But it does turn a useful set of architectural decisions into checks that run automatically, so an architectural mistake can fail before the code even reaches review.

And I think that's a good rule of thumb in general:

If an architectural rule can be checked automatically, don't rely on developers remembering it.


References:

Top comments (0)