eslint-plugin-fsd-lint
Version:
ESLint plugin for enforcing Feature-Sliced Design (FSD) architecture
686 lines (547 loc) • 19.6 kB
Markdown
# 🚀 eslint-plugin-fsd-lint
[](https://www.npmjs.com/package/eslint-plugin-fsd-lint)
[](https://www.npmjs.com/package/eslint-plugin-fsd-lint)
[](https://bundlephobia.com/package/eslint-plugin-fsd-lint)
[](https://github.com/effozen/eslint-plugin-fsd-lint/blob/main/LICENSE)
[English](README.md) | [한국어](README.ko.md)
> ESLint 9+ plugin for enforcing Feature-Sliced Design rules with Flat Config support.
## Overview
`eslint-plugin-fsd-lint` helps you keep a Feature-Sliced Design codebase predictable as it grows.
It focuses on the rules that usually start drifting first:
- layer boundaries
- public API discipline
- same-layer slice isolation
- relative import hygiene
- UI/business-logic separation
- stable import ordering
It is built for modern ESLint setups and works with:
- ESLint 9+
- Flat Config
- JavaScript and TypeScript projects
- Windows and Unix-style paths
- both `@shared/...` and `@/shared/...` aliases
- `tsconfig.json` / `jsconfig.json` `paths` and `baseUrl` mappings
- custom folder naming like `1_app`, `2_pages`, `5_features`
- custom source roots through `rootPath`
## Installation
Install the plugin and make sure ESLint 9+ is available in your project.
```shell
npm install --save-dev eslint eslint-plugin-fsd-lint
```
Or with pnpm:
```shell
pnpm add -D eslint eslint-plugin-fsd-lint
```
## Quick Start
### Recommended Preset
Use the built-in preset if you want sensible defaults with minimal setup.
```js
import fsdPlugin from "eslint-plugin-fsd-lint";
export default [fsdPlugin.configs.recommended];
```
### Strict Preset
Use `strict` when you want all architectural checks to fail the build. This
preset also requires slice-level public API imports and applies public API
enforcement to `shared`.
```js
import fsdPlugin from "eslint-plugin-fsd-lint";
export default [fsdPlugin.configs.strict];
```
### Base Preset
Use `base` if you are gradually adopting FSD rules in an existing codebase.
```js
import fsdPlugin from "eslint-plugin-fsd-lint";
export default [fsdPlugin.configs.base];
```
<details>
<summary>Recommended preset configuration</summary>
```js
{
plugins: {
fsd: fsdPlugin,
},
rules: {
"fsd/forbidden-imports": "error",
"fsd/no-cross-slice-dependency": "error",
"fsd/no-global-store-imports": "error",
"fsd/no-public-api-sidestep": "error",
"fsd/no-relative-imports": "error",
"fsd/no-ui-in-business-logic": "error",
"fsd/ordered-imports": "warn",
},
}
```
</details>
<details>
<summary>Strict preset configuration</summary>
```js
{
plugins: {
fsd: fsdPlugin,
},
rules: {
"fsd/forbidden-imports": "error",
"fsd/no-cross-slice-dependency": "error",
"fsd/no-global-store-imports": "error",
"fsd/no-public-api-sidestep": [
"error",
{
publicApi: {
allowSegmentImports: false,
enforceShared: true,
},
},
],
"fsd/no-relative-imports": "error",
"fsd/no-ui-in-business-logic": "error",
"fsd/ordered-imports": "error",
},
}
```
</details>
<details>
<summary>Base preset configuration</summary>
```js
{
plugins: {
fsd: fsdPlugin,
},
rules: {
"fsd/forbidden-imports": "warn",
"fsd/no-cross-slice-dependency": "warn",
"fsd/no-global-store-imports": "error",
"fsd/no-public-api-sidestep": "warn",
"fsd/no-relative-imports": "off",
"fsd/no-ui-in-business-logic": "error",
"fsd/ordered-imports": "warn",
},
}
```
</details>
### Manual Configuration
Use manual configuration when you want fine-grained control over each rule.
```js
import fsdPlugin from "eslint-plugin-fsd-lint";
export default [
{
plugins: {
fsd: fsdPlugin,
},
rules: {
"fsd/forbidden-imports": "error",
"fsd/no-relative-imports": "error",
"fsd/no-public-api-sidestep": "error",
"fsd/no-cross-slice-dependency": "error",
"fsd/no-ui-in-business-logic": "error",
"fsd/no-global-store-imports": "error",
"fsd/ordered-imports": "warn",
},
},
];
```
## What The Rules Enforce
- **Layer direction**: higher layers cannot import from lower-priority layers they should not know about.
- **Slice isolation**: one slice cannot reach directly into another slice in the same layer.
- **Public APIs**: other slices should import through `index.ts`, `index.js`, or allowed segment-level public entry points.
- **Stable imports**: cross-slice and cross-layer imports should use aliases instead of brittle relative paths.
- **Business logic purity**: model/api/lib code should not pull UI code into places where it does not belong.
- **Readable import blocks**: FSD imports are grouped in a predictable order.
## Common Configuration Options
Several rules support the same core options.
| Option | Purpose | Example |
| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- |
| `alias` | Defines the import alias format for your project. | `{ value: '@', withSlash: false }` |
| `rootPath` | Tells the plugin where the FSD tree starts in the absolute file path. Useful for monorepos or nested apps. | `'/apps/web/src/'` |
| `tsconfigPath` | Points the resolver at a specific `tsconfig.json` / `jsconfig.json`. Optional — when omitted, the plugin walks up from the linted file to find one. | `'./tsconfig.json'` |
| `folderPattern` | Supports numbered or customized layer directory names. | `{ enabled: true, regex: '^(\\d+_)?(.*)', extractionGroup: 2 }` |
| `testFilesPatterns` | Allows test files to bypass specific architectural rules. | `['**/*.test.*', '**/*.spec.*']` |
| `ignoreImportPatterns` | Skips selected import paths for a rule. | `['/types$', '^virtual:']` |
### `rootPath` Example
If your project does not start directly at `/src/`, set `rootPath` so file-path based rules can still resolve the current layer and slice correctly.
```js
import fsdPlugin from "eslint-plugin-fsd-lint";
export default [
{
plugins: {
fsd: fsdPlugin,
},
rules: {
"fsd/forbidden-imports": ["error", { rootPath: "/apps/storefront/src/" }],
"fsd/no-cross-slice-dependency": [
"error",
{ rootPath: "/apps/storefront/src/" },
],
"fsd/no-ui-in-business-logic": [
"error",
{ rootPath: "/apps/storefront/src/" },
],
},
},
];
```
Typical `rootPath` values:
- `'/src/'`
- `'/apps/web/src/'`
- `'/packages/admin/src/'`
- `'/src/root/'`
The value should match a stable segment inside the absolute path ESLint sees for your files.
### Filesystem-aware Import Resolution
Layer and slice information is now derived from the resolved file path, not from the literal text of an import. Each rule resolves an import in this order:
1. `tsconfig.json` / `jsconfig.json` `paths` (longest pattern wins, multiple targets fall through until one exists on disk)
2. `tsconfig.json` `baseUrl`
3. The configured FSD `alias` against `rootPath`
4. Relative path resolution against the importing file
5. Legacy string parsing (used when no real file can be resolved, for example when an alias is declared in a bundler config but not in `tsconfig.json`)
This is what closes the same-slice false positives that previously appeared with aliased imports. For example, given a `tsconfig.json` that maps `@articles/*` to `src/pages/articles/*`, the following stays inside the `articles` slice and is no longer reported:
```ts
// src/pages/articles/ui/articles-pending-page.tsx
import { articleSections } from "@articles/api/queries";
import { ArticlesLayout } from "@articles/ui/articles-page";
```
By default the resolver walks upward from the linted file looking for `tsconfig.json` or `jsconfig.json`. When that is not the file you want, point each rule at an explicit one with `tsconfigPath`:
```js
import fsdPlugin from "eslint-plugin-fsd-lint";
export default [
{
plugins: {
fsd: fsdPlugin,
},
rules: {
"fsd/forbidden-imports": [
"error",
{
rootPath: "/apps/web/src/",
tsconfigPath: "./apps/web/tsconfig.json",
},
],
"fsd/no-cross-slice-dependency": [
"error",
{
rootPath: "/apps/web/src/",
tsconfigPath: "./apps/web/tsconfig.json",
},
],
"fsd/no-public-api-sidestep": [
"error",
{
rootPath: "/apps/web/src/",
tsconfigPath: "./apps/web/tsconfig.json",
},
],
},
},
];
```
Resolution falls back to legacy string parsing whenever a target file cannot be located, so projects that rely on bundler-only aliases continue to work as before.
### Next.js App Router and Custom Layer Folder Names
Next.js App Router projects can keep Next's route files in `src/app` and use FSD layers for the rest of the application.
The recommended setup is to keep the standard FSD layer folder names (`app`, `pages`, `widgets`, `features`, `entities`, `shared`). Without a `layers` override, the plugin uses its built-in FSD folder names (`app`, `processes`, `pages`, `widgets`, `features`, `entities`, `shared`) by default, so existing projects that still use `processes` remain supported. Custom layer folder names are supported for existing projects, migrations, or framework constraints, but they are not recommended as a default choice because they make FSD conventions less recognizable.
If your project uses `screens` instead of the FSD `pages` folder name, map the `pages` layer to the `screens` folder with `layers.pages.pattern` in `eslint.config.js`. The same pattern works for other layer folder names as well. Keep the canonical FSD layer keys in the config and set each layer's `pattern` to the folder name used by your project.
```js
import fsdPlugin from "eslint-plugin-fsd-lint";
const fsdOptions = {
rootPath: "/src/",
alias: {
value: "@",
withSlash: true,
},
layers: {
app: {
pattern: "app",
},
pages: {
pattern: "screens",
},
widgets: {
pattern: "blocks",
},
features: {
pattern: "actions",
},
entities: {
pattern: "domain",
},
shared: {
pattern: "common",
},
},
ignoreImportPatterns: ["\\.css$"],
};
export default [
{
files: ["src/**/*.{js,jsx,ts,tsx}"],
plugins: {
fsd: fsdPlugin,
},
rules: {
"fsd/forbidden-imports": ["error", fsdOptions],
"fsd/no-cross-slice-dependency": ["error", fsdOptions],
"fsd/no-public-api-sidestep": ["error", fsdOptions],
"fsd/no-relative-imports": [
"error",
{
...fsdOptions,
allowSameSlice: true,
},
],
"fsd/no-global-store-imports": "error",
"fsd/ordered-imports": ["warn", fsdOptions],
},
},
];
```
```text
src/
├── app/
│ ├── layout.tsx
│ ├── page.tsx
│ └── api/health/route.ts
├── screens/
│ └── dashboard/
├── blocks/
├── actions/
├── domain/
└── common/
```
Options such as `allowedToImport`, `excludeLayers`, `publicApi.enforceForLayers`, and `ordered-imports.customOrder` still use the canonical layer keys (`app`, `pages`, `widgets`, `features`, `entities`, `shared`). The `pattern` value is the folder/import segment used on disk.
For new projects, prefer the standard layer folder names unless there is a clear reason to do otherwise.
## Advanced Configuration
```js
import fsdPlugin from "eslint-plugin-fsd-lint";
export default [
{
plugins: {
fsd: fsdPlugin,
},
rules: {
"fsd/forbidden-imports": [
"error",
{
rootPath: "/apps/web/src/",
alias: {
value: "@",
withSlash: false,
},
folderPattern: {
enabled: true,
regex: "^(\\d+_)?(.*)",
extractionGroup: 2,
},
},
],
"fsd/no-cross-slice-dependency": [
"error",
{
rootPath: "/apps/web/src/",
featuresOnly: false,
allowTypeImports: true,
},
],
"fsd/no-ui-in-business-logic": [
"error",
{
rootPath: "/apps/web/src/",
businessLogicLayers: ["model", "api", "lib"],
uiLayers: ["ui", "widgets", "features"],
},
],
"fsd/no-relative-imports": [
"error",
{
allowSameSlice: true,
allowTypeImports: false,
},
],
},
},
];
```
## Example Project Structure
```text
src/
├── app/
│ ├── providers/
│ ├── store/
│ └── index.ts
├── processes/
├── pages/
│ └── login/
│ ├── ui/
│ └── index.ts
├── widgets/
│ └── header/
│ ├── ui/
│ └── index.ts
├── features/
│ └── auth/
│ ├── api/
│ ├── lib/
│ ├── model/
│ ├── ui/
│ └── index.ts
├── entities/
│ └── user/
│ ├── model/
│ ├── ui/
│ └── index.ts
└── shared/
├── api/
├── config/
├── lib/
└── ui/
```
Numbered folders also work when `folderPattern` is enabled:
```text
src/
├── 1_app/
├── 2_pages/
├── 3_widgets/
├── 4_features/
├── 5_entities/
└── 6_shared/
```
## Rules
| Rule | What it protects | Key options |
| ------------------------------- | ---------------------------------------------------- | ----------------------------------------------------------------- |
| `fsd/forbidden-imports` | Layer direction and invalid layer-to-layer imports | `alias`, `rootPath`, `folderPattern`, `ignoreImportPatterns` |
| `fsd/no-relative-imports` | Relative imports across slices or layers | `allowSameSlice`, `allowTypeImports`, `ignoreImportPatterns` |
| `fsd/no-public-api-sidestep` | Direct access to internal modules | `publicApi`, `ignoreImportPatterns` |
| `fsd/no-cross-slice-dependency` | Direct dependencies between slices in the same layer | `rootPath`, `featuresOnly`, `allowTypeImports`, `excludeLayers` |
| `fsd/no-ui-in-business-logic` | UI imports inside model/api/lib code | `rootPath`, `uiLayers`, `businessLogicLayers`, `allowTypeImports` |
| `fsd/no-global-store-imports` | Direct store imports | `ignoreImportPatterns` |
| `fsd/ordered-imports` | Stable FSD import ordering | no options |
## Rule Examples
### `fsd/forbidden-imports`
```js
// features/auth/model/service.ts
// ✅ allowed
import { getUser } from "@entities/user";
import { Button } from "@shared/ui/Button";
// ❌ forbidden
import { LoginPage } from "@pages/login";
```
### `fsd/no-relative-imports`
```js
// features/auth/ui/LoginForm.tsx
// ✅ same-slice relative import
import { validateCredentials } from "../lib/validation";
// ❌ cross-layer relative import
import { store } from "../../../app/store";
// ✅ cross-layer alias import
import { store } from "@app/store";
```
### `fsd/no-public-api-sidestep`
```js
// ✅ public API import
import { authModel } from "@features/auth";
// ✅ segment-level public import
import { userModel } from "@entities/user/model";
// ❌ deep internal file import
import { authSlice } from "@features/auth/model/slice";
```
For stricter projects, segment-level public API imports can be disabled and the
`shared` layer can be included in public API enforcement:
```js
export default [
{
rules: {
"fsd/no-public-api-sidestep": [
"error",
{
publicApi: {
allowSegmentImports: false,
enforceShared: true,
},
},
],
},
},
];
```
### `fsd/no-cross-slice-dependency`
```js
// features/auth/model/service.ts
// ❌ direct dependency on another feature slice
import { profileService } from "@features/profile/model/service";
// ✅ dependency through lower layer
import { getProfile } from "@entities/profile";
```
### `fsd/no-ui-in-business-logic`
```js
// entities/user/model/user.ts
// ❌ business logic importing UI
import { Header } from "@widgets/header";
// ✅ business logic importing data or helpers
import { formatDate } from "@shared/lib/date";
```
### `fsd/no-global-store-imports`
```js
// ❌ direct store import
import { store } from "@app/store";
// ✅ use app wiring, hooks, or selectors instead
import { useSelector } from "react-redux";
```
### `fsd/ordered-imports`
```js
// Before
import { processPayment } from "@features/payment";
import { getUser } from "@entities/user";
import { formatCurrency } from "@shared/lib/currency";
import { useStore } from "@app/store";
// After --fix
import { useStore } from "@app/store";
import { processPayment } from "@features/payment";
import { getUser } from "@entities/user";
import { formatCurrency } from "@shared/lib/currency";
```
## Auto-fix Support
`fsd/ordered-imports` supports ESLint auto-fix.
```shell
npx eslint --fix .
```
## Troubleshooting
### My app lives inside a monorepo package
Use `rootPath` on rules that rely on the current file path:
```js
'fsd/forbidden-imports': ['error', { rootPath: '/packages/web/src/' }],
'fsd/no-cross-slice-dependency': ['error', { rootPath: '/packages/web/src/' }],
'fsd/no-ui-in-business-logic': ['error', { rootPath: '/packages/web/src/' }],
```
### I use `@/shared/...` instead of `@shared/...`
```js
'fsd/forbidden-imports': ['error', {
alias: {
value: '@',
withSlash: true,
},
}]
```
### I use numbered layer directories
```js
'fsd/forbidden-imports': ['error', {
folderPattern: {
enabled: true,
regex: '^(\\d+_)?(.*)',
extractionGroup: 2,
},
}]
```
## Development
```shell
npm run lint
npm test
```
## Contributing
Issues and pull requests are welcome. Please include tests and documentation updates for any new rule or behavior change.