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.
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
- 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.
- 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.
- Zwiększone obciążenie poznawcze: Po osiągnięciu 50 modeli monolityczny katalog
controllers/lubmodels/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
paymentsstanie 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:
createOrderServicestaje 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ówreqanires.- 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,resczynext? - Jasne interfejsy: Czy wspólne moduły oferują celowe, ograniczone interfejsy publiczne, czy to poprzez plik
index.js, czy też eksporty zpackage.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
- Projektowanie API Node.js w warstwach: od grubych kontrolerów do czystej architektury — Dowiedz się, jak przeredagować API Node.js na warstwy kontrolerów, usług i dostępu do danych, aby rozwiązać problemy z skomplikowaną logiką biznesową, niejednolitymi błędami oraz trudnościami w skalowaniu.
- Sześć zasad DDD dla strukturyzowania domen w aplikacjach NestJS — Poznaj sześć praktycznych zasad projektowania napędzanego domeną do organizacji modułów, entytetów i zdarzeń w NestJS, aby funkcje pozostawały izolowane i łatwe do utrzymania.