Feature-Sliced Design: архитектура фронтенд-проектов с нуля

Практический курс по архитектурной методологии FSD для фронтенд-разработчиков. Вы разберёте все слои архитектуры, правила организации кода и научитесь применять FSD в реальных проектах — с примерами кода и практическими заданиями.

Что такое FSD и зачем он нужен

Что такое FSD и зачем он нужен

Представьте: вы открываете проект, которому год. В папке components — 200 файлов. Половина называется Card, CardNew, CardV2, CardFinal. Хочешь найти логику авторизации — она размазана между hooks/useAuth.ts, services/authService.ts и components/LoginForm. Хочешь добавить новую фичу — боишься, потому что непонятно, что сломается. Знакомо? Именно эту боль решает Feature-Sliced Design.

Проблема, которую решает FSD

Большинство фронтенд-проектов начинаются одинаково: папки components, pages, hooks, utils. Это работает, пока проект маленький. Но когда команда растёт, фич становится больше, а требования меняются — структура начинает работать против вас.

Конкретные симптомы:

  • Новый разработчик не может понять, куда добавить код, не спросив коллегу
  • Изменение одного компонента ломает три других в неожиданных местах
  • Один и тот же бизнес-объект (например, «пользователь») описывается в пяти разных местах
  • Импорты превращаются в спагетти: components импортирует из pages, pages из hooks, hooks из components

Это называют "big ball of mud" — архитектурный антипаттерн, когда у кода нет структуры, только хаос, который нарастает со временем.

Что такое Feature-Sliced Design

Feature-Sliced Design (FSD) — это архитектурная методология для фронтенд-приложений, которая организует код вокруг бизнес-функциональности, а не технических деталей.

feature-sliced.design

Проще говоря: вместо того чтобы складывать файлы по типу («все компоненты сюда, все хуки туда»), вы складываете их по смыслу — «всё, что связано с корзиной, — сюда; всё, что связано с профилем пользователя, — туда».

FSD вводит два измерения организации кода:

  • Слои (layers) — горизонтальное разделение по уровню абстракции. Это фиксированный набор папок верхнего уровня: shared, entities, features, widgets, pages, processes, app.
  • Слайсы (slices) — вертикальное разделение внутри слоя по доменам. Например, внутри features будут слайсы add-to-cart, auth-by-email, edit-profile.

Внутри каждого слайса код делится на сегменты (segments) по технической роли: ui, model, api, lib, config.

Аналогия из жизни: представьте большой офис. Слои — это этажи здания (бухгалтерия на 2-м, разработка на 3-м, менеджмент на 4-м). Слайсы — это отделы на каждом этаже. Сегменты — это конкретные рабочие места внутри отдела. Каждый знает, где что находится, и никто не ходит на чужой этаж без причины.

Главное правило: зависимости только вниз

Ключевой принцип FSD — однонаправленные зависимости. Слой может импортировать только из слоёв, которые находятся ниже него в иерархии. Никогда — из слоёв выше.

Иерархия слоёв (от верхнего к нижнему):

app → processes → pages → widgets → features → entities → shared

Это значит: features может использовать entities и shared, но не может импортировать из pages или app. Нарушение этого правила создаёт циклические зависимости и превращает проект в тот самый клубок спагетти.

Пример нарушения, которое выглядит невинно, но разрушает архитектуру:

// ❌ entities/product/model/useProduct.ts
// Сущность импортирует из features — это запрещено!
import { AddToCartButton } from '@/features/add-to-cart';

Правильный подход — сущность ничего не знает о фичах. Фича сама знает о сущности:

// ✅ features/add-to-cart/ui/AddToCartButton.tsx
import { Product } from '@/entities/product'; // features → entities: разрешено
import { Button } from '@/shared/ui/button';   // features → shared: разрешено

Публичный API слайса

Второй фундаментальный принцип — каждый слайс общается с внешним миром только через публичный API. Это файл index.ts в корне слайса, который явно экспортирует только то, что разрешено использовать снаружи.

// features/add-to-cart/index.ts — публичный API фичи
export { AddToCartButton } from './ui/AddToCartButton';
export { useAddToCart } from './model/useAddToCart';
// Внутренние детали (cartApi, вспомогательные функции) НЕ экспортируются

Снаружи импортируем только через публичный API:

// ✅ Правильно
import { AddToCartButton } from '@/features/add-to-cart';

// ❌ Неправильно — прямой доступ к внутренностям
import { cartApi } from '@/features/add-to-cart/api/cartApi';

Зачем это нужно? Если вы решите переписать внутреннюю реализацию фичи, публичный API останется прежним — и ничего снаружи не сломается. Это как розетка в стене: вам не важно, как проложена проводка внутри, важно, что штепсель подходит.

Как FSD решает реальные проблемы

Вернёмся к симптомам из начала статьи и посмотрим, что меняется с FSD.

Проблема без FSD Решение в FSD
Непонятно, куда добавить код Чёткая иерархия слоёв и слайсов даёт однозначный ответ
Изменение ломает неожиданные места Публичный API и изолированные слайсы ограничивают зону влияния
Бизнес-объект описан в 5 местах Сущность (entity) — единственный источник правды о домене
Циклические импорты Правило однонаправленных зависимостей делает циклы невозможными
Долгий онбординг Структура отражает бизнес-логику — новый разработчик понимает её интуитивно

Команда из flaton.systems описывает это так: они адаптировали FSD под свой page-based подход и зафиксировали ключевой принцип — «держим связанные элементы рядом до тех пор, пока они не потребуются в другом месте». Это и есть суть FSD: локальность кода до момента, когда переиспользование становится реальной необходимостью.

Для каких проектов подходит FSD

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

FSD хорошо подходит, если:

  • Проект живёт дольше 3–6 месяцев и продолжает развиваться
  • В команде больше одного разработчика
  • Приложение имеет несколько доменных областей (пользователи, заказы, каталог и т.д.)
  • Требования часто меняются и нужна гибкость при рефакторинге

FSD работает с любым фреймворком — React, Vue, Angular, Svelte. Это методология, а не библиотека. Она не диктует стейт-менеджер или способ работы с API — только то, где должен жить код.

Первый шаг: думать доменами, а не файлами

Самое важное изменение при переходе на FSD — это не структура папок, а способ мышления. Прежде чем создавать файл, задайте себе вопросы:

  1. К какому домену относится этот код? (пользователь, заказ, корзина?)
  2. Это переиспользуемый примитив, бизнес-сущность или конкретное поведение пользователя?
  3. Кто должен знать об этом коде — весь проект или только одна страница?

Ответы на эти вопросы укажут на правильный слой и слайс. В следующей статье мы разберём каждый слой подробно — с примерами кода и конкретными правилами для shared, entities и features.

Слои архитектуры: Shared, Entities, Features

Слои архитектуры: Shared, Entities, Features

Три нижних слоя FSD — shared, entities, features — это фундамент всего приложения. Если они спроектированы правильно, верхние слои собираются как конструктор. Если нет — весь проект рассыпается. Разберём каждый из них до деталей: что туда кладут, что категорически нельзя, и как это выглядит в реальном коде.

Слой Shared: переиспользуемые примитивы

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

Типичное содержимое shared:

shared/
  ui/          # Кнопки, инпуты, модалки, иконки — UI без бизнес-логики
  lib/         # Утилиты: форматирование дат, валидация, хелперы
  api/         # Базовый HTTP-клиент, конфигурация axios/fetch
  config/      # Переменные окружения, константы приложения
  types/       # Общие TypeScript-типы (не доменные)

Ключевой вопрос для проверки: «Зависит ли этот код от конкретного домена приложения?» Если да — он не принадлежит shared.

Пример правильного компонента в shared/ui:

// shared/ui/Button/Button.tsx
interface ButtonProps {
  variant: 'primary' | 'secondary' | 'ghost';
  size: 'sm' | 'md' | 'lg';
  isLoading?: boolean;
  children: React.ReactNode;
  onClick?: () => void;
}

export const Button = ({ variant, size, isLoading, children, onClick }: ButtonProps) => {
  return (
    <button
      className={styles[variant]}
      disabled={isLoading}
      onClick={onClick}
    >
      {isLoading ? <Spinner size={size} /> : children}
    </button>
  );
};

Этот компонент ничего не знает о корзине, пользователях или заказах. Он просто кнопка.

Пример базового API-клиента в shared/api:

// shared/api/client.ts
import axios from 'axios';

export const apiClient = axios.create({
  baseURL: process.env.NEXT_PUBLIC_API_URL,
  headers: { 'Content-Type': 'application/json' },
});

apiClient.interceptors.response.use(
  (response) => response.data,
  (error) => Promise.reject(error.response?.data)
);

Частая ошибка: класть в shared хуки, которые обращаются к конкретному домену. Например, useCurrentUser — это не shared, это entities/user. В shared нет ничего про «текущего пользователя», «корзину» или «заказ».

Слой Entities: бизнес-сущности

Entities — слой бизнес-сущностей. Здесь живут объекты, которыми оперирует ваш бизнес: User, Product, Order, Cart. Каждая сущность — это отдельный слайс.

Структура типичного слайса сущности:

entities/
  user/
    ui/          # UserCard, UserAvatar — компоненты отображения
    model/       # Типы, стор, бизнес-правила сущности
    api/         # Запросы к API, связанные с пользователем
    index.ts     # Публичный API слайса
  product/
    ui/
    model/
    api/
    index.ts

Разберём слайс entities/user полностью:

// entities/user/model/types.ts
export interface User {
  id: string;
  name: string;
  email: string;
  role: 'admin' | 'customer';
  avatarUrl?: string;
}

// entities/user/model/userStore.ts
import { create } from 'zustand';
import type { User } from './types';

interface UserStore {
  currentUser: User | null;
  setCurrentUser: (user: User | null) => void;
}

export const useUserStore = create<UserStore>((set) => ({
  currentUser: null,
  setCurrentUser: (user) => set({ currentUser: user }),
}));
// entities/user/api/userApi.ts
import { apiClient } from '@/shared/api/client';
import type { User } from '../model/types';

export const fetchCurrentUser = (): Promise<User> =>
  apiClient.get('/users/me');

export const updateUser = (data: Partial<User>): Promise<User> =>
  apiClient.patch('/users/me', data);
// entities/user/ui/UserCard.tsx
import type { User } from '../model/types';

interface UserCardProps {
  user: User;
}

export const UserCard = ({ user }: UserCardProps) => (
  <div className={styles.card}>
    <img src={user.avatarUrl} alt={user.name} />
    <span>{user.name}</span>
    <span>{user.email}</span>
  </div>
);
// entities/user/index.ts — публичный API
export { UserCard } from './ui/UserCard';
export { useUserStore } from './model/userStore';
export { fetchCurrentUser, updateUser } from './api/userApi';
export type { User } from './model/types';

Обратите внимание: UserCard получает пользователя через пропсы. Компонент не знает, откуда пришли данные — из стора, из пропсов страницы или из запроса. Это делает его максимально переиспользуемым.

Что НЕ должно быть в entities: бизнес-логика, которая относится к конкретному действию пользователя. Например, «добавить товар в корзину» — это не сущность, это фича. Сущность Product знает, как выглядит продукт и доступен ли он. Но она не знает, как его добавить в корзину.

Пример бизнес-правила, которое правильно живёт в entities:

// entities/product/model/productUtils.ts
import type { Product } from './types';

// Правило доступности товара — это свойство самой сущности
export const isProductAvailable = (product: Product): boolean =>
  product.stock > 0 && !product.isArchived;

export const getDiscountedPrice = (product: Product): number =>
  product.discount
    ? product.price * (1 - product.discount / 100)
    : product.price;

Слой Features: пользовательские сценарии

Features — самый насыщенный слой. Здесь живут законченные пользовательские сценарии: «войти по email», «добавить товар в корзину», «применить промокод», «загрузить аватар». Каждая фича — это минимальная единица поведения, которая имеет ценность для пользователя.

Структура фичи:

features/
  add-to-cart/
    ui/          # AddToCartButton — компонент с кнопкой и состоянием загрузки
    model/       # Хук useAddToCart, логика добавления
    api/         # Запрос к API корзины
    index.ts     # Публичный API

Полный пример фичи add-to-cart:

// features/add-to-cart/api/cartApi.ts
import { apiClient } from '@/shared/api/client';

interface AddToCartPayload {
  productId: string;
  quantity: number;
}

export const addItemToCart = (payload: AddToCartPayload) =>
  apiClient.post('/cart/items', payload);
// features/add-to-cart/model/useAddToCart.ts
import { useState } from 'react';
import { isProductAvailable } from '@/entities/product';  // entities → разрешено
import { addItemToCart } from '../api/cartApi';
import type { Product } from '@/entities/product';

export const useAddToCart = () => {
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const addToCart = async (product: Product, quantity = 1) => {
    if (!isProductAvailable(product)) {
      setError('Товар недоступен');
      return;
    }

    setIsLoading(true);
    try {
      await addItemToCart({ productId: product.id, quantity });
    } catch {
      setError('Не удалось добавить товар');
    } finally {
      setIsLoading(false);
    }
  };

  return { addToCart, isLoading, error };
};
// features/add-to-cart/ui/AddToCartButton.tsx
import { Button } from '@/shared/ui/Button';       // shared → разрешено
import type { Product } from '@/entities/product'; // entities → разрешено
import { useAddToCart } from '../model/useAddToCart';

interface AddToCartButtonProps {
  product: Product;
}

export const AddToCartButton = ({ product }: AddToCartButtonProps) => {
  const { addToCart, isLoading, error } = useAddToCart();

  return (
    <>
      <Button
        variant="primary"
        isLoading={isLoading}
        onClick={() => addToCart(product)}
      >
        В корзину
      </Button>
      {error && <span className={styles.error}>{error}</span>}
    </>
  );
};
// features/add-to-cart/index.ts
export { AddToCartButton } from './ui/AddToCartButton';
export { useAddToCart } from './model/useAddToCart';

Как не превратить features в свалку

Самая распространённая ошибка — делать фичи слишком большими. Например, создать features/profile и сложить туда всё, что связано с профилем:

// ❌ Плохо — одна огромная фича
features/
  profile/
    ui/
      EditProfileForm.tsx
      ChangePasswordForm.tsx
      UploadAvatarButton.tsx
      ConnectSocialButton.tsx

Правильный подход — одна фича, одна задача:

// ✅ Хорошо — каждая фича решает одну задачу
features/
  edit-profile/
  change-password/
  upload-avatar/
  connect-social-account/

Это даёт три практических преимущества. Во-первых, разные разработчики могут работать над разными фичами параллельно без конфликтов. Во-вторых, каждую фичу можно покрыть тестами изолированно. В-третьих, фичу можно переиспользовать на разных страницах — например, upload-avatar может появиться и в настройках профиля, и в онбординге.

Ещё один важный момент: фичи одного слоя не должны импортировать друг друга. Если features/checkout нужна логика из features/apply-promo, это сигнал, что общую часть нужно опустить в entities или shared. Взаимодействие между фичами происходит через слои выше — через widgets или pages, которые их компонируют.

В следующей статье разберём верхние слои: widgets, pages, processes и app — и посмотрим, как они собирают всё вместе в готовый интерфейс.

Слои архитектуры: Widgets, Pages, Processes, App

Слои архитектуры: Widgets, Pages, Processes, App

Если shared, entities и features — это кирпичи, раствор и арматура, то widgets, pages, processes и app — это то, как из них строят здание. Верхние слои не создают новую логику с нуля, они компонуют то, что уже есть внизу. Разберём каждый слой и покажем, как они работают вместе.

Слой Widgets: крупные блоки интерфейса

Widgets — это самодостаточные блоки UI, которые объединяют несколько фич и сущностей в один законченный визуальный модуль. Виджет — это то, что пользователь воспринимает как единый элемент страницы: шапка сайта, боковая панель, карточка товара с кнопками действий, дашборд со статистикой.

Ключевое отличие виджета от фичи: фича — это одно конкретное действие («добавить в корзину»), виджет — это визуальный блок, который может содержать несколько действий сразу.

Структура типичного виджета:

widgets/
  product-card/
    ui/
      ProductCard.tsx
    model/
      useProductCard.ts
    index.ts
  header/
    ui/
      Header.tsx
      NavigationMenu.tsx
    index.ts

Пример виджета ProductCard, который объединяет сущность и несколько фич:

// widgets/product-card/ui/ProductCard.tsx
import { ProductInfo } from '@/entities/product';        // entities → разрешено
import { AddToCartButton } from '@/features/add-to-cart'; // features → разрешено
import { AddToWishlist } from '@/features/add-to-wishlist'; // features → разрешено
import { Card } from '@/shared/ui/Card';                  // shared → разрешено

interface ProductCardProps {
  productId: string;
}

export const ProductCard = ({ productId }: ProductCardProps) => {
  const { product, isLoading } = useProduct(productId);

  if (isLoading) return <Card.Skeleton />;
  if (!product) return null;

  return (
    <Card>
      <ProductInfo product={product} />
      <div className={styles.actions}>
        <AddToCartButton product={product} />
        <AddToWishlist productId={product.id} />
      </div>
    </Card>
  );
};
// widgets/product-card/index.ts
export { ProductCard } from './ui/ProductCard';

Виджет знает о фичах и сущностях, но страница знает только о виджете. Это важно: страница не должна напрямую импортировать AddToCartButton — она получает его в составе ProductCard.

Когда нужен виджет, а когда достаточно фичи? Простое правило: если блок UI используется на нескольких страницах и содержит больше одной фичи — это виджет. Если это одно конкретное действие — это фича.

Слой Pages: страницы как точки входа

Pages — слой, который соответствует маршрутам приложения. Каждая страница — это отдельный слайс, который компонует виджеты, фичи и сущности в готовый экран.

Главное правило страниц: они собирают, но не думают. Никакой сложной бизнес-логики на уровне страницы.

purpleschool.ru

Структура слоя pages:

pages/
  home/
    ui/
      HomePage.tsx
    index.ts
  product-details/
    ui/
      ProductDetailsPage.tsx
    model/
      useProductDetailsPage.ts  # Только логика загрузки данных для страницы
    index.ts
  cart/
    ui/
      CartPage.tsx
    index.ts

Пример страницы корзины — обратите внимание, насколько она «тупая»:

// pages/cart/ui/CartPage.tsx
import { CartItemsList, OrderSummary } from '@/entities/cart'; // entities
import { ApplyPromoCode } from '@/features/apply-promo-code'; // features
import { CheckoutButton } from '@/features/checkout';          // features
import { PageLayout } from '@/shared/ui/layouts/PageLayout';   // shared

export const CartPage = () => {
  // Страница не содержит бизнес-логики
  // Она только компонирует готовые блоки
  return (
    <PageLayout title="Корзина">
      <div className={styles.layout}>
        <CartItemsList />
        <aside>
          <ApplyPromoCode />
          <OrderSummary />
          <CheckoutButton />
        </aside>
      </div>
    </PageLayout>
  );
};

А вот пример страницы с деталями продукта, где нужна небольшая логика загрузки:

// pages/product-details/model/useProductDetailsPage.ts
import { useParams } from 'react-router-dom';
import { fetchProduct } from '@/entities/product';
import { useQuery } from '@tanstack/react-query';

// Логика загрузки данных для страницы — это допустимо на уровне pages
export const useProductDetailsPage = () => {
  const { productId } = useParams<{ productId: string }>();

  const { data: product, isLoading } = useQuery({
    queryKey: ['product', productId],
    queryFn: () => fetchProduct(productId!),
  });

  return { product, isLoading };
};
// pages/product-details/ui/ProductDetailsPage.tsx
import { ProductCard } from '@/widgets/product-card'; // widgets → разрешено
import { ProductReviews } from '@/widgets/product-reviews';
import { useProductDetailsPage } from '../model/useProductDetailsPage';

export const ProductDetailsPage = () => {
  const { product, isLoading } = useProductDetailsPage();

  return (
    <div>
      <ProductCard productId={product?.id} />
      <ProductReviews productId={product?.id} />
    </div>
  );
};

Слой Processes: сквозные сценарии

Processes — необязательный слой, который появляется в FSD для многошаговых сценариев, пересекающих несколько страниц. Классические примеры: онбординг нового пользователя, оформление заказа (checkout flow), мастер настройки.

Этот слой используется редко и только тогда, когда сценарий действительно «живёт» между страницами и не принадлежит ни одной из них.

processes/
  checkout-flow/
    ui/
      CheckoutStepper.tsx   # Индикатор шагов оформления
    model/
      checkoutStore.ts      # Состояние всего процесса оформления
      checkoutSteps.ts      # Конфигурация шагов
    index.ts
  onboarding/
    model/
      onboardingStore.ts
    index.ts

Пример стора для процесса оформления заказа:

// processes/checkout-flow/model/checkoutStore.ts
import { create } from 'zustand';

type CheckoutStep = 'cart' | 'delivery' | 'payment' | 'confirmation';

interface CheckoutStore {
  currentStep: CheckoutStep;
  completedSteps: CheckoutStep[];
  goToStep: (step: CheckoutStep) => void;
  completeStep: (step: CheckoutStep) => void;
}

export const useCheckoutStore = create<CheckoutStore>((set) => ({
  currentStep: 'cart',
  completedSteps: [],
  goToStep: (step) => set({ currentStep: step }),
  completeStep: (step) =>
    set((state) => ({
      completedSteps: [...state.completedSteps, step],
    })),
}));

Важный нюанс: в современных версиях FSD слой processes считается устаревающим (deprecated). Официальная документация рекомендует либо поднимать такую логику в app, либо распределять по страницам, используя стейт-менеджер. Но в реальных проектах processes всё ещё встречается и вполне оправдан для сложных многошаговых флоу.

Слой App: инициализация приложения

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

app/
  providers/
    AuthProvider.tsx      # Провайдер аутентификации
    ThemeProvider.tsx     # Провайдер темы
    QueryProvider.tsx     # React Query провайдер
    index.tsx             # Объединяет все провайдеры
  routing/
    routes.tsx            # Конфигурация маршрутов
    ProtectedRoute.tsx    # HOC для защищённых маршрутов
  styles/
    global.css            # Глобальные стили
    variables.css         # CSS-переменные
  index.tsx               # Точка входа приложения

Пример организации провайдеров:

// app/providers/index.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter } from 'react-router-dom';
import { ThemeProvider } from './ThemeProvider';
import { AuthProvider } from './AuthProvider';

const queryClient = new QueryClient({
  defaultOptions: {
    queries: { staleTime: 5 * 60 * 1000 },
  },
});

// Все провайдеры собраны в одном месте — удобно и предсказуемо
export const AppProviders = ({ children }: { children: React.ReactNode }) => (
  <BrowserRouter>
    <QueryClientProvider client={queryClient}>
      <ThemeProvider>
        <AuthProvider>
          {children}
        </AuthProvider>
      </ThemeProvider>
    </QueryClientProvider>
  </BrowserRouter>
);
// app/routing/routes.tsx
import { Routes, Route } from 'react-router-dom';
import { HomePage } from '@/pages/home';
import { ProductDetailsPage } from '@/pages/product-details';
import { CartPage } from '@/pages/cart';
import { ProtectedRoute } from './ProtectedRoute';

export const AppRouting = () => (
  <Routes>
    <Route path="/" element={<HomePage />} />
    <Route path="/products/:productId" element={<ProductDetailsPage />} />
    <Route
      path="/cart"
      element={
        <ProtectedRoute>
          <CartPage />
        </ProtectedRoute>
      }
    />
  </Routes>
);
// app/index.tsx — точка входа
import { AppProviders } from './providers';
import { AppRouting } from './routing';
import './styles/global.css';

export const App = () => (
  <AppProviders>
    <AppRouting />
  </AppProviders>
);

Как слои работают вместе

Теперь посмотрим на полную картину. Когда пользователь открывает страницу /products/42, происходит следующее:

  1. App — роутер определяет, что нужно рендерить ProductDetailsPage
  2. PagesProductDetailsPage загружает данные и компонует виджеты
  3. WidgetsProductCard объединяет отображение продукта и кнопки действий
  4. FeaturesAddToCartButton обрабатывает клик и вызывает API
  5. EntitiesProduct предоставляет тип данных и бизнес-правила (доступность)
  6. SharedButton, apiClient используются на всех уровнях

Каждый слой делает ровно то, что должен. Никто не лезет в чужую зону ответственности. Именно поэтому изменение в AddToCartButton не затронет ProductDetailsPage — они общаются только через публичный API виджета.

Правила и лучшие практики FSD

Правила и лучшие практики FSD

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

Правило публичного API — строже, чем кажется

Публичный API через index.ts — это не просто «удобный реэкспорт». Это контракт. Нарушение этого правила — самая частая ошибка при внедрении FSD.

Рассмотрим конкретный сценарий. Есть фича features/auth-by-email. Разработчик торопится и пишет:

// ❌ Прямой импорт внутренностей — нарушение публичного API
import { authSchema } from '@/features/auth-by-email/model/validation';
import { LoginForm } from '@/features/auth-by-email/ui/LoginForm';

Через месяц другой разработчик переименовывает validation.ts в schemas.ts и переносит LoginForm в подпапку. Всё ломается. Причём в неожиданных местах.

Правильный подход — всё, что нужно снаружи, явно экспортируется через index.ts:

// features/auth-by-email/index.ts
export { LoginForm } from './ui/LoginForm';
export { useAuthByEmail } from './model/useAuthByEmail';
// authSchema — внутренняя деталь, не экспортируем

// ✅ Снаружи импортируем только так:
import { LoginForm } from '@/features/auth-by-email';

Практическое правило: если вы пишете импорт с путём глубже одного уровня внутри чужого слайса — это нарушение. Путь @/features/auth-by-email — правильно. Путь @/features/auth-by-email/ui/LoginForm — нарушение.

Правило изоляции слайсов одного слоя

Слайсы внутри одного слоя не должны импортировать друг друга. Это правило часто удивляет новичков: «Как же так, ведь фичи могут быть связаны?»

Представьте: features/checkout импортирует features/apply-promo-code. Теперь изменение в apply-promo-code может сломать checkout. Вы получили скрытую связанность — именно то, от чего FSD защищает.

// ❌ Запрещено — фича импортирует другую фичу
// features/checkout/model/useCheckout.ts
import { usePromoCode } from '@/features/apply-promo-code';

Как решить проблему, если фичи действительно нужно взаимодействовать? Есть три пути:

Путь 1: Общую логику опустить в entities или shared.

// entities/cart/model/cartUtils.ts
// Логика расчёта скидки — это свойство корзины, не фичи
export const calculateCartTotal = (items: CartItem[], promoDiscount: number) =>
  items.reduce((sum, item) => sum + item.price * item.quantity, 0) * (1 - promoDiscount);

Путь 2: Взаимодействие организовать через слой выше — widgets или pages.

// widgets/checkout-summary/ui/CheckoutSummary.tsx
// Виджет знает об обеих фичах и координирует их
import { ApplyPromoCode } from '@/features/apply-promo-code';
import { CheckoutButton } from '@/features/checkout';

Путь 3: Использовать общий стор из entities как шину данных.

// entities/cart/model/cartStore.ts
// Обе фичи читают и пишут в один стор — не зная друг о друге
export const useCartStore = create<CartStore>(...);

Сегменты: стандартизация внутри слайса

Внутри каждого слайса код делится на сегменты по технической роли. Стандартные сегменты:

Сегмент Содержимое Пример
ui React-компоненты, стили LoginForm.tsx, UserCard.tsx
model Стейт, хуки, бизнес-логика useAuth.ts, userStore.ts
api Запросы к серверу authApi.ts, userApi.ts
lib Утилиты, специфичные для слайса formatUserName.ts
config Константы, конфигурация authConfig.ts
types TypeScript-типы types.ts

Не все сегменты обязательны. Маленькая фича может состоять только из ui и model. Главное — не изобретать свои названия без причины. Если вся команда использует model для стейта, а один разработчик создаёт store — это создаёт когнитивную нагрузку.

Алиасы путей: обязательная настройка

FSD без настроенных алиасов путей — это боль. Относительные пути вида ../../../../shared/ui/Button делают код нечитаемым и хрупким.

Настройка алиасов в tsconfig.json:

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"]
    }
  }
}

Настройка в vite.config.ts:

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';

export default defineConfig({
  plugins: [react()],
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src'),
    },
  },
});

После этого импорты становятся читаемыми и не зависят от глубины вложенности файла:

// ✅ Читаемо и стабильно
import { Button } from '@/shared/ui/Button';
import { UserCard } from '@/entities/user';
import { AddToCartButton } from '@/features/add-to-cart';

Линтер для архитектурных правил

Человеческий контроль за правилами зависимостей не работает в команде. Нужна автоматизация. Для этого существует плагин eslint-plugin-boundaries или специализированный @feature-sliced/eslint-config.

Пример конфигурации с eslint-plugin-boundaries:

// .eslintrc.js
module.exports = {
  plugins: ['boundaries'],
  settings: {
    'boundaries/elements': [
      { type: 'shared', pattern: 'src/shared/*' },
      { type: 'entities', pattern: 'src/entities/*' },
      { type: 'features', pattern: 'src/features/*' },
      { type: 'widgets', pattern: 'src/widgets/*' },
      { type: 'pages', pattern: 'src/pages/*' },
      { type: 'app', pattern: 'src/app/*' },
    ],
  },
  rules: {
    'boundaries/element-types': [
      'error',
      {
        default: 'disallow',
        rules: [
          { from: 'shared', allow: ['shared'] },
          { from: 'entities', allow: ['shared', 'entities'] },
          { from: 'features', allow: ['shared', 'entities'] },
          { from: 'widgets', allow: ['shared', 'entities', 'features'] },
          { from: 'pages', allow: ['shared', 'entities', 'features', 'widgets'] },
          { from: 'app', allow: ['shared', 'entities', 'features', 'widgets', 'pages'] },
        ],
      },
    ],
  },
};

Теперь попытка импортировать features из entities вызовет ошибку линтера прямо в редакторе — до того, как код попадёт в ревью.

Типичные ловушки и как их избежать

Ловушка 1: «Умные» страницы. Страница начинает содержать бизнес-логику — валидацию форм, расчёты, прямые вызовы API. Признак: в pages/checkout/ui/CheckoutPage.tsx больше 100 строк логики. Решение: вынести логику в model страницы или в фичу.

Ловушка 2: Раздутый shared. Разработчики кладут в shared всё, что «может пригодиться». Через полгода там 300 файлов, половина из которых доменно-специфична. Признак: в shared есть useCurrentUser, cartHelpers, orderFormatter. Решение: регулярно аудировать shared и перемещать доменный код в entities.

Ловушка 3: Слайсы-монолиты. Один слайс разрастается до нескольких тысяч строк. Признак: features/profile содержит 15 компонентов и 8 хуков. Решение: дробить по принципу «одна задача — один слайс».

Ловушка 4: Игнорирование публичного API в shared. Разработчики думают, что правило публичного API касается только features и entities. Но shared тоже должен иметь index.ts для каждой подпапки:

// shared/ui/index.ts
export { Button } from './Button';
export { Input } from './Input';
export { Modal } from './Modal';

// Использование:
import { Button, Input } from '@/shared/ui';
// Не: import { Button } from '@/shared/ui/Button/Button';

Когда FSD можно адаптировать

FSD — это методология, а не жёсткий стандарт. Реальные команды адаптируют её под свои нужды. Как описывает команда flaton.systems, они убрали слой processes, добавили слой styles для глобальных стилей и сделали страницы основными архитектурными единицами.

Это нормально. Главное — сохранять два фундаментальных принципа:

  1. Однонаправленные зависимости: слои зависят только от нижних слоёв
  2. Публичный API: слайсы общаются только через index.ts

Всё остальное — детали реализации, которые можно подстраивать под контекст проекта.

Практическое применение: структура реального проекта

Практическое применение: структура реального проекта

Теория без практики — это просто красивые схемы. Давайте построим реальный проект с нуля: интернет-магазин с каталогом, корзиной, авторизацией и оформлением заказа. Пройдём весь путь — от анализа доменов до готовой структуры папок с реальным кодом.

Шаг 1: Анализ доменов перед созданием папок

Прежде чем открывать редактор, ответьте на три вопроса о вашем приложении. Это называют domain-first подходом, и он принципиально важен: структура папок должна отражать бизнес, а не технологии.

Какие домены есть в приложении?

  • catalog — каталог товаров, категории, поиск
  • cart — корзина покупок
  • order — оформление и история заказов
  • user — профиль, авторизация

Какие сущности в каждом домене?

  • Product, Category — каталог
  • Cart, CartItem — корзина
  • Order, OrderItem — заказы
  • User, Address — пользователь

Какие пользовательские сценарии (фичи)?

  • auth-by-email, logout — авторизация
  • add-to-cart, remove-from-cart, update-cart-item — работа с корзиной
  • apply-promo-code — промокоды
  • checkout — оформление заказа
  • search-products, filter-products — поиск и фильтрация
  • edit-profile, change-password — профиль

Теперь у нас есть карта проекта. Можно строить структуру.

Шаг 2: Полная структура проекта

src/
├── app/
│   ├── providers/
│   │   ├── AuthProvider.tsx
│   │   ├── QueryProvider.tsx
│   │   ├── ThemeProvider.tsx
│   │   └── index.tsx
│   ├── routing/
│   │   ├── AppRouter.tsx
│   │   └── ProtectedRoute.tsx
│   ├── styles/
│   │   ├── global.css
│   │   └── variables.css
│   └── index.tsx
│
├── pages/
│   ├── home/
│   │   ├── ui/HomePage.tsx
│   │   └── index.ts
│   ├── catalog/
│   │   ├── ui/CatalogPage.tsx
│   │   ├── model/useCatalogPage.ts
│   │   └── index.ts
│   ├── product-details/
│   │   ├── ui/ProductDetailsPage.tsx
│   │   ├── model/useProductDetailsPage.ts
│   │   └── index.ts
│   ├── cart/
│   │   ├── ui/CartPage.tsx
│   │   └── index.ts
│   ├── checkout/
│   │   ├── ui/CheckoutPage.tsx
│   │   ├── model/useCheckoutPage.ts
│   │   └── index.ts
│   ├── orders/
│   │   ├── ui/OrdersPage.tsx
│   │   └── index.ts
│   ├── profile/
│   │   ├── ui/ProfilePage.tsx
│   │   └── index.ts
│   └── auth/
│       ├── ui/AuthPage.tsx
│       └── index.ts
│
├── widgets/
│   ├── header/
│   │   ├── ui/
│   │   │   ├── Header.tsx
│   │   │   └── NavigationMenu.tsx
│   │   └── index.ts
│   ├── product-card/
│   │   ├── ui/ProductCard.tsx
│   │   ├── model/useProductCard.ts
│   │   └── index.ts
│   ├── cart-sidebar/
│   │   ├── ui/CartSidebar.tsx
│   │   └── index.ts
│   └── order-summary/
│       ├── ui/OrderSummary.tsx
│       ├── model/useOrderSummary.ts
│       └── index.ts
│
├── features/
│   ├── auth-by-email/
│   │   ├── ui/LoginForm.tsx
│   │   ├── model/useAuthByEmail.ts
│   │   ├── api/authApi.ts
│   │   └── index.ts
│   ├── logout/
│   │   ├── ui/LogoutButton.tsx
│   │   ├── model/useLogout.ts
│   │   └── index.ts
│   ├── add-to-cart/
│   │   ├── ui/AddToCartButton.tsx
│   │   ├── model/useAddToCart.ts
│   │   ├── api/cartApi.ts
│   │   └── index.ts
│   ├── remove-from-cart/
│   │   ├── ui/RemoveFromCartButton.tsx
│   │   ├── model/useRemoveFromCart.ts
│   │   └── index.ts
│   ├── apply-promo-code/
│   │   ├── ui/PromoCodeForm.tsx
│   │   ├── model/useApplyPromoCode.ts
│   │   ├── api/promoApi.ts
│   │   └── index.ts
│   ├── checkout/
│   │   ├── ui/CheckoutForm.tsx
│   │   ├── model/useCheckout.ts
│   │   ├── api/orderApi.ts
│   │   └── index.ts
│   ├── search-products/
│   │   ├── ui/SearchBar.tsx
│   │   ├── model/useSearchProducts.ts
│   │   └── index.ts
│   ├── filter-products/
│   │   ├── ui/FilterPanel.tsx
│   │   ├── model/useFilterProducts.ts
│   │   └── index.ts
│   ├── edit-profile/
│   │   ├── ui/EditProfileForm.tsx
│   │   ├── model/useEditProfile.ts
│   │   ├── api/profileApi.ts
│   │   └── index.ts
│   └── change-password/
│       ├── ui/ChangePasswordForm.tsx
│       ├── model/useChangePassword.ts
│       └── index.ts
│
├── entities/
│   ├── user/
│   │   ├── ui/UserAvatar.tsx
│   │   ├── model/
│   │   │   ├── types.ts
│   │   │   └── userStore.ts
│   │   ├── api/userApi.ts
│   │   └── index.ts
│   ├── product/
│   │   ├── ui/
│   │   │   ├── ProductInfo.tsx
│   │   │   └── ProductPrice.tsx
│   │   ├── model/
│   │   │   ├── types.ts
│   │   │   └── productUtils.ts
│   │   ├── api/productApi.ts
│   │   └── index.ts
│   ├── cart/
│   │   ├── ui/
│   │   │   ├── CartItemCard.tsx
│   │   │   └── CartTotal.tsx
│   │   ├── model/
│   │   │   ├── types.ts
│   │   │   └── cartStore.ts
│   │   ├── api/cartApi.ts
│   │   └── index.ts
│   └── order/
│       ├── ui/OrderCard.tsx
│       ├── model/
│       │   ├── types.ts
│       │   └── orderUtils.ts
│       ├── api/orderApi.ts
│       └── index.ts
│
└── shared/
    ├── ui/
    │   ├── Button/
    │   │   ├── Button.tsx
    │   │   └── index.ts
    │   ├── Input/
    │   ├── Modal/
    │   ├── Spinner/
    │   ├── Badge/
    │   └── index.ts
    ├── api/
    │   ├── client.ts
    │   └── index.ts
    ├── lib/
    │   ├── formatPrice.ts
    │   ├── formatDate.ts
    │   └── index.ts
    ├── config/
    │   ├── env.ts
    │   └── index.ts
    └── types/
        └── index.ts

Шаг 3: Реализация ключевых частей

Посмотрим, как конкретные части этой структуры реализуются в коде. Начнём с сущности cart — она центральная для нашего магазина.

// entities/cart/model/types.ts
import type { Product } from '@/entities/product';

export interface CartItem {
  id: string;
  product: Product;
  quantity: number;
}

export interface Cart {
  items: CartItem[];
  promoCode: string | null;
  promoDiscount: number;
}
// entities/cart/model/cartStore.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { Cart, CartItem } from './types';
import type { Product } from '@/entities/product';

interface CartStore extends Cart {
  addItem: (product: Product, quantity?: number) => void;
  removeItem: (itemId: string) => void;
  updateQuantity: (itemId: string, quantity: number) => void;
  setPromoCode: (code: string, discount: number) => void;
  clearCart: () => void;
  getTotalPrice: () => number;
}

export const useCartStore = create<CartStore>()(
  persist(
    (set, get) => ({
      items: [],
      promoCode: null,
      promoDiscount: 0,

      addItem: (product, quantity = 1) => {
        set((state) => {
          const existingItem = state.items.find(
            (item) => item.product.id === product.id
          );
          if (existingItem) {
            return {
              items: state.items.map((item) =>
                item.product.id === product.id
                  ? { ...item, quantity: item.quantity + quantity }
                  : item
              ),
            };
          }
          return {
            items: [...state.items, { id: crypto.randomUUID(), product, quantity }],
          };
        });
      },

      removeItem: (itemId) =>
        set((state) => ({
          items: state.items.filter((item) => item.id !== itemId),
        })),

      updateQuantity: (itemId, quantity) =>
        set((state) => ({
          items: state.items.map((item) =>
            item.id === itemId ? { ...item, quantity } : item
          ),
        })),

      setPromoCode: (code, discount) =>
        set({ promoCode: code, promoDiscount: discount }),

      clearCart: () => set({ items: [], promoCode: null, promoDiscount: 0 }),

      getTotalPrice: () => {
        const { items, promoDiscount } = get();
        const subtotal = items.reduce(
          (sum, item) => sum + item.product.price * item.quantity,
          0
        );
        return subtotal * (1 - promoDiscount / 100);
      },
    }),
    { name: 'cart-storage' }
  )
);

Теперь фича add-to-cart, которая использует этот стор:

// features/add-to-cart/model/useAddToCart.ts
import { useCartStore } from '@/entities/cart';
import { isProductAvailable } from '@/entities/product';
import type { Product } from '@/entities/product';

export const useAddToCart = () => {
  const addItem = useCartStore((state) => state.addItem);

  const addToCart = (product: Product, quantity = 1) => {
    if (!isProductAvailable(product)) {
      throw new Error(`Товар "${product.name}" недоступен для заказа`);
    }
    addItem(product, quantity);
  };

  return { addToCart };
};
// features/add-to-cart/ui/AddToCartButton.tsx
import { useState } from 'react';
import { Button } from '@/shared/ui/Button';
import type { Product } from '@/entities/product';
import { useAddToCart } from '../model/useAddToCart';

interface AddToCartButtonProps {
  product: Product;
  quantity?: number;
}

export const AddToCartButton = ({ product, quantity = 1 }: AddToCartButtonProps) => {
  const { addToCart } = useAddToCart();
  const [added, setAdded] = useState(false);

  const handleClick = () => {
    addToCart(product, quantity);
    setAdded(true);
    setTimeout(() => setAdded(false), 2000);
  };

  return (
    <Button variant="primary" onClick={handleClick}>
      {added ? '✓ Добавлено' : 'В корзину'}
    </Button>
  );
};

Шаг 4: Страница как финальная сборка

Посмотрим, как страница каталога собирает всё вместе:

// pages/catalog/model/useCatalogPage.ts
import { useQuery } from '@tanstack/react-query';
import { fetchProducts } from '@/entities/product';
import { useFilterProducts } from '@/features/filter-products';
import { useSearchProducts } from '@/features/search-products';

export const useCatalogPage = () => {
  const { filters } = useFilterProducts();
  const { searchQuery } = useSearchProducts();

  const { data: products, isLoading } = useQuery({
    queryKey: ['products', filters, searchQuery],
    queryFn: () => fetchProducts({ ...filters, search: searchQuery }),
  });

  return { products, isLoading, filters, searchQuery };
};
// pages/catalog/ui/CatalogPage.tsx
import { SearchBar } from '@/features/search-products';
import { FilterPanel } from '@/features/filter-products';
import { ProductCard } from '@/widgets/product-card';
import { Spinner } from '@/shared/ui/Spinner';
import { useCatalogPage } from '../model/useCatalogPage';

export const CatalogPage = () => {
  const { products, isLoading } = useCatalogPage();

  return (
    <div className={styles.layout}>
      <aside className={styles.sidebar}>
        <FilterPanel />
      </aside>
      <main>
        <SearchBar />
        {isLoading ? (
          <Spinner />
        ) : (
          <div className={styles.grid}>
            {products?.map((product) => (
              <ProductCard key={product.id} productId={product.id} />
            ))}
          </div>
        )}
      </main>
    </div>
  );
};

Страница не знает, как работает фильтрация или поиск. Она только компонует готовые блоки.

Шаг 5: Роутинг в App

// app/routing/AppRouter.tsx
import { Routes, Route, Navigate } from 'react-router-dom';
import { HomePage } from '@/pages/home';
import { CatalogPage } from '@/pages/catalog';
import { ProductDetailsPage } from '@/pages/product-details';
import { CartPage } from '@/pages/cart';
import { CheckoutPage } from '@/pages/checkout';
import { OrdersPage } from '@/pages/orders';
import { ProfilePage } from '@/pages/profile';
import { AuthPage } from '@/pages/auth';
import { ProtectedRoute } from './ProtectedRoute';

export const AppRouter = () => (
  <Routes>
    <Route path="/" element={<HomePage />} />
    <Route path="/catalog" element={<CatalogPage />} />
    <Route path="/catalog/:productId" element={<ProductDetailsPage />} />
    <Route path="/cart" element={<CartPage />} />
    <Route path="/auth" element={<AuthPage />} />
    <Route
      path="/checkout"
      element={<ProtectedRoute><CheckoutPage /></ProtectedRoute>}
    />
    <Route
      path="/orders"
      element={<ProtectedRoute><OrdersPage /></ProtectedRoute>}
    />
    <Route
      path="/profile"
      element={<ProtectedRoute><ProfilePage /></ProtectedRoute>}
    />
    <Route path="*" element={<Navigate to="/" replace />} />
  </Routes>
);

Как мигрировать существующий проект

Если у вас уже есть проект без FSD, не нужно переписывать всё сразу. Команда pvsm.ru описывает реальный опыт: они внедряли FSD итерационно, раздел за разделом, и это заняло несколько спринтов.

Практический план миграции:

  1. Создайте папки слоёв рядом со старой структурой — app, pages, features, entities, shared
  2. Настройте алиасы путей в tsconfig.json и vite.config.ts
  3. Начните с shared — перенесите туда переиспользуемые UI-компоненты и утилиты
  4. Выберите одну страницу и полностью перепишите её по FSD — это даст команде живой пример
  5. При работе над новыми фичами сразу создавайте их в правильном слое
  6. Постепенно переносите старый код при рефакторинге — не трогайте то, что работает и не меняется

Ключевое правило миграции: «локально до последнего». Если код используется только на одной странице — пусть живёт внутри этой страницы. Выносить в features или entities нужно только тогда, когда появляется второй потребитель.

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