Skip to main content

Feature-sliced design (FSD). обов'язкова архітектура фронтенду для знання

Feature-Sliced Design (FSD) — архітектурна конвенція для фронтенд-додатків, яка організовує код у чіткі горизонтальні шари (app, pages, features, entities, shared), де кожен шар імпортує тільки з нижчих, а всі файли одного домену живуть разом в одному slice.

Теорія

TL;DR

  • Уяви структуру компанії: app — CEO, pages — відділи, features — команди, entities — бізнес-об'єкти, shared — канцелярія.
  • Головна відмінність від класичного підходу: код групується по фічі/домену, а не по типу файлів. Ніякого гігантського components/.
  • Правило залежностей: sharedentitiesfeaturespagesapp. Тільки знизу вгору.
  • Підходить для команди від 5 розробників або при частих змінах бізнес-вимог. Для прототипів до 1000 рядків — зайве ускладнення.
  • Жодної магії компілятора. Конвенція підтримується ESLint-плагіном.

Швидкий приклад

// Класична структура "по типах" (погано масштабується): src/ components/ # CartButton, UserCard, ProductList — все в купі pages/ cart.tsx # імпортує з components/, utils/, api/ utils/ api/ // FSD-структура (все зібрано за доменом): src/ app/ # маршрутизація, провайдери pages/cart/ ui/CartButton.tsx # UI кошика тут model/cartApi.ts # API кошика тут entities/Product/ ui/ProductCard.tsx model/product.ts # чистий доменний тип shared/ui/Button.tsx # тільки справді загальне

Нова фіча — один новий каталог. Не п'ять.

Головна ідея

Класична архітектура групує по типу файлу: всі кнопки в components/, всі запити в api/, всі утиліти в utils/. При 2000 рядках це нормально. При 30000 — додавання фічі «кошик» означає редагування 5+ каталогів, де файли розкидані по всьому проекту.

FSD перевертає цю логіку. Кнопка кошика живе разом з логікою, API-запитами і тестами кошика. Один каталог, одна відповідальність. Новий розробник знаходить все про замовлення в entities/Order/ і не гадає, де ховається API-виклик.

Правила залежностей

Кожен шар знаходиться на конкретному рівні ієрархії:

app (вгорі — оркеструє все) pages features entities shared (внизу — загальні утиліти)

Slice зі шару features/ може імпортувати з entities/ або shared/. Але не з pages/ чи app/. Компонент shared/ui/Button.tsx нічого не знає про бізнес-домен. Ці правила захищають від циклічних залежностей — варто shared/ почати імпортувати з features/, і вся структура руйнується.

Slices всередині одного шару також не можуть імпортувати один одного. features/cart/ не може імпортувати з features/auth/. Якщо їм потрібно щось спільне — це щось належить до entities/ або shared/.

Коли використовувати

  • Команда від 5 розробників на одній кодовій базі: FSD задає чіткі межі без зайвих нарад.
  • Бізнес-додатки (e-commerce, SaaS-дашборди): шар entities/ прямо відображає доменну модель.
  • Монорепо зі спільними бібліотеками: шар shared/ запобігає дублюванню між пакетами.
  • Прототип до 1000 рядків: пропускай. Накладні витрати перевищують користь.
  • Міграція з legacy-проекту: починай з pages/ (природні межі по маршрутах), потім витягуй entities/ зі своїх utils/.

Порівняння: FSD vs. класична структура

АспектКласична (по типах)FSD (по фічах)
Приклад папкиcomponents/CartButton/, api/cart.tspages/cart/ui/CartButton.tsx
Додати фічуЗмінити 5+ каталогів1 каталог
ЗалежностіБудь-що імпортує будь-щоТільки зверху вниз
100k+ рядківНавігація стає болючоюSlices залишаються ізольованими
ТестуванняПотрібні cross-folder мокиТести всередині slice
Рекомендується дляДодатки до 5k рядківПродакшн-додатки з командами

Як підтримується FSD

Жоден фреймворк не робить це за тебе. FSD — чиста конвенція, і конвенції розвалюються без інструментів. Стандартне рішення — пакет eslint-plugin-feature-sliced, який перевіряє порушення шарів. Якщо файл зі features/auth/ намагається імпортувати з app/routing — лінтер відразу сигналізує.

Vite і Webpack резолвять імпорти звичайним чином. Але завдяки co-location tree-shaking працює ефективніше: локальні залежності дають менші чанки. Команди повідомляють про зменшення бандлу на 20-30% після міграції, переважно завдяки кращому видаленню мертвого коду.

Особисто бачив у великих кодових базах: найбільший виграш — не розмір бандлу. А онбординг. Новий розробник, який розуміє назви шарів, знаходить будь-який файл за 30 секунд.

Типові помилки

Все скидати в shared/

tsx
// Неправильно: специфічний для кошика компонент в shared // src/shared/ui/CartButton.tsx // Правильно: перемістити туди, де він належить // src/features/cart/ui/CartButton.tsx

shared/ — для справді загального коду: компоненти дизайн-системи, утиліти, типові хелпери. Коли команди кладуть туди доменно-специфічні компоненти, shared/ перетворюється на смітник з 500+ файлів. На r/reactjs описують 40% роздування бандлу саме від цього патерну.

Крос-імпорти всередині одного шару

tsx
// Неправильно: features імпортують features // src/features/cart/model/cart.ts import { useAuth } from 'src/features/auth'; // ризик циклічних залежностей // Правильно: виносимо спільну доменну логіку в entities // src/entities/User/model/authApi.ts

Імпорт вгору по ієрархії шарів

tsx
// Неправильно: feature дотягується до шару app import { router } from 'src/app/routing'; // порушення правила шарів // Правильно: передаємо навігацію через callback export const loginSuccess = (onSuccess: () => void) => { // логіка аутентифікації onSuccess(); }; // Шар pages викликає: loginSuccess(() => router.navigate('/dashboard'))

Відсутність public API barrel-файлів

ts
// Неправильно: споживачі знають внутрішні шляхи import { Button } from 'src/shared/ui/Button/Button'; // Правильно: barrel-файл керує публічним API // src/shared/ui/index.ts export { Button } from './Button'; // Споживач: import { Button } from 'src/shared/ui'

Без index-файлів перейменування всередині slice ламає всіх споживачів по всьому проекту.

Де використовується

  • React + Next.js: Saleor react-storefront використовує FSD-патерни для pages/product/ та entities/Order/.
  • Vite + React: бойлерплейт fsd-vite має понад 10k зірок на GitHub.
  • Стейт-менеджмент: Zustand-стор живе в features/cart/model/, глобальний стан користувача — в entities/User/model/.
  • Nx монорепо: Angular і React проекти використовують структуру шарів FSD на межах Nx-бібліотек.
  • Vs. Atomic Design: Atomic Design орієнтований на UI (атоми, молекули, організми). FSD орієнтований на домен. Вони вирішують різні проблеми і можуть співіснувати: Atomic Design керує тим, що потрапляє в shared/ui/.

Питання на співбесіді

Q: Покажи структуру папок для todo-додатку.
A: app/ — роутер і провайдери. pages/todo-list/ — основна сторінка. features/add-todo/ — форма і логіка додавання. entities/Todo/ — тип Todo і можливо UI-картка. shared/ui/ — Input і Button.

Q: Що робити, якщо компонент потрібен у двох фічах?
A: Якщо бізнес-логіка різна — дублюй. Якщо це чистий доменний компонент (як ProductCard) — виноси в entities/Product/ui/. Дублювання дешевше за передчасну абстракцію.

Q: Як FSD працює з глобальним станом?
A: Локальний стан slice (Zustand-стор, Redux slice) живе в features/cart/model/. Сесія користувача та інший справді глобальний стан — в entities/User/model/ або app/.

Q: Чи можна поєднувати FSD з атомарним дизайном?
A: Так. Atomic Design може керувати тим, що потрапляє в shared/ui/. FSD визначає структуру всього проекту. Вони працюють на різних рівнях і не конфліктують.

Q: Як мігрувати legacy-проект на FSD?
A: Починай з pages/ — маршрути вже є природними межами. Потім визначай доменні об'єкти і переносиш їх в entities/. Нарешті виноси фіче-специфічний код з components/ і utils/. ESLint-плагін — страховка під час міграції. Команди повідомляють про скорочення складності шляхів імпорту на 30% і часу онбордингу вдвічі після двотижневої міграції.

Приклади

Базовий: фіча «додати до кошика»

Реальний e-commerce приклад на React і Zustand:

tsx
// src/features/cart/model/cartSlice.ts import { create } from 'zustand'; import { Product } from 'src/entities/Product/model/types'; // дозволено: feature → entity interface CartState { items: Product[]; add: (item: Product) => void; } export const useCart = create<CartState>(set => ({ items: [], add: item => set(state => ({ items: [...state.items, item] })) })); // src/features/cart/ui/AddToCartButton.tsx import { useCart } from '../model/cartSlice'; // локальний імпорт всередині slice export const AddToCartButton = ({ product }: { product: Product }) => { const add = useCart(state => state.add); return <button onClick={() => add(product)}>Додати до кошика</button>; };

Кнопка і стан живуть в одному slice. Тести цієї фічі йдуть в src/features/cart/. Рефакторинг логіки кошика не зачіпає нічого за межами цього каталогу.

Середній рівень: сторінка компонує entities і features

tsx
// src/pages/user-orders/index.tsx import { UserOrderList } from './ui/OrderList'; // локально для сторінки import { fetchUserOrders } from './model/ordersApi'; // локально для сторінки import { Order } from 'src/entities/Order'; // дозволено: page → entity export const UserOrdersPage = () => { const [orders, setOrders] = useState<Order[]>([]); useEffect(() => { fetchUserOrders().then(setOrders); }, []); return <UserOrderList orders={orders} />; };

Сторінка імпортує з entities/ (нижчий шар — все добре) і зі своїх локальних файлів. Жодних імпортів з інших сторінок чи фіч.

Просунутий рівень: виявлення і виправлення порушення шарів

Такий патерн не пройде ESLint-перевірку і створює довгострокові проблеми із зв'язністю:

tsx
// НЕПРАВИЛЬНО: feature дотягується до шару app // src/features/auth/model/auth.ts import { router } from 'src/app/routing'; // ESLint-порушення: імпорт з вищого шару export const login = async (credentials: Credentials) => { await authApi.login(credentials); router.navigate('/dashboard'); // жорстке зв'язування з шаром app }; // ПРАВИЛЬНО: інвертуємо контроль через callback // src/features/auth/lib/loginHandler.ts export const login = async ( credentials: Credentials, onSuccess: () => void ) => { await authApi.login(credentials); onSuccess(); }; // src/pages/login/index.tsx (шар pages відповідає за навігацію) import { login } from 'src/features/auth'; const handleLogin = (credentials: Credentials) => { login(credentials, () => router.navigate('/dashboard')); };

Виправлений варіант тримає features/auth/ незалежним від того, як реалізована навігація. Функцію login() можна використовувати на мобільному, на іншій сторінці або в тестах без будь-яких змін у фічі.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?