Структурирование сервисов Node.js с использованием модулей, основанных на концепции домена, и чистых слоев
Узнайте, как организовать кодбазу Node.js в компоненты, основанные на доменах, соблюдать строгую архитектуру в 3 уровня и предоставлять общие утилиты через чистые публичные 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) │
└─────────────────────────────────────────────────────────┘
Бизнес-сервисы и репозитории никогда не должны принимать объекты, специфичные для фреймворка — ни объекты req и res от Express, ни экземпляры запросов от Fastify.
// 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);
}
// 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;
}
// 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;
}
По мере роста приложения многим его компонентам начинают понадобляться общие решения, такие как логгирование, аутентификация, инструменты для подключения к базам данных и клиенты для внешних 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 — Ознакомьтесь с шестью практическими правилами проектирования, основанного на домене, для организации модулей, сущностей и событий в NestJS, чтобы функции оставались изолированными и были легко обслуживаемыми.