В прошлом посте мы разобрали, почему Feature-Sliced Design конфликтует с конвенциями Nuxt 3 и как эти конфликты разрешаются конфигурацией. Остался главный практический вопрос: чем охранять архитектуру? Правила, которые не проверяются автоматически, умирают за квартал — какой-нибудь спешный хотфикс импортнёт стор напрямую из внутренностей слайса, за ним второй, и через полгода от FSD останутся только названия папок.

Инструмента два: ручная конфигурация + линтер Steiger и Nuxt-модуль nuxt-fsd. Я сравнил их по десяти критериям на реальном production-проекте на Nuxt 3 (Apollo/GraphQL, Pinia, Storybook, сотня с лишним компонентов). Спойлер: выиграла ручная конфигурация, но не всухую — и проигравшие критерии не менее интересны, чем выигранные.

Подход A: вручную + Steiger

Философия — «FSD своими руками»: никакого стороннего модуля, только конфигурация и статический анализ. Именно этот сценарий описывает официальный гайд FSD.

Конфигурационный патч

Весь патч nuxt.config.ts — меньше 20 строк:

export default defineNuxtConfig({
  // 1. Алиас: '@/' указывает внутрь src/, а не в корень
  alias: {
    '@': './src',
  },

  // 2. Файловый роутинг и layouts переезжают внутрь src/app/
  dir: {
    pages: './src/app/routes',
    layouts: './src/app/layouts',
  },

  // 3. Авто-импорт компонентов — только домен-независимый UI-кит
  components: {
    dirs: [{ path: '~/src/shared/ui' }],
  },

  // 4. Авто-импорт composables/utils отключён — обязательное
  //    условие: линтер видит только явные импорты
  imports: {
    autoImport: false,
    dirs: [],
  },
})

Существующий код продолжает работать: никаких переименований компонентов, никаких массовых правок шаблонов.

Steiger: что ловит, что нет

Steiger — официальный линтер команды FSD (той же, что публикует спецификацию). Анализирует файловую структуру и статические import-выражения в AST, без привязки к фреймворку:

yarn add -D steiger @feature-sliced/steiger-plugin
// steiger.config.ts
import { defineConfig } from 'steiger'
import fsd from '@feature-sliced/steiger-plugin'

export default defineConfig([
  ...fsd.configs.recommended,
  { ignores: ['./node_modules', './.nuxt'] },
])

Запуск: npx steiger ./src разово или --watch в разработке. Из коробки — 20 правил в четырёх группах: импорты (запрет импорта из слоя выше, кросс-импорты между слайсами, обход публичного API), именование (тайпо в именах слоёв, неконсистентные имена), структура (слайсы без сегментов, избыточное дробление), ограничения слоёв (deprecated-слой processes, UI в app).

Важно понимать границу возможностей. Steiger гарантирует структурную корректность — но не смысловую:

Ловит автоматическиОстаётся на ревью
Импорт из более высокого слояПравильность выбора entities vs features
Обход index.ts (public API sidestep)Семантика имени слайса
Кросс-импорты слайсов одного слояПреждевременные абстракции в shared/
Отсутствие публичного API у слайсаБизнес-логика, попавшая в ui-сегмент

Два практических ограничения. Первое: ESLint-интеграции нет — Steiger это отдельный CLI, его нарушения не подсвечиваются в IDE как ESLint-диагностики; нужен либо watch-режим, либо CI-job. Второе: свои правила добавить нельзя — всё за пределами двадцати встроенных остаётся на code review.

Отсюда главная слабость подхода: архитектурные нарушения не ломают локальную сборку. Разработчик может собрать проект, запушить и увидеть проблему только на красном CI-job lint:fsd. Правило, которое проверяется «где-то потом», дисциплинирует слабее, чем ошибка компиляции.

Подход B: модуль nuxt-fsd

Противоположная философия: одна строка modules: ['nuxt-fsd'] — и модуль сам решает конфликты. Что он делает:

  • Авто-алиасы слоёв: @features/exchange вместо @/features/exchange — каждый слой становится путём импорта.
  • Переопределение pages/ и layouts/ — то же, что ручной dir-патч, но неявно, внутри модуля.
  • Авто-импорт компонентов с префиксом слоя и слайса: shared/ui/button.vue → <SharedButton />, features/exchange/ui/order-card.vue → <FeaturesExchangeOrderCard />.
  • Build-time запрет кросс-импортов: опция preventCrossImports (включена по умолчанию) — нарушение правил слоёв ломает nuxt build так же, как ошибка TypeScript.

Последний пункт — суперсила подхода: мгновенная локальная блокировка без зависимости от дисциплины запуска линтера. Но дальше начинаются нюансы.

Цена вопроса

Bus factor. На момент исследования: один мейнтейнер, 7 звёзд на GitHub, единственный релиз v1.0.0 (декабрь 2024) и 17+ месяцев без единого коммита. Ноль открытых issues при таких цифрах — признак малоизвестности, а не безупречности. Если следующий мажор Nuxt изменит module API — модуль сломается, и чинить его, возможно, будет некому. Для инструмента, который становится ядром архитектурной стратегии (а не вспомогательной утилитой), это серьёзный риск: форк технически прост (MIT), но означает, что команда берёт сопровождение Nuxt-модуля на себя.

Дыра в собственной защите. README модуля честно признаёт: «Auto-imports do not respect cross-import protection rules». То есть preventCrossImports запрещает статические import между слоями — но компоненты, зарегистрированные через авто-импорт, проверке не подлежат. Можно использовать <FeaturesExchangeOrderCard /> внутри shared/ui/, и модуль этого не заметит. Главный механизм защиты не покрывает главную фичу модуля.

Принудительное переименование. Формат <Layer><Slice><Name> не конфигурируется. Если в проекте компоненты называются <OrderCard />, после подключения модуля они обязаны стать <FeaturesExchangeOrderCard /> — при сотне с лишним компонентов это массовый рефакторинг шаблонов с высоким риском merge-конфликтов. И это же — стоимость отката: убрал модуль — глобальная регистрация исчезла — все шаблоны сломались.

Мелочи, которые не мелочи: добавление нового слайса перезапускает dev-сервер целиком (мейнтейнер признаёт это недоработкой), а совместимость со Storybook в README не упомянута вовсе — Storybook бандлится отдельно от Nuxt и может не увидеть ни глобальную регистрацию компонентов, ни Vite-плагин защиты.

Сравнение по десяти критериям

КритерийA: Manual + SteigerB: nuxt-fsdВердикт
Build-time enforcementтолько CI-jobломает nuxt build напрямуюB
Стоимость миграциипатч ≤20 строк+ переименование 100+ компонентовA
Bus factorкоманда FSD, активные релизы1 мейнтейнер, 17 мес. тишиныA
Nuxt 4 readinessстандартные конфиг-ключизаявлена, релизом не подтвержденаничья
DX импортов@/features/exchange@features/exchangeB
Совместимость со стекомничего не меняет в сборкеStorybook под вопросомA
Инкрементальностьслайс за слайсом, watch-режим«большой PR» при включенииA
Onboardingявные конфигисамодокументирующие префиксы, но скрытая магияничья
Lock-in / откатудалить пакет и 3 строки+ вернуть все имена компонентовA
Прозрачностьвсё в двух конфигах4 механизма за одной строкойA

Счёт: A — 6, B — 2, ничьи — 2. Но интереснее не счёт, а асимметрия: критерии, где выигрывает A, лежат в зоне долгосрочных рисков (bus factor, lock-in, совместимость, прозрачность); критерии B — в зоне ежедневного DX (мгновенная блокировка сборки, короткие импорты). Это разные валюты, и обменный курс между ними у каждой команды свой.

Причём оба выигрыша B устранимы внутри A: короткие алиасы по слоям добавляются шестью строками конфига ('@features': './src/features', …), а build-time блокировку частично заменяет дисциплина обязательного CI-gate. Обратное неверно: bus factor и принудительное переименование изнутри B не лечатся.

Пилотная миграция на бумаге

Прежде чем принимать решение на весь проект, полезно расписать миграцию одного слайса — по шагам, с оценкой. Наш пилот для слайса exchange уложился в восемь атомарных коммитов:

  1. Конфиг: alias, dir.pages, dir.layouts, скелет src/ — файлы не трогаем, сборка не ломается.
  2. Перенос страниц: git mv pages/ src/app/routes/ — чистое перемещение без рефакторинга.
  3. shared/: apollo-client, сгенерированные GraphQL-типы, error-logger — и точечная замена импортов.
  4. Слайс: стор → features/exchange/model/, GraphQL-операции → api/, middleware-гейт → config/ + регистрация через addRouteMiddleware.
  5. FSD-слой pages: содержимое страницы уезжает в pages/exchange-order/ui/, в маршрутной обёртке остаётся один тег.
  6. Компоненты: карточка ордера → features/exchange/ui/, виджет главного экрана → widgets/, импорт — строго через публичный API.
  7. Steiger в watch-режиме: устранение нарушений (обходы index.ts, кросс-импорты).
  8. Тесты + верификация ключей i18n.

Оценка: 2,5–3,5 человеко-дня на пилот — достаточно дёшево, чтобы проверить подход без риска для остального проекта, который продолжает жить в старой структуре (спасибо инкрементальности подхода A).

Из рисков пилота два стоят упоминания, потому что они не ловятся ни сборкой, ни типами:

  • Потеря <i18n> SFC-блока при переносе страницы: t('statusLabel') молча вернёт строку 'statusLabel' вместо перевода. Митигация — grep-проверка ключей после переноса как явный пункт чеклиста.
  • Устаревший путь codegen: если GraphQL-типы переехали в shared/api/types/, а конфиг кодогенерации не обновлён — следующий прогон запишет файл по старому пути и молча сломает импорты. Митигация: rm старого файла → прогон кодогенерации → проверка, что файл появился на новом месте.

Вердикт

Для большинства проектов — подход A: ручная конфигурация + Steiger. Аргументы, которые перевесили:

  • Steiger поддерживает та же команда, что развивает саму спецификацию FSD, — консистентность методологии и инструмента гарантирована;
  • миграция инкрементальна: один слайс мигрирует, остальной проект живёт как жил;
  • откат почти бесплатен — структура файлов остаётся, уходит только линтер;
  • всё поведение явно записано в двух конфигах, никакой магии за строкой modules: [...].

Подход B стоит рассмотреть, если: проект новый (нет сотни компонентов под переименование), команда готова принять формат <Layer><Slice><Name> с первого дня, и критично, чтобы нарушение границ ломало сборку локально, а не на CI. Но и тогда стоит перепроверить живость модуля: 17 месяцев без коммитов — это ставка на код, который никто не сопровождает.

И последнее. Какой бы инструмент вы ни выбрали, помните из первого поста: линтер проверяет структуру, а не смысл. Куда отнести бизнес-концепцию — entities или features, где граница слайса, что достойно shared/ — эти решения остаются за командой. Инструмент лишь гарантирует, что однажды принятое решение не разъедется по швам под давлением дедлайнов.