Олег Марков
Принципы Feature Sliced Design - полное практическое руководство
Введение
Feature-Sliced Design (FSD) — это методология архитектуры фронтенд‑приложений, которая помогает организовать код вокруг бизнес‑функциональности, а не вокруг технических деталей. Проще говоря, вы раскладываете проект так, как о нем думают пользователи и бизнес, а не так, как удобно конкретному фреймворку.
Здесь мы разберем именно принципы FSD — не просто «как сложить папки», а как думать о слоях, слайсах, зависимостях и изоляции. Смотрите, я покажу вам, как эти идеи постепенно складываются в систему, которую можно применять и в React, и в Vue, и в других SPA.
Чтобы статья была практичной, мы будем идти от базовой теории к конкретным правилам и примерам структуры проекта, с разбором, почему это важно и что будет, если эти принципы игнорировать.
Основные идеи Feature-Sliced Design
Бизнес-функциональность как основа архитектуры
Главная идея FSD: архитектура должна отражать функциональность приложения, а не технологии. Вместо того чтобы строить проект вокруг «pages», «components», «hooks», вы выстраиваете его вокруг:
- доменов (области бизнеса — например, «Профиль пользователя», «Каталог товаров»)
- сценариев использования (например, «оформление заказа»)
- общих для системы политик и правил (например, «аутентификация», «управление доступом»)
Поэтому в FSD все крутится вокруг фич (features) и процессов (processes), а технические детали (UI, API, утилиты) становятся только инструментом.
Слои и слайсы: два измерения архитектуры
Feature-Sliced Design вводит два измерения:
- Слои (layers) — горизонтальное разделение по уровню абстракции.
- Слайсы (slices) — вертикальное разделение по доменам/функциям.
Давайте разберемся, как это выглядит.
Слои
Базовый набор слоев (часто используемая схема):
app— инициализация приложения, глобальная конфигурацияprocesses— сквозные процессы (например, onboarding, checkout)pages— полноценные страницы, собирающие UI из фич и сущностейfeatures— законченное бизнес‑поведение (например, «лайкнуть пост», «оформить заказ»)entities— бизнес‑сущности (User, Product, Order) и работа с нимиshared— переиспользуемые примитивы, не завязанные на конкретный домен
Важно: чем ниже слой, тем более «общим» и абстрактным он считается. Зависимости разрешены только сверху вниз, но не наоборот. То есть features может использовать entities и shared, но не app.
Слайсы
Слайс — это доменная «вертикаль». Например:
- в
entitiesмогут быть слайсыuser,product,order - в
features—add-to-cart,edit-profile,like-post - в
pages—home,product-details,cart
Внутри каждого слоя код разделяется на независимые слайсы по смыслу. Это помогает локализовать изменения: вы рефакторите «cart», не затрагивая «wishlist».
Принцип слоистой архитектуры
Правило направленных зависимостей
Одно из ключевых формальных правил FSD:
Слой может зависеть только от такого же или более «низкого» слоя.
Допустимые направления:
app→processes→pages→features→entities→shared- внутри слоя — зависимости между слайсами регулируются дополнительными правилами (чуть позже разберем)
То есть, например:
features/add-to-cartможет использоватьentities/productиshared/ui/Button- но
entities/productне имеет права импортироватьfeatures/add-to-cart
Нарушение этого правила приводит к «архитектурным циклам» и резкому усложнению поддержки.
Пример допустимого и недопустимого импорта
Давайте посмотрим, как это может выглядеть в React‑проекте:
// features/add-to-cart/ui/AddToCartButton.tsx
import { Product } from '@/entities/product'; // допустимо - слой entities ниже
import { Button } from '@/shared/ui/button'; // допустимо - слой shared ниже
import { useCartStore } from '@/entities/cart/model';// допустимо - тоже entities
export const AddToCartButton = ({ product }: { product: Product }) => {
// Здесь мы реализуем фичу "добавить товар в корзину"
const addToCart = useCartStore((state) => state.add);
const handleClick = () => {
// Добавляем товар в корзину
addToCart(product);
};
return (
<Button onClick={handleClick}>
Добавить в корзину
</Button>
);
};
А вот так делать нельзя:
// entities/product/model/useProductWithCart.ts
// Такой импорт нарушает принцип слоев
import { AddToCartButton } from '@/features/add-to-cart/ui/AddToCartButton';
// entities не должен зависеть от features
// Здесь мы "подмешиваем" фичу в сущность - это ошибочный подход с точки зрения FSD
Если вам «очень нужно» использовать функциональность из верхнего слоя — это сигнал, что:
- либо вы неправильно выбрали слой для этой логики
- либо часть логики нужно опустить ниже по слоям, абстрагировав от конкретной фичи
Принцип единой точки входа слоя
Каждый слой должен иметь контролируемые точки входа. Обычно это реализуется через:
- публичный API папки (например,
index.tsв корне слайса) - выделение того, что можно использовать снаружи, а что остается внутренней реализацией
Например, для слайса entities/user:
// entities/user/index.ts
// Публичный API сущности User
export { UserCard } from './ui/UserCard';
// Экспортируем UI-компонент для отображения пользователя
export { useUser } from './model/useUser';
// Экспортируем хук для получения данных о пользователе
export type { User, UserId } from './model/types';
// Экспортируем типы, которые используются в других слоях
Внутренние детали (api, вспомогательные утилиты, приватные компоненты) не выносятся в API. Это позволяет:
- ограничить зону «сломанного кода» при изменениях
- упростить навигацию по проекту: вы сразу видите, чем «разрешено» пользоваться
Принцип domain‑first: сначала смысл, потом структура
Почему важен доменный взгляд
Feature-Sliced Design предлагает сначала ответить на вопросы:
- какие у приложения домены
- какие сущности в каждом домене
- какие сценарии взаимодействия с ними
И только потом переходить к структуре слоев и файлов. Это называют domain-first подходом.
Например, интернет‑магазин:
- домены:
catalog,cart,order,user - сущности: Product, Category, Cart, Order, User, Address
- фичи: поиск товаров, добавление в корзину, применение промокода, оплата заказа, редактирование профиля
Неправильный подход — сразу начинать создавать components, hooks, services без понимания доменной модели. Такой проект обычно быстро «разъезжается».
От домена к структуре проекта
Давайте разберемся на примере. Допустим, у нас есть домены:
user— профиль пользователя, авторизацияproduct— карточка товара, описание, характеристикиcart— корзинаorder— заказ, оплата
Мы можем разложить их по слоям так:
entities/user,entities/product,entities/cart,entities/orderfeatures/auth-by-email,features/add-to-cart,features/apply-promo,features/checkoutpages/cart,pages/product-details,pages/profileprocesses/checkout-flow
Пример структуры (чуть упрощенный):
src/
app/
providers/
routing/
index.tsx
processes/
checkout-flow/
ui/
model/
lib/
pages/
cart/
ui/
model/
index.ts
product-details/
ui/
model/
index.ts
features/
add-to-cart/
ui/
model/
api/
index.ts
auth-by-email/
ui/
model/
api/
index.ts
entities/
user/
ui/
model/
api/
index.ts
product/
ui/
model/
api/
index.ts
cart/
ui/
model/
api/
index.ts
shared/
ui/
lib/
api/
config/
Как видите, домены проявляются в entities и features, а слои обеспечивают границы ответственности и порядок зависимостей.
Принцип инкапсуляции и публичного API слайса
Почему слайс — это модуль, а не папка «для красоты»
Каждый слайс в FSD — это модуль, который:
- инкапсулирует внутренние детали реализации
- предоставляет четко определенный публичный API
- может развиваться почти независимо от других слайсов
Чтобы это работало, важно:
- не импортировать файлы напрямую «вглубь» чужих слайсов
- опираться только на публичные точки входа
Пример инкапсуляции фичи
Покажу вам, как это реализовано на практике.
features/
add-to-cart/
ui/
AddToCartButton.tsx
model/
useAddToCart.ts
events.ts
types.ts
api/
cartApi.ts
lib/
getCartItemPayload.ts
index.ts
Содержимое API слайса:
// features/add-to-cart/index.ts
// Публичный API фичи "добавить в корзину"
export { AddToCartButton } from './ui/AddToCartButton';
// Экспортируем компонент кнопки для использования на страницах
export { useAddToCart } from './model/useAddToCart';
// Экспортируем хук, если где-то нужен программный вызов добавления
Внутренние детали (cartApi, вспомогательные функции, типы) остаются скрытыми. Снаружи их импортировать не следует:
// Плохо - прямой импорт внутренностей фичи
import { cartApi } from '@/features/add-to-cart/api/cartApi';
// Так мы обходим публичный API и создаем хрупкую зависимость
// Хорошо - использовать только публичный API
import { AddToCartButton } from '@/features/add-to-cart';
Такой подход делает фичу «самодостаточной»: если вы решите поменять реализацию add-to-cart, вам достаточно сохранить совместимый публичный API, а внутренности вы можете переписать без страха сломать другие модули.
Принцип явных зависимостей и изоляции
Явные зависимости вместо глобального состояния
FSD поощряет явно передавать зависимости:
- через параметры компонентов
- через публичные API слайсов
- через конфигурацию на уровне
app
А не «прятать» логику в:
- глобальных синглтонах
- неограниченно общих хуках из
shared - неформальных соглашениях «все знают, что здесь можно так сделать»
Например, вместо «волшебного» доступа к текущему пользователю:
// Плохо - неявная зависимость от глобального состояния
import { useCurrentUser } from '@/shared/model/useCurrentUser';
export const ProfileMenu = () => {
const user = useCurrentUser();
// ...
};
Лучше сделать зависимость явной через сущность:
// Хорошо - зависимость от entities/user через публичный API
// entities/user/index.ts
export { useCurrentUser } from './model/useCurrentUser';
// features/profile-menu/ui/ProfileMenu.tsx
import { useCurrentUser } from '@/entities/user';
export const ProfileMenu = () => {
const user = useCurrentUser();
// ...
};
А если компонент является чистым UI, зависимость можно передать через пропсы:
// entities/user/ui/UserMenu.tsx
import type { User } from '../model/types';
interface UserMenuProps {
user: User | null;
}
export const UserMenu = ({ user }: UserMenuProps) => {
// Здесь компонент не знает, откуда пришел пользователь
// Это делает его более переиспользуемым
};
Изоляция бизнес-правил
Еще один важный принцип: бизнес‑правила не должны размазываться по всему приложению. Они должны быть локализованы:
- либо в слое
entities(правила, связанные с конкретной сущностью) - либо в слое
features(правила, связанные с конкретным поведением/сценарием)
Например, правило «товар нельзя добавить в корзину, если он не в наличии»:
// entities/product/model/isAvailable.ts
import type { Product } from './types';
// Здесь мы инкапсулируем бизнес-правило доступности товара
export const isProductAvailable = (product: Product) => {
return product.stock > 0 && !product.isArchived;
};
Использование в фиче:
// features/add-to-cart/model/useAddToCart.ts
import { isProductAvailable } from '@/entities/product/model/isAvailable';
import { useCartStore } from '@/entities/cart/model';
export const useAddToCart = () => {
const addToCart = useCartStore((state) => state.add);
const handleAdd = (product) => {
// Обратите внимание - бизнес-правило вынесено в entities
if (!isProductAvailable(product)) {
// Здесь мы можем выбросить доменную ошибку или показать уведомление
throw new Error('Product is not available');
}
addToCart(product);
};
return { addToCart: handleAdd };
};
Такое разделение помогает:
- избегать дублирования правил в разных местах
- упростить тестирование (вы можете тестировать бизнес‑правила отдельно от UI)
Принцип независимых фич и слабой связанности
Фича как минимальная законченная единица
Фича в FSD — это:
- законченный фрагмент поведения с ценностью для пользователя
- не просто компонент, а сочетание UI + модель + взаимодействие с сущностями
- элемент, который можно использовать на разных страницах или в разных процессах
Например, features/add-to-cart:
- UI — кнопка, иконка, индикатор процесса
- модель — состояние загрузки, эффекты, обработка ошибок
- связи с доменом — работа с
entities/cartиentities/product
То есть фича — это не «маленькая страничка», а «умная» единица поведения.
Избегаем «фич‑гигантов»
Распространенная ошибка: делать одну фичу слишком большой, просто по названию блока в UI, например features/profile и складывать туда:
- редактирование профиля
- смену пароля
- загрузку аватара
- привязку соцсетей
Лучше разделить фичи:
features/edit-profilefeatures/change-passwordfeatures/upload-avatarfeatures/connect-social-account
Каждая фича решает одну конкретную задачу. Тогда:
- вы можете переиспользовать только нужную часть
- проще покрыть фичи тестами
- проще договариваться в команде, кто за что отвечает
Принцип «pages собирают, но не думают»
Роль страницы в FSD
Слой pages в Feature-Sliced Design играет роль:
- композиционного слоя, который собирает страницу из фич и сущностей
- интеграционного слоя с роутингом
- связывающего звена между URL и внутренними модулями
Главное правило: страницы не должны содержать сложную бизнес‑логику. Они только:
- выбирают нужные фичи и компоненты
- передают им параметры
- подключают нужные процессы, если необходимо
Пример страницы
Давайте разберемся на примере страницы корзины:
// pages/cart/ui/CartPage.tsx
import { CartItemsList } from '@/entities/cart';
import { OrderSummary } from '@/entities/order';
import { ApplyPromoCode } from '@/features/apply-promo-code';
import { CheckoutButton } from '@/features/checkout';
import { PageLayout } from '@/shared/ui/layouts/PageLayout';
export const CartPage = () => {
// Обратите внимание - в компоненте нет сложной логики
// Он только компонирует уже готовые фичи и сущности
return (
<PageLayout title="Корзина">
<CartItemsList />
<OrderSummary />
<ApplyPromoCode />
<CheckoutButton />
</PageLayout>
);
};
Как видите, CartPage:
- не знает, как именно применяется промокод
- не знает, как именно считается итоговая сумма
- не знает, как происходит переход к оплате
Все это инкапсулировано внутри entities и features. Страница только «собирает» их.
Это дает:
- меньше дублирования логики между страницами
- возможность переиспользовать фичи на разных страницах
- лучшую изоляцию для тестирования отдельных частей
Принцип процессов: сквозные сценарии отдельно от фич
Когда не хватает только фич
Иногда в приложении есть сложные сценарии, проходящие через несколько страниц и фич. Например:
- пошаговый onboarding
- мастер оформления заказа (checkout)
- сложный wizard настройки
Если попытаться «распихать» такой сценарий по отдельным фичам и страницам, логика может размазаться и стать плохо управляемой. Для этого в FSD существует слой processes.
Что такое process в FSD
processes — это:
- сквозные сценарии, пересекающие несколько слоев
- координация нескольких фич и сущностей
- общие для сценария политики и состояния (например, текущий шаг, общий прогресс)
Покажу вам, как это выглядит в коде.
processes/
checkout-flow/
ui/
CheckoutProgressBar.tsx
model/
useCheckoutFlow.ts
steps.ts
lib/
validateStep.ts
index.ts
// processes/checkout-flow/model/steps.ts
// Здесь мы описываем шаги процесса оформления заказа
export enum CheckoutStep {
Cart = 'cart',
Delivery = 'delivery',
Payment = 'payment',
Review = 'review',
}
// processes/checkout-flow/model/useCheckoutFlow.ts
import { useState } from 'react';
import { CheckoutStep } from './steps';
// Кастомный хук для управления шагами checkout-процесса
export const useCheckoutFlow = () => {
const [currentStep, setCurrentStep] = useState<CheckoutStep>(CheckoutStep.Cart);
const goToNextStep = () => {
// Здесь мы описываем переходы между шагами
// Детали опускаем для краткости
};
const goToPreviousStep = () => {
// Переход назад
};
return {
currentStep,
goToNextStep,
goToPreviousStep,
};
};
Использование в страницах:
// pages/checkout/ui/CheckoutPage.tsx
import { CheckoutProgressBar } from '@/processes/checkout-flow';
import { CartStep } from '@/features/checkout-cart-step';
import { DeliveryStep } from '@/features/checkout-delivery-step';
import { PaymentStep } from '@/features/checkout-payment-step';
export const CheckoutPage = () => {
// Страница связывает процесс и отдельные шаги-фичи
return (
<>
<CheckoutProgressBar />
{/* Здесь мы условно рендерим нужный шаг */}
{/* Имплементация опущена для краткости */}
<CartStep />
<DeliveryStep />
<PaymentStep />
</>
);
};
Таким образом, процессы позволяют:
- отделить «сквозную» логику от локальных фич
- избежать ситуации, когда одна огромная фича начинает управлять всем сценарием
Принцип shared‑слоя: общий, но не «помойка»
Назначение shared
Слой shared — это:
- общие примитивы, не завязанные на конкретный домен
- компоненты, которые можно использовать в любом контексте
- утилиты, которые не знают про бизнес‑логику
Примеры:
shared/ui/Button,shared/ui/Modal,shared/ui/Inputshared/lib/formatDate,shared/lib/debounceshared/api/baseClientshared/config/env
Но важно: shared — не место, куда «складывается все, что не влезло в другие слои». У этого слоя также есть четкие правила.
Принцип «shared не должен знать о домене»
Компоненты и функции в shared не должны зависеть от:
entities,features,pages,processes- доменных типов или бизнес‑правил
Например, такой код в shared — ошибка:
// Плохо - shared/ui/ProductCard знает о сущности Product
import type { Product } from '@/entities/product';
interface ProductCardProps {
product: Product; // зависимость от домена
}
export const ProductCard = ({ product }: ProductCardProps) => {
// ...
};
Лучше вынести такой компонент в entities/product/ui/ProductCard. А в shared оставить только действительно общие элементы, вроде:
// shared/ui/Card.tsx
interface CardProps {
children: React.ReactNode;
// Здесь нет зависимостей от доменов
}
export const Card = ({ children }: CardProps) => {
// Простой визуальный контейнер
return <div className="card">{children}</div>;
};
Тогда ProductCard можно реализовать так:
// entities/product/ui/ProductCard.tsx
import { Card } from '@/shared/ui/card';
import type { Product } from '../model/types';
interface ProductCardProps {
product: Product;
}
export const ProductCard = ({ product }: ProductCardProps) => {
// Здесь мы компонируем доменные данные и общий UI-примитив
return (
<Card>
<h3>{product.name}</h3>
<p>{product.price} ₽</p>
</Card>
);
};
Принцип адаптивности под проект и эволюции
Методология, а не жесткий шаблон
Важно понимать: FSD — это набор принципов, а не строгий шаблон с жестко зафиксированными папками. В небольшой pet‑проект не обязательно сразу тащить все слои и процессы.
Подход такой:
- сначала выделите хотя бы
shared,entities,features,pages - постепенно добавляйте
processes,appи дополнительные детали по мере роста проекта - адаптируйте структуру под ваши домены и команду, не ломая базовые принципы (направление зависимостей, инкапсуляция, domain‑first)
Эволюционное внедрение
Давайте разберемся на примере миграции существующего проекта к FSD.
Шаг 1. Ввод базовых слоев
- создаете папки
shared,entities,features,pages,app - настраиваете алиасы импортов (
@/shared,@/entities, и так далее) - новые модули создаете уже по FSD
- создаете папки
Шаг 2. Постепенный перенос существующего кода
- сущности (типы данных, модели, API) переносите в
entities - переиспользуемые компоненты — в
shared/ui - сценарные компоненты (конкретные действия пользователя) — в
features
- сущности (типы данных, модели, API) переносите в
Шаг 3. Ввод процессов
- когда замечаете сквозные сценарии, выносите их в
processes - постепенно чистите страницы и фичи от «сквозной» логики
- когда замечаете сквозные сценарии, выносите их в
Шаг 4. Уточнение публичных API
- добавляете
index.tsво все слайсы - ограничиваете импорты только через них
- подключаете линтер, чтобы контролировать правила зависимостей
- добавляете
Так вы можете внедрить принципы FSD без «большого взрыва».
Типичные ошибки при применении принципов FSD
Ошибка 1. Архитектура «по папкам, но не по смыслу»
Распространенная ситуация: в проекте есть папки app, entities, features и так далее, но:
- в
featuresлежат просто «красивые компоненты» - в
entitiesлежат случайные типы и хелперы - бизнес‑логика размазана по
pages
Формально структура соблюдена, но принципы доменной декомпозиции и инкапсуляции — нет.
Чтобы этого избежать:
- для каждой фичи явно формулируйте: какую бизнес‑задачу она решает
- для каждой сущности формулируйте: какие бизнес‑правила к ней относятся
- выносите логику со страниц в фичи и сущности
Ошибка 2. Слишком универсальный shared
Еще одна типичная проблема — превращение shared в мусорную корзину:
- туда попадает все, что «жалко положить в entities»
- появляются доменные зависимости
- shared начинает знать про конкретные бизнес‑сценарии
Как ориентир: если элемент хоть как‑то зависит от предметной области — это уже не shared. Лучше разместить его в соответствующем домене (entities или features), а потом, если он действительно окажется универсальным, вынести в shared с минимальными зависимостями.
Ошибка 3. Прямые импорты вглубь слайсов
Например:
// Плохо - импортируем конкретный файл из чужого слайса
import { something } from '@/features/add-to-cart/model/useAddToCart';
// Хорошо - импортируем только из публичного API
import { useAddToCart } from '@/features/add-to-cart';
Такая ошибка приводит к:
- хрупкости кода (любое изменение структуры ломает импорты)
- обходу контрактов модулей
- неявным связям между модулями
Решение: ввести жесткое правило — импортировать только из корней слайсов (index.ts) и контролировать это линтером.
Краткое резюме принципов Feature-Sliced Design
Чтобы зафиксировать основные идеи, соберем их в одном месте:
- Слои — разделяют код по уровням абстракции, зависимости идут только сверху вниз.
- Слайсы — разделяют код по доменам/функциям, внутри каждого слоя.
- Domain‑first — сначала проектируете домены и сценарии, затем структуру кода.
- Модули с публичным API — каждый слайс — модуль с четко определенной внешней поверхностью.
- Явные зависимости — избегаете скрытых связей и глобальных синглтонов, завязанных на бизнес‑логику.
- Фичи как единицы поведения — фича = UI + модель + связи с сущностями для конкретной задачи.
- Pages без бизнес‑логики — страницы собирают фичи и сущности, но не содержат сложных правил.
- Processes для сквозных сценариев — сложные многошаговые сценарии выносятся в отдельный слой.
- Shared без домена — общий слой не должен знать о предметной области.
- Эволюционное внедрение — методология адаптируется под размер и стадию проекта.
Если вы будете держать в голове эти принципы и проверять по ним свои архитектурные решения, структура проекта станет более предсказуемой, а изменения — менее болезненными.
Частозадаваемые технические вопросы по теме и ответы
Как контролировать соблюдение слоев и запрет «обратных» импортов?
Используйте ESLint с плагином для контроля импортов, например eslint-plugin-boundaries или eslint-plugin-import. Настройте правила так, чтобы:
- запрещать импорты «вверх по слоям»
- разрешать только импорты через публичные API (
index.tsслайса)
Сначала включите правила в режиме предупреждений, затем переведите в ошибки.
Как быть с кросс‑доменными сущностями например Order который связан и с user и с cart?
Выберите главный домен для сущности (например, order) и разместите ее в entities/order. Связи с другими сущностями оформите через типы и адаптеры, но избегайте циклических зависимостей. Если домены слишком тесно связаны, рассмотрите объединение их в один укрупненный домен.
Где хранить общий стейт например глобальную тему или локаль?
Глобальную инфраструктурную конфигурацию (тема, локаль, конфиг API) храните в app или shared:
- провайдеры React контекстов — в
app/providers - хуки и вспомогательные функции — в
shared/libилиshared/config
При этом бизнес‑логика, завязанная на эти параметры, должна оставаться в entities и features.
Как организовать тесты с учетом FSD?
Размещайте тесты рядом с кодом в том же слайсе:
ui‑компоненты тестируйте через компонентные тестыmodel(сторы, хуки) — через unit‑тесты- процессы — через интеграционные тесты, проверяющие переходы между шагами
Важно: тесты тоже должны импортировать код только через публичный API слайса, чтобы не обходить архитектурные ограничения.
Что делать с legacy‑кодом который трудно сразу разложить по слоям?
Оставьте его в отдельной зоне, например legacy/, и постепенно выносите куски логики в FSD‑слои:
- новые фичи сразу делайте по FSD
- при изменении legacy‑модуля старайтесь вырезать оттуда часть логики в
entitiesилиfeatures - постепенно объем legacy‑кода будет сокращаться, не блокируя развитие проекта