Десять архітектурних звичок, які допомагають підтримувати кодові бази фронтенду придатними протягом багатьох років
Пояснює структурні звички, такі як оптимізація для можливості видалення, чіткий потік даних та ізоляція бізнес-логіки, які допомагають кодовим базам залишатися підтримуваними протягом багатьох років змін.
Кожна база коду фронтенду, яка існує достатньо довго, зрештою розділяється на дві окремі області.
Першу можна назвати Зоною небезпеки.
Це хаотична суміш менеджерів стану, латаних гаків життєвого циклу, розумних, але незрозумілих глобальних абстракцій, незавершених експериментів та допоміжних функцій, написаних багато років тому, які вже ніхто не може повністю пояснити.
Ніхто не хоче наближатися до неї.
Коли в цю частину додатку надходить запит на нову функцію, команда не оцінює обсяг роботи за складністю самої роботи.
Вони оцінюють її за рівнем ризику, пов’язаним із зміною цього коду.
Зміна, яка мала б зайняти два дні, перетворюється на роботу тривалістю два тижні, тому що всі знають, що більша частина часу піде на тестування, а не на розробку.
А ще є інша область.
Назвімо її Стабільною основою.
Це модулі, створені багато років тому, які тихо витримали кілька міграцій фреймворків, переробок, змін у напрямку продукту та змін у керівництві інженерною командою.
Вони майже ніколи не спричиняють перерв у роботі.
У способі їхнього написання немає нічого особливо винахідливого.
Коли до команди приєднується нова людина, вона може відкрити один із цих файлів, зрозуміти, що він робить, без необхідності пояснень, та надіслати перший pull request протягом дня чи двох.
Саме це варто помічати.
Код, який залишається у використанні, зазвичай не є найсучаснішим кодом у системі.
Це зазвичай найпростіший код.
Досвідчені інженери знають, що програмне забезпечення ніколи не залишається у тих умовах, в яких його створювали.
Вимоги змінюються.
Команди реорганізовуються.
Залежності змінюються.
Фреймворки розвиваються далі.
Компанії змінюють стратегію.
Люди залишають компанію.
Нові інженери приходять, не маючи жодної інформації про те, чому речі були створені саме так.
Тож правильне запитання не є:
"Наскільки чистим виглядає цей дизайн зараз?"
Воно є таким:
"Які витрати будуть пов’язані зі зміною цього через п’ять років?"
Нижче наведені структурні звички, які дозволяють досягти такої тривалості життя.
1. Оптимізуйте для можливості видалення, а не повторного використання
Багато рекомендацій з архітектури спрямовані на повторне використання.
Зробіть ваші компоненти придатними до повторного використання.
Створюйте універсальні сервіси.
Додавайте точки розширення.
Проектуйте архітектури плагінів.
Пишіть абстракції для реалізацій, які ви ще не створили.
Повторне використання має своє місце.
Але існує ще одна якість, яка часто має більше значення у продукті, що постійно розвивається:
Наскільки легко щось можна видалити.
Функції не є постійними.
Їх замінюють.
Їх перебудовують.
Їх об’єднують з іншими функціями.
Іноді компанія просто втрачає до них інтерес.
src/
components/
BillingTable.tsx
UserModal.tsx
SubscriptionCard.tsx
hooks/
useBillingData.ts
useUserData.ts
useSubscription.ts services/
billingApi.ts
userApi.ts
subscriptionApi.ts
На перший погляд це здається охайним.
Кожен тип файлу має свою відповідну папку.
Але припустимо, через вісімнадцять місяців компанія вирішує повністю відмовитися від процесу билінгу.
Де насправді знаходиться все, що пов’язано з билінгом?
Доведеться шукати в кількох різних директоріях.
Ви знаходите та видаляєте BillingTable.tsx.
Потім бачите useBillingData.ts.
Потім деякі визначення типів, написані спеціально для обліку оплат.
Потім допоміжна функція, яку викликає лише модуль обліку оплат.
Потім таблиця стилів.
Потім виклик API.
Потім тестовий фікстура.
Потім хук, який спочатку був хуком для обліку оплат, але згодом отримав іншу назву.
Ця функціональність вже видалена з продукту, проте її залишки все ще розкидані по кодбазі.
Саме так з часом накопичується „мертвий“ код.
Організація за функціональними елементами робить межі набагато більш очевидними:
src/
features/
billing/
components/
BillingTable.tsx
hooks/
useBillingData.ts
services/
billingApi.ts
types.ts
index.ts
Тепер облік оплат має одне чітке місце розташування.
Якщо компанія вирішує припинити підтримку цієї функціональності, перший крок є простим:
src/features/billing/
Видалити папку.
Тоді TypeScript виявить усе інше, що все ще залежить від неї.
Це набагато кращий тип залежності для обробки.
Можливість видалення — це форма підтримуваності
Модуль стає простішим у обслуговуванні, коли можна миттєво зрозуміти, де знаходяться його функції.
Це одна з причин, чому структури папок, засновані на функціях, постійно згадуються під час обговорень масштабування великих кодових баз React. Команди, які працюють над великими додатками, часто пропонують спосіб розміщення елементів разом саме тому, що це зменшує вплив будь-яких змін.
Мета не в тому, щоб досягти ідеально організованої деревоподібної структури папок.
Мета — мати можливість швидко відповісти на таке запитання:
„Якби ця функція зникла завтра, що мені довелося б видалити?“
Якщо на це запитання важко відповісти, межі вашої функції, ймовірно, занадто розпливчасті.
2. Не перетворюйте shared/ на смітник
Існує ще одна пастка, яка з’являється, коли команди переходять на архітектуру, засновану на функціях.
Усе, що явно не належить до однієї функціональності, потрапляє до папки shared/.
Через кілька місяців у вас виходить щось на кшталт:
shared/
utils/
helpers/
common/
services/
components/
hooks/
types/
І на тому етапі shared/ становить майже половину всього кодового базису.
Це створює власні проблеми з взаємозв’язками між компонентами.
Гарна порада, якої варто дотримуватися:
Код слід переміщувати до спільної папки тоді, коли кілька функціоналів дійсно потребують однієї й тієї самої концепції, а не тому, що ви не можете вирішити, куди його ще покласти.
Універсальний компонент кнопки природно вписується у систему дизайну.
Клієнт для автентифікації може логічно знаходитися у рівні спільної інфраструктури.
Допоміжний функціонал для форматування дат також може бути спільним.
Але функція на кшталт:
calculateEnterpriseRenewalDiscount()
майже напевно належить до тієї функціональності, яка використовує саме це бізнес-правило.
Утримайтеся від бажання переміщувати елементи до глобальних папок лише для того, щоб дерево каталогів виглядало охайніше.
Спільний код — це не щось безкоштовне, адже кожна функція, яка з ним працює, стає потенційним залежним елементом.
Чим більше росте папка shared/, тим складніше з’ясувати, хто насправді відповідає за певну поведінку програми.
3. Створюйте захисні адаптери навколо зовнішніх залежностей
Існує велика ймовірність того, що ваше застосування буде продовжувати працювати ще довго після того, як деякі з його залежностей зникнуть.
Сьогодні ви можете користуватися Axios, а завтра — перейти на вбудований метод fetch.
Зараз ви можете використовувати одного постачальника аналітики, а через кілька років ваша компанія може перейти на іншого.
Сьогодні ви можете інтегрувати бібліотеку автентифікації, а згодом нові вимоги до безпеки можуть змусити вас обрати іншу бібліотеку.
Крихкий спосіб створення продуктів — це імпортування цих зовнішніх пакетів безпосередньо до десятків компонентів.
import axios from 'axios';
import { trackMixpanelEvent } from 'mixpanel-browser';
export function CheckoutCard() {
const handlePurchase = async () => {
await axios.post('/api/checkout', payload); trackMixpanelEvent('checkout_completed');
};
}
На цьому етапі шар користувацького інтерфейсу має пряме уявлення про те, який саме HTTP-клієнт та який постачальник аналітики ви використовуєте. Якщо така схема застосовуватиметься до сорока п’яти компонентів, заміна постачальника вже не буде локальною операцією — це стане зміною, яка вплине на весь репозиторій.
Шар меж утримує залежності такими, що їх можна замінювати. Наприклад:
// src/shared/lib/analytics.ts
import mixpanel from 'mixpanel-browser';export const analytics = {
trackCheckoutCompleted(
orderId: string,
amount: number
) {
mixpanel.track('checkout_completed', {
orderId,
amount,
});
},
};
Тепер компонент взаємодіє з концепцією, визначеною вашим власним додатком:
analytics.trackCheckoutCompleted(orderId, amount);
Він не має жодного уявлення про те, чи під цим знаходиться Mixpanel, PostHog, Segment чи якийсь інший інструмент. Якщо змінюється постачальник, контракт, від якого залежить ваш додаток, може залишатися абсолютно незмінним.
Але не абстрагуйте все
Цей момент є настільки ж важливим, як і попередній.
Використання адаптера для обгортки залежності не є саме по собі хорошим підходом у проектуванні. Якщо ви створюєте власний інтерфейс навколо кожної невеликої бібліотеки, яку використовуєте, у кінцевому підсумку ви можете написати більше коду, ніж містить сама бібліотека.
Справжнє запитання, яке варто поставити, звучить так:
„Чи буде заміна цієї залежності пізніше дорогою, чи буде ризиковано дозволяти їй поширюватися без контролю в усьому кодовому базі?“
Якщо відповідь «так», інвестиції у адаптер того варті. Якщо ні, безпосереднє використання залежності, ймовірно, буде простішим та достатнім.
Статус старшого інженера не означає накладання абстракцій на все, що ви торкаєтесь. Це означає встановлення меж саме там, де їх пропуск, ймовірно, коштуватиме вам більше пізніше.
4. Чіткий потік даних кращий за магію
Один із найшвидших способів зробити кодову базу заплутаною — це приховати джерело значень.
Глобальні емітери подій є класичним прикладом.
eventBus.emit('USER_UPDATED', {
id: user.id,
});
Цей рядок повідомляє про те, що відправлена подія. Він нічого не каже про те, хто її прослуховує.
Ви могли б переглянути всю кодову базу та зрештою знайти щось на кшталт:
eventBus.on('USER_UPDATED', handler);
Але може бути три окремі слухачі. Один з них міг бути доданий два роки тому. Інший може змінювати глобальний стан. Третій може відправляти запити для аналітики. Раптово справжня поведінка, викликана початковою функцією, розсіяна по всьому додатку, замість того щоб знаходитися в одному місці.
Тепер порівняйте це з контрактом, який сформульований чітко:
interface UserCardProps {
user: User;
onUserRoleChange: (
userId: string,
newRole: Role
) => Promise<void>;
}
Тут компонент відкрито оголошує, яку дію він підтримує, а батьківський компонент відкрито оголошує, що відбувається, коли ця дія запускається. Потік даних є видимим на сторінці.
Так, це більш розгорнуте, ніж запуск анонімного події. Але ця додаткова розгорнутість має сенс, оскільки залишає простежуваний шлях у коді.
Коли хтось, хто не знайомий із компонентом, відкриває файл, він має змогу відповісти на три запитання, не переглядаючи решту репозиторію:
Звідки беруться ці дані?
Зазвичай це props, параметри маршруту, хук або якийсь чітко визначений шар доступу до даних.
Що може їх змінити?
Видимий виклик функції, мутація, дія або пряма зміна стану.
Що відбувається, коли користувач виконує цю дію?
Прямий виклик функції, реалізацію якої можна простежити.
Чим менше поведінки ви приховуєте, тим легше стає розуміти всю систему.
5. Тримайте бізнес-логіку поза життєвими циклами фреймворку
Фреймворки — це не постійні елементи; це одне з найнадійніших припущень, які можна робити під час роботи над фронтендом.
Лише React пройшов кілька значних змін. Класові компоненти втратили популярність. Хуками була переорганізована структура логіки зі станом. У багатьох проєктах Create React App було замінено на інструменти на кшталт Vite або вбудовані рішення фреймворку. Рендеринг спочатку на сервері та новіші підходи до маршрутизації змінили спосіб, у який команди мислять щодо отримання даних та визначення меж додатку.
Фреймворк, який ви зараз використовуєте, може зовсім не нагадувати стандартний варіант через п’ять років. Водночас ваші бізнес-правила мають продовжувати функціонувати незалежно від цього.
Візьмемо, наприклад, обчислення податків. Крихка версія ховає справжню логіку всередині React hook:
export function useTaxCalculator(
cartItems: CartItem[]
) {
const [tax, setTax] = useState(0);
useEffect(() => {
let calculated = 0; // 60 lines of tax calculation,
// rounding rules,
// country logic,
// exemptions... setTax(calculated);
}, [cartItems]); return tax;
}
Тепер ваше обчислення податків прив’язане до React. Його тестування означає створення середовища React. Виклик з боку серверної дії стає незручним. Його виконання всередині Web Worker також є незручним. Перенесення на інший фреймворк користувацького інтерфейсу стає дуже складним завданням.
Кращий підхід — розділити ці два аспекти:
export function calculateTax(
cartItems: CartItem[],
countryCode: string
): number {
// Pure business logic
return totalTax;
}
Тоді шар React просто викликає цей модуль:
const tax = calculateTax(cartItems, countryCode);
За такої структури логіка, яка насправді має значення, зовсім не залежить від React. Вона може виконуватися в будь-якому середовищі. Її можна тестувати за допомогою звичайних юніт-тестів. Серверний процес може використовувати її безпосередньо. Крім того, вона залишається функціональною після міграції на іншу фреймворк-систему без необхідності переписування.
Фреймворки мають знаходитися на периферії
Корисним способом для уявлення цього є шарова діаграма:
┌──────────────────────────────┐
│ UI Layer │
│ React / Next.js │
├──────────────────────────────┤
│ Application Logic │
├──────────────────────────────┤
│ Domain Logic │
│ Pure TypeScript │
├──────────────────────────────┤
│ Infrastructure │
│ APIs / DB / Vendors / SDKs │
└──────────────────────────────┘
Чим ближче шматок коду знаходиться до центру, тим менше він повинен залежати від певного фреймворку чи бібліотеки виробника.
Це не означає, що кожен проект на React вимагає повноцінної налаштування „Чистої архітектури“.
Це означає, що вам потрібно чітко розуміти, які частини вашого коду є справді специфічними для React, а які відображають ваші справжні бізнес-правила.
Це дві різні категорії, і саме коли їх ототожнюють, починаються проблеми.
6. Уникайте перетворення хуків на міні-додатки
Ця практика постійно зустрічається у кодових базах React.
Зазвичай все починається безневинно:
function useUser() {
// fetch user
}
З часом же накопичуються додаткові вимоги.
function useUser() {
// fetch user
// loading state // error handling // permissions // analytics // transformations // caching // retry logic // business rules // notifications // feature flags
}
Незабаром те, що спочатку було простим хуком, тихо перетворюється на додаток з 500 рядків коду, прихований під назвою функції, яка здається безневинною.
Хуки справді корисні.
Але хук не повинен ставати місцем зберігання всіх проблем лише через те, що він має зручний доступ до стану та ефектів React.
Кращим підходом є делегування функцій хука меншим, спеціалізованим компонентам:
function useUser() {
const user = useUserQuery();
const permissions =
calculatePermissions(user.data); return {
user: user.data,
permissions,
isLoading: user.isLoading,
};
}
За такої структури хук стає шаром оркестрації, який об’єднує різні елементи.
Це вже не вся архітектура, яка запихнута в одну функцію.
Цей кордон набагато легше підтримувати з часом.
7. Фіксуйте архітектурні рішення у записах, а не у безкінечних Вікі
Одним із найпоширеніших джерел занепаду архітектури зовсім не є хаотичний код.
Це втрата контексту.
Ось як це зазвичай відбувається: розробник приймає неочевидне рішення. Це рішення є обґрунтованим, і всі у команді на той момент розуміють причини, що стояли за ним. Потім ця людина переходить до інших завдань.
Через кілька місяців новий інженер натрапляє на цю незвичайну реалізацію та думає:
"Чому ми робимо це саме так? Має існувати більш чисте рішення."
Тож вони переписують код, не усвідомлюючи, що знову створюють саме ту проблему, яку спробували уникнути з самого початку.
Як приклад можна навести панель керування, побудовану на Server-Sent Events замість WebSockets.
Без контексту новий розробник може цілком обґрунтовано дійти висновку:
"WebSockets — це сучасніший стандарт. Давайте перейдемо на нього."
Але початкова команда могла обрати саме SSE через те, що багато корпоративних клієнтів використовують обмежувальні корпоративні проксі, які неправильно обробляють з’єднання WebSocket.
Цей мотив невидимий, якщо дивитися лише на сам код.
Саме таку прогалину призначені заповнити записи архітектурних рішень.
Наприклад:
# ADR 003: Use Server-Sent Events for Dashboard Feeds
## ContextOur dashboard requires real-time metric updates.We evaluated WebSockets and Server-Sent Events.## DecisionWe chose Server-Sent Events because:1. Communication is strictly server-to-client.
2. SSE uses standard HTTP infrastructure.
3. Browser reconnection is supported natively.
4. The solution works reliably within our enterprise network environment.## ConsequencesIf we later require client-to-server
bi-directional streaming, we should
re-evaluate this decision.
За наявності такого запису наступному інженерові не доводиться заново аналізувати логіку рішення.
Він може одразу побачити чому.
Це просто
/docs/adr/
Папка у вашому репозиторії може зберігати роки інституційних знань, які інакше б зникли разом із тими, хто залишає команду.
Документуйте рішення, а не все
Вам не потрібно підтримувати величезний вікі з сотнею сторінок.
У більшості випадків сам код має бути достатньо зрозумілим, щоб пояснити що він робить.
Документація повинна фіксувати ті міркування, які код не може висловити сам по собі:
- чому була обрана певна технологія
- чому була проігнорована більш очевидна альтернатива
- чому взагалі існує незвичайна обмеження
- чому все ще потрібен тимчасовий рішення, яке здається зайвим
Документація виправдовує своє існування саме тоді, коли фіксує контекст, який інакше зник би разом із людьми, що його мали.
8. Проектування для інженера, який приєднається після вас
Це може бути найпростішим тестом на міцність архітектури.
Чи зможе нова команда продовжувати нею керувати?
Якщо ваша чесна відповідь — ні, це не обов’язково означає, що у вас бракує розробників. Це означає, що система має приховану залежність від знань певних людей.
Система, створена для довготривалого використання, повинна дозволяти самостійно з’ясовувати її важливі аспекти роботи.
Нова людина має мати можливість відкрити репозиторій та поступово знаходити відповіді на такі запитання:
- Де саме у кодовій базі знаходиться ця функція?
- Який модуль відповідає за цю поведінку?
- Звідки насправді надходять ці дані?
Саме тому чіткі межі мають таку велику значимість.
Початківець не повинен вивчати всю історію компанії лише для того, щоб зрозуміти, що робить код.
Сама база коду має нести з собою достатньо інформації про цю історію.
9. Зменшуйте радіус впливу змін
Хорошим способом оцінки архітектури є підрахунок кількості файлів, які змушені бути змінені внаслідок будь-якої зміни.
У сильно взаємопов’язаній системі для цього може знадобитися редагування файлів, розташованих у різних місцях:
components/
hooks/
services/
utils/
types/
global state/
shared helpers/
Інженер може бути змушений працювати з десятком файлів лише для того, щоб додати одну кнопку.
Порівняйте це з належним чином структурованою архітектурою функціоналу:
features/
billing/
components/
hooks/
services/
utils/
Тут та сама зміна може залишатися майже повністю всередині власної папки функціоналу оплати.
Саме це мають на увазі, коли говорять про зменшення радіусу поширення змін.
Малий радіус поширення дає вам:
- Кменше регресій
- Простіші перевірки коду
- Швидше впровадження
- Кменше конфліктів при об’єднанні
- Простіше тестування
- Безпечніші рефакторинги
Для досягнення цього не потрібна складна архітектура. Потрібні межі, які насправді відображають те, як продукт розвивається на практиці.
10. Припиніть оптимізувати за схемою архітектури
Чудова схема архітектури все одно може приховувати кодову базу, з якою важко працювати.
Ви можете позначити кожну з цих галочок:
- дотримання принципів Clean Architecture
- застосування принципів проектування SOLID
- належне зворотне підключення залежностей
- обгортання доступу до даних за допомогою шаблонів repository
- створення об’єктів за допомогою шаблонів factory
- з’єднання елементів за допомогою подій
- шарування кількох рівнів абстракції один на одного
і все одно перетворити просту функціональність на багатоденну роботу.
Архітектура має зменшувати складність, а не додавати її. Якщо ваш архітектурний шар вводить більше концепцій, ніж сам продукт, щось пішло не так.
Часто найкраща архітектура — це та, про яку ніхто не хоче говорити, тому що інженери можуть просто прочитати код та слідувати йому. На практиці це може виглядати так:
features/
billing/
checkout/
accounts/
у поєднанні з простим:
shared/
ui/
lib/
а ще кілька функцій, пов’язаних виключно з бізнес-логікою.
Усе це не здається особливо вражаючим. Але якщо через чотири роки цей код все ще функціонує та має сенс, це означає, що він робить саме те, для чого призначена архітектура.
Що насправді оптимізують старші інженери
Старші інженери не обов’язково створюють більш складний код. Різниця полягає у наборі запитань, які вони ставлять перед його написанням.
Менш досвідчений інженер може запитати:
"Як зробити цей код повторно використовуваним?"
Старший інженер натомість запитує:
"Чи справді цей код потребує повторного використання?"
Менш досвідчений інженер може запитати:
"Як мені абстрагувати цей код?"
Старший інженер натомість запитує:
«Яку конкретну проблему має вирішити ця абстракція?»
Менш досвідчений інженер може запитати:
«Куди слід помістити цю функцію-засіб?»
Натомість досвідчений інженер запитує:
«Хто насправді відповідає за цю частину поведінки?»
Менш досвідчений інженер може запитати:
«Як ми підготуємося до будь-яких наступних вимог?»
Натомість досвідчений інженер запитує:
«Яка майбутня зміна є достатньо ймовірною, щоб виправдати додавання цієї складності зараз?»
І, можливо, найважливіше з усіх запитань:
«Яким буде цей код, коли людина, яка його написала, піде?»
Саме з цього питання починається довгострокове мислення в інженерії.
Підсумок: надійний код зазвичай виглядає непримітним
Код, який продовжує ефективно працювати після багатьох змін, рідко є кодом, створеним за допомогою найсучаснішої фреймворк-системи, найрозумнішого шаблону проектування чи найелегантнішої абстракції. Це зазвичай просто код із чіткими межами та практичними, здоровими рішеннями — такий, що інший інженер може відкрити репозиторій та зрозуміти, що відбувається, без необхідності знаходити того, хто його спочатку написав.
Основні принципи є простими:
- Організуйте код за функціями та власниками. Функції мають бути легкодоступними та, коли настане час, легко видалятися.
- Віддавайте перевагу можливості видалення перед максимізацією повторного використання. Не кожен фрагмент логіки заслуговує на те, щоб стати спільною абстракцією.
Найвища похвала, яку може отримати кодова база, — це не:
"Ця архітектура надзвичайно розумна."
Це:
"Я це розумію."
Тому що через п’ять років початкові інженери, ймовірно, вже підуть. Фреймворк, ймовірно, зміниться. Дизайн також зазнає змін. Продукт буде розвиватися. Сам бізнес може зовсім не виглядати так, як сьогодні.
Але доки межі залишаються чіткими, логіка простою, а обґрунтування ключових рішень записане десь, код може продовжувати розвиватися разом із усім іншим навколо нього.
Ось як насправді виглядає надійний програмний продукт.
Пов’язана література
- Три шаблони TypeScript, які покращують архітектуру React-додатків — Дізнайтеся, як шаблони Repository, Observer та Builder використовують систему типів TypeScript для створення більш чистих та легкозберіганих кодових баз React та Next.js.
- Десять поширених звичок у JavaScript, які тихо підривають вашу кодову базу — Описано десять поширених проблем у JavaScript та TypeScript, від слабкої рівності до зміни стану, та показано безпечніші шаблони для їх заміни.