Главная / Статьи / Структурирование сервисов Node.js с использованием модулей, основанных на концепции домена, и чистых слоев

Структурирование сервисов Node.js с использованием модулей, основанных на концепции домена, и чистых слоев

Узнайте, как организовать кодбазу Node.js в компоненты, основанные на доменах, соблюдать строгую архитектуру в 3 уровня и предоставлять общие утилиты через чистые публичные API.

1300 слов

Когда вы запускаете новый сервис на 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

Почему техническое разделение не срабатывает в масштабе

  1. Низкая локальность: Разработка одной функции — скажем, процесса возврата заказа — заставляет разработчика переходить между четырьмя или более не связанными папками.
  2. Нечеткие границы: Поскольку все технические элементы находятся вместе, инженерам приходится напрямую обращаться к различным сервисам и моделям, что приводит к циклической зависимости и тесной связности.
  3. Увеличение когнитивной нагрузки: При наличии более 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);
}

Почему это приводит к проблемам:

  • 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;
}

По мере роста приложения многим его компонентам начинают понадобляться общие решения, такие как логгирование, аутентификация, инструменты для подключения к базам данных и клиенты для внешних 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?
  • Правильный поток данных: Передаются ли данные строго вниз, от контроллера к сервису, затем к хранилищу, без возможности нижних уровней обращаться к вышестоящим для импорта?

Связанные материалы

  • Почему декодирование кусков буфера как текста нарушает загрузку файлов — Объясняется, как обработка двоичных данных буфера как текста формата UTF-8 тайно повреждает загружаемые файлы, и показано правильное обращение с данными на уровне байтов для предотвращения этого.