Галоўная / Артыкулы / Структураванне служб 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 называе Bounded Contexts. Кожны компонент становіцца самодастатнім доменам, які об’едначае своія кантролеры, сервісы, репазітарыі і моделі домена.

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

Супаўзеленыя матэрыялы

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