← назад к разделу

Слои разложены, папки названы, в описании проекта написано: «нижние слои не знают о верхних, чужой слайс — только через его публичный вход». Через полгода в проекте обнаруживается импорт из 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, знающей всё про предметную область: формально это нижний слой, никаких нарушений. Не поймает и обратное — фичу, которая по названию про заказы, а по содержимому про уведомления.

Такие вещи ловятся только чтением кода. Поэтому правило не отменяет ревью — оно освобождает ревью от механической части, чтобы человек смотрел на смысл, а не на строки импортов.

Как вводить в существующий проект

Включить всё сразу на живом проекте — верный способ получить триста ошибок и отключённое правило через неделю. Порядок, который работает:

  1. Включить правила в режиме предупреждения и посмотреть масштаб.
  2. Починить нарушения по одному слою за раз, начиная с самого нижнего: shared обычно самый грязный и самый важный.
  3. Перевести починенное в ошибку и зафиксировать: --max-warnings 0 в сборке, чтобы новые нарушения не появлялись.
  4. Остаток разгребать по мере того, как трогаешь те файлы по другим задачам.

Главное — шаг третий. Пока правило в предупреждениях, оно ничего не стережёт: список нарушений просто растёт вместе с проектом.

Коротко

  • Договорённость об импортах держится до первого дедлайна; правило, которое проверяет машина, — держится всегда.
  • Направление зависимостей по слоям описывается правилом линтера с внятным сообщением, а не строчкой в описании проекта.
  • Чужой слайс доступен только через index.ts — это даёт свободу менять внутренности, не трогая соседей.
  • Циклические зависимости запрещают отдельным правилом: они ломаются редко и в самый неудобный момент.
  • Мёртвый код ищут анализатором по графу от точек входа; на живом проекте начинают с отчёта, а не с ошибки.
  • Линтер видит пути, а не смысл: расползание ответственности внутри слоя ловится только чтением кода.
  • В существующий проект правила вводят по слоям и обязательно доводят до ошибки — иначе они ничего не стерегут.

Что почитать дальше

  • Feature-Sliced Design — сами слои, слайсы и сегменты.
  • Подходы к архитектуре фронтенда — что было до FSD и почему выбирают именно его.
  • Структура проекта — с чего начинается раскладка файлов.