Слои разложены, папки названы, в описании проекта написано: «нижние слои не знают о верхних, чужой слайс — только через его публичный вход». Через полгода в проекте обнаруживается импорт из entities/order в shared/lib/format, а страница напрямую тянет внутренний файл чужой фичи.
Никто не саботировал. Просто в четверг вечером было нужно одно поле, редактор сам подставил путь, а на ревью коллега смотрел на логику, а не на строку импорта. Так и работают договорённости: держатся ровно до первого дедлайна.
Правило, которое нельзя обойти
Разница между договорённостью и правилом в том, что правило проверяет машина. Направление зависимостей во Feature-Sliced Design выглядит так:
shared → entities → features → widgets → pages → app
Слой может импортировать только те, что левее. Это описывается правилом линтера — и нарушение становится не замечанием, а красной сборкой:
// eslint.config.js
import importPlugin from "eslint-plugin-import";
const layers = ["shared", "entities", "features", "widgets", "pages", "app"];
export default [
{
plugins: { import: importPlugin },
rules: {
"import/no-restricted-paths": ["error", {
zones: layers.flatMap((layer, i) =>
layers.slice(i + 1).map((upper) => ({
target: `./src/${layer}`,
from: `./src/${upper}`,
message: `${layer} не может импортировать из ${upper}: зависимости идут только вниз`,
})),
),
}],
},
},
];
Сообщение здесь важнее правила. Разработчик, который увидит «entities не может импортировать из features: зависимости идут только вниз», поймёт, что делать. Тот, кто увидит просто «restricted path», пойдёт отключать правило комментарием.
Для FSD есть и готовый инструмент — steiger: он знает про слои, слайсы и сегменты и проверяет структуру целиком, включая случаи, которые правилом импорта не выразить.
Публичный вход у слайса
Второе правило, без которого слои расползаются: снаружи слайс доступен только через index.ts.
features/cancel-order/
├── index.ts ← единственная дверь наружу
├── ui/CancelButton.tsx
├── model/useCancelOrder.ts
└── lib/reasons.ts
// features/cancel-order/index.ts
export { CancelButton } from "./ui/CancelButton";
export type { CancelReason } from "./lib/reasons";
Смысл не в красоте, а в свободе менять внутренности. Пока наружу торчит один файл, можно переименовать useCancelOrder, разбить его на два хука, перенести файлы — снаружи никто не заметит. Как только соседи импортируют features/cancel-order/model/useCancelOrder, любое переименование становится правкой в десяти местах.
Запрет тоже ставится правилом: импорт разрешён на features/*, но не на features/*/**.
"no-restricted-imports": ["error", {
patterns: [{
group: ["@/features/*/*", "@/entities/*/*", "@/widgets/*/*"],
message: "Чужой слайс — только через его index.ts",
}],
}],
Циклы
Отдельная проверка — циклические зависимости: модуль A импортирует B, B импортирует A. Такой код обычно работает, пока не перестаёт: при некоторых порядках загрузки один из модулей оказывается ещё не инициализированным, и на ровном месте прилетает undefined.
"import/no-cycle": ["error", { maxDepth: 4 }],
Цикл почти всегда означает, что общая часть просится в отдельный модуль ниже по слоям. Правило подсказывает это в момент появления, а не через полгода, когда распутывать придётся десяток файлов.
Мёртвый код
Границы стерегут структуру, но есть ещё одна вещь, которая копится незаметно: файлы, экспорты и зависимости, которые больше никто не использует. Их не видно — они не ломают сборку и не мешают тестам, просто лежат.
Инструменты вроде knip строят граф от точек входа и показывают, до чего нельзя дойти:
Unused files (3)
src/features/old-filter/ui/FilterPanel.tsx
src/shared/lib/formatOldDate.ts
src/entities/promo/model/usePromo.ts
Unused exports (2)
src/shared/lib/date.ts: formatShortDate
src/shared/ui/Badge.tsx: BadgeSize
Unused dependencies (1)
lodash
Это стоит гонять в сборке — но не ошибкой сразу, а сначала отчётом: в первый запуск на живом проекте список будет длинным, и половина потребует разбора, а не удаления.
Чего линтер не проверяет
Стоит понимать границы приёма, иначе будет ложное спокойствие.
Линтер видит пути импортов, а не смысл. Он не поймает случай, когда shared/lib разросся до функции calculateOrderDiscount, знающей всё про предметную область: формально это нижний слой, никаких нарушений. Не поймает и обратное — фичу, которая по названию про заказы, а по содержимому про уведомления.
Такие вещи ловятся только чтением кода. Поэтому правило не отменяет ревью — оно освобождает ревью от механической части, чтобы человек смотрел на смысл, а не на строки импортов.
Как вводить в существующий проект
Включить всё сразу на живом проекте — верный способ получить триста ошибок и отключённое правило через неделю. Порядок, который работает:
- Включить правила в режиме предупреждения и посмотреть масштаб.
- Починить нарушения по одному слою за раз, начиная с самого нижнего:
sharedобычно самый грязный и самый важный. - Перевести починенное в ошибку и зафиксировать:
--max-warnings 0в сборке, чтобы новые нарушения не появлялись. - Остаток разгребать по мере того, как трогаешь те файлы по другим задачам.
Главное — шаг третий. Пока правило в предупреждениях, оно ничего не стережёт: список нарушений просто растёт вместе с проектом.
Коротко
- Договорённость об импортах держится до первого дедлайна; правило, которое проверяет машина, — держится всегда.
- Направление зависимостей по слоям описывается правилом линтера с внятным сообщением, а не строчкой в описании проекта.
- Чужой слайс доступен только через
index.ts— это даёт свободу менять внутренности, не трогая соседей. - Циклические зависимости запрещают отдельным правилом: они ломаются редко и в самый неудобный момент.
- Мёртвый код ищут анализатором по графу от точек входа; на живом проекте начинают с отчёта, а не с ошибки.
- Линтер видит пути, а не смысл: расползание ответственности внутри слоя ловится только чтением кода.
- В существующий проект правила вводят по слоям и обязательно доводят до ошибки — иначе они ничего не стерегут.
Что почитать дальше
- Feature-Sliced Design — сами слои, слайсы и сегменты.
- Подходы к архитектуре фронтенда — что было до FSD и почему выбирают именно его.
- Структура проекта — с чего начинается раскладка файлов.