Skip to main content

Модульна архітектура

Модульна архітектура ділить застосунок на незалежні самодостатні блоки (модулі), які спілкуються лише через визначені публічні інтерфейси. Це дає командам змогу розробляти, тестувати і масштабувати кожну частину окремо, не ламаючи решту.

Теорія

TL;DR

  • Аналогія: кухня ресторану, де кожна станція (заготовка, гриль, соуси) має свої інструменти, робить свою справу і передає результат через "вікно видачі" (public API), ніколи не лізучи в шухляди сусідньої станції.
  • Головний принцип: модулі є чорними скриньками. Зовнішній код бачить лише те, що явно експортовано.
  • Модулі можуть залежати один від одного, але тільки через публічний index.js, ніколи через внутрішні шляхи файлів.
  • Правило вибору: якщо є кілька команд, функції з незалежним циклом змін або кодова база більша за 10k рядків, модульна архітектура себе виправдовує.

Швидкий приклад

javascript
// 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. Імпорт з внутрішніх папок

javascript
// Неправильно import { userCache } from './userModule/internal/store.js'; // Правильно import { getUserData } from './userModule/index.js';

Коли власник модуля переробить внутрішнє, твій код зламається. Публічний API це контракт. Внутрішня структура таким контрактом не є.

2. Скорочення через внутрішні файли

javascript
// paymentModule/components/PaymentForm.js // Неправильно: обходимо публічний API "щоб зекономити час" import { validateToken } from '../../authModule/internal/tokenValidator.js'; // Правильно import { validateToken } from '../../authModule/index.js';

Це створює приховані залежності. На review paymentModule і authModule виглядають незалежними. У продакшені рефакторинг authModule тихо ламає PaymentForm.

3. Циклічні залежності

javascript
// userModule/api/user.js import { logActivity } from '../../analyticsModule/index.js'; // analyticsModule/api/analytics.js import { getUser } from '../../userModule/index.js'; // Неправильно

Бандлери не можуть коректно вирішити циклічні імпорти. Отримуєш undefined замість експортів або збій білду. Модулі мають утворювати спрямований ациклічний граф (DAG). Якщо двом модулям потрібна спільна логіка, виноси її в третій.

4. Експортувати все "про запас"

javascript
// Неправильно: виставляємо внутрішні хелпери 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. Спільний конфіг як спосіб уникнути дублювання

javascript
// 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

javascript
// 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-клієнт, жодний зовнішній файл не зміниться.

Запобігання циклічним залежностям

javascript
// 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.

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

Поступова міграція до модульних меж

javascript
// До: прямі внутрішні імпорти розкидані по всьому проекту 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/*

Кожен крок маленький і перевіряється окремо. Не потрібен цілий спринт, щоб переписати все відразу.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?