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