Projektowanie warstwowe API w Node.js: od obsługujących wiele zadań kontrolerów do czystej architektury
Dowiedz się, jak przeredagować API w Node.js na warstwy kontrolera, usługi i dostępu do danych, aby naprawić skomplikowaną logikę biznesową, niespójne błędy oraz trudności z skalowaniem.
Źródła: I Built a Node.js API. Then I Realized I Was Doing It Wrong.
Pрактиczne porady dotyczące prawidłowej strukturyzacji, skalowania i utrzymywania backendu w Node.js, oparte na doświadczeniach z tworzenia API produkcyjnych.
Gdy po raz pierwszy zaczynasz pisać API w Node.js, praca nad backendem może wydawać się zaskakująco prosta.
Zdefiniuj trasę.
Połącz ją z bazą danych.
Wyślij z powrotem trochę JSON.
To wszystko.
I przez jakiś czas taki podejście rzeczywiście działa.
Następnie aplikacja zaczyna rosnąć.
Nagle stajesz przed następującymi problemami:
- Zasady biznesowe wbudowane bezpośrednio w kontrolery
- Zapytania do bazy danych rozproszone w każdym pliku trasy
- Powtarzająca się logika walidacji
- Obsługa błędów różniąca się w zależności od punktu końcowego
- Zbyt duże pliki
- Kod, który utrudnia automatyczne testowanie
- Logika autoryzacji splątana z logiką domeny
- Malże zmiany, które psują funkcje wydające się niespowiązane
API technicznie nie „awariuje”.
Występuje coś bardziej subtelnego, co idzie nie tak:
Wnioskowanie zmian w nim stało się naprawdę trudne.
To właśnie wtedy uświadamiamy sobie, że istnieje rzeczywała różnica pomiędzy API, które po prostu działa, a takim, które może wytrzymać ciągły rozwój w rzeczywistych warunkach.
1. Moja wczesna architektura Node.js
Typowe API na wczesnym etapie mogło wyglądać tak:
const express = require("express");
const app = express();
app.get("/users", async (req, res) => {
const users = await db.query("SELECT * FROM users"); res.json(users);
});app.post("/users", async (req, res) => {
const { name, email } = req.body; const user = await db.query(
"INSERT INTO users (name, email) VALUES ($1, $2)",
[name, email]
); res.json(user);
});app.listen(3000);
Żaden z tych kodów sam w sobie nie jest błędny.
Dla szybkiego prototypu to zupełnie rozsądny punkt wyjścia.
Problemy pojawiają się, gdy projekt zaczyna się rozwijać.
Wyobraźmy sobie warstwowanie takich funkcjonalności jak:
- Autoryzacja
- Prawa dostępu
- Walidacja danych wejściowych
- Weryfikacja adresu e-mail
- Przetwarzanie płatności
- Zarządzanie kuponami
- Kierowanie zamówieniami
- Śledzenie zapasów
A teraz wyobraź sobie, że musisz włączyć wszystkie te elementy bezpośrednio do swoich obsługowników tras.
W takim przypadku nie masz już prawdziwej architektury.
Masz po prostu masę kodu, który się wykonuje.
2. Problemem nie był Node.js
To błąd, który łatwo popełnić na początku.
Pierwotny odruch to myślenie w ten sposób:
">Mój kod staje się chaotyczny tylko dlatego, że aplikacja rośnie.
To jest tylko część historii.
Prawdziwą przyczyną była brak wyraźnego podziału obowiązków.
Jeden kontroler zajmował się zbyt wieloma rzeczami.
Odpowiadał za:
- Analizę przychodzącej żądania
- Walidację danych wejściowych
- Weryfikację autoryzacji
- Wykonywanie logiki biznesowej
To sześć lub siedem odrębnych zadań zebranych w jednym miejscu.
Za każdym razem, gdy zmieniały się wymagania, trzeba było ponownie edytować ten sam plik.
Ponawianie tego procesu.
3. Architektura, której używam obecnie
Czystszą strategią jest podzielenie aplikacji na odrębne warstwy.
Naprzимер:
src/
│
├── controllers/
│ └── user.controller.ts
│
├── services/
│ └── user.service.ts
│
├── repositories/
│ └── user.repository.ts
│
├── dto/
│ └── create-user.dto.ts
│
├── middleware/
│ └── auth.middleware.ts
│
├── routes/
│ └── user.routes.ts
│
├── utils/
│ └── error-handler.ts
│
└── app.ts
Zasada leżąca u podstaw jest prosta:
Każda warstwa powinna mieć jedną jasno określoną responsybilność.
4. Kontrolery nie powinny zawierać logiki biznesowej
Zadaniem kontrolera powinny być wyłącznie kwestie związane z HTTP.
Mniej więcej tak:
export class UserController {
constructor(
private readonly userService: UserService
) {}
async createUser(req: Request, res: Response) {
try {
const user = await this.userService.createUser(req.body); return res.status(201).json({
success: true,
data: user,
});
} catch (error) {
return res.status(500).json({
success: false,
message: error.message,
});
}
}
}
Kontroler nie musi wiedzieć:
- Gdzie lub jak przechowywane są dane użytkownika
- Która technologia bazy danych jest używana
To nie jest jego zadaniem.
Jego jedyną odpowiedzialnością jest obsługa przychodzących żądań i kształtowanie wychodzących odpowiedzi.
5. Logika biznesowa w usługach
Szczegółowa logika aplikacji znajduje się w warstwie usług.
export class UserService {
constructor(
private readonly userRepository: UserRepository
) {}
async createUser(data: CreateUserDto) {
const existingUser =
await this.userRepository.findByEmail(data.email); if (existingUser) {
throw new Error("User already exists");
} const hashedPassword = await hashPassword(
data.password
); return this.userRepository.create({
...data,
password: hashedPassword,
});
}
}
To umożliwia ponowne użycie tej logiki.
Inna część systemu, na przykład, może po prostu wywołać:
userService.createUser(data);
bez konieczności symulowania żądania HTTP.
Ta możliwość ponownego użycia ma duże znaczenie zarówno pod względem testowania, jak i długoterminowej utrzymywalności.
6. Dostęp do bazy danych wymaga osobnej warstwy
Logika biznesowa nie powinna być przepełniona surowymi zapytaniami SQL czy rozproszonymi wywolaniami ORM.
To właśnie problem, który rozwiązują repozytoria.
export class UserRepository {
async findByEmail(email: string) {
return prisma.user.findUnique({
where: {
email,
},
});
} async create(data: CreateUserInput) {
return prisma.user.create({
data,
});
}
}
Gdy to zostanie wdrożone, rozdzielenie zadań staje się oczywiste.
Request
↓
Controller
↓
Service
↓
Repository
↓
Database
Każda warstwa zna tylko to, co koniecznie musi wiedzieć, i nic więcej.
7. Dlaczego DTO są ważniejsze, niż się spodziewasz
Częstym błędem na początku jest przekazywanie surowych treści żądań bezpośrednio do aplikacji.
Cos takiego:
const user = await userService.createUser(req.body);
To jest ryzykowne.
Co tak naprawdę znajduje się wewnątrz req.body?
Czy możesz być pewien?
DTO ustala jasną, określoną umowę dotyczącą tego, czego oczekuje się.
class CreateUserDto {
name: string;
email: string;
password: string;
}
Następnie dane przychodzące są weryfikowane, zanim zostanie im pozwolone dotrzeć do logiki aplikacji.
Naprzимер, treść żądania taka jak:
{
"name": "Mit",
"email": "mit@example.com",
"password": "secret123"
}
może zostać sprawdzona pod kątem takich reguł jak:
- Pole obowiązkowe
- Format adresu e-mail
- Długość hasła
- Typy danych
Zasada podstawowa jest prosta:
Nigdy nie ufaj danych wprowadzanych przez klienta.
Frontend nie pełni roli bariery bezpieczeństwa.
8. Spójność w obsłudze błędów ma znaczenie
Kolejnym problemem w wczesnych fazach tworzenia API jest niestabilna struktura odpowiedzi.
Jedna ścieżka może zwracać:
{
"error": "User not found"
}
Inna odpowiada w ten sposób:
{
"message": "Not found"
}
A jeszcze inna wygląda tak:
{
"success": false,
"data": null
}
Dla każdego, kto buduje frontend integrujący się z tym API, ta niestabilność jest wyczerpująca.
Zastosowanie jednego, centralizowanego podejścia do obsługi błędów znacznie ułatwia pracę z API.
Naprzимер:
{
"success": false,
"message": "User not found",
"code": "USER_NOT_FOUND"
}
Konkretna struktura nie ma większego znaczenia.
Ważne jest to, że zawsze jest taka sama.
9. Autoryzacja to nie upoważnienie
To rozróżnienie sprawia wiele trudności wielu programistom na wczesnym etapie.
Autoryzacja odpowiada na jedno pytanie:
Kto jesteś?
Prawa dostępu odpowiadają na inne pytanie:
Cóż możesz robić?
W uproszczeniu wygląda to mniej więcej tak:
Login
↓
Authentication
↓
JWT / Session
↓
User identified
↓
Authorization
↓
Can this user delete the product?
To, że użytkownik został zautoryzowany, nie oznacza, że może wykonywać dowolne działania, jakie chce.
Dlatego właśnie sprawdzanie ról i uprawnień musi odbywać się oddzielnie od podstawowej logiki logowania i autoryzacji.
10. W końcu trzeba pomyśleć o skalowalności
Gdy kod jest już w miarę dobrze zorganizowany, pojawia się nowe pytanie:
Co się stanie, gdy ruch wzrośnie?
To właśnie w tym momencie narzędzia takie jak Redis, kolejki zadań i indeksy bazy danych zaczynają odgrywać ważną rolę.
Załóżmy endpoint, który przy każdym żądaniu wykonywałby kosztowną zapytanie do bazy danych.
Zamiast tego:
Request
↓
Database
↓
Response
system może ostatecznie przejść na coś w rodzaju:
Request
↓
Redis Cache
↓
Cache Hit → Response
│
└── Cache Miss
↓
Database
↓
Redis
↓
Response
A jeśli chodzi o wszystko, co nie powinno blokować odpowiedzi:
API
↓
Queue
↓
Worker
↓
Email / Notification / Processing
To zapobiega temu, by powolne, nieważne operacje opóźniały rzeczywiste żądanie.
Ale oto zastrzeżenie, które warto powtórzyć:
Nie uciekaj się do Redis, kolejek, Kafka, mikrosług czy Kubernetes tylko dlatego, że wyglądają imponująco.
Architektura istnieje po to, by rozwiązywać rzeczywiste problemy.
Nie powinna tworzyć nowych.
11. Najważniejsza lekcja
Prawdziwa zmiana sposobu myślenia sprowadza się do tego:
Przestań optymalizować szybkość pisania kodu.
Zamiast tego optymalizuj to, jak bezpiecznie można go później zmienić.
Gdy rozpoczyna się pierwszy projekt, umieszczanie wszystkiego w jednym pliku wydaje się efektywne.
I na początku rzeczywiście tak jest.
Jednak napisanie wersji początkowej to zaledwie niewielka część całej pracy.
Większość zmian w bazie kodu następuje później.
Z upływem czasu projekt będzie wymagał:
- Dodawania nowych funkcji
- Likwidowania błędów
- Przystosowywania się do zmieniających się wymagań
- Zmiany bibliotek
- Aktualizacji schematów bazy danych
- Dodawania mechanizmów autoryzacji
- Wprowadzania systemów uprawnień
- Sprawiania, by zapytania działały szybciej
- Łączenia się z usługami third-party
Jeśli architektura sprawia, że każda z tych czynności staje się trudna, szybkość osiągnięta na początku tak naprawdę nie była zaletą.
12. Lepszy punkt wyjścia
Nowoczesny backend w Node.js mógłby być rozplanowany mniej więcej w ten sposób:
src/
│
├── modules/
│ ├── users/
│ │ ├── user.controller.ts
│ │ ├── user.service.ts
│ │ ├── user.repository.ts
│ │ ├── user.dto.ts
│ │ └── user.routes.ts
│ │
│ ├── products/
│ │ ├── product.controller.ts
│ │ ├── product.service.ts
│ │ ├── product.repository.ts
│ │ └── product.dto.ts
│
├── middleware/
├── config/
├── database/
├── utils/
└── app.ts
W większych aplikacjach organizowanie kodu wokół modułów lub funkcji zazwyczaj przynosi lepsze rezultaty niż gromadzenie wszystkiego w ogromnych folderach współdzielonych, pełnych niespowiązanych kontrolerów i usług.
Nie chodzi o dokładną strukturę folderów.
Ważne jest, aby odpowiedzialność każdej części była oczywista.
13. Siedem zasad, których warto przestrzegać
W skrócie wszystko sprowadza się do siedmiu zasad:
1. Utrzymuj kontrolery w prostocie
Kontrolery służą do obsługi HTTP, nic więcej.
2. Umieszczaj logikę biznesową w usługach
To właśnie w usługach powinno znajdować się rzeczywiste zachowanie aplikacji.
3. Izoluj operacje bazodanowe
Unikaj sytuacji, gdy zapytania do bazy danych przenikają do każdego zakątka kodu.
4. Waliduj dane wejściowe
Nie zakładaj, że req.body zawiera to, czego się oczekuje.
5. Ujednolicaj błędy
Zrób tak, aby odpowiedzi na błędy były przewidywalne i jednolite.
6. Dodawaj infrastrukturę tylko w razie potrzeby
Redis, kolejki i mikrosługi to narzędzia stosowane w uzasadnionych przypadkach, a nie oznaka wyższej kwalifikacji.
7. Projektuj z myślą o zmianach
Każdy, kto będzie utrzymywał ten kod, będzie go modyfikował znacznie częściej niż pisał od nowa.
Wniosek
Rozruch API w Node.js nie jest trudny.
Zachowanie jego zrozumiałości przez miesiące podczas ciągłej pracy nad nowymi funkcjami to znacznie większe wyzwanie.
Latwo utożsamiać dobrą pracę nad backendem z umiejętnością podłączania:
GET
POST
PUT
DELETE
To jest właściwie tylko punkt wyjścia.
Prawdziwa umiejętność polega na wiedzy o tym, gdzie powinna znajdować się każda część logiki, jak różne elementy systemu ze sobą komunikują się oraz jak rozwijać go, nie niszcząc przy tym reszty.
Nie każdy projekt wymaga skomplikowanej architektury.
Wymaga on architektury proporcjonalnej do złożoności rzeczywistego problemu.
Ponieważ celem nigdy nie było:
"Ustawić to do pracy."
Prawdziwym celem jest:
"Ułatwić adaptację, gdy wymagania nieuchronnie ulegną zmianie."
Literatura pokrewna
- Przejście w architekturze backend: od stałych API do systemów agentów AI — Dowiedz się, jak zmienia się projekt backendu, gdy agenci AI zastępują stałe ścieżki API, z rzeczywistymi przykładami kodu, przypadkami użycia oraz praktycznymi kwestiami do rozważenia.