Олег Марков
Путь к файлу в FSD - fsd-path и практики организации кода
Введение
Путь к файлу в проекте — это не просто место, где лежит код. От структуры путей зависят:
- скорость навигации по проекту;
- простота рефакторинга;
- возможность разделять ответственность между командами;
- предсказуемость импорта модулей.
В архитектуре Feature-Sliced Design (FSD) эта тема выделена в отдельный аспект, который часто обозначают как fsd-path — система правил, по которым вы выстраиваете структуру директорий и файлов в проекте.
Здесь мы разберем, что такое fsd-path в контексте FSD, как выбирать путь к файлу в зависимости от слоя, типа сущности и контекста применения, а также как это влияет на импорты, переиспользование и масштабируемость проекта. Смотрите, я покажу вам это на практических схемах и фрагментах кода.
Что такое fsd-path и зачем он нужен
Основная идея fsd-path
В FSD путь к файлу — это отражение:
- слоя (layer), к которому относится модуль;
- типа сущности (slice) — app, processes, pages, widgets, features, entities, shared;
- конкретной фичи, сущности или виджета;
- типа артефакта (ui, model, lib, api и другие).
Проще говоря, fsd-path — это соглашения о том, где именно в дереве папок должен лежать модуль с определенной ролью.
Главный принцип: по пути к файлу вы всегда можете понять:
- какова зона ответственности этого кода;
- насколько он переиспользуемый;
- какие уровни абстракции он может использовать и кого может импортировать.
Почему это важнее, чем просто «красиво разложить файлы»
Если архитектура не согласована с путями к файлам:
- сложно понять, что можно импортировать, а что приведет к циклическим зависимостям;
- появляются «слои-помойки», куда складывают все подряд;
- при масштабировании проекта изменятся десятки импортов из-за хаотичного перемещения файлов.
FSD предлагает договориться об общей карте проекта. fsd-path — это как маршруты на этой карте.
Базовая структура проекта и слои FSD
Стандартные слои
В классическом FSD выделяют следующие слои верхнего уровня:
- app — инициализация приложения, корневые провайдеры, глобальные стили;
- processes — долгоживущие бизнес-процессы, охватывающие несколько страниц;
- pages — страницы приложения;
- widgets — крупные композиционные блоки интерфейса (секции страниц);
- features — законченные пользовательские возможности (логин, поиск, фильтрация);
- entities — бизнес-сущности доменной области (User, Product, Order);
- shared — переиспользуемая инфраструктура и примитивы, не привязанные к домену.
Смотрите, как может выглядеть корень проекта:
src/
app/
processes/
pages/
widgets/
features/
entities/
shared/
Здесь каждый каталог верхнего уровня — это первый шаг в формировании fsd-path.
Принцип «от общего к частному»
Путь к файлу в FSD, как правило, следует паттерну:
слой / конкретная-сущность / внутренняя-структура
Например:
- features/auth/by-username/model/services/loginByUsername.ts
- entities/user/ui/UserAvatar.tsx
- widgets/cart/ui/CartWidget.tsx
Давайте разберемся, как именно решать, куда класть новый модуль.
Как выбрать правильный слой для файла
Когда использовать entities
Слой entities описывает модель предметной области:
- типы сущностей (User, Product, Order);
- их базовые компоненты отображения;
- простые операции с ними, не завязанные на конкретный сценарий (например, нормализация данных).
Если вы создаете модуль, который:
- описывает сущность;
- используется в нескольких фичах;
- не завязан жестко на UX и конкретный сценарий,
то его путь к файлу, скорее всего, пройдет через entities.
Пример:
src/entities/user/
model/
types/user.ts # Типы для сущности User
selectors/getUser.ts # Селекторы сущности
ui/
UserAvatar.tsx # Базовый UI-компонент для аватара
UserName.tsx # Компонент для отображения имени
Комментарии в файлах могут выглядеть так:
// model/types/user.ts
// Описание типа сущности пользователя
export interface User {
id: string
username: string
avatarUrl?: string
}
Когда использовать features
Слой features отвечает за завершенные пользовательские возможности. Это то, что пользователь воспринимает как действие или функцию: «войти», «добавить в избранное», «оформить заказ».
Вы выбираете features, если:
- модуль реализует полноценный сценарий или его часть;
- логика завязана на UX и контекст взаимодействия;
- код использует одну или несколько entities.
Пример пути:
src/features/auth/by-username/
ui/
LoginForm.tsx
model/
types/loginForm.ts
selectors/getLoginState.ts
services/loginByUsername.ts
И поясняющий код:
// services/loginByUsername.ts
// Сервис авторизации по имени пользователя
export const loginByUsername = async (username: string, password: string) => {
// Здесь реализован запрос к API
// В реальном проекте вы вынесете URL и клиент в shared/api
}
Когда использовать widgets
widgets — это композиции, которые собирают:
- несколько features;
- entities;
- shared-компоненты
в единый блок интерфейса.
Выберите widgets, если:
- блок может использоваться на разных страницах;
- он достаточно крупный (например, «Корзина», «Шапка сайта», «Секция профиля»);
- внутри есть независимые фичи.
Пример:
src/widgets/cart/
ui/
CartWidget.tsx
// ui/CartWidget.tsx
// Виджет корзины, собирает несколько фич и сущностей
export const CartWidget = () => {
// Здесь может быть вывод списка товаров, итоговой суммы, кнопки «Оформить заказ»
return (
<section>
{/* Контент корзины */}
</section>
)
}
Когда использовать pages
pages — это слой, привязанный к маршрутам:
- каждая страница отражает url-роут;
- внутри собираются widgets и features, относящиеся к конкретному экрану.
Путь к файлу страницы обычно:
src/pages/profile/
ui/
ProfilePage.tsx
model/
# опционально - состояние, специфичное только для этой страницы
Когда использовать app
Слой app — это точка входа и глобальная конфигурация:
- инициализация маршрутизации;
- провайдеры состояния;
- глобальные стили/темы;
- корневые компоненты.
Пример:
src/app/
providers/
routes/
styles/
App.tsx
Когда использовать processes
processes — редкий, но полезный слой для долгоживущих процессов, которые:
- включают несколько страниц;
- описывают сложный бизнес-процесс;
- имеют собственное состояние и логику.
Пример:
src/processes/checkout/
model/
ui/
Когда использовать shared
shared — это:
- ui — переиспользуемые атомарные или молекулярные компоненты (кнопки, инпуты);
- lib — утилиты и хелперы;
- api — общие клиенты и конфигурация запросов;
- config — константы и настройки.
Если модуль не зависит от домена и может быть перенесен в другой проект без изменений доменной логики, он почти всегда находится в shared.
Внутренняя структура сущности по fsd-path
Типовая структура сущности (slice)
Для каждого «слайса» (например, features/auth, entities/user) часто используют следующий шаблон:
- ui — компоненты интерфейса;
- model — состояние, действия, редьюсеры, селекторы;
- api — запросы к серверу, специфичные для этого слайса;
- lib — утилиты и хелперы, связанные только с этим слайсом;
- config или consts — локальные настройки.
Смотрите пример для features/auth/by-username:
src/features/auth/by-username/
ui/
LoginForm.tsx
model/
selectors/
getLoginIsLoading.ts
services/
loginByUsername.ts
slices/
loginSlice.ts
types/
loginSchema.ts
lib/
mapApiErrorToMessage.ts
api/
loginApi.ts
Теперь вы увидите, как это выглядит в коде для одного из модулей:
// model/services/loginByUsername.ts
import { loginApi } from '../../api/loginApi'
// Сервис авторизации по имени пользователя
export const loginByUsername = async (username: string, password: string) => {
// Вызываем API-метод для авторизации
const response = await loginApi.login({ username, password })
// Обрабатываем результат и возвращаем данные
return response.data
}
Комментарии помогают быстро понять назначение файла, не заглядывая глубоко внутрь.
Как выбирать имена папок и файлов
Есть несколько правил, которые хорошо сочетаются с fsd-path:
Папка должна отражать доменную или техническую роль:
- model, ui, api, lib, config;
- selectors, services, slices, types внутри model.
Имя файла должно быть достаточно конкретным:
- loginByUsername.ts вместо login.ts;
- getUserAuthData.ts вместо selector.ts.
Для React-компонентов обычно используют PascalCase:
- LoginForm.tsx;
- UserAvatar.tsx.
Для функций и утилит — camelCase:
- formatPrice.ts;
- mapApiErrorToMessage.ts.
Импорт модулей и относительные пути при fsd-path
Проблема «лесенки» с относительными импортами
В больших проектах легко получить импорт вида:
import { LoginForm } from '../../../../features/auth/by-username/ui/LoginForm'
Такой путь:
- плохо читается;
- сложно обновляется при перемещении файлов;
- провоцирует ошибки при рефакторинге.
В FSD принято использовать alias (webpack, Vite, tsconfig) для слоев:
- app -> app
- processes -> processes
- pages -> pages
- widgets -> widgets
- features -> features
- entities -> entities
- shared -> shared
Тогда импорт будет выглядеть так:
// Импорт формы логина по алиасу слоя
import { LoginForm } from 'features/auth/by-username'
Индексные файлы как часть fsd-path
Чтобы упростить импорт, внутри слайса часто создают index-файл, который является публичным API этого слайса:
src/features/auth/by-username/
index.ts
ui/
LoginForm.tsx
model/
services/
loginByUsername.ts
index.ts:
// index.ts
// Экспортируем только то, что можно использовать снаружи
export { LoginForm } from './ui/LoginForm'
export { loginByUsername } from './model/services/loginByUsername'
Теперь из других слоев вы импортируете только через публичное API:
// Импорт UI-компонента фичи
import { LoginForm } from 'features/auth/by-username'
// Импорт бизнес-логики фичи
import { loginByUsername } from 'features/auth/by-username'
Обратите внимание, что вы не лезете внутрь структуры фичи (ui/model/services). Это снижает связанность и позволяет менять внутреннюю структуру, не ломая импорты.
Правила зависимостей между слоями
Базовый граф зависимостей
FSD предлагает направление зависимостей:
- app может зависеть от всех слоев;
- processes — от pages, widgets, features, entities, shared;
- pages — от widgets, features, entities, shared;
- widgets — от features, entities, shared;
- features — от entities, shared;
- entities — от shared;
- shared — не должен зависеть от других слоев.
Смотрите, как это можно сформулировать в виде кратких правил:
- Нельзя импортировать сверху вниз (например, из features в widgets) — только в сторону более высокоуровневых слоев.
- shared не знает о домене, поэтому не тянет за собой entities или features.
Пример корректного и некорректного импорта
Корректный импорт:
// pages/profile/ui/ProfilePage.tsx
// Страница может использовать виджеты, фичи и сущности
import { ProfileHeader } from 'widgets/profile-header'
import { ProfileCard } from 'entities/profile'
import { UpdateProfileForm } from 'features/profile/update-profile'
Некорректный импорт:
// features/profile/update-profile/model/services/updateProfile.ts
// ПЛОХО - фича импортирует страницу (обратная зависимость)
import { ProfilePage } from 'pages/profile'
В идеале подобные нарушения контролируются линтером, но основа — все равно в четкой структуре путей и разделении ответственности.
Публичное и приватное API слайсов
Публичное API через index.ts
fsd-path тесно связан с концепцией public API. Для каждого слайса (features, entities, widgets, иногда pages) определяют:
- index.ts — публичный вход в модуль;
- внутренние пути, на которые не полагаются снаружи.
Пример:
src/entities/user/
index.ts
ui/
UserAvatar.tsx
UserName.tsx
model/
selectors/
getUserAuthData.ts
types/
user.ts
index.ts:
// index.ts
// Экспортируем только то, что реально нужно снаружи
export { UserAvatar } from './ui/UserAvatar'
export { UserName } from './ui/UserName'
export type { User } from './model/types/user'
export { getUserAuthData } from './model/selectors/getUserAuthData'
Теперь во всех местах проекта вы используете только путь:
import { UserAvatar, getUserAuthData } from 'entities/user'
Если вы решите изменить внутреннюю структуру (например, добавить поддиректорию ui/profile), внешние импорты останутся неизменными.
Скрытие внутренней реализации
Главный плюс такого пути:
- вы можете менять организацию файлов и папок внутри сущности, не трогая десятки мест импорта;
- вы ограничиваете, что можно использовать снаружи, и не допускаете «утечки деталей».
Примеры реальных путей fsd-path для разных задач
Пример 1: Авторизация пользователя
Представим, что нужно реализовать фичу «авторизация по логину и паролю». Давайте посмотрим, как это можно разложить по FSD.
Пути к файлам:
src/
entities/
user/
index.ts
model/
types/user.ts
selectors/getUserAuthData.ts
ui/
UserAvatar.tsx
UserName.tsx
features/
auth/
by-username/
index.ts
ui/
LoginForm.tsx
model/
services/loginByUsername.ts
types/loginSchema.ts
lib/
mapApiErrorToMessage.ts
pages/
login/
ui/
LoginPage.tsx
widgets/
auth-by-username/
ui/
AuthByUsernameWidget.tsx
Фрагмент страницы:
// pages/login/ui/LoginPage.tsx
import { AuthByUsernameWidget } from 'widgets/auth-by-username'
// Страница логина - собирает виджет авторизации
export const LoginPage = () => {
return (
<main>
{/* Виджет авторизации по имени пользователя */}
<AuthByUsernameWidget />
</main>
)
}
Фрагмент виджета:
// widgets/auth-by-username/ui/AuthByUsernameWidget.tsx
import { LoginForm } from 'features/auth/by-username'
// Виджет, оборачивающий форму логина
export const AuthByUsernameWidget = () => {
return (
<section>
{/* Фича авторизации подставляется внутрь виджета */}
<LoginForm />
</section>
)
}
Фичу в будущем можно будет переиспользовать в другом месте (например, в модальном окне), так как путь к ней фиксированный и понятный.
Пример 2: Список товаров и добавление в избранное
Допустим, есть доменная сущность Product и фича «добавить в избранное».
Пути:
src/entities/product/
index.ts
ui/
ProductCard.tsx
model/
types/product.ts
src/features/favorites/toggle-favorite/
index.ts
ui/
FavoriteButton.tsx
model/
services/toggleFavorite.ts
src/widgets/catalog/
ui/
CatalogWidget.tsx
Смотрите, как может выглядеть виджет каталога:
// widgets/catalog/ui/CatalogWidget.tsx
import { ProductCard } from 'entities/product'
import { FavoriteButton } from 'features/favorites/toggle-favorite'
// Виджет каталога - список продуктов с возможностью добавления в избранное
export const CatalogWidget = ({ products }) => {
return (
<section>
{products.map((product) => (
<article key={product.id}>
{/* Карточка товара из слоя сущностей */}
<ProductCard product={product} />
{/* Кнопка избранного из слоя фич */}
<FavoriteButton productId={product.id} />
</article>
))}
</section>
)
}
Здесь fsd-path подсказывает:
- entities/product — отвечает за отображение и типизацию товара;
- features/favorites/toggle-favorite — за сценарий добавления товара в избранное;
- widgets/catalog — за компоновку всего этого для конкретной страницы.
Нейминг и группировка фич и сущностей в fsd-path
Группировка по доменам
В больших проектах возникает вопрос: как группировать фичи и сущности, чтобы пути были понятными?
Частый подход:
- использовать домены верхнего уровня (auth, profile, orders, catalog и т.д.);
- внутри домена выделять конкретные сценарии.
Примеры:
features/auth/by-username/
features/auth/by-email/
features/profile/update-profile/
features/cart/add-to-cart/
features/cart/remove-from-cart/
entities/user/
entities/product/
entities/cart/
Так пути к файлам остаются выразительными и не превращаются в «features/v1/feature1».
Когда вводить дополнительные уровни папок
Иногда внутри одного домена накапливается много сценариев. Тогда можно ввести дополнительный уровень:
features/profile/edit/
features/profile/change-password/
features/profile/upload-avatar/
Вместо одной перегруженной папки features/profile.
Главный ориентир: если при открытии директории вы теряетесь в десятках файлов и подпапок, значит, структуру стоит уточнить и, возможно, добавить доменный уровень или дополнительное разбиение.
Инструменты и автоматизация для контроля fsd-path
Настройка alias в TypeScript
Чтобы использовать fsd-path с алиасами, многие настраивают paths в tsconfig:
{
"compilerOptions": {
"baseUrl": "src",
"paths": {
"app/*": ["app/*"],
"processes/*": ["processes/*"],
"pages/*": ["pages/*"],
"widgets/*": ["widgets/*"],
"features/*": ["features/*"],
"entities/*": ["entities/*"],
"shared/*": ["shared/*"]
}
}
}
Комментарии к конфигу:
// baseUrl - корень, от которого считаются алиасы
// paths - соответствие алиаса реальной папке
После этого в коде можно писать:
// Импортируем страницу по алиасу
import { ProfilePage } from 'pages/profile'
Настройка alias в Webpack или Vite
Пример для Webpack:
// webpack.config.js
const path = require('path')
module.exports = {
// ...
resolve: {
alias: {
app: path.resolve(__dirname, 'src/app'),
processes: path.resolve(__dirname, 'src/processes'),
pages: path.resolve(__dirname, 'src/pages'),
widgets: path.resolve(__dirname, 'src/widgets'),
features: path.resolve(__dirname, 'src/features'),
entities: path.resolve(__dirname, 'src/entities'),
shared: path.resolve(__dirname, 'src/shared'),
},
},
}
Комментарии:
// alias - задаем короткие имена для импортов
// path.resolve - получаем абсолютный путь к каталогу слоя
Типичные ошибки при выборе пути к файлу и как их избегать
Ошибка 1: Все складывать в shared
Сценарий: «фича использует общую кнопку, значит, положим все в shared/ui». Через время в shared оказывается и логика авторизации, и формы, и бизнес-правила.
Как избежать:
- помните, что shared — слой без домена и без знаний о бизнес-логике;
- UI-компонент, связанный с конкретным доменом, должен жить в entities или features, а не в shared/ui.
Ошибка 2: Фича без отдельной папки
Иногда разработчики создают фичу так:
src/features/
loginForm.tsx
loginSlice.ts
Такая плоская структура плохо масштабируется. Гораздо яснее ввести директорию для фичи:
src/features/auth/by-username/
ui/LoginForm.tsx
model/loginSlice.ts
Ошибка 3: Дублирование путей и ролей
Пример неудачной структуры:
src/features/auth/
ui/
LoginForm.tsx
model/
LoginForm.ts # файл с тем же именем, другая роль
В итоге становится сложно понять, какой LoginForm где использовать.
Лучше:
src/features/auth/by-username/
ui/LoginForm.tsx
model/loginSlice.ts
И не использовать одинаковые имена в ui и model для разных сущностей.
Ошибка 4: Импорты во внутренние файлы слайса
Если везде писать:
import { loginByUsername } from 'features/auth/by-username/model/services/loginByUsername'
то вы теряете преимущество public API, и любое изменение структуры приведет к массовому рефакторингу.
Лучше ограничиться:
import { loginByUsername } from 'features/auth/by-username'
а в index.ts экспортировать нужный сервис.
Как постепенно внедрять fsd-path в существующий проект
Шаг 1: Ввести слои верхнего уровня
Сначала можно просто:
- создать папки app, pages, widgets, features, entities, shared;
- начать складывать новые модули по слоям.
Старые модули можно не трогать сразу.
Шаг 2: Ввести индексные файлы и публичное API
Далее вы:
- для каждой новой фичи создаете index.ts;
- в остальном коде импортируете только через index-файлы.
Это уже добавляет стабильность путям.
Шаг 3: Постепенный перенос старых модулей
Когда вы будете трогать старый код:
- при необходимости меняете путь к файлу, приводя к fsd-path;
- создаете сущности и фичи вместо «грязных» папок типа components или utils.
С точки зрения теории fsd-path — это не единственный возможный способ организовать проект, но он делает архитектуру более явной и проверяемой. Чем последовательнее вы будете придерживаться его принципов, тем проще станет навигация по проекту и развитие кода в команде.
Частозадаваемые технические вопросы по fsd-path
Как быть с тестами - где хранить файлы тестов в структуре FSD
Обычно тесты кладут рядом с тестируемым модулем, сохраняя тот же путь. Например, для features/auth/by-username/model/services/loginByUsername.ts создайте файл features/auth/by-username/model/services/loginByUsername.test.ts. Это помогает сразу видеть связь между кодом и тестом. Если вы используете единый test-слой, сохраняйте внутри него те же подпути, что и в src, чтобы путь отражал домен и слой.
Как размещать стили - делать отдельные style-папки или хранить рядом с компонентом
Чаще всего стили хранят рядом с компонентом, чтобы путь оставался локальным к UI. Например, entities/user/ui/UserAvatar.tsx и entities/user/ui/UserAvatar.module.scss. Это соответствует идее FSD - каждый слайс инкапсулирует свою реализацию, включая стили. Отдельные style-папки на верхнем уровне делают навигацию по стилям сложнее и нарушают локальность.
Где хранить типы для API-ответов - в shared или в entities
Если типы описывают конкретную доменную сущность, стоит разместить их в entities - например, entities/user/model/types/user.ts. Если тип привязан к конкретному эндпоинту или сценарию, его можно оставить в соответствующем api-модуле внутри features или entities. В shared имеет смысл выносить только полностью инфраструктурные типы - вроде общих оберток response или error.
Как организовать путь к файлам для монорепозитория с несколькими приложениями
Часто создают общий src и подпапки для каждого приложения. Например apps/admin/src и apps/client/src. Внутри каждого src повторяют FSD-слои - app, pages, widgets и так далее. Общие сущности и shared-код выносят в отдельные пакеты или в общий каталог packages/entities, packages/shared. Важно сохранять единый подход к слоям и алиасам для всех приложений.
Как поступать с конфигурацией роутинга - это app или pages
Конфигурация маршрутов обычно относится к слою app, потому что она описывает каркас всего приложения. При этом сами компоненты страниц лежат в pages. То есть в app/routes вы храните описание маршрутов и их привязку к страницам, а реализация UI страниц находится по путям вида pages/profile/ui/ProfilePage.tsx. Такой подход сохраняет роль app как точки сборки и инициализации проекта.