Як перетворити feature-based структуру на повноцінну фронтенд-архітектуру
Мене звати Владислав Муращенко, я фронтенд-розробник. У цій статті хочу поговорити про архітектуру фронтенду. Ця стаття буде корисною та цікавою для фронтенд-розробників будь-якого рівня.
Головна мета архітектури — керувати складністю.
Але що, якщо ви тільки починаєте новий застосунок і видимої складності ще немає? Чи означає це, що архітектура не потрібна?
Потрібна — але вона має починатися просто.
У цій статті я розгляну підхід до фронтенд-архітектури, який починається з простоти та дозволяє продукту зростати в складності, не втрачаючи модульності та структурної ясності.
Чому feature-based структури недостатньо?
Feature-based підхід — чудова відправна точка. У своїй основі він визначає лише кілька принципів:
- Організовувати код навколо фіч — користувацьких можливостей, а не технічних шарів.
- Робити фічі максимально незалежними одна від одної.
Ці принципи потужні — але самі по собі вони не утворюють повноцінної архітектури. Навіть на початку feature-based структура залишає важливі питання без відповідей:
- Як уникнути дублювання між фічами, без звалища коду у вигляді shared папки?
- Наскільки великою може стати фіча і як контролювати її зростання?
- Що на практиці означає незалежність фіч і як її забезпечити?
Якщо ці питання залишаються без відповідей, архітектура з часом почне деградувати. Ідеальних відповідей не існує. Але з часом я зрозумів, що явні відповіді на ці питання перетворюють feature-based структуру на справжню архітектуру.
Як уникнути дублювання між фічами, без звалища коду у вигляді shared папки?
У базовій feature-based структурі весь код, пов’язаний із фічею, розміщується якомога ближче до цієї фічі. Тож якщо у вас є Button всередині фічі, він має залишатися в папці цієї фічі. Але завтра той самий Button знадобиться в іншій фічі. Що тоді робити?
Варіант 1: імпортувати його прямо з фічі
Залишити Button там, де він є, і імпортувати його з іншої фічі. Це найгірший варіант. Він створює залежність між фічами, порушуючи принцип, який feature-based архітектура намагається захистити: фічі мають залишатися максимально незалежними одна від одної.
Варіант 2: перенести його в shared/components.
Винести Button у папку shared/components — і кидати туди будь-який компонент, який починає використовуватися в кількох місцях. Спочатку це здається цілком логічним рішенням. Але з часом shared має тенденцію розростатися і перетворюватися на звалище:
shared/ ├── components/ ├── hooks/ └── utils/
Справжня проблема в тому, що shared не має чіткої відповідальності.
У shared можуть опинитися:
- Eлементи дизайн-системи
- доменно-специфічна логіка
- виклики API
- випадкові абстракції
Адже shared створюється для повторного використання — а не для логічної цілісності.
Варіант 3: мати власну UI бібліотеку
Ідея полягає в тому, щоб взагалі не розглядати елементи дизайн-системи як частину фіч. Button — це не відповідальність фічі. Це UI відповідальність.
Замість того щоб розміщувати його всередині фічі, а потім виносити, він має одразу жити в окремій UI бібліотеці.
UI бібліотека має чітку відповідальність: вона відповідає за консистентний вигляд застосунку, незалежний від його домену. UI бібліотека може виглядати так:
libs/ui/ ├── modal/ │ ├── ConfirmModal.tsx # Confirmation dialog with cancel/confirm actions │ ├── FormModal.tsx # Modal with form submit functionality │ └── Modal.tsx ├── utils/ │ ├── cn.ts # ClassName utility │ ├── formatDuration.ts # Format milliseconds to "Xh Ym Zs" string │ └── showToast.ts # Display toast notifications ├── Button.tsx ├── ButtonGroup.tsx ├── Card.tsx ├── DateTimePicker.tsx ├── FieldError.tsx ├── Input.tsx ├── Label.tsx ├── Spinner.tsx ├── TextField.tsx └── Toast.tsx
API бібліотека
UI — це один із наскрізних аспектів, який присутній у фічах.
Інший фундаментальний аспект — це дані.
Фічі працюють із даними, але логіка їх отримання, оновлення, кешування та синхронізації не повинна належати окремим фічам.
Якщо у вас є хук на кшталт useUser, він не повинен належати жодній фічі. Він представляє контракт даних на рівні застосунку і може бути використаний в багатьох фічах.
Так само, як UI примітиви належать UI бібліотеці, доступ до даних має знаходитися в API бібліотеці. Як конкретно будуть виглядати абстракції залежить від інструментів які ви використовуєте для отримана данних, їх кешування. Наприклад якщо це TanStack Query, то це будуть результати виклику хелперів queryOptions та mutationOptions як і рекомендує ця бібліотека.
У випадку додаку, який я робив як приклад, API бібліотека приховує той факт, що насправді ніякого API немає, а всі данні живуть в IndexedDB. Тому в мене API бібліотека виглядає так:
libs/api/ ├── _internal/ │ └── db.ts # Private implementation details ├── active-task/ │ ├── completeActiveTask.ts │ ├── pauseActiveTask.ts │ ├── startTask.ts │ └── useActiveTaskState.ts ├── tasks/ │ ├── createTask.ts │ ├── deleteTask.ts │ ├── reopenTask.ts │ ├── useTask.ts │ ├── useTasks.ts │ └── updateTaskName.ts └── time-intervals/ ├── createTimeInterval.ts ├── deleteTimeInterval.ts ├── updateTimeInterval.ts ├── useTaskDuration.ts └── useTaskTimeIntervals.ts
На відміну від UI, API бібліотека побудована навколо доменних концепцій. Однак це не сам доменний рівень, бо доменна логіка має знаходитися на бекенді. Якщо певні доменні правила все ж повинні виконуватися на клієнті, їх слід реалізувати в Domain бібліотеці.
Domain бібліотека
Мета Domain бібліотеки — винести доменну логіку з фіч, централізувати її в одному місці та забезпечити повторне використання в усьому застосунку.
Domain бібліотека може використовуватися фічами та API бібліотекою, але не повинна імпортуватися з UI бібліотеки.
В мене Domain бібліотека вийшла така:
libs/domain/ ├── active-task/ │ └── model.ts # ActiveTaskState type ├── tasks/ │ └── model.ts # Task type └── time-intervals/ ├── calculateDuration.ts ├── calculateDuration.test.ts ├── getInitial.ts ├── getInitial.test.ts ├── model.ts # TimeInterval type ├── sortIntervals.ts ├── sortIntervals.test.ts ├── validateInterval.ts └── validateInterval.test.ts
Разом бібліотеки UI, API та domain покривають більшість випадків повторного використання коду між фічами.
Наскільки великою може стати фіча і як контролювати її зростання?
Фіча може зростати. Це нормально. Проблема не в розмірі. Проблема у відсутності модульності.
Уявімо фічу profile. Спочатку вона була простою. З часом вона розрослася. Усередині неї з’явилося кілька внутрішніх частин. Що робити?
Варіант 1: розділити за типом файлів
Найгірше, що ми можемо зробити, — механічно розділити все на:
features/ └── profile/ ├── components/ ├── hooks/ ├── utils/ └── Profile.tsx // composes everything together
Це створює ілюзію модульності, але насправді код залишається сильно зв’язаним.
Варіант 2: винести внутрішні частини в окремі фічі
Може здатися, що якщо фіча стає занадто великою, її просто потрібно розділити на кілька менших:
features/ ├── privacy/ ├── identity/ ├── security/ └── profile/ └── Profile.tsx // composes three other features
На перший погляд це виглядає як чиста декомпозиція. Однак такий підхід створює кілька проблем:
- Хибна незалежність.
identity,securityіprivacyнасправді не є окремими фічами — це внутрішні частиниprofile. Структура створює враження модульності, але насправді ці модулі не можуть існувати самі по собі і мають сенс тільки в контексті бітька. - Порушення ізоляції фіч. Дозволяючи фічам імпортувати інші фічі, ми порушуємо принцип: фічі мають залишатися якомога більш незалежними одна від одної. Коли з’являються імпорти між фічами, межі залежностей починають розмиватися і стають значно складнішими для контролю.
Як результать, те, що виглядає як краща модульність, насправді може призвести до сильнішого зв’язування та архітектурної неоднозначності.
Варіант 3: ввести модулі фіч
UI за своєю природою має деревоподібну структуру — тож нехай він таким і залишається:
features/ └── profile/ ├── identity/ ├── security/ ├── privacy/ └── Profile.tsx // composes three nested modules
Замість того щоб виділяти штучні фічі верхнього рівня, ми створюємо внутрішні модулі всередині фічі.
Кожен модуль — це папка, яка:
- може містити кілька файлів
- має власну публічну та приватну частини
- інкапсулює свою логіку
- залишається внутрішньою для батьківської фічі й не доступна за її межами
Вкладені модулі, у свою чергу, можуть містити власні вкладені модулі. У результаті фіча органічно зростає у вигляді дерева — зберігаючи як природну структуру UI, так і чіткі модульні межі. Таким чином ми уникаємо хибної незалежності, зберігаючи справжню інкапсуляцію.
В моєму додатку дерево фічі виглядає так:
features/ └── tasks/ # This app has only one root feature, this is fine ├── index.ts # exports Tasks ├── Filters.tsx ├── TaskList.tsx # imports Task ├── Tasks.tsx # imports CreateTask, ActiveTask ├── Tasks.test.tsx ├── useTemporaryHiddenTaskId.ts ├── active-task/ │ ├── index.ts # exports ActiveTask │ ├── ActiveTask.tsx │ ├── ActiveTask.test.tsx │ ├── TaskName.tsx │ └── Timer.tsx ├── create-task/ │ ├── index.ts # exports CreateTask │ ├── CreateTask.tsx │ ├── CreateTask.test.tsx │ └── useAutoFocusOnDesktop.ts └── task/ ├── index.ts # exports Task ├── DeleteTask.tsx ├── Task.tsx ├── Task.test.tsx ├── TaskDuration.tsx # imports TimeIntervalsModal ├── TaskName.tsx └── time-intervals/ ├── index.ts # exports TimeIntervalsModal ├── AddIntervalButton.tsx # imports CreateInterval ├── TimeInterval.tsx # imports EditInterval ├── TimeIntervals.tsx ├── TimeIntervalsModal.tsx ├── TimeIntervalsModal.test.tsx ├── sortIntervals.ts ├── sortIntervals.test.ts └── interval-forms/ ├── index.ts # exports CreateInterval, EditInterval ├── CreateInterval.tsx # imports IntervalForm ├── CreateInterval.test.tsx ├── CreateInterval.utils.ts ├── CreateInterval.utils.test.ts ├── EditInterval.tsx # imports IntervalForm ├── EditInterval.test.tsx └── interval-form/ ├── index.ts # exports IntervalForm ├── IntervalForm.tsx ├── IntervalForm.test.tsx ├── validateInterval.ts └── validateInterval.test.ts
Можливо, вас трохи лякає глибока вкладеність, яка з’являється з таким підходом. Але насправді вкладеність — це не погано. Навпаки, це добре, адже часто свідчить про хорошу модульність.
Справжня проблема виникає тоді, коли вкладені модулі починають імпортувати щось на кшталт ../../../parentModule, або коли батьківський модуль імпортує щось на зразок nested/module/from/children. Це вже порушення модульності. Тому я рекомендую повністю заборонити такі імпорти через ESLint.
Але цей підхід теж не ідеальний — він створює нову проблему:
Що, якщо модуль фічі має використовуватися двома іншими модулями фічі?
У такому випадку ми не можемо просто вкласти його в один із них, адже вкладеність помилково створюватиме враження, що цей модуль йому належить. Ідеального рішення для цієї проблеми не існує.
Прагматичне — і, варто визнати, неідеальне — рішення, до якого я зрештою прийшов, полягає у введенні окремої папки: shared-features.
shared фічі можуть імпортуватися будь-де в середині фічі. Виглядає це так:
features/ └── tasks/ ├── ... ├── active-task/ │ ├── ... │ └── TaskName.tsx # imports EditTaskNameModal └── task/ ├── ... └── TaskName.tsx # imports EditTaskNameModal shared-features/ └── edit-task-name/ ├── index.ts # exports EditTaskNameModal ├── EditTaskNameForm.tsx ├── EditTaskNameModal.tsx └── EditTaskNameModal.test.tsx
В цьому прикладі TaskName в кожному із модулів фіч — це дві окремі і незалежні реалізації, але обидві вони по кліку мають відкрити одне і те ж модальне вікно.
shared фічі — це свідомий компроміс, до якого варто вдаватися лише тоді, коли уникнення дублювання (DRY) важливіше за збереження суворої архітектурної ізоляції.
Надмірне використання shared фіч може перетворити застосунок із доглянутого саду на непрохідні джунглі.
Що на практиці означає незалежність фіч і як її забезпечити?
На мою думку, незалежність фіч передусім означає контроль над імпортами. Якщо фічі не можуть імпортувати одна одну, їм складніше утворювати тісні зв’язки.
Незалежність не повинна триматися лише на дисципліні. Якщо вона не зафіксована в інструментах, вона залишається лише рекомендацією — а не правилом. Тому архітектурні межі слід забезпечити за допомогою правил ESLint:
- Фічі не можуть імпортувати інші фічі
- Для модулів фіч дозволені лише публічні точки входу
- Глибокі імпорти заборонені
- Бібліотеки UI та API не повинні знати одна про одну
- Domain може використовуватися лише фічами та API

На картинці зображено дозволений напрямок залежностей у запропонованій архітектурі.
Звісно, сам по собі контроль імпортів не усуває зв’язування:
- фічі все ще можуть використовувати спільний стан під час виконання
- вони також можуть покладатися на неявні поведінкові контракти, наприклад через
props
Однак структурні імпорти — це єдина форма зв’язування, яку можна надійно контролювати за допомогою інструментів. І коли структурні межі стають суворими, інші форми зв’язування стає складніше утворити випадково.
Фінальна архітектура
Архітектура, яку я пропоную, базується на простому фундаменті:
- Feature-based структура як основна модель організації коду
- Окремі бібліотеки UI, API та domain для чіткого розділення відповідальностей
- Фічі, які природно зростають у вигляді дерева
- Суворе дотримання архітектурних меж за допомогою ESLint (або подібного інструмента)
Тут немає жодної революції. Жодної нової парадигми. Це баланс між:
- feature-based організацією
- шаровим розділенням
- модульним контролем залежностей
Для зручності я об’єднав ці ідеї в цілісну систему й назвав її Feature Garden архітектура. Повну документацію можна знайти за посиланням:
github.com/...rashchenko/feature-garden
Якщо хочете побачити додаток, побудований на цих принципах, з якого я брав приклади:
github.com/...ashchenko/productivity-up
Репозиторій містить приклад конфігурації ESLint, яка на практиці забезпечує дотримання архітектурних меж.
Висновок
Feature-based структура — це хороший початок, але сама по собі вона не є архітектурою.
Щоб структура перетворилася на архітектуру, необхідно явно відповісти на кілька важливих питань:
- як уникати дублювання між фічами?
- як контролювати їх зростання?
- як забезпечити їхню незалежність?
У цій статті я запропонував прагматичний підхід до вирішення цих проблем. Жодне з цих рішень не є ідеальним. Але разом вони створюють просту систему, яка дозволяє застосунку зростати, не втрачаючи модульності та структурної ясності.
Архітектура не повинна бути складною. Її мета — зробити складність керованою.
Сподобалась стаття? Підписуйтесь на автора, щоб отримувати сповіщення про нові публікації на пошту.
10 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів