Feature-sliced design (FSD). обов'язкова архітектура фронтенду для знання
Feature-Sliced Design (FSD) — архітектурна конвенція для фронтенд-додатків, яка організовує код у чіткі горизонтальні шари (app, pages, features, entities, shared), де кожен шар імпортує тільки з нижчих, а всі файли одного домену живуть разом в одному slice.
Теорія
TL;DR
- Уяви структуру компанії:
app— CEO,pages— відділи,features— команди,entities— бізнес-об'єкти,shared— канцелярія. - Головна відмінність від класичного підходу: код групується по фічі/домену, а не по типу файлів. Ніякого гігантського
components/. - Правило залежностей:
shared→entities→features→pages→app. Тільки знизу вгору. - Підходить для команди від 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.ts | pages/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/
// Неправильно: специфічний для кошика компонент в shared
// src/shared/ui/CartButton.tsx
// Правильно: перемістити туди, де він належить
// src/features/cart/ui/CartButton.tsxshared/ — для справді загального коду: компоненти дизайн-системи, утиліти, типові хелпери. Коли команди кладуть туди доменно-специфічні компоненти, shared/ перетворюється на смітник з 500+ файлів. На r/reactjs описують 40% роздування бандлу саме від цього патерну.
Крос-імпорти всередині одного шару
// Неправильно: features імпортують features
// src/features/cart/model/cart.ts
import { useAuth } from 'src/features/auth'; // ризик циклічних залежностей
// Правильно: виносимо спільну доменну логіку в entities
// src/entities/User/model/authApi.tsІмпорт вгору по ієрархії шарів
// Неправильно: feature дотягується до шару app
import { router } from 'src/app/routing'; // порушення правила шарів
// Правильно: передаємо навігацію через callback
export const loginSuccess = (onSuccess: () => void) => {
// логіка аутентифікації
onSuccess();
};
// Шар pages викликає: loginSuccess(() => router.navigate('/dashboard'))Відсутність public API barrel-файлів
// Неправильно: споживачі знають внутрішні шляхи
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:
// 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
// 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-перевірку і створює довгострокові проблеми із зв'язністю:
// НЕПРАВИЛЬНО: 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() можна використовувати на мобільному, на іншій сторінці або в тестах без будь-яких змін у фічі.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.