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
And the dependency direction should basically go from top to bottom:
App
↓
Pages
↓
Widgets
↓
Features
↓
Entities
↓
Shared
For example:
-
Pagescan useWidgets -
Featurescan useEntities -
Entitiescan useShared
But:
-
Sharedshouldn't import fromFeatures -
Entitiesshouldn't import fromWidgets -
Featuresshouldn't import fromPages
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";
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/*" },
];
At this point ESLint can understand that:
src/Features/Auth
belongs to features, while:
src/Entities/User
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: [
// ...
],
},
],
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"],
},
},
},
}
And entities can only depend on shared:
{
from: {
element: {
type: "entities",
},
},
allow: {
to: {
element: {
type: ["shared"],
},
},
},
}
So this is fine:
// src/Features/Profile/ui/Profile.tsx
import { UserAvatar } from "@/Entities/User";
import { Button } from "@/Shared/UI";
But this isn't:
// src/Entities/User/ui/UserCard.tsx
import { EditProfile } from "@/Features/EditProfile";
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";
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";
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/*"]
}
}
So instead of something like:
import { Button } from "../../../Shared/UI/Button";
we can write:
import { Button } from "@/Shared/UI/Button";
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 @/.",
},
],
},
],
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";
But it doesn't reject every possible relative import between different slices.
For example:
import { something } from "../OtherFeature";
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";
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:
Sharedis a lower architectural layer and shouldn't depend onWidgets.
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}"],
}
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";
Instead of:
// ❌ Directly into the slice's internals
import { UserAvatar } from "@/Entities/User/ui/UserAvatar";
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)