Пошук уроків, статей та іншого контенту
Будуємо моноліт із чіткими модулями та межами й визначаємо, коли він кращий або гірший за мікросервіси.
Модульний моноліт — це застосунок, який:
розгортається як один сервіс;
запускається в одному процесі або в одному контейнері;
має спільну інфраструктуру, наприклад базу даних;
усередині поділений на модулі з чіткими межами та відповідальністю.
Модуль — це не просто папка з файлами. Він володіє певною частиною бізнес-логіки й надає іншим модулям обмежений публічний API.
Наприклад, інтернет-магазин можна поділити на:
Catalog — товари та їхні ціни;
Orders — створення замовлень;
Payments — оплата;
Users — облікові записи покупців.
Зовні це один застосунок. Усередині кожен модуль має власні правила та не повинен напряму змінювати внутрішні дані інших модулів.
Звичайний моноліт часто має структуру на кшталт:
controllers/
services/
models/
utils/Така структура групує код за технічними ролями. З часом логіка одного бізнес-напрямку опиняється в багатьох папках, а будь-який компонент може звернутися до будь-якої таблиці чи сервісу.
Модульний моноліт групує код за бізнес-відповідальністю:
src/
catalog/
catalog.js
catalog-repository.js
orders/
orders.js
orders-repository.js
payments/
payments.jsУ такій структурі легше відповісти на запитання:
де знаходиться логіка замовлень;
хто відповідає за зміну ціни товару;
який API має модуль;
від яких інших модулів він залежить.
Головна різниця полягає не в назвах папок, а в правилах доступу між модулями.
Добре визначений модуль має такі властивості:
Одна основна відповідальність
Модуль замовлень не повинен містити правила зміни паролів користувачів.
Приватний внутрішній стан
Інші частини системи не повинні змінювати його дані напряму.
Невеликий публічний API
Інші модулі викликають лише явно дозволені функції.
Контрольовані залежності
Модуль залежить від публічних операцій іншого модуля, а не від його внутрішніх файлів.
Власні бізнес-правила
Правила, пов’язані із замовленнями, мають залишатися в модулі Orders.
Умовно модуль можна поділити на:
orders/
index.js # публічний API
order-service.js # внутрішня логіка
order-repository.js # внутрішня робота з данимиІнші модулі імпортують лише orders/index.js. Вони не повинні напряму використовувати order-repository.js.
Це схоже на публічні та приватні методи класу: зовнішній код бачить тільки те, що йому справді потрібно.
Розглянемо невеликий застосунок магазину. Він має два модулі:
Catalog зберігає товари;
Orders створює замовлення та перевіряє наявність товару.
У реальному проєкті ці модулі зазвичай були б окремими файлами або каталогами. Для демонстрації меж використаємо функції, які приховують внутрішній стан через замикання.
'use strict';
// Модуль Catalog
function createCatalogModule() {
// Внутрішні дані модуля недоступні напряму ззовні.
const products = new Map([
['book-1', { id: 'book-1', name: 'Clean Code', price: 30, stock: 4 }],
['book-2', { id: 'book-2', name: 'The Pragmatic Programmer', price: 40, stock: 2 }]
]);
function findAvailableProduct(productId, quantity) {
const product = products.get(productId);
if (!product) {
throw new Error('Товар не знайдено');
}
if (product.stock < quantity) {
throw new Error('Недостатньо товару на складі');
}
// Повертаємо копію, щоб зовнішній код не змінив внутрішній стан.
return { ...product };
}
function reserveProduct(productId, quantity) {
const product = products.get(productId);
if (!product) {
throw new Error('Товар не знайдено');
}
if (product.stock < quantity) {
throw new Error('Недостатньо товару на складі');
}
product.stock -= quantity;
return { ...product };
}
// Це публічний API модуля Catalog.
return {
findAvailableProduct,
reserveProduct
};
}
// Модуль Orders
function createOrdersModule({ catalog }) {
const orders = [];
function createOrder({ productId, quantity }) {
if (!Number.isInteger(quantity) || quantity <= 0) {
throw new Error('Кількість має бути додатним цілим числом');
}
// Orders не читає внутрішню колекцію товарів.
// Він користується лише публічним API Catalog.
const product = catalog.findAvailableProduct(productId, quantity);
catalog.reserveProduct(productId, quantity);
const order = {
id: `order-${orders.length + 1}`,
productId: product.id,
productName: product.name,
quantity,
total: product.price * quantity,
status: 'created'
};
orders.push(order);
return { ...order };
}
function listOrders() {
return orders.map((order) => ({ ...order }));
}
// Це публічний API модуля Orders.
return {
createOrder,
listOrders
};
}
// Композиція застосунку — місце, де модулі з'єднуються.
const catalog = createCatalogModule();
const orders = createOrdersModule({ catalog });
const firstOrder = orders.createOrder({
productId: 'book-1',
quantity: 2
});
console.log('Створене замовлення:', firstOrder);
console.log('Усі замовлення:', orders.listOrders());
try {
orders.createOrder({
productId: 'book-2',
quantity: 3
});
} catch (error) {
console.log('Помилка:', error.message);
}Збережіть код у файл app.js і запустіть:
node app.jsУ цьому прикладі:
Catalog володіє колекцією товарів;
Orders володіє колекцією замовлень;
Orders не отримує прямий доступ до products;
взаємодія між модулями відбувається через методи catalog;
обидва модулі працюють в одному процесі.
Функція createOrdersModule({ catalog }) отримує залежність через параметр. Це робить межу явною: модулю Orders потрібен саме публічний API каталогу, а не його внутрішні структури.
Перед створенням модулів корисно визначити, хто за що відповідає.
Наприклад:
Orders -> Catalog
Orders -> Users
Payments -> OrdersЦе означає, що:
Orders може запитувати інформацію в Catalog;
Orders може використовувати публічні операції Users;
Payments може працювати з даними замовлення через API Orders.
Не обов’язково забороняти всі залежності. Важливо, щоб вони були:
явними;
односторонніми, де це можливо;
обмеженими публічним API;
зрозумілими з бізнес-логіки.
Небажана залежність має такий вигляд:
// Погано: Orders знає внутрішню структуру Catalog.
catalog.products.get(productId).stock -= quantity;Проблеми цього підходу:
Orders знає, як саме зберігаються товари;
зміна структури Catalog зламає Orders;
правила зменшення залишку можуть опинитися в кількох місцях;
межа між модулями фактично відсутня.
Краще:
// Добре: Orders викликає операцію, яку надає Catalog.
catalog.reserveProduct(productId, quantity);Тоді Catalog сам вирішує, як перевірити та змінити залишок.
Модульний моноліт часто використовує одну базу даних. Це саме по собі не руйнує модульність, але потрібно домовитися про володіння даними.
Наприклад:
Catalog володіє таблицями товарів;
Orders володіє таблицями замовлень;
Payments володіє таблицями платежів.
Інший модуль не повинен змінювати чужі таблиці напряму. Якщо Orders потребує змінити товар, він має викликати операцію модуля Catalog.
Такий підхід допомагає зберегти бізнес-правила в одному місці навіть тоді, коли фізично всі таблиці знаходяться в одній базі.
Модульний моноліт часто є хорошим вибором для нового або невеликого продукту.
Один застосунок простіше:
запустити;
розгорнути;
оновити;
моніторити;
протестувати локально.
Не потрібно одразу налаштовувати мережеву взаємодію між багатьма сервісами, окремі процеси та складний процес розгортання.
У модульному моноліті виклик іншого модуля зазвичай є звичайним викликом функції. У мікросервісах для цього потрібні мережеві запити, обробка тайм-аутів, повторні спроби та інші механізми надійності.
Коли кілька змін потрібно виконати разом, це легше зробити в одному застосунку та одній базі даних. У розподіленій системі узгодження таких змін складніше.
Невелика команда може зосередитися на функціональності продукту, а не на підтримці великої кількості сервісів.
Мікросервіси можуть мати сенс, якщо:
окремі частини системи потрібно незалежно масштабувати;
різні команди мають незалежно розробляти й розгортати компоненти;
модулі мають дуже різні вимоги до інфраструктури;
ізоляція відмови важлива для конкретного компонента;
система вже має зрілі процеси моніторингу, розгортання та роботи з розподіленими системами.
Мікросервіси не є автоматично кращими. Вони додають мережеві виклики, складніші розгортання, окреме керування конфігурацією та більше сценаріїв відмови.
Модульний моноліт також має обмеження:
усі модулі зазвичай масштабуються разом;
помилка в одному модулі може вплинути на весь процес;
незалежне розгортання модулів неможливе без додаткової архітектури;
команда може поступово порушити межі, якщо не контролювати залежності.
Тому важливо не лише один раз створити каталоги, а й підтримувати правила модульності під час подальшої розробки.
Модульний моноліт може стати підготовкою до майбутнього виділення мікросервісів.
Якщо модуль має:
чітку відповідальність;
власний публічний API;
контроль над своїми даними;
обмежені залежності,
його легше відокремити в окремий сервіс у майбутньому.
Але не слід створювати модулі лише заради можливого поділу. Спочатку потрібно розв’язати реальну проблему продукту або команди. Межі мають відображати бізнес-відповідальності, а не майбутню моду на мікросервіси.
Для початку можна діяти так:
Перелічіть основні бізнес-напрями системи.
Згрупуйте правила та дані кожного напряму в окремий модуль.
Визначте публічні операції кожного модуля.
Визначте дозволені залежності між модулями.
Забороніть прямий доступ до внутрішніх даних сусідніх модулів.
Перевірте, чи можна тестувати модуль через його публічний API.
Підтримуйте ці правила в рев’ю коду.
Якщо будь-який код може імпортувати будь-який внутрішній файл іншого каталогу, межі не існують.
Як виправити: визначити публічний API модуля та імпортувати тільки його.
Коли всі частини системи використовують один спільний об’єкт або модель, зміна одного модуля легко ламає інші.
Як виправити: кожен модуль має володіти власними моделями та перетворювати дані на публічному API.
Модуль напряму читає або змінює таблиці іншого модуля, минаючи його правила.
Як виправити: звертатися до операцій власника даних.
Наприклад, Orders залежить від Payments, а Payments одночасно напряму залежить від внутрішнього коду Orders.
Як виправити: переглянути напрямок залежностей і залишити взаємодію через чітко визначений API.
Якщо кожна невелика функція стає окремим модулем, система втрачає зрозумілість.
Як виправити: об’єднувати код за цілісною бізнес-відповідальністю, а не за розміром файлу.
Додаткові сервіси створюються ще до появи реальної потреби в незалежному масштабуванні чи розгортанні.
Як виправити: почати з модульного моноліту та виділяти окремий сервіс лише за обґрунтованої потреби.
Модульний моноліт — це один застосунок із чітко відокремленими бізнес-модулями.
Кожен модуль має власну відповідальність, внутрішні дані та публічний API.
Модулі повинні взаємодіяти через дозволені операції, а не через внутрішні структури чи чужі таблиці.
Для невеликої команди та нового продукту модульний моноліт часто простіший і дешевший за мікросервіси.
Мікросервіси виправдані, коли потрібні незалежне масштабування, розгортання або сильніша ізоляція компонентів.
Якісні межі модулів допомагають підтримувати систему та за потреби спрощують майбутнє виділення сервісів.