Коротко: Разбираем Feature-Sliced Design (FSD) в React и TypeScript: слои, слайсы, сегменты, правила импортов, Public API, практический пример и частые ошибки.
Когда React-приложение перерастает рамки MVP, классическая структура каталогов начинает тормозить разработку. Папка components превращается в свалку сотен компонентов с неявными зависимостями, хуки в hooks связывают несвязанные части системы, а рефакторинг любого модуля грозит непредсказуемыми поломками в других частях интерфейса.
Feature-Sliced Design (FSD) — это архитектурная методология, которая решает проблему масштабирования фронтенда. Она заменяет группировку файлов «по техническому типу» на структурирование по бизнес-ценности и зонам ответственности с жестко регламентированными правилами зависимостей.
Что такое Feature-Sliced Design и какие проблемы он решает
Кризис классической структуры: почему папки components, hooks и services перестают работать
Большинство базовых руководств по React предлагают группировать файлы по их технической роли:
src/
├── components/
├── hooks/
├── services/
├── utils/
└── types/
На ранних стадиях проекта такая схема кажется удобной. Однако с ростом функциональности возникают системные проблемы:
- Размытая ответственность. Компонент кнопки корзины лежит в
components/CartButton.tsx, логика корзины — вhooks/useCart.ts, типы — вtypes/cart.ts, а запросы — вservices/cartApi.ts. Чтобы внести изменение в одну функциональность, разработчик вынужден открывать файлы из диаметрально противоположных директорий. - Неконтролируемые циклические зависимости. Компоненты начинают импортировать друг друга по горизонтали, создавая эффект карточного домика: удаление старой промо-акции ломает оформление заказа.
- Высокий порог входа. Новому разработчику сложно понять границы предметной области проекта, глядя на список из сотен изолированных файлов.
Фундаментальные принципы FSD: High Cohesion, Low Coupling и контролируемые зависимости
FSD опирается на принципы модульности и предметно-ориентированного проектирования (DDD):
- Высокая связность (High Cohesion): код, решающий одну бизнес-задачу (UI, запросы, состояние, хелперы), находится в одном месте.
- Слабая связанность (Low Coupling): модули изолированы друг от друга и взаимодействуют только через явные интерфейсы (Public API).
- Однонаправленный поток зависимостей: модули верхнего уровня могут обращаться к нижним, но обратные или горизонтальные импорты строго запрещены.
Анатомия FSD: слои (layers), слайсы (slices) и сегменты (segments)
Методология разделяет кодовую базу на три иерархических уровня:
Слой (Layer) ➔ Слайс (Slice) ➔ Сегмент (Segment)
src/
├── app/ # Слой
├── pages/ # Слой
│ └── product-details/ # Слайс
│ ├── ui/ # Сегмент
│ ├── model/ # Сегмент
│ └── index.ts # Public API слайса
Иерархия слоев сверху вниз
Слои строго стандартизированы. Их последовательность определяет допустимые направления импортов:
app— инициализация приложения: роутер, провайдеры контекста (Redux, TanStack Query), глобальные стили и точки входа.pages— маршруты и законченные страницы. Слой отвечает за компоновку виджетов под конкретный URL.widgets— крупные самодостаточные блоки интерфейса (шапка сайта, интерактивный каталог, сайдбар профиля).features— сценарии взаимодействия пользователя с системой, несущие непосредственную бизнес-ценность (добавление в корзину, фильтрация каталога, переключение темы, авторизация).entities— бизнес-сущности предметной области (пользователь, товар, заказ, статья). Содержат UI-отображение сущности, базовое состояние и методы работы с API.shared— переиспользуемый инфраструктурный код, не привязанный к бизнес-логике приложения: UI-kit (кнопки, инпуты, модалки), HTTP-клиент, утилиты форматирования дат, базовые TypeScript-хелперы.
Слайсы: декомпозиция по бизнес-доменам
Слои pages, widgets, features и entities разбиваются на слайсы. Слайс — это изолированная смысловая единица (директория).
- В слое
entities:user,product,order. - В слое
features:auth-by-email,add-to-cart,search-products. - В слое
widgets:header,product-card-grid,cart-drawer.
Слои app и shared на слайсы не делятся. В shared модули группируются сразу по сегментам (shared/ui, shared/api, shared/lib).
Сегменты: техническое наполнение
Внутри каждого слайса код распределяется по функциональному назначению:
ui— React-компоненты, стили, анимации.model— бизнес-логика: хранилища состояния (Zustand, Redux slices), хуки, селекторы.api— сетевые запросы, DTO, эндпоинты, мапперы данных.lib— вспомогательные функции и утилиты, специфичные исключительно для этого слайса.config— локальные конфигурационные константы.
Золотое правило FSD: направление зависимостей и изоляция
Главная ценность FSD заключается в жестких ограничениях на импорты.
┌──────────────┐
│ app │
└──────┬───────┘
│
▼
┌──────────────┐
│ pages │
└──────┬───────┘
│
▼
┌──────────────┐
│ widgets │
└──────┬───────┘
│
▼
┌──────────────┐
│ features │
└──────┬───────┘
│
▼
┌──────────────┐
│ entities │
└──────┬───────┘
│
▼
┌──────────────┐
│ shared │
└──────────────┘
Однонаправленный поток импортов
- Модуль на слое
pagesможет импортировать код изwidgets,features,entities,shared. - Модуль на слое
featuresможет импортировать толькоentitiesиshared. - Модуль на слое
entitiesможет использовать исключительноshared. - Слой
sharedне имеет зависимостей от других слоев приложения.
Запрет горизонтальных связей между слайсами
Слайсы внутри одного слоя не могут импортировать друг друга. Например, features/add-to-cart не имеет права напрямую импортировать features/auth-by-email.
Если двум фичам нужно общее действие, логика либо выносится на слой выше (в widgets или pages), либо декомпозируется вниз (в entities или shared).
Public API: фасад модуля через index.ts
Каждый слайс обязан иметь входной файл index.ts. Он выполняет роль фасада, явно декларируя, какие компоненты, хуки и типы доступны внешнему миру:
// features/add-to-cart/index.ts
export { AddToCartButton } from './ui/add-to-cart-button';
export { useAddToCart } from './model/use-add-to-cart';
export type { AddToCartPayload } from './model/types';
Внешние модули имеют право импортировать код только из корня слайса:
// Корректно:
import { AddToCartButton } from '@/features/add-to-cart';
// Нарушение Public API:
import { AddToCartButton } from '@/features/add-to-cart/ui/add-to-cart-button';
Прямой доступ к внутренностям слайса запрещен. Это позволяет проводить внутренний рефакторинг без риска сломать внешние вызовы.
Разбор ключевой дилеммы: в чем разница между entities, features и widgets
Наибольшие сложности при освоении FSD вызывает разграничение средних слоев.
┌─────────────────────────────────────────────────────────────┐
│ WIDGETS │
│ Композиция данных и действий (Header, ProductCardWithBuy) │
└──────────────────────────────┬──────────────────────────────┘
│
┌───────────────┴───────────────┐
▼ ▼
┌─────────────────────────────┐ ┌─────────────────────────────┐
│ FEATURES │ │ ENTITIES │
│ Действия пользователя │ │ Данные и их отображение │
│ (AddToCart, ToggleFavorite) │ │ (ProductCard, UserAvatar) │
└─────────────────────────────┘ └─────────────────────────────┘
entities — данные и сущности предметной области
Слой entities представляет бизнес-концепции продукта. Он содержит модель данных, сетевые вызовы для получения этих данных и компоненты их пассивного отображения.
Примеры:
- Карточка товара (
ProductCard) с названием, ценой и изображением. - Аватар и краткая информация о пользователе (
UserProfileCard). - Хук
useProduct(id)для загрузки данных товара.
Важное ограничение: entities не содержат кнопок, запускающих самостоятельные бизнес-сценарии (например, кнопку «Купить» или «Удалить»).
features — действия и сценарии пользователя
Слой features реализует действия, приносящие прямую пользу бизнесу и пользователю.
Примеры:
- Кнопка «Добавить в корзину» с обработкой состояния загрузки и отправкой события в аналитику.
- Форма авторизации.
- Строка поиска с debounce и подсказками.
widgets — самостоятельные композиционные блоки
Виджеты объединяют entities и features в законченные UI-структуры.
Пример:
Полноценная карточка товара в ленте каталога: берет компонент отображения товара из entities/product и внедряет в него кнопку покупки из features/add-to-cart и кнопку добавления в избранное из features/toggle-favorite.
Настройка и использование FSD в связке с React и TypeScript
Конфигурация путей: path aliases в tsconfig.json
Для чистых импортов настраиваются алиасы путей к каждому слою:
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/app/*": ["src/app/*"],
"@/pages/*": ["src/pages/*"],
"@/widgets/*": ["src/widgets/*"],
"@/features/*": ["src/features/*"],
"@/entities/*": ["src/entities/*"],
"@/shared/*": ["src/shared/*"]
}
}
}
В сборщике (например, Vite через vite-tsconfig-paths или кастомные алиасы resolve.alias) эти пути синхронизируются с конфигурацией TypeScript.
Строгая типизация Public API: изоляция внутренних типов и DTO
TypeScript помогает предотвратить утечку серверных типов (DTO) в бизнес-логику интерфейса:
// entities/product/api/types.ts — внутренний тип API
export interface ProductServerDto {
id: string;
product_name: string;
price_cents: number;
}
// entities/product/model/types.ts — доменный тип приложения
export interface Product {
id: string;
name: string;
price: number;
}
В Public API слайса entities/product/index.ts наружу экспортируется только доменный интерфейс Product. Преобразование ProductServerDto в Product инкапсулируется внутри сегмента api или model.
Автоматизация контроля архитектуры с помощью линтеров
Человеческий фактор неизбежно приводит к случайным нарушениям правил импорта. Для автоматического контроля используются специализированные инструменты:
eslint-plugin-boundaries— позволяет гибко настроить типы модулей и ограничения на импорты между ними.@feature-sliced/eslint-config— готовый набор правил для FSD.
Пример конфигурации ограничений через ESLint:
// .eslintrc.js
module.exports = {
rules: {
'boundaries/element-types': [
2,
{
default: 'disallow',
rules: [
{ from: 'app', allow: ['pages', 'widgets', 'features', 'entities', 'shared'] },
{ from: 'pages', allow: ['widgets', 'features', 'entities', 'shared'] },
{ from: 'widgets', allow: ['features', 'entities', 'shared'] },
{ from: 'features', allow: ['entities', 'shared'] },
{ from: 'entities', allow: ['shared'] },
{ from: 'shared', allow: ['shared'] },
],
},
],
},
};
Практический пример: каталог и добавление товара в корзину
Рассмотрим реализацию взаимодействия слоев при добавлении товара в корзину.
1. Слой entities/product
Сущность отвечает только за отображение данных товара и предоставление слотов для действий:
// src/entities/product/ui/product-card.tsx
import { ReactNode } from 'react';
import type { Product } from '../model/types';
interface ProductCardProps {
product: Product;
actionSlot?: ReactNode;
}
export const ProductCard = ({ product, actionSlot }: ProductCardProps) => {
return (
<article className="rounded-lg border p-4 shadow-sm">
<img src={product.imageUrl} alt={product.name} className="h-48 w-full object-cover" />
<h3 className="mt-2 text-lg font-semibold">{product.name}</h3>
<p className="text-gray-600">${product.price.toFixed(2)}</p>
{actionSlot && <div className="mt-4">{actionSlot}</div>}
</article>
);
};
// src/entities/product/index.ts
export { ProductCard } from './ui/product-card';
export type { Product } from './model/types';
2. Слой features/add-to-cart
Фича содержит кнопку с логикой отправки действия в стейт или на сервер:
// src/features/add-to-cart/ui/add-to-cart-button.tsx
import { Button } from '@/shared/ui/button';
import { useCartStore } from '@/entities/cart';
interface AddToCartButtonProps {
productId: string;
}
export const AddToCartButton = ({ productId }: AddToCartButtonProps) => {
const addToCart = useCartStore((state) => state.addItem);
const handleClick = () => {
addToCart(productId);
};
return (
<Button onClick={handleClick} variant="primary">
В корзину
</Button>
);
};
// src/features/add-to-cart/index.ts
export { AddToCartButton } from './ui/add-to-cart-button';
3. Сборка widgets/product-card-view и страницы pages/catalog
Виджет выполняет композицию: объединяет entities/product и features/add-to-cart через механизм слотов (actionSlot):
// src/widgets/product-card-view/ui/product-card-view.tsx
import { ProductCard, type Product } from '@/entities/product';
import { AddToCartButton } from '@/features/add-to-cart';
interface ProductCardViewProps {
product: Product;
}
export const ProductCardView = ({ product }: ProductCardViewProps) => {
return (
<ProductCard
product={product}
actionSlot={<AddToCartButton productId={product.id} />}
/>
);
};
Страница pages/catalog собирает сетку виджетов:
// src/pages/catalog/ui/catalog-page.tsx
import { ProductCardView } from '@/widgets/product-card-view';
import { useCatalogProducts } from '../model/use-catalog-products';
export const CatalogPage = () => {
const { products, isLoading } = useCatalogProducts();
if (isLoading) return <div>Загрузка каталога...</div>;
return (
<main className="container mx-auto py-8">
<h1 className="mb-6 text-2xl font-bold">Каталог товаров</h1>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{products.map((product) => (
<ProductCardView key={product.id} product={product} />
))}
</div>
</main>
);
};
Благодаря такой структуре ProductCard остается независимым: его можно переиспользовать в админ-панели, подставив в actionSlot кнопку «Редактировать» вместо покупки.
Типичные ошибки при внедрении FSD и способы их избежать
1. Превращение shared в свалку кода
Частая ошибка начинающих команд — складывать в shared всё подряд: компоненты со сложной доменной логикой, контексты пользователей и глобальные запросы.
Решение: В shared может находиться только код, не знающий специфики предметной области. Если кнопка в shared содержит текст «Купить курс» или зависит от роли пользователя — это грубая ошибка декомпозиции.
2. Создание избыточных слоев (Overengineering)
Попытка сразу разбить простую форму из одного текстового поля на три отдельных слайса в entities, features и widgets.
Решение: Начинайте с простого. Если функциональность не переиспользуется и представляет собой локальный UI-блок, ее можно разместить непосредственно внутри слайса конкретной страницы в pages/.../ui. Выносить код в features или entities следует тогда, когда возникает потребность в композиции или повторном использовании.
3. Нарушение Public API и глубокие импорты
Импорт файлов в обход index.ts уничтожает инкапсуляцию и связывает модули намертво.
Решение: Настройте линтеры на запрет относительных импортов за пределы текущего слайса (../) и запрет прямых путей к сегментам чужих модулей.
Когда FSD необходим, а когда избыточен: вердикт архитектора
Feature-Sliced Design — мощный инструмент, но он не является универсальной серебряной пулей для каждого проекта.
| Критерий | FSD рекомендован | FSD избыточен |
|---|---|---|
| Размер команды | От 3–4 разработчиков, работающих над кодовой базой параллельно | 1 разработчик |
| Жизненный цикл | Долгоживущие enterprise-приложения, B2B/B2C платформы | Быстрые MVP, лендинги, промо-сайты со сроком жизни до полугода |
| Сложность бизнес-логики | Множество ролей, сущностей, интеграций и сценариев | Базовый CRUD с парой экранов |
| Требования к рефакторингу | Высокая частота смены бизнес-требований | Стабильный, неизменный функционал |
FSD раскрывается в средних и крупных проектах, где критически важно снизить стоимость внесения изменений, упростить онбординг новых инженеров и свести к минимуму возникновение циклических зависимостей.
Часто задаваемые вопросы (FAQ)
1. Обязательно ли использовать все слои FSD в проекте?
Нет. Слои widgets или features могут отсутствовать в простых проектах или на ранних стадиях разработки. Если в вашем приложении нет сложной композиции, страница pages может напрямую агрегировать entities и shared. Главное требование — сохранять относительный порядок зависимостей для тех слоев, которые вы используете.
2. Как FSD работает с Next.js App Router?
Next.js App Router использует файловую маршрутизацию внутри папки app/. В FSD-архитектуре системная директория app/ фреймворка используется исключительно как тонкий слой роутинга: файлы page.tsx и layout.tsx просто импортируют и рендерят компоненты из слоя pages вашей FSD-структуры (например, src/pages/home).
3. Что делать, если двум слайсам в features нужно взаимодействовать?
Горизонтальные импорты между фичами запрещены. Есть три архитектурных решения:
- Вынести общее состояние и типы на уровень ниже — в слой
entities. - Реализовать координацию на уровень выше — внутри
widgetsилиpages, передавая обработчики через пропсы или композицию. - Использовать шину событий или глобальные действия через стейт-менеджер, не связывая UI-компоненты напрямую.
4. Где хранить глобальный стейт (Redux, Zustand) и инстанс API-клиента?
Базовый инстанс API-клиента (например, сконфигурированный axios или fetch) размещается в shared/api. Конфигурация глобального стора (создание Redux Store, корневые провайдеры) находится в слое app. При этом слайсы состояния конкретных сущностей лежат внутри соответствующих слайсов entities/*/model или features/*/model и подключаются к корневому стору на уровне app.
5. В чем разница между shared/ui и entities?
Компоненты в shared/ui — это абстрактные строительные блоки дизайн-системы (Input, Button, Modal, Card). Они ничего не знают о пользователях, товарах, ценах или заказах. Компоненты в entities привязаны к конкретной предметной области: они принимают доменные объекты (например, Product) и отображают специфичные для бизнеса атрибуты.
6. Считается ли слой processes устаревшим?
Да, в актуальной спецификации FSD слой processes признан устаревшим (deprecated). Ранее он использовался для сквозных многошаговых сценариев (например, сложных шагов оформления заказа). На практике такой функционал эффективнее и проще реализуется внутри композиционных виджетов или слайсов страниц.
Заключение
Feature-Sliced Design переносит акцент с технической группировки файлов на прозрачную доменную структуру приложения. Четкие правила изоляции модулей, явный Public API и однонаправленный поток зависимостей избавляют кодовую базу от запутанных связей, снижают техдолг и позволяют команде масштабировать React-приложение годами без необходимости регулярного переписывания с нуля.




.svg.webp)




