Структурування сервісів Node.js за допомогою модулів, заснованих на домені, та чистих шарів
Дізнайтеся, як організувати кодову базу Node.js у компоненти, засновані на доменах, запровадити сувору архітектуру з трьома рівнями та зробити доступними спільні інструменти через чисті публічні API.
Коли ви запускаєте новий сервіс на Node.js, імпульс розвитку швидко зростає. Фреймворки на кшталт Express, Fastify та NestJS дозволяють запустити REST- чи GraphQL-кінцеву точку протягом кількох хвилин. Однак у міру зростання команд та складності бізнес-логіки в цих кодових базах починає проявлятися знайома проблема: вони перетворюються на заплутану, складну для розуміння структуру, яку часто називають „спагеті-бекендом“.
У цій статті представлено чотири частини, присвятовані практикам використання Node.js на рівні корпоративних проектів, з акцентом на створення архітектурної основи, здатної витримувати навантаження від зростаючої команди. Ви дізнаєтесь, як організувати код за принципом бізнес-доменів, зберегти чітке розділення шарів та створювати спільні модулі, які не стануть тягарем.
1. Структурування за бізнес-компонентами (організація за принципом доменів)
Однією з постійних помилок у проектах Node.js є розташування кодової бази виключно за технічною функцією на верхньому рівні папки src.
Антипаттерн: технічне розташування на корені
❌ AVOID: Layered by technical type
src/
├── controllers/
│ ├── userController.js
│ ├── orderController.js
│ └── paymentController.js
├── models/
│ ├── userModel.js
│ ├── orderModel.js
│ └── paymentModel.js
├── services/
│ ├── userService.js
│ ├── orderService.js
│ └── paymentService.js
└── routes/
├── userRoutes.js
├── orderRoutes.js
└── paymentRoutes.js
Чому технічне розташування зазнає невдачі у масштабах
- Погана локальність: Створення однієї функціональності — скажімо, процесу „Відшкодування замовлення“ — змушує вас переходити між чотирма або більше нерелевантними папками.
- Нечіткі межі: Оскільки все технічне знаходиться разом, інженери змушені працювати безпосередньо з різними сервісами та моделями, що призводить до циклічних залежностей та тісного зв’язку.
- Підвищена когнітивна навантаження: Як тільки кількість моделей перевищує 50, монолітична директорія
controllers/абоmodels/стає справді складною для навігації.
Рішення: модульні компоненти домену
Кращим підходом є організація за принципом бізнес-функціоналів — те, що у підході Domain-Driven Design називається Обмеженими контекстами. Кожен компонент стає самодостатньою сферою, яка об’єднує в собі власні контролери, сервіси, репозиторії та моделі домену.
✅ PREFER: Component-based architecture
src/
├── components/
│ ├── users/
│ │ ├── users.controller.js
│ │ ├── users.service.js
│ │ ├── users.repository.js
│ │ └── users.routes.js
│ ├── orders/
│ │ ├── orders.controller.js
│ │ ├── orders.service.js
│ │ ├── orders.repository.js
│ │ └── orders.routes.js
│ └── payments/
│ ├── payments.controller.js
│ ├── payments.service.js
│ └── payments.repository.js
└── shared/
├── logger/
└── database/
Переваги:
- Самодостатність: Усе, що пов’язано з
orders, залишається в одній папці. - Готовність до мікросервісів: Якщо компонент
paymentsстає занадто великим або складним, виділити його у окремий мікросервіс значно простіше, оскільки його залежності вже ізольовані від решти програми.
2. Забезпечення суворої трьохшарової архітектури всередині компонентів
У кожному бізнес-компоненті необхідно суворо дотримуватися розділення на три шари:
┌─────────────────────────────────────────────────────────┐
│ 1. Entry Point / Transport Layer │
│ (Controllers, Event Subscribers, Route Handlers) │
└───────────────────────────┬─────────────────────────────┘
│ Calls with plain DTOs
▼
┌─────────────────────────────────────────────────────────┐
│ 2. Domain / Business Layer │
│ (Services, Business Logic, Validation Rules) │
└───────────────────────────┬─────────────────────────────┘
│ Calls repository methods
▼
┌─────────────────────────────────────────────────────────┐
│ 3. Data Access Layer │
│ (Repositories, ORMs, Database Queries) │
└─────────────────────────────────────────────────────────┘
Золоте правило: не вводити веб-об’єкти у логіку домену
Бізнес-сервіси та репозиторії ніколи не повинні отримувати об’єкти, специфічні для певної фреймворк-системи — жодного Express req чи res, жодної інстанції запиту Fastify.
Неправильний підхід: витік об’єктів HTTP-транспорту
// orders.service.js
async function createOrderService(req, res) {
// BAD: Service knows about HTTP headers, status codes, and req.body
const userId = req.headers['x-user-id'];
const orderData = req.body;
if (!orderData.items || orderData.items.length === 0) {
return res.status(400).json({ error: "Cart cannot be empty" });
}
const newOrder = await db.orders.insert({ userId, ...orderData });
return res.status(201).json(newOrder);
}
Чому це призводить до проблем:
createOrderServiceстає непридатним для використання поза HTTP-контекстом — його неможливо викликати з скрипту CLI, споживача Kafka чи RabbitMQ або завдання cron, оскільки жоден з цих інструментів не надає об’єктівreqчиres.- Тепер для тестування потрібно імітувати об’єкти HTTP-запиту та відповіді замість передачі звичайних значень JavaScript.
Правильний підхід: роз’єднаний шар сервісів
// orders.controller.js (Transport Layer)
const orderService = require('./orders.service');
async function handleCreateOrder(req, res, next) {
try {
// 1. Extract values from web context
const userId = req.headers['x-user-id'];
const { items, shippingAddress } = req.body;
// 2. Call domain service with pure primitives/DTOs
const order = await orderService.createOrder({
userId,
items,
shippingAddress
});
// 3. Format HTTP response
return res.status(201).json({ status: 'success', data: order });
} catch (err) {
next(err); // Defer error handling to central middleware
}
}
// orders.service.js (Domain Layer)
const orderRepo = require('./orders.repository');
async function createOrder({ userId, items, shippingAddress }) {
// 1. Pure business logic validation
if (!items || items.length === 0) {
throw new ValidationError('Order must contain at least one item.');
}
// 2. Business calculation
const totalAmount = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
// 3. Persist via repository
const createdOrder = await orderRepo.saveOrder({
userId,
items,
shippingAddress,
totalAmount,
status: 'PENDING'
});
return createdOrder;
}
3. Модульнізація утиліт та забезпечення чистих публічних API
У міру зростання додатку багато різних компонентів починають потребувати спільних засобів, таких як облік подій, автентифікація, інструменти для підключення до баз даних та клієнти для зовнішніх API. Замість того, щоб дозволяти кожному користувачеві безпосередньо доступатися до глибоких внутрішніх шляхів файлів, необхідно упакувати ці спільні засоби у внутрішні модулі з чітко визначеною публічною точкою входу.
Небезпека глибокого імпорту
// ❌ BAD: Tightly coupled to internal folder structures
const { formatLog } = require("../../shared/logger/utils/formatters/textFormatter.js");
Якщо команда, відповідальна за облік подій, згодом вирішить переорганізувати структуру своїх внутрішніх папок, кожен користувач, який імпортував дані з цього глибокого шляху, негайно почне працювати некоректно.
Рішення А: Експорт через index.js (CommonJS)
Створіть єдиний файл-вхід, який буде знову експортувати лише ті елементи, призначені для зовнішнього використання, приховуючи все інше.
// shared/logger/index.js
const { logger } = require('./loggerCore');
const { auditLog } = require('./auditLogger');
// Expose ONLY public functions
module.exports = {
logger,
auditLog
};
Тоді користувачі зможуть імпортувати дані через цей чистий інтерфейс, замість того щоб доступатися до внутрішніх елементів:
// ✅ GOOD: Clean import via public interface
const { logger } = require('../../shared/logger');
Рішення B: Експорт пакетів за допомогою ESM (Node.js Workspaces / сучасний ESM)
Якщо ви працюєте з сучасним кодом Node.js у форматі ESM або пакетами в монорепозиторії, скористайтеся полем exports у файлі package.json, щоб чітко визначити, які файли можна імпортувати ззовні пакету.
// shared/logger/package.json
{
"name": "@my-app/logger",
"version": "1.0.0",
"main": "./src/index.js",
"exports": {
".": "./src/index.js"
}
}
З такою конфігурацією будь-яка спроба імпорту приватного шляху, наприклад @my-app/logger/src/internal/formatter.js, призводить до помилки під час виконання, що створює чіткий бар’єр та запобігає випадковому зв’язку з деталями реалізації.
Чек-лист архітектури для частини 1
Перш ніж переходити до обробки помилок та асинхронних процесів, скористайтеся цим чек-листом, щоб переконатися, що ваш код відповідає вищезазначеним принципам:
- Організація домену: Чи впорядкований код за функціональними компонентами (
orders,users) замість загальних технічних папок (controllers,models)? - Чисті бізнес-сервіси: Чи не містять функції сервісів аргументів рівня транспортування, таких як
req,resчиnext? - Чіткі інтерфейси: Чи мають спільні модулі навмисно обмежену публічну інтерфейсну поверхню, чи то через файл
index.js, чи через експорти зpackage.json? - Правильний потік даних: Чи передаються дані суворо вниз — від контролера до сервісу, а потім до репозиторію, без можливості нижчих шарів імпортувати дані з вищих?
Пов’язана література
- Проєктування API Node.js у шарах: від „товстих“ контролерів до чистої архітектури — Дізнайтеся, як переписати API Node.js на основі шарів контролерів, сервісів та доступу до даних, щоб усунути складну бізнес-логіку, непослідовні помилки та проблеми з масштабуванням.
- Шість правил DDD для структурування доменів у застосунках NestJS — Ознайомтесь із шістьма практичними правилами проєктування, заснованими на принципах DDD, для організації модулів, ентитетів та подій у NestJS, щоб функції залишалися ізольованими та легкими для підтримки.