Представьте фронтенд-проект на Nuxt 3, которому несколько лет: 58 composables в одной папке, 33 стора, 12 middleware и под сотню компонентов в components/blocks/. Плоский список без семантической организации. Чтобы понять, куда положить новый composable, нужно сначала прочитать все существующие — иначе рискуешь продублировать. Новичок в команде обязан держать весь корень проекта в голове; вход растягивается на недели.

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

Что болит без архитектуры

Четыре проблемы плоской структуры, знакомые любому выросшему проекту:

  • Онбординг. Нет таксономии — новый разработчик не знает, куда класть код и что уже существует.
  • Перегруженные каталоги. Найти middleware для проверки доступа среди 12 файлов без группировки, или бизнес-логику фичи среди 58 composables — чистая когнитивная нагрузка. Поиск по имени работает, только если знаешь, что ищешь.
  • Связность. Компонент свободно импортирует из stores/, composables/, models/ и graphql/types одновременно. Граница фичи — неформальное соглашение в головах. Любой файл может импортировать любой другой, и механической проверки изолированности нет.
  • Дублирование. Два разработчика параллельно пишут похожие форматтеры и хелперы, потому что нет инструмента заметить аналог до code review.

Честности ради — что FSD не решает: покрытие тестами, производительность, размер бандла, качество бизнес-логики. А с дублированием у него отношения особые (об этом ниже). FSD — это про предсказуемость структуры и проверяемость границ, не про магию.

FSD за десять минут

Слои. Весь код делится на шесть слоёв, упорядоченных по «знанию о бизнесе»:

  • app — точка входа: роутинг, провайдеры, глобальные стили;
  • pages — полные экраны, соответствующие маршрутам;
  • widgets — крупные самодостаточные блоки UI;
  • features — пользовательские действия с бизнес-ценностью («создать ордер», «пройти проверку»);
  • entities — бизнес-сущности: модели, сторы, представление (user, currency, order);
  • shared — фундамент: UI-кит, утилиты, адаптеры — без привязки к домену.

Главное правило: слой импортирует только из слоёв ниже себя. features знает про entities и shared, но не про pages.

Слайсы. Внутри слоя код группируется по смыслу для бизнеса: exchange, p2p, verification — а не module1. Слайс максимально самодостаточен (high cohesion) и не знает о соседях по слою (zero coupling): прямые импорты между слайсами одного слоя запрещены.

Сегменты. Внутри слайса — группировка по технической природе: ui (компоненты), api (запросы к бэкенду), model (типы, сторы), lib (composables, хелперы), config (константы, фиче-флаги). Ненужные сегменты не создаются.

Типичный слайс:

features/exchange/
├── ui/
│   └── exchange-order-card.vue
├── api/
│   └── get-exchange-order.graphql
├── model/
│   └── store.ts
├── config/
│   └── access-middleware.ts
└── index.ts          ← Public API

Public API. index.ts в корне слайса — единственная точка входа для внешнего кода, как exports в package.json npm-пакета:

// features/exchange/index.ts
export { default as ExchangeOrderCard } from './ui/exchange-order-card.vue'
export { useExchangeStore }             from './model/store'
export type { ExchangeOrder }           from './model/types'
// ❌ Нарушение: импорт из внутренностей
import { useExchangeStore } from '@/features/exchange/model/store'

// ✅ Через публичный API
import { useExchangeStore } from '@/features/exchange'

Всё, что не экспортировано, — деталь реализации. Завтра можно переписать внутренности слайса, и потребители ничего не заметят.

Кросс-импорты @x/. Иногда сущности реально зависят друг от друга: у order есть поле типа Currency. Для этого есть специальная нотация — отдельный публичный API для конкретного слайса-потребителя:

// entities/order/model/store.ts
import type { Currency } from '@/entities/currency/@x/order'

Официальная спека рекомендует применять @x/ только на слое entities — везде выше это сигнал плохой границы между слайсами.

Needs Driven. Самый недооценённый принцип FSD: методология осознанно предпочитает небольшое дублирование преждевременной абстракции. Два похожих форматтера в разных слайсах — это нормально, если у фич разные требования. «Общий умный форматтер» ради экономии десяти строк создаёт скрытую связность между несвязанными частями. Локализованное дублирование всегда дешевле абстракции, которая неявно связывает несвязанное.

По сути FSD — это «монорепо без монорепо»: каждый слайс ведёт себя как независимый пакет с явным публичным API. Когда возникает вопрос «а почему нельзя X?» — ответ почти всегда сводится к нарушению изоляции слайсов или публичного контракта.

Где Nuxt 3 ломает FSD

Nuxt исповедует convention over configuration: фреймворк заранее объявил, как называются директории и где лежат файлы. FSD — наоборот, explicit architecture: структуру декларирует программист и охраняет инструментами. Эти философии сталкиваются в четырёх конкретных точках.

Конфликт 1: нет src/

Nuxt кладёт pages/, components/, composables/ прямо в корень, рядом с nuxt.config.ts и .env. FSD требует, чтобы все слои были ветками одного поддерева — обычно src/. Решение из официального гайда FSD — алиас в nuxt.config.ts:

alias: {
  "@": "../src",
},

Теперь @/features/exchange резолвится в src/features/exchange.

Конфликт 2: коллизия pages/

Самый неприятный. В Nuxt pages/ — файловый роутинг: каждый .vue-файл автоматически становится маршрутом. В FSD pages — слой со слайсами экранов, у каждого сегменты ui/, model/, api/. В одной директории эти концепции несовместимы: Nuxt превратит pages/exchange/ui/exchange-page.vue в маршрут /exchange/ui/exchange-page, чего никто не ждёт. Решение — переселить роутинг Nuxt:

dir: {
  pages: "./src/app/routes",
},

src/pages/ становится чистым FSD-слоем, а в src/app/routes/ лежат тонкие обёртки: страница src/app/routes/exchange/order/[id].vue содержит один тег <ExchangeOrderPage /> — вся реализация в src/pages/exchange-order/.

Конфликт 3: коллизия layouts/

Та же история: Nuxt ждёт layout-компоненты в layouts/, у FSD такого слоя нет. Решение аналогичное:

dir: {
  layouts: "./src/app/layouts",
},

Семантически корректно: layouts — инфраструктура приложения, их место в слое app.

Конфликт 4: авто-импорты

По умолчанию Nuxt автоматически импортирует всё из composables/ и utils/ — файлы доступны глобально без import. Для FSD это яд: если composable из features/exchange/lib/ неявно доступен в entities/ и shared/, правила импортов неисполнимы. Хуже того — линтер их не увидит: Steiger анализирует статические import-выражения в AST исходников, а Nuxt-авто-импорты резолвятся позже, на этапе Vite-трансформации. В исходном коде их просто нет.

Решение радикальное:

imports: {
  autoImport: false,
  dirs: [],
},

Да, это означает явные импорты везде. Это цена проверяемости границ — и, на практике, ещё и подарок для навигации по коду: IDE перестаёт гадать, откуда взялся идентификатор. Отдельно останется авто-импорт компонентов из ~/components — его тоже стоит отключить, консистентности ради.

Как разложить то, что не описано в спеке

Официальный гайд FSD по Nuxt заканчивается на трёх конфигурационных конфликтах. Реальный проект на этом только начинается: куда девать middleware, i18n-ключи, 58 composables и 33 стора? Вот правила, выработанные на практике.

Middleware: гейты — в слайсы, глобальные — в app

В типичном проекте middleware делятся на две группы: глобальные (аутентификация, контроль доступа, предзагрузка данных — исполняются на каждом маршруте в фиксированном порядке) и фича-гейты (ограничивают доступ к маршрутам конкретной фичи по флагу или состоянию домена).

Глобальные — это инфраструктура: src/app/middleware/. А вот фича-гейт в отрыве от своего слайса — висящая строка, которую забудут удалить вместе с фичей. Поэтому гейт переезжает в слайс и регистрируется через setup-плагин:

// src/features/exchange/lib/setup.ts
import accessMiddleware from '../config/access-middleware'

export default defineNuxtPlugin(() => {
  addRouteMiddleware('exchange-access', accessMiddleware)
})

Страница подключает его через definePageMeta({ middleware: ['exchange-access'] }). Бонус: удаление фичи — это rm -rf features/exchange, и middleware уезжает вместе с ней.

Composables: четыре правила размещения

Плоская папка на 58 composables скрывает фундаментальную разницу между useTimer (чистый setInterval) и useActiveOrders (агрегирует три стора). Правила:

  1. Чистый browser/утилита → shared/lib/. Только Web API, Vue-реактивность, стандартные библиотеки. Ни одного импорта сторов или доменных типов. Примеры: useDevice, useTimer, useNumberFormatter (locale — параметром).
  2. Знает одну сущность → entities/<entity>/lib/. Импортирует стор или тип ровно одной сущности: useCurrencyFormatter, зависящий от стора валют, живёт в entities/currency/lib/.
  3. Несколько сущностей или флоу → features/<feature>/lib/. Агрегирует несколько доменов или реализует конкретный сценарий: useActiveOrders, собирающий три стора, — это features/active-orders/lib/.
  4. Только для одной страницы → pages/<page>/lib/. Редкий случай: composable, чей смысл понятен только в контексте одного экрана.

Тест правильности: слой composable равен слою его самой высокоуровневой зависимости. Импортирует стор — бери слой стора. Только browser API — shared.

Два важных исключения. Во-первых, обёртки над API-запросами (useContactQuery и подобные) — это сегмент api/, не lib/: смешение превращает lib/ в свалку и скрывает сетевые зависимости слайса. Во-вторых, прагматика бывает важнее буквы: форматтер дат, читающий из стора настроек одно поле locale, по правилам должен жить в entities — но разумнее вынести locale в параметр и оставить утилиту в shared/lib/i18n/. Такие исключения допустимы, если зафиксированы явно.

Сторы: те же четыре уровня

Плоский stores/ на 33 файла страдает той же болезнью: useToastStore (чистый UI-сигнал) лежит рядом со стором бизнес-сущности. Распределение зеркалит composables:

Что за сторКудаПример
Глобальный UI-state без доменаapp/model/тосты, тема, определение платформы
CRUD/кэш одной сущностиentities/<entity>/model/стор валют, стор курсов
Состояние одного флоуfeatures/<feature>/model/стор сценария обмена
Состояние одной страницыpages/<page>/model/почти не встречается

Ключевое правило миграции: как только у стора появляется второй потребитель из другого слайса, стор опускается на слой ниже. Стор тостов, который импортируют полсотни файлов из разных фич, не может жить ни в одной из них — его место в app/model/.

i18n: гибрид локального и глобального

Ключи переводов раскладываются по простому принципу: если ключ нужен только шаблону .vue — он живёт в <i18n> SFC-блоке компонента (нулевой boilerplate, не засоряет глобальный JSON); если ключ нужен стору, composable или middleware — он в глобальном JSON под namespace слайса (errors.exchange.limitExceeded). Причина жёсткая: ключи SFC-блока изолированы в scope компонента и физически недоступны вне .vue-контекста — стор их не увидит при всём желании.

Итог: нужен ли вам FSD

FSD стоит внедрять, если у вас сходится хотя бы три пункта:

  • каталоги перевалили за пару десятков файлов без группировки, и поиск «всего про фичу X» — это grep по трём папкам;
  • границы фич — устная традиция, и рефакторинг требует ручного поиска «кто на это завязан»;
  • команда растёт, и онбординг упирается в «держи весь проект в голове»;
  • вы готовы отключить авто-импорты и платить явными import за проверяемость границ;
  • есть готовность принять Needs Driven — то есть перестать считать каждое дублирование багом.

Если же проект маленький, команда из двух человек и структура помещается в голове — FSD принесёт больше церемоний, чем пользы.

Все конфликты Nuxt и FSD, как мы видели, разрешимы конфигурацией. Открытым остаётся вопрос «чем это охранять»: правила, которые не проверяются автоматически, умирают за квартал. В следующем посте — два подхода к внедрению: ручная конфигурация с линтером Steiger против модуля nuxt-fsd, сравнение по десяти критериям и оценка пилотной миграции.