Як перетворити feature-based структуру на повноцінну фронтенд-архітектуру

💡 Усі статті, обговорення, новини про Front-end — в одному місці. Приєднуйтесь до Front-end спільноти!

Мене звати Владислав Муращенко, я фронтенд-розробник. У цій статті хочу поговорити про архітектуру фронтенду. Ця стаття буде корисною та цікавою для фронтенд-розробників будь-якого рівня.

Головна мета архітектури — керувати складністю.

Але що, якщо ви тільки починаєте новий застосунок і видимої складності ще немає? Чи означає це, що архітектура не потрібна?

Потрібна — але вона має починатися просто.

У цій статті я розгляну підхід до фронтенд-архітектури, який починається з простоти та дозволяє продукту зростати в складності, не втрачаючи модульності та структурної ясності.

Чому 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
До обраногоВ обраному3
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Ще можна розвити в сторону гексагональної архітектури ) де все крутится на вколо портів та адапторів

Мета гексагнальної архітектури зробити домен незалежним від інфраструктури. В feature garden домен — це окрема бібліотека, яка якраз таки має бути незалежною ні від чого. А гексагональна архітектура є лише одним із можливих способів цього досягти. Інший спосіб, який я використав в проекті прикладі — мати чисте функціональне ядро, тобто весь домен реалізований як набір чистих функцій.

Дуже класний підхід. Намагався притримуватись подібної структури вже давно. Не вистачає тільки прикладів підходів коли до коду додається інфраструктура, така як тести, ассети, різний тулінг. Наприклад storybook для кожного компонента або фічі. Дублювати конфігурацію ? Ні. Я просто виношу таку конфігурацію в окремий пакет яку використовую для кожної фічі. Тести ? Як в Python додавати директорію __tests__ ? Бо в один файл всі тести не розмістиш... А тести теж треба організовувати. Приходимо до того що майже кожен компонент це ніби як окремий під-проєкт, зі своїми конфігами, тестами, білдами, і так далі. А ось як організувати подібну «мета-організацію», оце поки не дуже зрозуміло.

Інфраструктура і конфігурації це якраз ідеальний use case для libs. Просто створюєш окрему бібліотеку libs/my-custom-lib і цим по суті вирішується дуже багато питань.

Щодо тестів. В середині features я тести тримав прямо поряд з компонентами. Як на мене то це чудовий підхід, бо тести колокейтяться з реалізацією. storybook в принципі не підходить для фіч, це інструмент для дизайн систем. Тож для нього можна або мати окрему бібліотеку, або колокейтити поряд з компонентами із libs/ui. Всі інші тести для бібліотек тут як подобається можна колокейтити, бо створювати __TEST__ як подобається.

Приходимо до того що майже кожен компонент це ніби як окремий під-проєкт, зі своїми конфігами, тестами, білдами, і так далі. А ось як організувати подібну «мета-організацію», оце поки не дуже зрозуміло.

Якщо компонент має свої власні конфіги і тести — то це все буде в папці фічі лежати поряд з ним. Щодо білдити і деплоїти компоненти окремо, тут вже потрібні мікрофонтенди, але треба точно розуміти навіщо потрібні окремі деплої. Можливо замість окремих деплоїв можна просто перейти на Trunk-Based Development з фіче флагами і це вирішить проблему.

Як на ваш погляд, в подібній архітектурі шерити стейт між фічами? І головне, як зробити очевидним, що деякі фічі комунікують між собою через якийсь shared state?

Основна частина стану який дійсно шериться — це кеш данних з бекенду, і це відбувається за рахунок перевикористання абстракцій з API бібліотеки.

Якщо ж є ще якийсь стан який треба зашерити, то скоріше всього для нього можна мати окрему свою бібліотеку.

Якщо поділитесь деталями про те, що це за стан, що він робить, то я зможу більш розгорнуто відповісти на це питання, конкретно під вашу ситуацію.

Повністю погоджуюсь з таким підходом. В принципі, +/- все можна задизайнити таким чином. Мінімальний ЮІ стейт можна шерити через React Context, по такому принципу, як працює API бібліотека.

Але, скажімо, є ЮІ налаштування (React State) кількох незалежних віджетів і в якийсь момент, є нова панель, яка має змінювати всі ці віджети одночасно. В ідеалі — це має бути API для зберігання такого стейту, але якщо по якимось причинам, його не виходить зробити?

Саме так в ідеалі має бути API для налаштувань, якщо ж його немає, то це означає необхідність мати глобальний стейт менеджмент.

Глобальний стейт менеджмент означає, що треба мати окрему бібліотеку store, в якій будуть зберігатися наприклад jotai атоми. Налаштування це зазвичай це дуже точкові стани. Наприклад Boolean який каже що щось увімкнено. Тож атомарний стейт менеджмент скоріше всього чудово підійде.

Щоб гарно організувати цю бібліотеку, ріжте її на вертикальні слайси, але цей слайсинг не обов’язково має дублювати фічі.

Важливо що при створенні нової бібліотеки треба явно задекларувати через Eslint, що ця бібліотека може імпортувати, а що ні. Це важливо, щоб бібліотека не перетворилась на смітник. Наприклад store бібліотека, може імпортувати domain, але не може features, app, ui, api.

А ви Api сервіси всі виносите на верхній рівень? Не тримаєте ближче до домейну? Бо деякі api сервіси не використовуються у делькох місцях

Дякую за питання, воно насправді дуже глибоке.

Домен в мене знаходиться в окремій бібліотеці. Ідея домену в тому щоб він нічого не знав про інфраструктуру. Тобто в домені будуть знаходитися тільки типи важливих для домену сутностей і чисті функції які працюють з цими типами. Домен взагалі не знає, що у вас існує бекенд :)

API сервіси, вони ж як правило напряму використовують інфраструктуру для отримання данних з бекенду, чи з бази данних. Вони знають деталі про те, звідки треба брати ці данні. Вони по суті і є інфраструктура. Тому ця інфраструктура знаходиться в окремій бібліотеці яка використовує домен.

Чому не тримати API сервіси в фічах?
Та тому що ми насправді майже ніколи не можемо гарантувати, що цей API сервіс завтра не знадобиться в іншій фічі. Якщо ж частину сервісів тримати глобально, в частину локально, то виникає архітектурна неоднозначність: куди ж мені зараз покласти цей сервіс локально чи глобально? Тож я рекомендую класти завжди глобально і не паритись. Просто прийняти той факт, що всі сервіси в одному місці. Такий собі компроміс задля того, щоб не морочити собі голову і не переміщати файли з локальних в глобальні лишній раз.

Ну і звичайно діліть їх на вертикальні слайси відповідно до вашого домену.

Можливо, якщо у вас GraphQL ви можете прийняти іншу альтернативу — дублювати квері в кожній фічі замість того, щоб намагатися їх перевикористовувати. Але мені бракує експертизи в GraphQL, щоб оцінити наскільки така ідея буде хорошою — не знаю чи не вплине це на алгоритм кешування в тому ж Apolo.

Підписатись на коментарі