Модульна архітектура
Модульна архітектура ділить застосунок на незалежні самодостатні блоки (модулі), які спілкуються лише через визначені публічні інтерфейси. Це дає командам змогу розробляти, тестувати і масштабувати кожну частину окремо, не ламаючи решту.
Теорія
TL;DR
- Аналогія: кухня ресторану, де кожна станція (заготовка, гриль, соуси) має свої інструменти, робить свою справу і передає результат через "вікно видачі" (public API), ніколи не лізучи в шухляди сусідньої станції.
- Головний принцип: модулі є чорними скриньками. Зовнішній код бачить лише те, що явно експортовано.
- Модулі можуть залежати один від одного, але тільки через публічний
index.js, ніколи через внутрішні шляхи файлів. - Правило вибору: якщо є кілька команд, функції з незалежним циклом змін або кодова база більша за 10k рядків, модульна архітектура себе виправдовує.
Швидкий приклад
// userModule/index.js (PUBLIC API - єдиний вхід)
export { UserProfile } from './components/UserProfile.js';
export { useUser } from './hooks/useUser.js';
export { fetchUser } from './api/userService.js';
// App.js (зовнішній код)
import { UserProfile, useUser } from './userModule/index.js';
// OK: використовуємо публічний API
import { cacheUser } from './userModule/internal/store.js';
// Неправильно: звертаємось до внутрішнього файлу напрямуВсе, що знаходиться в userModule/internal/, це приватна справа модуля. Інші модулі навіть не знають про його існування.
Головна різниця
Модульна архітектура це не просто розкладання файлів по папках. Справжній тест: чи ламає зміна внутрішньої структури одного модуля код інших модулів? Якщо так, маєш організований код, а не модульний. Різниця полягає в жорстких межах: модулі взаємодіють лише через явні контракти, тому можна переписати внутрішнє, змінити реалізацію або видалити модуль повністю, не чіпаючи нічого поза ним.
Коли застосовувати
- Кілька команд в одній кодовій базі, де кожна команда відповідає за свій модуль і мержі не конфліктують
- Функції з незалежним циклом змін, щоб платежі оновлювались без торкання авторизації
- Кодова база, яка переросла одного розробника, бо без меж отримуєш хаос взаємних залежностей
- Функціональність, яку можна вмикати і вимикати
- Код, який потрібно перевикористовувати у веб і мобільних застосунках
Як бандлер обробляє межі модулів
Коли Webpack або Vite обробляє код, він трасує імпорти від точок входу і будує граф залежностей. Кожен модуль отримує власну область видимості, змінні всередині не потрапляють до глобального простору. Tree-shaking прибирає все, що не виставлено через публічний API. Спроба імпортувати з userModule/internal/store.js падає, якщо цей шлях не виставлений в index.js. Межа це не просто конвенція. Вона технічно підкріплена.
Типові помилки
1. Імпорт з внутрішніх папок
// Неправильно
import { userCache } from './userModule/internal/store.js';
// Правильно
import { getUserData } from './userModule/index.js';Коли власник модуля переробить внутрішнє, твій код зламається. Публічний API це контракт. Внутрішня структура таким контрактом не є.
2. Скорочення через внутрішні файли
// paymentModule/components/PaymentForm.js
// Неправильно: обходимо публічний API "щоб зекономити час"
import { validateToken } from '../../authModule/internal/tokenValidator.js';
// Правильно
import { validateToken } from '../../authModule/index.js';Це створює приховані залежності. На review paymentModule і authModule виглядають незалежними. У продакшені рефакторинг authModule тихо ламає PaymentForm.
3. Циклічні залежності
// userModule/api/user.js
import { logActivity } from '../../analyticsModule/index.js';
// analyticsModule/api/analytics.js
import { getUser } from '../../userModule/index.js'; // НеправильноБандлери не можуть коректно вирішити циклічні імпорти. Отримуєш undefined замість експортів або збій білду. Модулі мають утворювати спрямований ациклічний граф (DAG). Якщо двом модулям потрібна спільна логіка, виноси її в третій.
4. Експортувати все "про запас"
// Неправильно: виставляємо внутрішні хелпери
export { getUser } from './api/user.js';
export { validateEmail } from './internal/validators.js'; // Внутрішнє!
export { userCache } from './internal/store.js'; // Внутрішнє!
// Правильно: тільки те, що навмисно публічне
export { getUser } from './api/user.js';Щойно щось виставлено назовні, зовнішній код починає від цього залежати. Змінити вже не вийде без наслідків. index.js треба сприймати як публічний контракт.
5. Спільний конфіг як спосіб уникнути дублювання
// Module A
export const config = { apiUrl: 'https://api.example.com' };
// Module B
import { config } from './moduleA/index.js';
// Тепер Module B падає, якщо Module A змінює форму конфігу
// Краще: кожен модуль управляє власним конфігом
// moduleA/config.js і moduleB/config.js оголошують свій apiUrl окремоДублювання між модулями краще за приховане зчеплення.
Де зустрічається в реальних проектах
- React: feature-модулі (auth, dashboard, settings) кожен експортує компоненти, хуки та сервіси через
index.js - Express: route-модулі (users, payments, admin) експортують роутери, які збираються в
app.js - Redux: feature slices (user, cart, notifications) це незалежні reducer-и зі своїми actions та selectors
- Next.js: App Router трактує кожен маршрут як самодостатній модуль зі своїм layout і data fetching
- npm-пакети: кожен пакет це модуль, який використовується через публічний API, ніколи через внутрішні шляхи в
node_modules
Один патерн, який найчастіше приносить проблеми: команди вважають, що спільні TypeScript-типи є приводом імпортувати з внутрішніх файлів. "Це просто тип, нічого не зламається" до того моменту, поки власник модуля не перемістить цей тип і білд розсипається у шести місцях одразу.
Питання на співбесіді
Q: Як запобігти циклічним залежностям між модулями?
A: Використовуй інструменти аналізу графу залежностей, наприклад Madge або Dpdm, в CI, щоб ловити їх до мержу. Проєктуй систему так, щоб залежності йшли в одному напрямку: UI-модулі залежать від feature-модулів, feature-модулі від core/shared, ніколи навпаки. Якщо двом модулям потрібна спільна логіка, виноси її в третій.
Q: Чим модульна архітектура відрізняється від мікросервісів?
A: Модульна архітектура існує всередині однієї кодової бази і одного процесу, модулі ділять пам'ять і запускаються разом. Мікросервіси це окремі процеси, що спілкуються через мережу. Почати з модульної архітектури простіше. Мікросервіси додають складність деплойменту, але дають незалежне масштабування.
Q: Як управляти спільним станом між модулями без зчеплення?
A: Використовуй шар управління станом (Redux, Zustand, Context), яким не володіє жоден модуль напряму. Модулі відправляють дії і підписуються на зрізи стану. Або використовуй event emitter: модулі генерують події, інші слухають, не знаючи хто саме. Уникай прямого імпорту об'єктів стану між модулями.
Q: Модулі можуть залежати один від одного напряму, або все через центральний хаб?
A: Прямі залежності нормальні, якщо вони ациклічні. A залежить від B, B від C це добре. A від B, B від A вже ні. Центральний хаб (Redux store або event bus) теж підходить, але створює вузьке місце: кожен модуль мусить про нього знати.
Q (рівень senior): Як мігрувати кодову базу з "все залежить від усього" до модульної структури без зупинки продакшену?
A: Почни з визначення природних доменних меж: auth, payments, users, notifications. Створи файли index.js з публічними API, не переміщаючи жодного внутрішнього файлу. Поступово переводь імпорти з внутрішніх шляхів на публічні API. Додай lint-правило, яке забороняє прямі імпорти з */internal/*. Роби це паралельно зі звичайною роботою над фічами, не намагайся переписати все за один спринт.
Приклади
Базова структура модуля в React
// userModule/index.js - публічний API, єдиний файл для зовнішніх імпортів
export { UserProfile } from './components/UserProfile.js';
export { useUser } from './hooks/useUser.js';
export { fetchUser } from './api/userService.js';
// userModule/internal/store.js - приватний, не виставляється з index.js
const userCache = new Map();
export function cacheUser(id, data) {
userCache.set(id, data);
}
// userModule/api/userService.js - приватний, використовується тільки всередині
import { cacheUser } from '../internal/store.js';
export async function fetchUser(id) {
const response = await fetch('/api/users/' + id);
const data = await response.json();
cacheUser(id, data); // стратегія кешування - внутрішня деталь
return data;
}
// App.js - зовнішній код
import { UserProfile, useUser } from './userModule/index.js';
// fetchUser теж доступний. cacheUser і userCache - ні.Якщо команда вирішить замінити Map на Redis-клієнт, жодний зовнішній файл не зміниться.
Запобігання циклічним залежностям
// paymentModule/index.js
export { PaymentForm } from './components/PaymentForm.js';
// paymentModule/components/PaymentForm.js
import { notifyUser } from '../../notificationModule/index.js';
// OK: імпорт з публічного API іншого модуля
// notificationModule/api/notify.js
// Неправильно: циклічна залежність назад до paymentModule:
// import { PaymentForm } from '../../paymentModule/index.js';
// Правильно: notificationModule нічого не знає про paymentModule
export function notifyUser(message, type) {
console.log('[' + type + '] ' + message);
}
// Payments залежить від notifications. Notifications не залежить від payments.Кожен тестується окремо, і жоден не може випадково зламати білд іншого.
Поступова міграція до модульних меж
// До: прямі внутрішні імпорти розкидані по всьому проекту
import { validateToken } from './authModule/internal/tokenValidator.js';
import { userCache } from './userModule/internal/store.js';
// Крок 1: створюємо публічні API, не переміщаючи файли
// authModule/index.js
export { validateToken } from './internal/tokenValidator.js';
// userModule/index.js
export { getUserFromCache } from './internal/store.js';
// Крок 2: оновлюємо імпорти до публічних API
import { validateToken } from './authModule/index.js';
import { getUserFromCache } from './userModule/index.js';
// Крок 3: додаємо lint-правило, що забороняє внутрішні імпорти
// .eslintrc: заборонено прямі імпорти з шляхів */internal/*Кожен крок маленький і перевіряється окремо. Не потрібен цілий спринт, щоб переписати все відразу.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.