Strona główna / Artykuły / Strukturyzowanie usług Node.js za pomocą modułów opartych na domenie i czystych warstw

Strukturyzowanie usług Node.js za pomocą modułów opartych na domenie i czystych warstw

Dowiedz się, jak zorganizować bazę kodu Node.js w komponenty oparte na domenach, wprowadzić ścisłą architekturę 3 warstw oraz udostępnić wspólne narzędzia za pomocą czystych publicznych API.

1300 słów

Gdy uruchamiasz nową usługę Node.js, dynamika rozwoju rośnie szybko. Frameworki takie jak Express, Fastify i NestJS pozwalają uruchomić punkt końcowy typu REST lub GraphQL w ciągu zaledwie kilku minut. Jednak w miarę rozwoju zespołów i wzrostu złożoności logiki biznesowej w tych bazach kodowych pojawia się typowe problemy: kod staje się splątany i trudny do zrozumienia, co często określa się mianem „spaghetti backendu”.

W tym artykule przedstawiamy czteroczęściowy przegląd praktyk stosowanych w enterprise-grade rozwiązaniach Node.js, skupiając się na stworzeniu fundamentów architektonicznych, które pomogą sprostać wyzwaniom związanym z rosnącym zespołem. Dowiesz się, jak organizować kod wokół domen biznesowych, zachować wyraźną separację między warstwami oraz tworzyć wspólne moduły, które nie staną się obciążeniem.

1. Struktura według komponentów biznesowych (organizacja oparta na domenach)

Jednym z często popełnianych błędów w projektach Node.js jest organizowanie kodu wyłącznie według roli technicznej na najwyższym poziomie foldera src.

Antypatron: Warstwowość techniczna u źródła

❌ 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

Dlaczego warstwowość techniczna zawodzi w dużych skali

  1. Słaba lokalizacja: Tworzenie pojedynczej funkcjonalności — na przykład procesu „Zwrot zamówienia” — zmusza do przechodzenia między czterema lub więcej niespowiązanych folderami.
  2. Niejasne granice: Ponieważ wszystko techniczne znajduje się razem, inżynierowie muszą korzystać bezpośrednio z różnych usług i modeli, co prowadzi do zależności cyklicznych i ścisłego powiązania komponentów.
  3. Zwiększone obciążenie poznawcze: Po osiągnięciu 50 modeli monolityczny katalog controllers/ lub models/ staje się naprawdę trudny do nawigacji.

Rozwiązanie: Modułowe komponenty domeny

Lepszym podejściem jest organizacja wokół możliwości biznesowych – to, co Domain-Driven Design nazywa ograniczonymi kontekstami. Każdy komponent staje się samowystarczalną domeną, łączącą w sobie własne kontrolery, usługi, repozytoria oraz modele domenowe.

✅ 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/

Korzyści:

  • Samowystarczalność: Wszystko, co jest związane z orders, znajduje się w jednej folderze.
  • Gotowość na mikrousługi: Jeśli komponent payments stanie się zbyt duży lub skomplikowany, łatwiej będzie przenieść go do osobnego mikrousługi, ponieważ jego zależności są już odizolowane od reszty aplikacji.

2. Wprowadzenie ścisłej architektury trzech warstw wewnątrz komponentów

W każdym komponencie biznesowym należy zachować wyraźne rozdzielenie między trzema warstwami:

┌─────────────────────────────────────────────────────────┐
│ 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)              │
└─────────────────────────────────────────────────────────┘

Złota zasada: Unikaj używania obiektów sieciowych w logice domeny

Słuzby biznesowe i repozytoria nigdy nie powinny otrzymywać obiektów specyficznych dla frameworka — żadnego Express req ani res, żadnej instancji żądania Fastify.

Zła praktyka: Uciekanie obiektów transportu 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);
}

Dlaczego to powoduje problemy:

  • createOrderService staje się niewykorzystywalny poza kontekstem HTTP — nie można go wywołać z skryptu CLI, konsumenta Kafka lub RabbitMQ ani zadania cron, ponieważ żaden z nich nie dostarcza obiektów req ani res.
  • Testy muszą teraz symulować obiekty żądania i odpowiedzi HTTP zamiast przekazywać zwykłe wartości JavaScript.

Prawidłowa praktyka: Warstwa usług odseparowana

// 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. Modularizuj narzędzia i egzekwuj czyste API publiczne

Gdy aplikacja rośnie, wiele różnych komponentów potrzebuje wspólnych rozwiązań takich jak logowanie, autoryzacja, narzędzia do łączenia z bazą danych oraz klienty do zewnętrznych API. Zamiast pozwalać każdemu użytkownikowi na bezpośredni dostęp do złożonych, wewnętrznych ścieżek plików, należy spakować te wspólne narzędzia jako moduły wewnętrzne z jasno określonym punktem wejścia publicznym.

Zagrożenie wynikające z głębokich importów

// ❌ BAD: Tightly coupled to internal folder structures
const { formatLog } = require("../../shared/logger/utils/formatters/textFormatter.js");

Jeśli zespół odpowiedzialny za mechanizm logowania później zdecyduje się na zmianę struktury wewnętrznych folderów, każdy użytkownik, który importował dane z tej głębokiej ścieżki, natychmiast przestanie działać.

Rozwiązanie A: Eksportowanie przez index.js (CommonJS)

Należy utworzyć jeden plik wejściowy, który ponownie eksportuje tylko te elementy przeznaczone do użycia zewnętrznego, ukrywając wszystko inne.

// shared/logger/index.js
const { logger } = require('./loggerCore');
const { auditLog } = require('./auditLogger');

// Expose ONLY public functions
module.exports = {
  logger,
  auditLog
};

Wtedy użytkownicy mogą importować dane przez tę uporządkowaną interfejs, zamiast korzystać z wewnętrznych elementów aplikacji:

// ✅ GOOD: Clean import via public interface
const { logger } = require('../../shared/logger');

Rozwiązanie B: Eksport pakietów za pomocą ESM (przestrzenie robocze Node.js / nowoczesne ESM)

Jeśli pracujesz z nowoczesnym kodem ESM w Node.js lub pakietami w ramach monorepo, użyj pola exports w pliku package.json, aby wyraźnie określić, które pliki mogą być importowane z zewnątrz pakietu.

// shared/logger/package.json
{
  "name": "@my-app/logger",
  "version": "1.0.0",
  "main": "./src/index.js",
  "exports": {
    ".": "./src/index.js"
  }
}

Dzięki takiemu ustawieniu każda próba importu prywatnej ścieżki, takiej jak @my-app/logger/src/internal/formatter.js, kończy się błędem w czasie wykonywania, co stanowi wyraźną granicę chroniącą zespół przed przypadkowym powiązaniem z detalami implementacji.

Listwa kontrolna architektury dla części 1

Zanim przejdziesz do obsługi błędów i asynchronicznych procesów, skorzystaj z tej listwy kontrolnej, aby upewnić się, że twoja baza kodu przestrzega powyższych zasad:

  • Organizacja domeny: Czy kod jest uporządkowany według komponentów funkcjonalnych (orders, users) zamiast ogólnych folderów technicznych (controllers, models)?
  • Czyste usługi biznesowe: Czy funkcje usług pozostają wolne od argumentów warstwy transportowej, takich jak req, res czy next?
  • Jasne interfejsy: Czy wspólne moduły oferują celowe, ograniczone interfejsy publiczne, czy to poprzez plik index.js, czy też eksporty z package.json?
  • Prawidłowy przepływ danych: Czy dane przemieszczają się wyłącznie w dół, od kontrolera do usługi, a następnie do repozytorium, bez możliwości dostępu warstw niższych do danych z warstw wyższych?

Literatura pokrewna

  • Dlaczego dekodowanie kawałków bufora jako tekstu niszczy przesyłanie plików — Wyjaśnia, jak traktowanie binarnych danych buforowych jako tekstu UTF-8 w tajemnicy uszkadza przesyłane pliki, oraz pokazuje właściwe sposoby obsługi na poziomie bajtów, aby temu zapobiec.