Skip to main content
architectureIntermediate50 минут

Feature-Sliced Design на практике

Практическое руководство по Feature-Sliced Design - архитектуре для масштабируемых фронтенд-приложений. От теории к реальным кейсам.

#fsd#architecture#frontend#react#nextjs#typescript#code-organization#best-practices

Feature-Sliced Design на практике

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

Как пользоваться материалом

  • Начинаешь новый проект? Читай разделы про структуру слоёв и правила импортов.
  • Рефакторишь legacy код? Изучи миграцию и антипаттерны.
  • Работаешь в команде? Сфокусируйся на соглашениях и ESLint правилах.
  • Хочешь быстро понять концепцию? Смотри примеры из реальных проектов.

Глоссарий

FSD (Feature-Sliced Design) - Архитектурная методология для фронтенд-проектов, основанная на разделении по слоям и сегментам.

Слой (Layer) - Горизонтальный уровень архитектуры (app, pages, widgets, features, entities, shared).

Сегмент (Segment) - Директория внутри слайса для группировки кода по назначению (ui, model, api, lib, config).

Слайс (Slice) - Вертикальный модуль в рамках слоя, объединяющий логику одной бизнес-сущности.

Public API - Явно экспортируемый интерфейс модуля через index.ts (barrel export).

Barrel Export - Паттерн реэкспорта через index.ts для создания единой точки входа.

Isolation - Принцип изоляции: слой не знает о вышележащих слоях.

Low Coupling - Слабая связанность: минимум зависимостей между модулями.

High Cohesion - Сильная связность внутри модуля: код объединён по смыслу.

Введение: Почему FSD?

Проблемы традиционных подходов

Структура "по типам файлов":

Дерево показывает хаос структуры по типам файлов — так делать не нужно.

src/
  components/  ← 200+ файлов
  hooks/       ← Куча хуков
  utils/       ← Свалка утилит
  services/    ← Непонятная логика

Проблемы:

  • Невозможно найти код фичи
  • Всё связано со всем
  • Сложно удалить фичу (файлы разбросаны)
  • Нет понятных границ ответственности

Структура "по фичам" (наивная):

Дерево с наивной структурой по фичам демонстрирует отсутствие правил.

src/
  features/
    user-profile/  ← Что здесь? UI? Логика? API?
    payments/
    notifications/

Проблемы:

  • Нет единых соглашений
  • Переиспользуемость страдает
  • Циклические зависимости
  • Каждый делает как хочет

FSD как решение

Стандартная целевая структура слоёв FSD для сравнения.

src/
  app/         ← Инициализация приложения
  pages/       ← Страницы (роутинг)
  widgets/     ← Композиции фич
  features/    ← Пользовательские сценарии
  entities/    ← Бизнес-сущности
  shared/      ← Переиспользуемый код

Преимущества:

  • Понятная структура для любого разработчика
  • Строгие правила импортов (нет спагетти)
  • Легко масштабировать команду
  • Простое удаление фич
  • Переиспользование без дублирования

Слои FSD: Подробно

Главное правило FSD: слои импортируют только нижележащие слои. Это как слоёный пирог - верхние слои опираются на нижние, но не наоборот.

1. Shared - Фундамент

Назначение: Переиспользуемый код без бизнес-логики.

Что здесь:

  • UI-компоненты (Button, Input, Card)
  • Утилиты (cn, formatDate, validators)
  • API клиенты (axios, fetch обёртки)
  • Конфиги (константы, env)
  • Типы общего назначения

Структура:

Скелет содержимого слоя shared, чтобы видеть, что считается фундаментом.

shared/
  ui/               ← UI-примитивы
    button/
      button.tsx
      button.test.tsx
    index.ts        ← Barrel export
  lib/              ← Утилиты
    cn.ts
    format.ts
  api/              ← API базовый слой
    client.ts
    types.ts
  config/           ← Конфигурация
    routes.ts
    constants.ts
  icons/            ← Иконки
    index.ts

Пример: UI компонент

Реализация UI-примитива Button показывает, как выглядит код в shared.

// shared/ui/button/button.tsx
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/shared/lib";
 
const buttonVariants = cva(
  "inline-flex items-center justify-center rounded-md text-sm font-medium",
  {
    variants: {
      variant: {
        default: "bg-primary text-primary-foreground hover:bg-primary/90",
        outline: "border border-input bg-background hover:bg-accent",
        ghost: "hover:bg-accent hover:text-accent-foreground",
      },
      size: {
        default: "h-10 px-4 py-2",
        sm: "h-9 rounded-md px-3",
        lg: "h-11 rounded-md px-8",
      },
    },
    defaultVariants: {
      variant: "default",
      size: "default",
    },
  }
);
 
export interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {}
 
export function Button({ className, variant, size, ...props }: ButtonProps) {
  return (
    <button
      className={cn(buttonVariants({ variant, size, className }))}
      {...props}
    />
  );
}

Barrel shared/ui фиксирует публичный API примитивов.

// shared/ui/index.ts (Public API)
export { Button, type ButtonProps } from "./button/button";
export { Input, type InputProps } from "./input/input";
export { Card, CardHeader, CardContent } from "./card/card";

Пример: Утилиты

Утилита cn объединяет классы — пример простого shared/lib.

// shared/lib/cn.ts
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
 
export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

Форматтеры дат и чисел как типичные shared/lib функции.

// shared/lib/format.ts
export function formatDate(date: Date): string {
  return new Intl.DateTimeFormat("ru-RU", {
    day: "2-digit",
    month: "long",
    year: "numeric",
  }).format(date);
}
 
export function formatNumber(num: number): string {
  return new Intl.NumberFormat("ru-RU").format(num);
}

Barrel shared/lib показывает, что именно мы раскрываем наружу.

// shared/lib/index.ts (Public API)
export { cn } from "./cn";
export { formatDate, formatNumber } from "./format";

2. Entities - Бизнес-сущности

Назначение: Бизнес-модели без пользовательских сценариев.

Что здесь:

  • Модели данных (User, Post, Product)
  • Минимальный UI (карточки, списки)
  • API методы для CRUD
  • Типы и схемы валидации

Структура:

Дерево entities иллюстрирует, как группировать бизнес-сущности.

entities/
  user/
    model/
      types.ts      ← Типы User
      schema.ts     ← Zod/Yup схемы
    api/
      user-api.ts   ← API методы
    ui/
      user-card.tsx ← Карточка пользователя
    index.ts        ← Public API
  post/
    model/
    api/
    ui/
    index.ts

Пример: Entity User

Типы User описывают модель сущности в entities.

// entities/user/model/types.ts
export type User = {
  id: string;
  email: string;
  username: string;
  avatar?: string;
  role: "user" | "admin";
  createdAt: Date;
};
 
export type UserProfile = User & {
  bio?: string;
  website?: string;
  location?: string;
};

userApi показывает, что CRUD живёт внутри сущности.

// entities/user/api/user-api.ts
import { apiClient } from "@/shared/api";
import type { User } from "../model/types";
 
export const userApi = {
  async getById(id: string): Promise<User> {
    const response = await apiClient.get<User>(`/users/${id}`);
    return response.data;
  },
 
  async getAll(): Promise<User[]> {
    const response = await apiClient.get<User[]>("/users");
    return response.data;
  },
 
  async update(id: string, data: Partial<User>): Promise<User> {
    const response = await apiClient.patch<User>(`/users/${id}`, data);
    return response.data;
  },
};

UserCard демонстрирует минимальный UI для сущности без бизнес-логики.

// entities/user/ui/user-card.tsx
import { Card } from "@/shared/ui";
import type { User } from "../model/types";
 
export function UserCard({ user }: { user: User }) {
  return (
    <Card>
      <div className="flex items-center gap-3">
        {user.avatar && (
          <img
            src={user.avatar}
            alt={user.username}
            className="h-12 w-12 rounded-full"
          />
        )}
        <div>
          <h3 className="font-semibold">{user.username}</h3>
          <p className="text-muted-foreground text-sm">{user.email}</p>
        </div>
      </div>
    </Card>
  );
}

Barrel entities/user фиксирует публичный API сущности.

// entities/user/index.ts (Public API)
export type { User, UserProfile } from "./model/types";
export { userApi } from "./api/user-api";
export { UserCard } from "./ui/user-card";

3. Features - Пользовательские сценарии

Назначение: Действия пользователя (добавить в корзину, лайкнуть, войти).

Что здесь:

  • Формы и кнопки действий
  • Модалки и диалоги
  • Бизнес-логика сценариев
  • Обработка событий

Структура:

Структура feature login раскрывает разбиение фичи по сегментам.

features/
  auth/
    login-form/
      ui/
        login-form.tsx
      model/
        use-login.ts
      index.ts
    logout-button/
      ui/
      index.ts
  post/
    create-post/
    like-post/
    share-post/

Пример: Feature Login

Компонент LoginForm показывает UI сценария авторизации.

// features/auth/login-form/ui/login-form.tsx
"use client";
 
import { useState } from "react";
import { Button, Input } from "@/shared/ui";
import { useLogin } from "../model/use-login";
 
export function LoginForm() {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const { login, isLoading, error } = useLogin();
 
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    await login({ email, password });
  };
 
  return (
    <form onSubmit={handleSubmit} className="space-y-4">
      <Input
        type="email"
        placeholder="Email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        required
      />
      <Input
        type="password"
        placeholder="Пароль"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
        required
      />
      {error && <p className="text-danger text-sm">{error}</p>}
      <Button type="submit" disabled={isLoading} className="w-full">
        {isLoading ? "Вход..." : "Войти"}
      </Button>
    </form>
  );
}

Хук useLogin хранит бизнес-логику сценария и работу с API.

// features/auth/login-form/model/use-login.ts
"use client";
 
import { useState } from "react";
import { useRouter } from "next/navigation";
import { authApi } from "@/shared/api";
 
type LoginCredentials = {
  email: string;
  password: string;
};
 
export function useLogin() {
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const router = useRouter();
 
  const login = async (credentials: LoginCredentials) => {
    setIsLoading(true);
    setError(null);
 
    try {
      const { token } = await authApi.login(credentials);
      localStorage.setItem("auth_token", token);
      router.push("/dashboard");
    } catch (err) {
      setError(err instanceof Error ? err.message : "Ошибка входа");
    } finally {
      setIsLoading(false);
    }
  };
 
  return { login, isLoading, error };
}

Barrel login-form экспортирует публичный интерфейс фичи.

// features/auth/login-form/index.ts
export { LoginForm } from "./ui/login-form";

4. Widgets - Композиции

Назначение: Самодостаточные блоки страниц из features и entities.

Что здесь:

  • Хедер, футер, сайдбар
  • Комплексные секции страниц
  • Композиции фич

Структура:

Структура widgets демонстрирует, какие блоки собирают фичи.

widgets/
  site-header/
    ui/
      site-header.tsx
      nav.tsx
    config/
      navigation.ts
    index.ts
  site-footer/
  product-grid/
  user-dashboard/

Пример: Widget SiteHeader

SiteHeader показывает композицию фич и сущностей в виджете.

// widgets/site-header/ui/site-header.tsx
"use client";
 
import Link from "next/link";
import { Button } from "@/shared/ui";
import { navigation } from "../config/navigation";
import { LogoutButton } from "@/features/auth";
 
export function SiteHeader() {
  return (
    <header className="bg-background/95 sticky top-0 z-50 w-full border-b backdrop-blur">
      <div className="container flex h-16 items-center justify-between">
        <Link href="/" className="text-xl font-bold">
          Logo
        </Link>
 
        <nav className="hidden gap-6 md:flex">
          {navigation.map((item) => (
            <Link
              key={item.href}
              href={item.href}
              className="hover:text-primary text-sm font-medium transition-colors"
            >
              {item.label}
            </Link>
          ))}
        </nav>
 
        <div className="flex items-center gap-4">
          <Button variant="ghost" asChild>
            <Link href="/login">Войти</Link>
          </Button>
          <LogoutButton />
        </div>
      </div>
    </header>
  );
}

navigation.ts хранит конфигурацию ссылок для хедера.

// widgets/site-header/config/navigation.ts
export const navigation = [
  { label: "Главная", href: "/" },
  { label: "Проекты", href: "/projects" },
  { label: "Блог", href: "/blog" },
  { label: "Контакты", href: "/contacts" },
] as const;

Barrel виджета хедера ограничивает публичные экспорты.

// widgets/site-header/index.ts
export { SiteHeader } from "./ui/site-header";

5. Pages - Страницы (в App Router)

Назначение: Композиция виджетов для конкретных роутов.

В Next.js App Router pages слой обычно находится в app/ директории:

Главная страница демонстрирует сборку виджетов в роуте.

// app/page.tsx (главная страница)
import { SiteHeader, Hero, FeaturedProjects, SiteFooter } from "@/widgets";
 
export default function HomePage() {
  return (
    <>
      <SiteHeader />
      <main>
        <Hero />
        <FeaturedProjects />
      </main>
      <SiteFooter />
    </>
  );
}

Страница каталога Projects иллюстрирует другую композицию виджетов.

// app/projects/page.tsx
import { SiteHeader, ProjectsGrid, SiteFooter } from "@/widgets";
 
export default function ProjectsPage() {
  return (
    <>
      <SiteHeader />
      <main>
        <ProjectsGrid />
      </main>
      <SiteFooter />
    </>
  );
}

6. App - Инициализация приложения

Назначение: Глобальная конфигурация, провайдеры, стили.

Layout приложения показывает, где живут метаданные и глобальные стили.

// app/layout.tsx
import type { Metadata } from "next";
import { Providers } from "./providers";
import "./globals.css";
 
export const metadata: Metadata = {
  title: "My App",
  description: "Built with FSD",
};
 
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="ru">
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}

Providers иллюстрирует подключение глобальных провайдеров.

// app/providers.tsx
"use client";
 
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ThemeProvider } from "next-themes";
 
const queryClient = new QueryClient();
 
export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <QueryClientProvider client={queryClient}>
      <ThemeProvider attribute="data-theme" defaultTheme="system">
        {children}
      </ThemeProvider>
    </QueryClientProvider>
  );
}

Правила импортов

Нарушение правил импортов - самая частая ошибка при использовании FSD. Настройте ESLint, чтобы ловить их автоматически!

Главное правило: направление импортов

Импорты идут только вниз по слоям:

Сводная таблица показывает допустимые направления импортов между слоями.

app       ⬇️ может импортировать всё ниже
pages     ⬇️ может импортировать widgets, features, entities, shared
widgets   ⬇️ может импортировать features, entities, shared
features  ⬇️ может импортировать entities, shared
entities  ⬇️ может импортировать только shared
shared    ⬇️ ничего не импортирует (кроме внутренних модулей)

✅ Правильные импорты

ProductCard пример корректного импорта features/entities из виджета.

// ✅ widgets импортирует features и entities
// widgets/product-card/ui/product-card.tsx
import { Button } from "@/shared/ui";
import { Product } from "@/entities/product";
import { AddToCart } from "@/features/cart";
 
export function ProductCard({ product }: { product: Product }) {
  return (
    <div>
      <h3>{product.name}</h3>
      <AddToCart productId={product.id} />
    </div>
  );
}

AddToCartButton показывает правильный импорт entities из feature.

// ✅ features импортирует entities
// features/cart/add-to-cart/ui/add-to-cart-button.tsx
import { Button } from "@/shared/ui";
import { Product } from "@/entities/product";
 
export function AddToCartButton({ product }: { product: Product }) {
  return <Button>Добавить {product.name}</Button>;
}

❌ Неправильные импорты

Антипример: entity тянет feature, нарушая слой.

// ❌ entities НЕ МОЖЕТ импортировать features
// entities/product/ui/product-card.tsx
import { AddToCart } from "@/features/cart"; // ❌ ОШИБКА!
 
export function ProductCard() {
  return (
    <div>
      <AddToCart /> {/* Это нарушает изоляцию слоёв */}
    </div>
  );
}

Антипример: feature импортирует другую feature напрямую.

// ❌ features НЕ МОЖЕТ импортировать другие features напрямую
// features/cart/model/use-cart.ts
import { useAuth } from "@/features/auth"; // ❌ ОШИБКА!
 
// Решение: композиция на уровне widgets или pages

Антипример: shared импортирует entity и нарушает изоляцию.

// ❌ shared НЕ МОЖЕТ импортировать entities
// shared/ui/user-avatar.tsx
import { User } from "@/entities/user"; // ❌ ОШИБКА!
 
// Решение: переместить в entities/user/ui/

Public API через barrel exports

Обязательное правило: Все импорты идут через index.ts.

Блок сравнивает deep imports с правильными barrel-импортами.

// ❌ Deep imports (напрямую в файл) - ЗАПРЕЩЕНЫ
import { Button } from "@/shared/ui/button/button";
import { UserCard } from "@/entities/user/ui/user-card";
 
// ✅ Barrel imports (через index.ts) - ПРАВИЛЬНО
import { Button } from "@/shared/ui";
import { UserCard } from "@/entities/user";

Пример Public API:

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

// entities/user/index.ts
// Это единственная точка входа в модуль user
 
export type { User, UserProfile } from "./model/types";
export { userApi } from "./api/user-api";
export { UserCard } from "./ui/user-card";
export { UserList } from "./ui/user-list";
 
// ❌ НЕ экспортируем внутренние детали:
// export { validateEmail } from "./lib/validate"; // internal only

Настройка ESLint для FSD

Конфиг ESLint иллюстрирует запрет глубоких импортов.

// eslint.config.mjs
import { FlatCompat } from "@eslint/eslintrc";
 
const compat = new FlatCompat({
  baseDirectory: import.meta.dirname,
});
 
export default [
  ...compat.config({
    rules: {
      // Запрет deep imports
      "no-restricted-imports": [
        "error",
        {
          patterns: [
            {
              group: ["@/shared/ui/*", "!@/shared/ui"],
              message: "Use barrel import: @/shared/ui",
            },
            {
              group: ["@/shared/lib/*", "!@/shared/lib"],
              message: "Use barrel import: @/shared/lib",
            },
            {
              group: ["@/entities/*/**"],
              message: "Use barrel import: @/entities/{entity}",
            },
            {
              group: ["@/features/*/**"],
              message: "Use barrel import: @/features/{feature}/{slice}",
            },
            {
              group: ["@/widgets/*/**"],
              message: "Use barrel import: @/widgets/{widget}",
            },
          ],
        },
      ],
    },
  }),
];

Управление состоянием в FSD

Server State живёт в entities, Client State — в features и widgets. Не создавайте анонимных shared/stores.

Server State (данные с бекенда)

  • Где хранить: entities/{entity}/api + хелперы в model.
  • Инструмент: @tanstack/react-query, RTK Query или SWR — выбирайте, но не смешивайте.
  • Query Keys: рядом с сущностью (entities/product/api/query-keys.ts) или инлайн в хуке.
  • Мутации: в entities/{entity}/api, экспортируются как useUpdateUser, useCreateOrder.

Query keys и useWorkspace показывают, как хранить server state в entities.

// entities/workspace/api/query-keys.ts
export const workspaceKeys = {
  all: ["workspace"] as const,
  list: () => [...workspaceKeys.all, "list"] as const,
  byId: (id: string) => [...workspaceKeys.all, "detail", id] as const,
};
 
// entities/workspace/api/use-workspace.ts
import { useQuery } from "@tanstack/react-query";
import { apiClient } from "@/shared/api";
import { workspaceKeys } from "./query-keys";
 
export function useWorkspace(id: string) {
  return useQuery({
    queryKey: workspaceKeys.byId(id),
    queryFn: () => apiClient.get(`/workspaces/${id}`).then((r) => r.data),
    staleTime: 60_000,
  });
}

Client State (UI, модалки, формы)

  • Где хранить: Внутри фич (features/.../model) или виджетов (widgets/.../model).
  • Инструмент: локальный useState, useReducer или Zustand-стор, если состояние нужно за пределами одного компонента.
  • Пересечение фич: Создавайте стор внутри слоя, который их объединяет (обычно widgets). Не заводите глобальный стор в shared.

Zustand-стор order-flow пример client state внутри виджета.

// widgets/order-flow/model/order-store.ts
import { create } from "zustand";
import { devtools } from "zustand/middleware";
 
type State = {
  step: "shipping" | "payment" | "summary";
  selectedShipping?: string;
};
 
type Actions = {
  nextStep: () => void;
  setShipping: (id: string) => void;
};
 
export const useOrderFlowStore = create<State & Actions>()(
  devtools((set, get) => ({
    step: "shipping",
    setShipping: (id) => set({ selectedShipping: id }),
    nextStep: () =>
      set({ step: get().step === "shipping" ? "payment" : "summary" }),
  }))
);

OrderFlow компонент показывает использование стора и данных сущности.

// widgets/order-flow/ui/order-flow.tsx
import { useOrderFlowStore } from "../model/order-store";
import { useOrder } from "@/entities/order";
 
export function OrderFlow({ orderId }: { orderId: string }) {
  const { data: order } = useOrder(orderId);
  const step = useOrderFlowStore((s) => s.step);
 
  if (!order) return null;
 
  return step === "shipping" ? (
    <ShippingForm />
  ) : step === "payment" ? (
    <PaymentForm />
  ) : (
    <Summary order={order} />
  );
}

Где хранить «общий» стейт

  • Выбор Workspace: entities/workspace/model (серверные данные + выбранный workspace) экспортирует useActiveWorkspace.
  • Глобальные UI: провайдеры в app/providers.tsx, хук управления — в shared/lib (без бизнес-логики).
  • Формы: сложные формы — локальный Zustand/Reducer в features/{feature}/model.

Антипаттерн: shared/stores/useAppStore.ts с состоянием всех модалок и фильтров. Делите по слоям и доменам.

SSR и Next.js

  • Серверные компоненты тянут данные через entities/{entity}/api с fetch/revalidate и передают их в клиентские виджеты через пропсы.
  • Для React Query: делайте prefetchQuery/dehydrate в серверном компоненте страницы и кладите dehydratedState в провайдер внутри app/.

Реальные примеры из проектов

Кейс 1: E-commerce (интернет-магазин)

Структура:

Дерево e-commerce проекта иллюстрирует распределение слоёв.

src/
  shared/
    ui/          ← Button, Input, Card, Badge
    lib/         ← formatPrice, cn
    api/         ← apiClient

  entities/
    product/     ← Product модель, ProductCard
    category/    ← Category модель, CategoryTag
    user/        ← User, UserProfile
    cart/        ← CartItem модель (данные)

  features/
    product/
      add-to-cart/      ← Кнопка "В корзину"
      product-filters/  ← Фильтры товаров
    auth/
      login-form/
      register-form/
    checkout/
      checkout-form/
      payment-form/

  widgets/
    product-grid/       ← Сетка товаров + фильтры
    cart-sidebar/       ← Боковая корзина
    header/
    footer/

  app/
    page.tsx           ← Главная
    products/
      page.tsx         ← Каталог
      [id]/page.tsx    ← Карточка товара
    cart/page.tsx      ← Корзина

Пример композиции:

ProductGrid пример композиции фич и сущностей в виджете каталога.

// widgets/product-grid/ui/product-grid.tsx
import { ProductCard } from "@/entities/product";
import { ProductFilters } from "@/features/product/product-filters";
import { AddToCart } from "@/features/product/add-to-cart";
 
export function ProductGrid() {
  const products = useProducts();
 
  return (
    <div>
      <ProductFilters />
      <div className="grid grid-cols-3 gap-4">
        {products.map((product) => (
          <div key={product.id}>
            <ProductCard product={product} />
            <AddToCart productId={product.id} />
          </div>
        ))}
      </div>
    </div>
  );
}

Кейс 2: SaaS Dashboard

Структура:

Структура SaaS dashboard показывает FSD для B2B.

src/
  shared/
    ui/
    lib/
    api/

  entities/
    workspace/   ← Workspace модель
    project/     ← Project модель
    task/        ← Task модель
    user/

  features/
    workspace/
      create-workspace/
      switch-workspace/
    project/
      create-project/
      archive-project/
    task/
      create-task/
      assign-task/
      task-status-toggle/

  widgets/
    workspace-sidebar/
    project-kanban/
    task-list/
    stats-dashboard/

  app/
    dashboard/
      page.tsx
      projects/[id]/page.tsx

Кейс 3: Блог-платформа

Структура:

Структура блог-платформы демонстрирует применимость для контента.

src/
  shared/
    ui/
    lib/

  entities/
    post/        ← Post модель, PostCard
    author/      ← Author модель
    comment/     ← Comment модель
    tag/         ← Tag модель

  features/
    post/
      create-post/
      edit-post/
      like-post/
      share-post/
    comment/
      add-comment/
      reply-to-comment/
    subscription/
      subscribe-button/

  widgets/
    post-feed/
    trending-sidebar/
    author-card/
    comments-section/

Частые антипаттерны и ошибки

Эти ошибки встречаются даже у опытных разработчиков. Изучите их, чтобы не повторять!

❌ Антипаттерн 1: "Бог-компонент" в shared

Плохо:

Плохой пример бог-компонента в shared, чтобы показать антипаттерн.

// shared/ui/user-profile-card.tsx
import { userApi } from "@/entities/user"; // ❌
import { FollowButton } from "@/features/user/follow"; // ❌
 
export function UserProfileCard({ userId }: { userId: string }) {
  const user = await userApi.getById(userId); // Бизнес-логика в shared!
 
  return (
    <div>
      <h3>{user.name}</h3>
      <FollowButton userId={userId} />
    </div>
  );
}

Почему плохо:

  • shared импортирует entities и features
  • Компонент знает о бизнес-логике
  • Нельзя переиспользовать без этих зависимостей

Хорошо:

Card-примитив иллюстрирует правильное место UI без бизнес-логики.

// shared/ui/card.tsx (примитив)
export function Card({ children }: { children: React.ReactNode }) {
  return <div className="rounded-lg border p-4">{children}</div>;
}
 
// entities/user/ui/user-card.tsx
import { Card } from "@/shared/ui";
import type { User } from "../model/types";
 
export function UserCard({
  user,
  actions,
}: {
  user: User;
  actions?: React.ReactNode;
}) {
  return (
    <Card>
      <h3>{user.name}</h3>
      {actions}
    </Card>
  );
}
 
// widgets/user-profile/ui/user-profile.tsx
import { UserCard } from "@/entities/user";
import { FollowButton } from "@/features/user/follow";
 
export function UserProfile({ userId }: { userId: string }) {
  const user = useUser(userId);
 
  return <UserCard user={user} actions={<FollowButton userId={userId} />} />;
}

❌ Антипаттерн 2: Циклические зависимости между features

Плохо:

Антипример циклических зависимостей между фичами.

// features/auth/model/use-auth.ts
import { clearCart } from "@/features/cart"; // ❌
 
export function useAuth() {
  const logout = () => {
    clearCart(); // feature зависит от другой feature
    // ...
  };
}
 
// features/cart/model/use-cart.ts
import { isAuthenticated } from "@/features/auth"; // ❌
 
export function useCart() {
  if (!isAuthenticated()) {
    // циклическая зависимость
    // ...
  }
}

Хорошо:

Вариант 1: Композиция на уровне widgets/pages

UserMenu демонстрирует композицию на уровне виджета вместо связи фич.

// widgets/user-menu/ui/user-menu.tsx
import { LogoutButton } from "@/features/auth";
import { useCart } from "@/features/cart";
 
export function UserMenu() {
  const { clearCart } = useCart();
 
  const handleLogout = async () => {
    await clearCart(); // Композиция здесь, не в features
    await logout();
  };
 
  return <button onClick={handleLogout}>Выйти</button>;
}

Вариант 2: Общая логика в entities

sessionModel показывает перенос общей логики в entities.

// entities/session/model/session.ts
export const sessionModel = {
  isAuthenticated: () => Boolean(localStorage.getItem("token")),
  // ...
};
 
// features/cart используют entities/session, а не features/auth
import { sessionModel } from "@/entities/session";

❌ Антипаттерн 3: Раздутый index.ts

Плохо:

Раздутый shared/ui/index.ts как антипример barrel.

// shared/ui/index.ts (500+ строк)
export * from "./button/button";
export * from "./input/input";
export * from "./card/card";
// ... 100+ компонентов
export * from "./complex-form/form"; // ❌ Сложная форма в shared?
export * from "./user-dashboard/dashboard"; // ❌ Dashboard в shared?

Почему плохо:

  • Долгая компиляция
  • Сложно найти нужный экспорт
  • Бизнес-логика просочилась в shared

Хорошо:

Минимальный shared/ui/index.ts как правильный API.

// shared/ui/index.ts (минимальный)
// Экспортируем только UI-примитивы
export { Button } from "./button/button";
export { Input } from "./input/input";
export { Card, CardHeader, CardContent } from "./card/card";
export { Badge } from "./badge/badge";
 
// Сложные формы → features
// features/auth/login-form/index.ts
export { LoginForm } from "./ui/login-form";
 
// Dashboard → widgets
// widgets/user-dashboard/index.ts
export { UserDashboard } from "./ui/user-dashboard";

❌ Антипаттерн 4: Переиспользование через copy-paste

Плохо:

LikeButton в feature post демонстрирует переиспользование примитива.

// features/post/like-post/ui/like-button.tsx
export function LikePostButton() {
  return (
    <button className="bg-primary flex items-center gap-2 rounded-lg px-4 py-2">
      <HeartIcon /> Нравится
    </button>
  );
}
 
// features/comment/like-comment/ui/like-button.tsx
export function LikeCommentButton() {
  return (
    <button className="bg-primary flex items-center gap-2 rounded-lg px-4 py-2">
      <HeartIcon /> Нравится
    </button>
  );
}

Хорошо:

LikeButton примитив выносит общую кнопку в shared.

// shared/ui/like-button/like-button.tsx
export function LikeButton({
  onClick,
  isLiked,
}: {
  onClick: () => void;
  isLiked?: boolean;
}) {
  return (
    <button
      onClick={onClick}
      className={cn(
        "flex items-center gap-2 rounded-lg px-4 py-2",
        isLiked ? "bg-danger" : "bg-primary"
      )}
    >
      <HeartIcon className={isLiked ? "fill-current" : ""} />
      Нравится
    </button>
  );
}
 
// features/post/like-post/ui/like-post-button.tsx
import { LikeButton } from "@/shared/ui";
 
export function LikePostButton({ postId }: { postId: string }) {
  const { like, isLiked } = useLikePost(postId);
 
  return <LikeButton onClick={like} isLiked={isLiked} />;
}
 
// features/comment/like-comment/ui/like-comment-button.tsx
import { LikeButton } from "@/shared/ui";
 
export function LikeCommentButton({ commentId }: { commentId: string }) {
  const { like, isLiked } = useLikeComment(commentId);
 
  return <LikeButton onClick={like} isLiked={isLiked} />;
}

Миграция на FSD

Не нужно переписывать всё сразу! Мигрируйте постепенно, модуль за модулем.

Стратегия миграции

Этап 1: Подготовка

  1. Создайте структуру папок FSD
  2. Настройте алиасы в tsconfig.json
  3. Настройте ESLint правила

Этап 2: Миграция shared

Команда «Было» показывает перенос в shared/entities при миграции.

# Было
src/components/ui/Button.tsx
src/utils/cn.ts
 
# Стало
src/shared/ui/button/button.tsx
src/shared/lib/cn.ts

Этап 3: Миграция entities

Команда «Стало» для следующего шага миграции демонстрирует структуру.

# Было
src/models/User.ts
src/components/UserCard.tsx
src/api/userApi.ts
 
# Стало
src/entities/user/
  model/types.ts
  ui/user-card.tsx
  api/user-api.ts
  index.ts

Этап 4: Выделение features

Антипример цельного компонента до разделения на фичи.

// Было: всё в одном компоненте
function UserProfile() {
  const handleFollow = () => {
    /* ... */
  };
  const handleMessage = () => {
    /* ... */
  };
 
  return (
    <div>
      <UserCard />
      <button onClick={handleFollow}>Подписаться</button>
      <button onClick={handleMessage}>Написать</button>
    </div>
  );
}
 
// Стало: разделено на features
function UserProfile() {
  return (
    <div>
      <UserCard />
      <FollowButton /> {/* features/user/follow-button */}
      <MessageButton /> {/* features/user/message-button */}
    </div>
  );
}

Инкрементальная миграция

Используйте Strangler Fig Pattern - постепенно заменяйте старый код:

Шаг 1 миграции: реэкспорт из старого места для совместимости.

// Шаг 1: Создали новый модуль в FSD
// entities/user/index.ts
export { UserCard } from "./ui/user-card";
 
// Шаг 2: Реэкспортируем из старого места (для обратной совместимости)
// src/components/UserCard.tsx (deprecated)
export { UserCard } from "@/entities/user";
console.warn("Import from @/entities/user instead");
 
// Шаг 3: Постепенно обновляем импорты
// Было
import { UserCard } from "@/components/UserCard";
 
// Стало
import { UserCard } from "@/entities/user";
 
// Шаг 4: Удаляем старый файл когда все импорты обновлены

Best Practices

1. Сегменты (segments) внутри слайсов

Стандартные сегменты:

Шаблон сегментов демонстрирует стандартное содержимое каждой фичи.

feature-name/
  ui/          ← React компоненты
  model/       ← Бизнес-логика, хуки, стор
  api/         ← API запросы
  lib/         ← Вспомогательные функции (только для этого слайса)
  config/      ← Конфигурация, константы
  types/       ← TypeScript типы (если много)
  index.ts     ← Public API

Пример:

Пример сегментов на реальной фиче auth.

features/
  auth/
    login-form/
      ui/
        login-form.tsx
        login-form.test.tsx
      model/
        use-login.ts
        validation.ts
      api/
        auth-api.ts
      config/
        constants.ts
      index.ts

2. Композиция через слоты

Используйте слоты для гибкости:

ProductCard со слотами показывает композицию через пропсы.

// entities/product/ui/product-card.tsx
export function ProductCard({
  product,
  actions, // ← слот для actions
  badge, // ← слот для badge
}: {
  product: Product;
  actions?: React.ReactNode;
  badge?: React.ReactNode;
}) {
  return (
    <Card>
      {badge}
      <h3>{product.name}</h3>
      <p>{product.price}</p>
      {actions}
    </Card>
  );
}
 
// widgets/product-grid/ui/product-item.tsx
import { ProductCard } from "@/entities/product";
import { AddToCart } from "@/features/cart/add-to-cart";
import { Badge } from "@/shared/ui";
 
<ProductCard
  product={product}
  badge={product.isNew ? <Badge>Новинка</Badge> : null}
  actions={<AddToCart productId={product.id} />}
/>;

3. Dependency Injection для переиспользования

NotificationList демонстрирует DI для переиспользования рендера.

// entities/notification/ui/notification-list.tsx
export function NotificationList({
  notifications,
  renderNotification, // ← DI для кастомного рендера
}: {
  notifications: Notification[];
  renderNotification?: (n: Notification) => React.ReactNode;
}) {
  return (
    <ul>
      {notifications.map((n) =>
        renderNotification ? (
          renderNotification(n)
        ) : (
          <NotificationCard key={n.id} notification={n} />
        )
      )}
    </ul>
  );
}
 
// widgets/user-notifications/ui/user-notifications.tsx
<NotificationList
  notifications={notifications}
  renderNotification={(n) => (
    <CustomNotification notification={n} onDismiss={handleDismiss} />
  )}
/>;

4. Тестирование в FSD

Тесты рядом с кодом:

Схема расположения тестов рядом с кодом.

features/
  auth/
    login-form/
      ui/
        login-form.tsx
        login-form.test.tsx          ← Unit тест компонента
      model/
        use-login.ts
        use-login.test.ts            ← Unit тест хука
      __tests__/
        login-form.integration.test.tsx  ← Интеграционный тест

Пример теста:

Тест LoginForm иллюстрирует unit проверку компонента.

// features/auth/login-form/ui/login-form.test.tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { LoginForm } from "./login-form";
 
describe("LoginForm", () => {
  it("should submit form with credentials", async () => {
    const user = userEvent.setup();
    const onSubmit = vi.fn();
 
    render(<LoginForm onSubmit={onSubmit} />);
 
    await user.type(screen.getByLabelText("Email"), "test@example.com");
    await user.type(screen.getByLabelText("Пароль"), "password123");
    await user.click(screen.getByRole("button", { name: "Войти" }));
 
    expect(onSubmit).toHaveBeenCalledWith({
      email: "test@example.com",
      password: "password123",
    });
  });
});

5. Документация через README

Добавляйте README в сложные модули:

Пример структуры с README в фиче checkout.

features/
  checkout/
    README.md     ← Документация фичи
    payment-form/
    shipping-form/
    order-summary/

README-файл показывает, как документировать фичу.

# Checkout Feature
 
Оформление заказа в 3 шага:
 
1. Адрес доставки (shipping-form)
2. Способ оплаты (payment-form)
3. Подтверждение (order-summary)
 
## Использование
 
\`\`\`tsx
import { CheckoutWizard } from "@/features/checkout";
 
<CheckoutWizard onComplete={handleOrderComplete} />
\`\`\`
 
## API
 
- `CheckoutWizard` - основной компонент
- `useCheckout` - хук для управления состоянием

Инструменты и линтинг

Path Aliases (tsconfig.json)

Alias-настройки в tsconfig демонстрируют пути для слоёв.

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/app/*": ["app/*"],
      "@/widgets/*": ["src/widgets/*"],
      "@/features/*": ["src/features/*"],
      "@/entities/*": ["src/entities/*"],
      "@/shared/*": ["src/shared/*"],
 
      // Barrel aliases (для удобства)
      "@/shared/ui": ["src/shared/ui/index.ts"],
      "@/shared/lib": ["src/shared/lib/index.ts"],
      "@/entities/user": ["src/entities/user/index.ts"],
      "@/entities/product": ["src/entities/product/index.ts"]
    }
  }
}

ESLint: запрет deep imports

ESLint пример упрощённого запрета deep imports.

// eslint.config.mjs
export default [
  {
    rules: {
      "no-restricted-imports": [
        "error",
        {
          patterns: [
            {
              group: ["@/shared/ui/*", "!@/shared/ui"],
              message: "Import from @/shared/ui barrel instead",
            },
            {
              group: ["@/entities/*/**"],
              message: "Import from @/entities/{entity} barrel instead",
            },
          ],
        },
      ],
    },
  },
];

Steiger - автоматический анализ FSD

Команда установки Steiger показывает как подключить линтер.

npm install -D steiger @steiger/eslint-plugin
 
# Запуск анализа
npx steiger src

Steiger находит:

  • Нарушения правил импортов между слоями
  • Циклические зависимости
  • Отсутствующие Public API (index.ts)
  • Неиспользуемые модули

FSD в аду: реалии продакшена

Фича upload-document: прогресс, ошибки, повтор

Дерево фичи upload-document показывает сегменты сложной фичи.

features/
  documents/
    upload-document/
      ui/upload-document.tsx
      model/use-upload-document.ts
      lib/validators.ts
      index.ts

Компонент UploadDocument иллюстрирует работу с прогрессом/повтором.

// features/documents/upload-document/ui/upload-document.tsx
"use client";
 
import { useUploadDocument } from "../model/use-upload-document";
import { Button, Input } from "@/shared/ui";
 
export function UploadDocument() {
  const { selectFile, progress, error, retry, isUploading } =
    useUploadDocument();
 
  return (
    <div className="space-y-3">
      <Input type="file" accept=".pdf,.doc,.png" onChange={selectFile} />
      {progress !== null && (
        <div className="bg-muted h-2 rounded">
          <div
            className="bg-primary h-full rounded"
            style={{ width: `${progress}%` }}
          />
        </div>
      )}
      {error && (
        <div className="text-danger flex items-center gap-2 text-sm">
          Ошибка: {error}
          <Button size="sm" variant="outline" onClick={retry}>
            Повторить
          </Button>
        </div>
      )}
      <Button disabled={isUploading} onClick={retry}>
        {isUploading ? `Загружено ${progress ?? 0}%` : "Загрузить"}
      </Button>
    </div>
  );
}

Хук useUploadDocument показывает обработку валидации и прогресса.

// features/documents/upload-document/model/use-upload-document.ts
"use client";
 
import { useState, useCallback } from "react";
import { documentsApi } from "@/shared/api";
import { validateFile } from "../lib/validators";
 
export function useUploadDocument() {
  const [file, setFile] = useState<File | null>(null);
  const [progress, setProgress] = useState<number | null>(null);
  const [error, setError] = useState<string | null>(null);
  const [isUploading, setUploading] = useState(false);
 
  const upload = useCallback(async (fileToUpload: File) => {
    setUploading(true);
    setError(null);
    setProgress(0);
 
    const controller = new AbortController();
 
    try {
      validateFile(fileToUpload);
      await documentsApi.upload(fileToUpload, {
        signal: controller.signal,
        onProgress: (v) => setProgress(v), // прогресс 0-100
      });
      setProgress(100);
    } catch (err) {
      setError(
        err instanceof Error ? err.message : "Не удалось загрузить файл"
      );
    } finally {
      setUploading(false);
    }
  }, []);
 
  const selectFile = (e: React.ChangeEvent<HTMLInputElement>) => {
    const nextFile = e.target.files?.[0];
    if (!nextFile) return;
    setFile(nextFile);
    void upload(nextFile);
  };
 
  const retry = () => file && void upload(file);
 
  return { selectFile, retry, progress, error, isUploading };
}

Миграция легаси-компонента OrderSummary

Дерево миграции легаси OrderSummary показывает изоляцию.

widgets/
  order-summary/
    ui/order-summary.tsx
    lib/legacy-adapter.tsx    ← единственная точка контакта с монолитом
    index.ts
legacy/
  order/OrderSummary.jsx      ← чужой код, не трогаем

LegacyOrderSummaryAdapter инкапсулирует сторонний компонент.

// widgets/order-summary/lib/legacy-adapter.tsx
// Изолируем все зависимости легаси внутри адаптера
import LegacyOrderSummary from "@/legacy/order/OrderSummary";
 
type LegacyProps = { onSubmit: () => void; locale: string; customerId: string };
 
export function LegacyOrderSummaryAdapter({
  onConfirm,
  orderId,
}: {
  onConfirm: () => void;
  orderId: string;
}) {
  // маппим пропсы и скрываем грязную API-форму
  const props: LegacyProps = {
    onSubmit: onConfirm,
    locale: "ru",
    customerId: orderId,
  };
 
  return <LegacyOrderSummary {...props} />;
}

OrderSummary виджет показывает чистый API поверх легаси.

// widgets/order-summary/ui/order-summary.tsx
import { LegacyOrderSummaryAdapter } from "../lib/legacy-adapter";
import { OrderInfo } from "@/entities/order";
 
export function OrderSummary({ orderId }: { orderId: string }) {
  const order = OrderInfo.useOrder(orderId); // чистый FSD API
 
  return (
    <section>
      <h2 className="text-lg font-semibold">Итоги заказа</h2>
      <LegacyOrderSummaryAdapter
        orderId={order.id}
        onConfirm={() => OrderInfo.confirm(order.id)}
      />
    </section>
  );
}

Правила миграции легаси:

  • Легаси импортируется только из адаптера внутри слайса, дальше наружу выходит чистый API.
  • Все побочные эффекты и костыли держим в lib/legacy-*.
  • Добавьте тесты на адаптер, чтобы не зависеть от легаси-среды.

Сложная сущность Product с агрегированными данными

Дерево сущности product показывает, как хранить связанные типы.

entities/
  product/
    model/types.ts
    model/selectors.ts
    api/product-api.ts
    lib/normalizers.ts
    ui/product-card.tsx
    index.ts

Типы Product/Variant/Seo описывают сложную модель.

// entities/product/model/types.ts
export type ProductImage = { id: string; url: string; alt?: string };
export type ProductVariant = {
  id: string;
  sku: string;
  price: number;
  currency: string;
  inStock: boolean;
};
 
export type ProductSeoData = {
  title: string;
  description: string;
  keywords: string[];
  canonical: string;
};
 
export type Product = {
  id: string;
  title: string;
  description?: string;
  variants: ProductVariant[];
  images: ProductImage[];
  seo: ProductSeoData;
};

fetchProduct агрегирует данные из нескольких эндпоинтов.

// entities/product/api/product-api.ts
import { apiClient } from "@/shared/api";
import type { Product } from "../model/types";
 
export async function fetchProduct(id: string): Promise<Product> {
  const [core, seo, media] = await Promise.all([
    apiClient.get(`/products/${id}`),
    apiClient.get(`/products/${id}/seo`),
    apiClient.get(`/products/${id}/media`),
  ]);
 
  return {
    ...core.data,
    seo: seo.data,
    images: media.data.images,
    variants: core.data.variants,
  };
}

useProduct хук проксирует серверный state через react-query.

// entities/product/model/use-product.ts
import { useQuery } from "@tanstack/react-query";
import { fetchProduct } from "../api/product-api";
 
export function useProduct(id: string, options?: { enabled?: boolean }) {
  return useQuery({
    queryKey: ["product", id],
    queryFn: () => fetchProduct(id),
    staleTime: 5 * 60 * 1000,
    enabled: options?.enabled ?? Boolean(id),
  });
}

Почему так:

  • Вся серверная стейт-логика живёт в entities/product/api и model.
  • Фичи и виджеты получают единый useProduct без знания о трёх эндпоинтах.
  • При изменении бекенда переписываем только слой entities.

Тестирование с FSD: изоляция и интеграция

Мокаем снизу вверх: виджеты → фичи → entities → shared/api. Минимально мокаем инфраструктуру, максимум проверяем связки.

Unit-тесты: бизнес-логика и форматирование

Unit-тест formatUserName показывает проверку бизнес-утилиты.

// entities/user/lib/format-user-name.test.ts
import { formatUserName } from "./format-user-name";
 
describe("formatUserName", () => {
  it("returns username fallback when no name", () => {
    expect(
      formatUserName({ username: "john", firstName: "", lastName: "" })
    ).toBe("john");
  });
});

Тест useLogin демонстрирует мок API и router.

// features/auth/login-form/model/use-login.test.ts
import { renderHook, act } from "@testing-library/react";
import { vi } from "vitest";
import { useLogin } from "./use-login";
 
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
vi.mock("@/shared/api", () => ({
  authApi: { login: vi.fn().mockResolvedValue({ token: "t" }) },
}));
 
describe("useLogin", () => {
  it("stores token and redirects", async () => {
    const { result } = renderHook(() => useLogin());
 
    await act(async () => {
      await result.current.login({ email: "a@a", password: "123" });
    });
 
    expect(localStorage.getItem("auth_token")).toBe("t");
  });
});

Интеграционные тесты: связки слоёв

Интеграционный тест ProductGrid показывает связку виджет-фичи-entity.

// widgets/product-grid/__tests__/product-grid.integration.test.tsx
import { render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ProductGrid } from "../ui/product-grid";
import { server } from "@/tests/msw/server";
import { http, HttpResponse } from "msw";
 
server.use(
  http.get("/products", () =>
    HttpResponse.json([{ id: "1", title: "MacBook Pro" }])
  )
);
 
describe("ProductGrid", () => {
  it("renders products from API", async () => {
    const client = new QueryClient();
    render(
      <QueryClientProvider client={client}>
        <ProductGrid />
      </QueryClientProvider>
    );
 
    await waitFor(() => {
      expect(screen.getByText("MacBook Pro")).toBeInTheDocument();
    });
  });
});

Подход:

  • Unit — мокаем только прямые зависимости слайса (API, роутер).
  • Интеграционные — мокаем только нижний слой (shared/api или HTTP), всё остальное используем как в реальности.
  • Для легаси-адаптеров — пишем снапшот/контракт-тесты, чтобы поймать неожиданные изменения.

Серые зоны FSD и боевые решения

Утилиты с бизнес-логикой

  • Если функция знает о домене — её место в entities/{entity}/lib и она попадает в Public API.
  • Пример: entities/currency/lib/format-price.ts, экспортируется как formatPrice из entities/currency.
  • В shared/lib оставляем только чистые утилиты без доменной модели.

Общая аналитика и кросс-фичи

  • Создайте отдельный слайс для ядра процесса: entities/analytics или processes/analytics.
  • Экспортируйте чистые функции trackEvent, trackError, которые не знают о UI.
  • Фичи вызывают аналитику напрямую, не импортируя друг друга, чтобы не нарушать изоляцию.

Глобальные UI-состояния (toast, modal, confirm)

  • Провайдеры живут в app/ (ToastProvider, ModalProvider).
  • Хуки для диспатча — в shared/lib/use-toast.ts, shared/lib/use-modal.ts.
  • Фичи и виджеты импортируют хуки из shared/lib, не зная о конкретной реализации UI.

Пересечение фич

  • Если фичи A и B используют общую бизнес-логику — вынесите её в entity (entities/session, entities/permissions).
  • Если общая логика — процесс (например, онбординг) — заведите слайс processes/onboarding и композиционируйте его во widgets.

FSD: итоговый вердикт

Плюсы: изоляция модулей, чистые границы, облегчённое удаление/добавление фич, масштабирование команд и кода, предсказуемые импорты.

Минусы: больше boilerplate (папки, index.ts), высокий порог входа, необходимость строгих линтов и ревью, риск over-engineering на небольших проектах, нужно дисциплинированно поддерживать Public API.

Когда не использовать: короткие проекты (< 6 месяцев), команда 1–3 человека, быстрые прототипы и лендинги, проекты с сильно плавающими требованиями, когда скорость важнее долгосрочной модульности.

Альтернативы:

  • Grouping by File Type — быстро стартовать, но хаотичные связи растут экспоненциально.
  • Grouping by Route (Next.js route groups) — хорошо для небольших маркетинговых сайтов с независимыми страницами.
  • Простая структура "по фичам" без слоёв — ок для небольших SPA, но требует дисциплины в зависимостях.

Заключение

FSD - это не просто папки. Это архитектурное мышление: изоляция, явные зависимости, композиция вместо наследования.

Ключевые выводы

  1. Слои = иерархия ответственности - каждый слой решает свою задачу
  2. Импорты только вниз - строгая направленность зависимостей
  3. Public API обязателен - barrel exports через index.ts
  4. Композиция > наследование - слоты, DI, props для гибкости
  5. Изоляция features - фичи не зависят друг от друга
  6. shared без бизнес-логики - только переиспользуемые примитивы
  7. ESLint - ваш друг - автоматизируйте проверки правил

Чек-лист для code review

  • Импорты идут только вниз по слоям
  • Нет deep imports (только через index.ts)
  • shared не содержит бизнес-логики
  • features не импортируют другие features
  • entities содержат только модели и минимальный UI
  • widgets композируют features и entities
  • Нет циклических зависимостей
  • Public API документирован в index.ts
  • Код находится в правильном слое
  • Сегменты (ui, model, api) используются корректно

Когда НЕ использовать FSD

Если ваш кейс из раздела «FSD: итоговый вердикт» (лендинг, прототип, маленькая команда) — используйте простую структуру и не тратьте время на слои:

Упрощённое дерево для маленьких проектов показывает альтернативу FSD.

src/
  components/
  utils/
  pages/

Следующие шаги

  1. Практика - начните новый проект с FSD или мигрируйте существующий
  2. ESLint - настройте автоматические проверки правил
  3. Code Review - внедрите чек-лист в процесс ревью
  4. Документация - документируйте соглашения команды
  5. Обучение - проведите воркшоп для команды

Полезные ресурсы

Официальная документация:

Инструменты:

Сообщество:

  • FSD Telegram - русскоязычное комьюнити
  • FSD Discord - международное сообщество

Статьи и доклады:

Помните: Архитектура - это не догма. Адаптируйте FSD под свой проект и команду. Главное - понимать принципы и следовать им осознанно.

Feature-Sliced Design на практике — Learning Center — Potapov.me