NestJS w praktyce: jak uporządkować architekturę aplikacji Node.js

0
45
Rate this post

Nawigacja:

Dlaczego w ogóle porządkować architekturę aplikacji Node.js?

Skrypt node’owy kontra aplikacja biznesowa

Prosty skrypt w Node.js to często pojedynczy plik: kilka importów, jedno wywołanie API, może zapis do pliku. Taki kod łatwo „ogarnąć wzrokiem” i nawet jeśli nie jest idealny, rzadko sprawia poważne problemy. Aplikacja biznesowa to zupełnie inna liga: rosnące wymagania, wiele zespołów, integracje z zewnętrznymi systemami, zmieniające się zasady biznesowe, testy automatyczne, monitoring, bezpieczeństwo.

Gdy różnica między skryptem a systemem produkcyjnym jest ignorowana, projekt zaczyna pękać w szwach. Logika biznesowa miesza się z komunikacją HTTP, połączenia do bazy danych otwierane są „gdzieś po drodze”, a zmiana jednego endpointu potrafi przypadkiem zepsuć inny. W pewnym momencie dochodzi się do ściany: naprawa błędów zajmuje więcej czasu niż dodawanie nowych funkcji.

NestJS powstał właśnie jako odpowiedź na ten problem. Dodaje do świata Node.js strukturę znaną z dojrzałych frameworków backendowych: moduły, warstwy, wstrzykiwanie zależności, jasny przepływ żądania. Dzięki temu aplikacja Node.js przestaje być zlepkiem plików i funkcji, a staje się uporządkowanym systemem.

Chaos callbacków i promisów kontra uporządkowane warstwy

Node.js historycznie kojarzy się z callbackami i „callback hell”. Dzisiejszy ekosystem (async/await, Promises) bardzo to uprościł, ale problem chaosu nie zniknął – przeniósł się poziom wyżej. Zamiast zagnieżdżonych funkcji pojawia się mieszanie odpowiedzialności: logika domenowa wywoływana bezpośrednio z routera, walidacja rozrzucona po kodzie, ręczne zarządzanie zależnościami między modułami.

Architektura NestJS opiera się na prostym założeniu: każda część aplikacji ma swoje konkretne zadanie. Kontroler odpowiada za warstwę transportu (HTTP, WebSocket), serwis za logikę biznesową, repozytorium za dostęp do danych. Dodatkowe elementy – takie jak guards, interceptors, pipes czy filters – pozwalają „zahaczać się” w przepływ żądania w kontrolowanym miejscu, zamiast wstrzykiwać logikę pomocniczą w losowe fragmenty kodu.

Ten podział nie jest sztuką dla sztuki. Gdy kod zaczyna rosnąć, możliwość szybkiego odnalezienia fragmentu odpowiedzialnego za konkretną rzecz (walidację, autoryzację, zapis do bazy) oszczędza godziny pracy. Kluczowy jest też fakt, że poszczególne warstwy można testować osobno – bez podnoszenia całego serwera HTTP.

Typowe problemy projektów Express

Express jest świetny na start: prosty, elastyczny, pozbawiony narzutu. Wraz z rozmiarem projektu zaczynają się jednak powtarzać te same wzorce problemów:

  • Rozrastający się plik app.js/server.js – z czasem zawiera routing, konfigurację, logikę autoryzacji, bezpośrednie wywołania do bazy, middleware do wszystkiego i kilka „hacków”, które ktoś kiedyś dodał „tymczasowo”.
  • Brak jasnych granic modułów – funkcje z modułu „users” nagle używane są bezpośrednio w module „orders”, bo „tak było szybciej”, a potem zaczyna się spirala zależności w obie strony.
  • Rozproszona konfiguracja – zmienne środowiskowe używane bezpośrednio w wielu miejscach, konfiguracja bazy w jednym pliku, konfiguracja zewnętrznego API w innym, brak jednego źródła prawdy.
  • Trudne testowanie – funkcje wymagają masy setupu, bo nie ma wstrzykiwania zależności; zamiast testów jednostkowych dominują testy integracyjne, które są wolne i kruche.

Każdy z tych problemów da się rozwiązać ręcznie, tworząc własne konwencje i narzędzia. NestJS dostarcza taki zestaw konwencji z pudełka i narzuca je poprzez strukturę frameworka. Zamiast wymyślać własny „mini-framework” na bazie Expressa, można oprzeć się na sprawdzonych wzorcach.

Co realnie daje adopcja NestJS

Dla zespołu NestJS to przede wszystkim wspólny język. Gdy nowa osoba dołącza do projektu, nie musi zgadywać, jaką architekturę ktoś wymyślił – wie, że logika trafi do serwisów, walidacja do pipes, autoryzacja do guards, konfiguracja do modułów konfiguracyjnych. To skraca onboarding oraz zmniejsza liczbę „autorskich” rozwiązań, które zna tylko ich twórca.

Druga korzyść to przewidywalność kodu. Z góry wiadomo, gdzie szukać konkretnej funkcji i gdzie ją dołożyć. Dzięki temu rośnie szansa, że kolejne osoby będą rozbudowywać istniejące mechanizmy zamiast tworzyć dublujące się fragmenty.

Trzeci element to testowalność. Architektura NestJS, mocno oparta na Dependency Injection, sprzyja izolowaniu serwisów i testowaniu ich bez realnej bazy, kolejki czy systemu plików. Przy dużych aplikacjach różnica między „da się przetestować” a „testy są realnie używane i utrzymywane” jest kluczowa dla jakości i bezpieczeństwa zmian.

Fragment kodu Ruby on Rails z podświetloną składnią na ekranie
Źródło: Pexels | Autor: Digital Buggu

Fundamenty NestJS – z czego składa się typowa aplikacja

Intuicja: NestJS jako „Angular po stronie serwera”

Twórca NestJS inspirował się Angularem. Widać to w kilku kluczowych elementach: modułach, kontrolerach, serwisach i dekoratorach. Niezależnie od tego, czy korzystasz z Angulara, intuicja jest prosta: aplikacja składa się z małych cegiełek, które łączysz w moduły, a moduły składasz w całość.

Najważniejsze pojęcia:

  • Module – logiczna jednostka aplikacji grupująca kontrolery i providerów (np. serwisy, repozytoria). Przykłady: UsersModule, AuthModule, PaymentsModule.
  • Controller – odpowiada za przyjmowanie żądań (np. HTTP) i zwracanie odpowiedzi. Nie powinien zawierać złożonej logiki biznesowej.
  • Service – miejsce na logikę biznesową, operacje na modelach, współpracę z repozytoriami.
  • Provider – dowolna klasa zarządzana przez kontener NestJS (serwisy, repozytoria, adaptery do zewnętrznych systemów).
  • Dekorator – adnotacja (np. @Controller(), @Injectable()), która mówi NestJS, jak traktować daną klasę lub metodę.

Ta struktura pozwala zachować porządek od pierwszej linijki. Zamiast tworzyć „jakkolwiek” kolejne pliki, trzymasz się spójnego słownika pojęć.

Rola Dependency Injection i kontenera IoC

Dependency Injection (wstrzykiwanie zależności) to mechanizm, w którym klasa nie tworzy swoich zależności sama (new Service()), tylko dostaje je z zewnątrz – z kontenera IoC (Inversion of Control). W NestJS każdy provider oznaczony dekoratorem @Injectable() może być wstrzyknięty do innego providera przez konstruktor.

Dzięki temu:

  • nie tworzysz „ręcznie” instancji w setkach miejsc,
  • łatwo podmieniasz implementacje (np. w testach lub innych środowiskach),
  • masz centralne miejsce rejestracji zależności – moduły z listami providerów.

Prosty przykład: serwis użytkownika korzystający z repozytorium:

@Injectable()
export class UsersService {
  constructor(private readonly usersRepository: UsersRepository) {}

  async createUser(data: CreateUserDto): Promise<User> {
    const exists = await this.usersRepository.findByEmail(data.email);
    if (exists) {
      throw new Error('User already exists');
    }
    return this.usersRepository.create(data);
  }
}

Klasa UsersService nie wie, jak powstała instancja UsersRepository. Dla testów możesz wstrzyknąć mock, w środowisku produkcyjnym – prawdziwe repozytorium, w innym – wersję korzystającą z innej bazy. Kontener NestJS zajmuje się tworzeniem i podłączaniem tych obiektów.

Moduły główne: AppModule i moduły funkcjonalne

Każda aplikacja NestJS zaczyna się od AppModule. To główny moduł, który scala inne moduły i definiuje „startowy” skład systemu. W małych projektach rzeczywiście można zmieścić większość logiki w jednym module, ale wraz ze wzrostem systemu konieczne staje się rozbicie na moduły featurowe (domenowe).

Przykładowy podział modułów:

  • UsersModule – zarządzanie użytkownikami, profile, rejestracja.
  • AuthModule – logowanie, JWT, odświeżanie tokenów.
  • OrdersModule – zamówienia, ich statusy, powiązanie z płatnościami.
  • PaymentsModule – integracje z bramkami płatniczymi, webhooki.
  • ConfigModule – centralna konfiguracja systemu (często globalny moduł).

Każdy moduł dostarcza kontrolery i serwisy związane z jedną domeną. W rezultacie struktura katalogów i konfiguracja modułów same dokumentują podział systemu na obszary odpowiedzialności.

Przepływ żądania w NestJS

Intuicyjny obraz przepływu żądania HTTP w architekturze NestJS można ująć w kilku krokach:

  1. Klient wysyła żądanie HTTP (np. POST /users).
  2. Na wejściu mogą działać guards (np. sprawdzające autoryzację) oraz pipes (walidujące i przekształcające dane wejściowe).
  3. Żądanie trafia do odpowiedniej metody kontrolera (np. UsersController.create()).
  4. Kontroler deleguje logikę do serwisu (np. UsersService.createUser()).
  5. Serwis korzysta z repozytoriów/adapterów (np. UsersRepository) do odczytu i zapisu danych.
  6. Wynik wraca do kontrolera, który zwraca odpowiedź HTTP; po drodze mogą działać interceptors (np. logowanie, mapowanie odpowiedzi).

Ten schemat można rozbudowywać o kolejne warstwy (CQRS, eventy domenowe, komunikacja asynchroniczna), ale podstawowy wzór pozostaje niezmienny: każda część ma swoją rolę, a przepływ jest możliwy do prześledzenia od wejścia do wyjścia.

Przykładowy endpoint: jak mapuje się na moduł, kontroler i serwis

Wyobraźmy sobie prosty endpoint tworzący użytkownika. Struktura w NestJS będzie wyglądać tak:

  • UsersModule – rejestruje UsersController, UsersService i UsersRepository.
  • UsersController – ma metodę create() oznaczoną @Post().
  • UsersService – zawiera metodę createUser() z logiką biznesową.
  • UsersRepository – obsługuje operacje na bazie danych.
@Controller('users')
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Post()
  async create(@Body() dto: CreateUserDto) {
    return this.usersService.createUser(dto);
  }
}

Taki układ jest powtarzalny i szybko staje się naturalny. Każdy kolejny endpoint wpisuje się w ten sam schemat, co utrwala porządek w architekturze NestJS.

Organizacja katalogów i modułów – od małego projektu do większej platformy

Domyślny układ z CLI i jego ograniczenia

Generując projekt komendą nest new, otrzymuje się podstawową strukturę: app.module.ts, app.controller.ts, app.service.ts i kilka plików pomocniczych. Dla małej aplikacji jest to wystarczające, jednak przy większym systemie pojawiają się pytania:

  • gdzie umieścić logikę domenową dla poszczególnych obszarów (users, orders, payments),
  • jak rozdzielić warstwę infrastruktury (np. integracje z zewnętrznymi API) od warstwy domeny,
  • jak uniknąć „potwora” w postaci jednego modułu z dziesiątkami providerów.

CLI NestJS ułatwia generowanie modułów, kontrolerów i serwisów (np. nest g module users, nest g controller users), ale nie narzuca, jak daleko i jak szczegółowo dzielić strukturę. To już świadoma decyzja projektowa, która mocno wpływa na utrzymanie aplikacji.

Dwa style: podział warstwowy kontra domenowy

W praktyce spotyka się dwa dominujące style organizacji katalogów NestJS:

  • podział warstwowy – katalogi typu controllers/, services/, entities/,
  • podział domenowy (feature-based) – katalogi typu users/, orders/, payments/, z pełnym zestawem plików wewnątrz.

Dlaczego układ domenowy wygrywa przy większych systemach

Układ warstwowy jest kuszący na początku – wszystko ma swoją „szufladkę”: kontrolery do kontrolerów, serwisy do serwisów. Po kilku miesiącach łatwo jednak skończyć z katalogiem services/ pełnym plików, które trudno powiązać z konkretną częścią biznesu. Szukając logiki rejestracji użytkownika, przeskakujesz między controllers/, services/, entities/, a do tego dochodzą integracje, eventy, komendy CQRS.

Układ domenowy odwraca perspektywę: wszystko, co dotyczy danej domeny, leży obok siebie. Szukając funkcji związanej z koszykiem, zaglądasz po prostu do katalogu cart/ i tam znajdujesz kontrolery, serwisy, encje, testy, eventy.

Przykładowa struktura domenowa:

src/
  users/
    users.module.ts
    users.controller.ts
    users.service.ts
    users.repository.ts
    entities/
      user.entity.ts
    dto/
      create-user.dto.ts
  orders/
    orders.module.ts
    orders.controller.ts
    orders.service.ts
    orders.repository.ts
    entities/
      order.entity.ts
    dto/
      create-order.dto.ts
  payments/
    payments.module.ts
    payments.controller.ts
    payments.service.ts
    payments.provider.ts
  shared/
    exceptions/
    utils/

Taki układ lepiej wspiera rozrost aplikacji i pracę w zespole. Osoby odpowiedzialne za moduł zamówień pracują głównie w orders/, bez ciągłego „skakania” po całym projekcie.

Stopniowe porządkowanie – kiedy zacząć dzielić monolit na moduły

Na początku mały monolit w jednym katalogu jest w porządku. Problem zaczyna się, gdy:

  • masz kilkanaście kontrolerów w jednym module,
  • często dotykasz tych samych plików przy niepowiązanych zadaniach,
  • rośnie liczba zależności między serwisami.

Dobry moment na wydzielenie osobnego modułu to chwila, gdy dana część biznesu:

  • ma już kilku własnych kontrolerów i serwisów,
  • jest sensownie opisana jednym słowem (np. billing, delivery),
  • da się ją sprawnie rozwijać względnie niezależnie od reszty.

Nie ma sensu tworzyć N modułów na zapas. Lepiej zacząć od kilku większych (np. UsersModule, OrdersModule, CatalogModule), a później rozbijać je, gdy zajdzie taka potrzeba (np. wydzielić z OrdersModule osobny PaymentsModule).

Moduły wspólne i biblioteki wewnętrzne

W każdej większej aplikacji pojawiają się elementy współdzielone: obsługa maili, kolejki, logger, metryki. Jeśli wrzucać je losowo do katalogu shared/ bez ładu, szybko powstaje „worek na wszystko”. Lepiej zachować zasady:

  • każdy element współdzielony to osobny moduł NestJS (np. MailModule, LoggerModule),
  • moduł eksportuje publiczny interfejs (serwisy, tokeny), resztę trzyma w środku,
  • tylko nieliczne moduły są globalne; większość importuje się tam, gdzie są naprawdę potrzebne.

Przykładowo, moduł maili:

@Module({
  providers: [MailService],
  exports: [MailService],
})
export class MailModule {}

W domenie zamówień importujesz go jawnie:

@Module({
  imports: [MailModule],
  providers: [OrdersService],
  controllers: [OrdersController],
})
export class OrdersModule {}

Taki schemat jest czytelny: z samej deklaracji modułu widać, które zależności zewnętrzne są mu potrzebne.

Rozbijanie na pakiety / monorepo – kiedy wyjść poza jedno src/

Jeśli projekt rośnie do platformy (np. kilka mikroserwisów, workerów, panel admina), sam katalog src/ może już nie wystarczyć. NestJS dobrze współpracuje z monorepo, np. w stylu Nx lub pnpm workspaces.

Intuicja jest taka: moduły domenowe, które zaczynają żyć własnym życiem (np. payments jako oddzielny serwis), można przenieść do biblioteki lub osobnego appa w monorepo:

apps/
  api-gateway/
  payments-service/
  orders-service/
libs/
  users/
  shared-kafka/
  shared-config/

Logika stricte biznesowa (np. reguły naliczania rabatów) ląduje w libs/, a każda usługa NestJS w apps/ wykorzystuje te same biblioteki. Dzięki temu unikasz kopiowania kodu i utrzymujesz spójne zachowanie w całej platformie.

Zbliżenie na wypukły schemat zęba w książce z alfabetem Braille’a
Źródło: Pexels | Autor: Yan Krukau

Warstwy w NestJS – od kontrolera po bazę danych

Intuicyjny podział na warstwy

Nawet w prostej aplikacji backendowej pojawiają się te same klocki: warstwa wejścia (transport), logika biznesowa, dostęp do danych. NestJS nie narzuca jednego „świętego” wzorca, ale daje narzędzia, by oddzielić te warstwy na tyle, na ile jest to potrzebne.

Typowy, czytelny układ:

  • warstwa transportu – kontrolery HTTP/GraphQL/WebSocket lub message handlers,
  • warstwa aplikacyjna – serwisy, czasem komendy/handlery CQRS, koordynują przebieg przypadków użycia,
  • warstwa domenowa – modele domenowe, reguły biznesowe, polityki i eventy,
  • warstwa infrastruktury – bazy danych, kolejki, systemy zewnętrzne.

Kontroler jako cienka warstwa transportu

Kontroler nie powinien „myśleć za system”. Jego zadania to:

  • przyjąć dane z żądania,
  • oddać je dalej w sensownej formie (np. DTO),
  • zwrócić odpowiedź w oczekiwanym formacie.

Przykładowy kontroler zamówień:

@Controller('orders')
export class OrdersController {
  constructor(private readonly ordersService: OrdersService) {}

  @Post()
  create(@Body() dto: CreateOrderDto) {
    return this.ordersService.placeOrder(dto);
  }

  @Get(':id')
  findOne(@Param('id') id: string) {
    return this.ordersService.getOrderById(id);
  }
}

Nie ma tu logiki typu „jak policzyć rabat”, „kiedy wysłać maila”, „czy to już czas na fakturę”. Wszystko to żyje niżej – w serwisach i modelach domenowych.

Serwisy aplikacyjne i domenowe – gdzie trzymać reguły biznesowe

W praktyce często przydaje się rozdział na:

  • serwisy aplikacyjne – odpowiadają za konkretny przypadek użycia, np. „złóż zamówienie”, „podpisz umowę”,
  • serwisy domenowe – agregują wiedzę biznesową z danego obszaru, np. zasady naliczania rabatów, walidacji koszyka.

W małych projektach obie role często są w jednym serwisie. W większych systemach opłaca się je rozdzielić – łatwiej wtedy ponownie wykorzystać logikę w różnych kanałach (np. API publiczne, panel admina, worker).

Prosty przykład „serwisu aplikacyjnego” w NestJS:

@Injectable()
export class OrdersService {
  constructor(
    private readonly cartService: CartService,
    private readonly pricingService: PricingService,
    private readonly ordersRepository: OrdersRepository,
  ) {}

  async placeOrder(dto: CreateOrderDto): Promise<Order> {
    const cart = await this.cartService.getCart(dto.cartId);
    const price = this.pricingService.calculateTotal(cart, dto.discounts);
    const order = await this.ordersRepository.create(cart, price, dto.customerId);

    // tu można wyemitować event domenowy, np. OrderPlaced
    return order;
  }
}

Serwis nie zna szczegółów bazy, ale decyduje „co po czym”: najpierw walidacja koszyka, potem wycena, potem zapis zamówienia.

Repozytoria i adaptery infrastruktury

Bezpośrednie użycie ORMa (np. TypeORM, Prisma) w całej aplikacji szybko prowadzi do powiązania logiki biznesowej z konkretną biblioteką. Repozytoria to cienka warstwa, która chowa szczegóły implementacyjne.

Minimalny przykład repozytorium użytkowników:

@Injectable()
export class UsersRepository {
  constructor(private readonly prisma: PrismaService) {}

  findByEmail(email: string) {
    return this.prisma.user.findUnique({ where: { email } });
  }

  create(data: CreateUserDto) {
    return this.prisma.user.create({ data });
  }
}

Jeśli kiedyś zmienisz Prisma na inny ORM, zmiany koncentrują się w repozytorium. Serwisy pozostają w dużej mierze nietknięte.

CQRS i eventy jako opcjonalne rozszerzenie

Przy rozbudowanej logice biznesowej (np. w systemach finansowych) proste wywołanie serwisu może już nie wystarczać. NestJS ma gotowy moduł CQRS, który pozwala wprowadzić komendy (polecenia) i eventy (zdarzenia).

Przykład komendy:

export class PlaceOrderCommand {
  constructor(
    public readonly cartId: string,
    public readonly customerId: string,
  ) {}
}

I jej handler:

@CommandHandler(PlaceOrderCommand)
export class PlaceOrderHandler implements ICommandHandler<PlaceOrderCommand> {
  constructor(private readonly ordersService: OrdersService) {}

  async execute(command: PlaceOrderCommand) {
    return this.ordersService.placeOrder({
      cartId: command.cartId,
      customerId: command.customerId,
      discounts: [],
    });
  }
}

Taki wzór porządkuje złożone przepływy (np. gdy jedno polecenie uruchamia kilka akcji, w tym asynchronicznych), a jednocześnie pozwala nadal wykorzystywać znane już warstwy NestJS.

Konfiguracja, środowiska i zależności – porządek od pierwszego dnia

Czemu konfiguracja lubi się wymknąć spod kontroli

Adresy baz danych, klucze API, adresy webhooków, flagi eksperymentalne – to wszystko na początku ląduje w process.env „na żywca” lub w kilku plikach .env. Po kilkunastu miesiącach nikt nie wie, jakie zmienne są faktycznie używane, a które są starym śmieciem.

Dlatego warto potraktować konfigurację jak normalny kod: typować, walidować, trzymać w jednym, przewidywalnym miejscu.

ConfigModule i centralne źródło prawdy

W NestJS rolę „centrum konfiguracji” pełni ConfigModule z pakietu @nestjs/config. Typowy, prosty układ to:

  • jeden globalny moduł konfiguracyjny,
  • osobne pliki z konfiguracją dla domen (np. database.config.ts, auth.config.ts),
  • silna walidacja zmiennych środowiskowych.

Przykładowa konfiguracja bazy danych z walidacją:

// database.config.ts
import * as Joi from 'joi';

export default () => ({
  database: {
    url: process.env.DATABASE_URL,
  },
});

export const databaseValidationSchema = Joi.object({
  DATABASE_URL: Joi.string().uri().required(),
});

Rejestracja w AppModule:

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      load: [databaseConfig, authConfig],
      validationSchema: Joi.object({
        ...databaseValidationSchema.describe().keys,
        // pozostałe zmienne
      }),
    }),
  ],
})
export class AppModule {}

Od tej pory w serwisach można wstrzykiwać ConfigService i korzystać z typowanego dostępu do konfiguracji.

Mapowanie konfiguracji na moduły domenowe

Zamiast odczytywać process.env w losowych miejscach, lepiej zdefiniować „podkonfiguracje” dla konkretnych modułów. Przykład dla modułu płatności:

// payments.config.ts
export default () => ({
  payments: {
    provider: process.env.PAYMENTS_PROVIDER ?? 'stripe',
    stripeKey: process.env.STRIPE_KEY,
  },
});

W serwisie płatności:

@Injectable()
export class PaymentsService {
  private readonly provider: string;
  private readonly stripeKey: string;

  constructor(private readonly configService: ConfigService) {
    const paymentsConfig = this.configService.get('payments');
    this.provider = paymentsConfig.provider;
    this.stripeKey = paymentsConfig.stripeKey;
  }
}

Konfiguracja jest trzymana w jednym miejscu, ale konsumowana tam, gdzie należy – w modułach domenowych.

Profile środowiskowe: dev, test, staging, prod

Różne środowiska mają inne bazy, inne bramki płatnicze, inne poziomy logowania. W NestJS wygodnie jest używać kilku plików .env (np. .env.development, .env.test) oraz zmiennej NODE_ENV, by wybrać właściwy zestaw.

Z ConfigModule można to połączyć w ten sposób:

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      envFilePath: [`.env.${process.env.NODE_ENV || 'development'}`],
    }),
  ],
})
export class AppModule {}

Dodatkowo da się przechowywać sekrety (hasła, tokeny) poza repozytorium, np. w menedżerze haseł chmurowym, a .env traktować jak „wskazówki”, gdzie ich szukać. Kluczowa jest jednoznaczność: w danym środowisku da się od razu ustalić, skąd biorą się wszystkie niezbędne wartości.

Kontenery DI i moduły jako granice odpowiedzialności

Konfiguracja to nie tylko zmienne środowiskowe. Dużą część „porządku” w NestJS robi sam kontener DI (Dependency Injection), czyli mechanizm wstrzykiwania zależności. Jeśli modularyzacja będzie sensowna, kontener stanie się mapą architektury – po samych modułach i providerach da się odczytać, jak zbudowana jest aplikacja.

Przydatna praktyka przy większych projektach to traktowanie modułów jak granic koncepcyjnych, a nie tylko technicznych:

  • moduły domenowe – np. OrdersModule, UsersModule, BillingModule,
  • moduły techniczne – np. DatabaseModule, LoggingModule, AuthModule,
  • moduły integracyjne – np. PaymentsStripeModule, MailSendgridModule.

Takie rozbicie ułatwia odpowiedź na banalne pytanie nowego developera: „gdzie dopisać obsługę takiej funkcji?”. Jeśli zmiana dotyczy logiki płatności – szuka w modułach płatności, a nie w losowych folderach.

Moduły funkcjonalne vs. moduły „biblioteki”

W projektach NestJS często miesza się dwa typy modułów: takie, które reprezentują fragment biznesu, i takie, które dostarczają „narzędzia”. Gdy rozdzieli się je wyraźnie, łatwiej kontrolować zależności.

Prosty przykład układu katalogów:

src/
  app.module.ts
  core/
    database/
      database.module.ts
      prisma.service.ts
    logging/
      logging.module.ts
      logger.service.ts
  domains/
    users/
      users.module.ts
      users.controller.ts
      users.service.ts
      users.repository.ts
    orders/
      orders.module.ts
      orders.controller.ts
      orders.service.ts
      orders.repository.ts
  integrations/
    payments/
      stripe/
        payments-stripe.module.ts
        stripe.service.ts
      payu/
        payments-payu.module.ts
        payu.service.ts

core nie zna świata domeny (brak importów z domains). Domeny korzystają z core oraz wybranych integracji. Dzięki temu importy idą „do środka” systemu, a nie w obie strony, co ogranicza spaghetti-zależności.

Tworzenie modułów dynamicznych dla infrastruktury

Dla komponentów infrastruktury z konfiguracją (np. klient Redis, ElasticSearch) wygodnie jest budować moduły dynamiczne. Pozwalają wstrzyknąć zależności i konfigurację w jednym miejscu, zamiast przekazywać je ręcznie do każdego serwisu.

Fragment przykładowego modułu dla Redisa:

// redis.module.ts
import { DynamicModule, Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { createClient } from 'redis';

@Module({})
export class RedisModule {
  static forRoot(): DynamicModule {
    return {
      module: RedisModule,
      imports: [ConfigModule],
      providers: [
        {
          provide: 'REDIS_CLIENT',
          useFactory: async (config: ConfigService) => {
            const url = config.get<string>('redis.url');
            const client = createClient({ url });
            await client.connect();
            return client;
          },
          inject: [ConfigService],
        },
      ],
      exports: ['REDIS_CLIENT'],
    };
  }
}

W głównym module aplikacji taki moduł rejestrowany jest raz:

@Module({
  imports: [
    ConfigModule.forRoot({ isGlobal: true }),
    RedisModule.forRoot(),
    // ...
  ],
})
export class AppModule {}

Od tego momentu każdy moduł, który potrzebuje Redisa, wstrzykuje po prostu token 'REDIS_CLIENT'. Kontener DI dba o to, by istniała jedna, poprawnie skonfigurowana instancja klienta.

Zależności cykliczne i jak ich unikać

Częsty problem przy rozbudowanych modułach to zależności cykliczne (moduł A importuje B, a B importuje A). NestJS potrafi je częściowo obsłużyć przez forwardRef, ale jeśli cykli jest dużo, widać, że warstwy się rozmywają.

Zamiast leczyć objawy forwardRef, lepiej często:

  • wyciągnąć wspólną logikę do trzeciego modułu (np. SharedOrdersUsersModule lub bardziej neutralnie: AccountsModule),
  • zmienić kierunek zależności – serwis z wyższej warstwy wywołuje niższą, a nie odwrotnie,
  • zastąpić bezpośrednie wywołania komunikacją pośrednią (eventy domenowe, kolejka).

Dobrym sygnałem ostrzegawczym jest chwila, gdy kilka modułów musi się znać nawzajem, żeby cokolwiek zrobić. Zwykle oznacza to, że granice domeny zostały narysowane zbyt „technicznie”, a nie biznesowo.

Nastolatek pisze na białej tablicy podczas nauki w klasie
Źródło: Pexels | Autor: Katerina Holmes

Skalowalna logika biznesowa – wzorce i dobre praktyki w NestJS

DTO i modele domenowe – dwie różne perspektywy

Intuicyjnie kuszące jest używanie tych samych klas zarówno jako modeli w bazie (np. encje ORM), jak i struktur wejścia/wyjścia API. Im większy system, tym bardziej to się mści. Inne wymagania ma baza, inne API publiczne, a jeszcze inne – wewnętrzna logika biznesowa.

Bezpieczniej rozdzielić kilka warstw danych:

  • DTO (Data Transfer Objects) – opisują, co wchodzi i wychodzi z API, z walidacją,
  • modele domenowe – klasy reprezentujące pojęcia biznesowe (Order, Invoice),
  • modele persystencji – struktury przyjazne bazie (encje, rekordy ORM).

W NestJS DTO łączy się najczęściej z klasami i dekoratorami z class-validator oraz class-transformer:

export class CreateOrderDto {
  @IsUUID()
  cartId: string;

  @IsUUID()
  customerId: string;

  @IsOptional()
  @IsArray()
  @IsString({ each: true })
  discounts?: string[];
}

Modele domenowe nie muszą być mapą 1:1 do bazy. Czasem jedna encja w bazie odpowiada kilku obiektom w domenie, a czasem jest odwrotnie. Dzięki temu logika biznesowa nie jest zakładnikiem struktury tabel.

Enkapsulacja logiki w klasach domenowych

Część reguł biznesowych da się wygodniej opisać w samych modelach domenowych niż w serwisach. Zamiast palety rozproszonych utili, powstają metody „mówiące” językiem biznesu.

export class Order {
  constructor(
    readonly id: string,
    private status: 'draft' | 'placed' | 'paid' | 'cancelled',
    private readonly lines: OrderLine[],
  ) {}

  place() {
    if (this.status !== 'draft') {
      throw new Error('Only draft orders can be placed');
    }
    if (this.lines.length === 0) {
      throw new Error('Order must have at least one line');
    }
    this.status = 'placed';
  }

  cancel() {
    if (this.status === 'paid') {
      throw new Error('Paid orders cannot be cancelled');
    }
    this.status = 'cancelled';
  }
}

Serwis zamiast ręcznie sprawdzać wszystkie warunki, woła po prostu order.place() lub order.cancel(). Zasady są w jednym miejscu, łatwe do przetestowania i ponownego użycia.

Use case per metoda – serwisy małe, ale konkretne

Dobrym nawykiem jest projektowanie metod serwisów aplikacyjnych tak, jakby każda była osobnym przypadkiem użycia. Zamiast jednego wielkiego processOrder(dto), lepiej mieć kilka precyzyjnych metod: placeOrder, payForOrder, cancelOrder.

Ich sygnatury odzwierciedlają realne scenariusze:

@Injectable()
export class OrdersService {
  // ...

  placeOrder(dto: CreateOrderDto): Promise<Order> {
    // ...
  }

  payForOrder(orderId: string, method: PaymentMethod): Promise<Order> {
    // ...
  }

  cancelOrder(orderId: string, reason?: string): Promise<Order> {
    // ...
  }
}

W kontrolerze, handlerze komendy CQRS czy workerze kolejki wywołuje się konkretny przypadek użycia zamiast „uniwersalnej” metody. Dzięki temu przepływy są bardziej czytelne, a ewentualne side-effecty (np. wysyłka maila, zapis logów audytowych) można przypisać do jednego, jasnego momentu.

Obsługa błędów po stronie domeny i API

Jeśli błędy biznesowe miesza się z technicznymi (np. „brak połączenia z bazą” vs. „zamówienie nie może być opłacone”), debugowanie szybko staje się uciążliwe. Pomaga prosty podział:

  • błędy domenowe – własne klasy dziedziczące po Error (np. OrderAlreadyPaidError),
  • błędy techniczne – to, co zwraca ORM, zewnętrzne API, sieć.

W NestJS można zaimplementować globalny filtr wyjątków, który mapuje konkretne błędy domenowe na odpowiednie statusy HTTP:

@Catch(DomainError)
export class DomainExceptionFilter implements ExceptionFilter {
  catch(exception: DomainError, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();

    response.status(exception.httpStatus ?? 400).json({
      message: exception.message,
      code: exception.code, // np. ORDER_ALREADY_PAID
    });
  }
}

Logika biznesowa rzuca własne wyjątki, a warstwa HTTP decyduje, jak je przekuć w odpowiedź. Tym samym nie trzeba mieszać dekoratorów HTTP w kodzie domenowym.

Moduły domenowe a testowalność

Jeśli moduł domenowy ma jasno określone zależności (repozytoria, serwisy pomocnicze), testy przestają być przykrym obowiązkiem. W NestJS można użyć TestingModule, ale przy dobrze wyizolowanej domenie często wystarczy czysty TypeScript z ręcznie wstrzykiwanymi mockami.

Przykład prostego testu serwisu bez uruchamiania całego NestJS:

describe('OrdersService', () => {
  it('places order with calculated price', async () => {
    const cartService = { getCart: jest.fn().mockResolvedValue(sampleCart) };
    const pricingService = { calculateTotal: jest.fn().mockReturnValue(100) };
    const ordersRepository = { create: jest.fn().mockResolvedValue(sampleOrder) };

    const service = new OrdersService(
      cartService as any,
      pricingService as any,
      ordersRepository as any,
    );

    const order = await service.placeOrder({
      cartId: 'cart-1',
      customerId: 'cust-1',
      discounts: [],
    });

    expect(order).toEqual(sampleOrder);
    expect(pricingService.calculateTotal).toHaveBeenCalledWith(sampleCart, []);
  });
});

Takie testy są szybkie, odporne na infrastrukturę i wprost opisują założenia biznesowe.

API HTTP, GraphQL i WebSocket – porządek na warstwie transportu

Wspólny rdzeń, różne „skórki” transportowe

W wielu projektach ten sam zestaw funkcji musi być dostępny przez różne kanały: klasyczne REST, GraphQL, WebSocket, czasem nawet gRPC. Najmniej bólu przynosi podejście, w którym logika biznesowa jest zupełnie ślepa na rodzaj transportu, a kontrolery / resolver’y / gateway’e stają się cienką fasadą.

Przyjrzyjmy się prostej funkcji – tworzenie zamówienia. Ten sam serwis i DTO, trzy różne wejścia.

HTTP: kontrolery REST jako najprostsza fasada

W REST-owym API zadaniem kontrolera jest przełożyć żądanie HTTP na wywołanie przypadku użycia. Bez dodatkowej magii:

@Controller('orders')
export class OrdersHttpController {
  constructor(private readonly ordersService: OrdersService) {}

  @Post()
  create(@Body() dto: CreateOrderDto) {
    return this.ordersService.placeOrder(dto);
  }

  @Post(':id/cancel')
  cancel(@Param('id') id: string, @Body('reason') reason?: string) {
    return this.ordersService.cancelOrder(id, reason);
  }
}

W kontrolerze nie pojawia się logika „jeśli zamówienie opłacone, to…”. Tam, gdzie wchodzi protokół HTTP (nagłówki, statusy), tam też kończy się odpowiedzialność kontrolera.

GraphQL: resolver korzystający z tych samych serwisów

GraphQL w NestJS ma własną warstwę transportu, ale z punktu widzenia architektury aplikacji to po prostu inny sposób rozmowy z tym samym serwisem. Resolver nie powinien „wiedzieć więcej” niż kontroler.

@Resolver(() => OrderType)
export class OrdersResolver {
  constructor(private readonly ordersService: OrdersService) {}

  @Mutation(() => OrderType)
  createOrder(@Args('input') input: CreateOrderInput) {
    // CreateOrderInput może być mapowane 1:1 na CreateOrderDto
    return this.ordersService.placeOrder(input);
  }

  @Mutation(() => OrderType)
  cancelOrder(
    @Args('id') id: string,
    @Args('reason', { nullable: true }) reason?: string,
  ) {
    return this.ordersService.cancelOrder(id, reason);
  }

  @Query(() => OrderType, { nullable: true })
  order(@Args('id') id: string) {
    return this.ordersService.getOrderById(id);
  }
}

Warstwa GraphQL troszczy się o schemat, typy, selekcję pól. Reszta wygląda bardzo podobnie do kontrolera HTTP.

WebSocket / Gateway – stan sesji zamiast stateless

Dla połączeń WebSocket pojawia się dodatkowy wymiar: stan sesji. Klient jest podłączony dłużej, więc trzeba wiedzieć, kto jest kim i jakie ma uprawnienia. W NestJS reprezentuje to zwykle @WebSocketGateway() wraz z obsługą zdarzeń.

Najczęściej zadawane pytania (FAQ)

Po co mi NestJS, skoro mam już działający projekt w Express?

Express świetnie się sprawdza przy małych i średnich projektach, ale wraz ze wzrostem kodu zaczynają się typowe problemy: gigantyczny plik app.js, brak jasnych granic między modułami, trudne testowanie i powtarzające się „szybkie hacki”. NestJS dokłada do tego samego runtime’u Node.js warstwy i konwencje znane z dojrzałych frameworków – moduły, serwisy, kontrolery, wstrzykiwanie zależności.

Efekt jest taki, że aplikacja przestaje być zlepkiem plików, a staje się uporządkowanym systemem. Nowi programiści szybciej rozumieją projekt, łatwiej zlokalizować błędy, a testy nie wymagają podnoszenia całego serwera HTTP przy każdej zmianie. NestJS nadal może używać Expressa „pod spodem”, więc nie tracisz nic z ekosystemu, tylko zyskujesz strukturę.

Czym różni się NestJS od „gołego” Node.js i prostych skryptów?

Skrypt node’owy to zwykle pojedynczy plik, kilka funkcji i jedno wywołanie API. Tam architektura prawie nie ma znaczenia, bo całość da się ogarnąć jednym rzutem oka. Aplikacja biznesowa to zupełnie inna skala: wiele zespołów, integracje, zmienne reguły biznesowe, monitoring, bezpieczeństwo, testy.

NestJS jest zaprojektowany właśnie do takich złożonych systemów. Narzuca podział na warstwy (transport, logika biznesowa, dostęp do danych), jasno określa odpowiedzialności i daje miejsce na rzeczy przekrojowe: autoryzację, logowanie, walidację. Dzięki temu kod rośnie w szerz w przewidywalny sposób, zamiast w gąszcz przypadkowych zależności i „tymczasowych” rozwiązań.

Jak wygląda podstawowa architektura aplikacji w NestJS?

Intuicyjnie można myśleć o NestJS jak o „Angularze na backendzie”: aplikacja składa się z modułów, a w nich z kontrolerów i serwisów. Moduł (Module) grupuje logicznie powiązane elementy, np. UsersModule czy AuthModule. Kontroler przyjmuje żądania HTTP lub WebSocket, a serwis zawiera faktyczną logikę biznesową.

Najważniejsze cegiełki to: moduły, kontrolery, serwisy oraz providery (czyli klasy zarządzane przez kontener NestJS, np. repozytoria czy adaptery do zewnętrznych systemów). Całość łączy AppModule, który jest punktem wejścia. Taka konstrukcja od początku wymusza porządek – wiadomo, gdzie trafić z nową funkcją i gdzie szukać istniejącej.

Co daje w praktyce Dependency Injection w NestJS?

Dependency Injection (wstrzykiwanie zależności) oznacza, że klasy nie tworzą swoich zależności samodzielnie przez new, tylko dostają je z zewnątrz. W NestJS robi to za nas kontener IoC: serwis dostaje w konstruktorze np. repozytorium, a nie tworzy go sam. Dzięki temu znikają setki miejsc, w których ręcznie buduje się obiekty.

W praktyce ułatwia to kilka rzeczy naraz: testy (łatwo wstrzyknąć mock zamiast prawdziwej bazy), konfigurację (różne implementacje w zależności od środowiska) oraz kontrolę nad zależnościami między modułami. W dużych projektach to różnica między „teoretycznie da się przetestować” a „testy rzeczywiście są szybkie i używane na co dzień”.

Jak NestJS pomaga uniknąć chaosu w kodzie i „callback hell” na wyższym poziomie?

Dzisiejszy Node.js nie cierpi już tak bardzo na „callback hell” dzięki async/await, ale bałagan przeniósł się poziom wyżej: do pomieszanych odpowiedzialności. W wielu projektach router HTTP wykonuje logikę domenową, walidację, dostęp do bazy i obsługę błędów w jednym pliku. Takiego kodu po roku nikt nie chce dotykać.

NestJS rozdziela te światy: kontroler obsługuje tylko transport, serwis logikę, repozytorium dane. Dodatkowe mechanizmy – guards, pipes, interceptors, filters – pozwalają wpiąć się w przepływ żądania w określonych miejscach: osobno dla autoryzacji, walidacji, logowania czy modyfikacji odpowiedzi. Dzięki temu, zamiast „losowych ifów” porozrzucanych po kontrolerach, masz wyraźnie oznaczone punkty, które robią konkretną rzecz.

Czy NestJS nadaje się do małych projektów, czy dopiero do dużych systemów?

Architektura NestJS jest projektowana z myślą o rosnących systemach, ale nie oznacza to, że małe projekty są wykluczone. Można zacząć od jednego AppModule, jednego modułu funkcjonalnego i kilku serwisów, a potem stopniowo rozbijać to na mniejsze moduły, gdy projekt zaczyna się rozrastać.

Plusem jest to, że od pierwszego dnia używasz tych samych wzorców, których później będziesz potrzebować przy większej skali. Dla małego API może to być minimalnie „cięższe” niż czysty Express, ale cena jest niewielka w porównaniu z oszczędzonym czasem, gdy projekt urośnie i trzeba będzie coś zmienić bez wywracania całości.

Jak NestJS wpływa na pracę zespołu i onboarding nowych programistów?

NestJS daje zespołowi wspólny słownik i przewidywalną strukturę. Nowa osoba nie musi zgadywać, gdzie wrzucić walidację, logikę domenową czy integrację z zewnętrznym API – wie, że są do tego konkretne miejsca: pipes, serwisy, moduły integracyjne. Zmniejsza to liczbę „autorskich” rozwiązań, które zna tylko jedna osoba w zespole.

W praktyce przekłada się to na szybszy onboarding, mniej konfliktów przy code review i mniej dublującego się kodu. Kiedy każdy wie, gdzie coś dodać i gdzie to później znaleźć, praca nad funkcjami jest bardziej przewidywalna, a zmiany w jednym module rzadziej psują coś w innym.

Bibliografia

  • NestJS Documentation. NestJS – Oficjalna dokumentacja architektury, modułów, DI i testowania w NestJS
  • Node.js v22.x Documentation. OpenJS Foundation – Opis modelu asynchronicznego, event loop, callbacki, Promises w Node.js
  • Domain-Driven Design: Tackling Complexity in the Heart of Software. Addison-Wesley (2003) – Wzorce logiki domenowej i separacji warstw w systemach biznesowych
  • Clean Architecture: A Craftsman’s Guide to Software Structure and Design. Pearson (2017) – Zasady separacji odpowiedzialności, granice modułów, testowalność
  • Inversion of Control Containers and the Dependency Injection pattern. Martin Fowler (2004) – Klasyczny esej wyjaśniający DI i kontenery IoC
  • Angular Developer Guide. Google – Moduły, serwisy, dekoratory – kontekst inspiracji architektury NestJS
  • Twelve-Factor App. Heroku (2011) – Dobre praktyki konfiguracji, podziału odpowiedzialności i środowisk