Kurs NestJS · Moduł 1: Podstawy NestJS

Serwisy i providery - specjaliści Imperium

5 min czytania
W tej lekcji6

Centurion z poprzedniej lekcji przyjmuje rozkazy i przekazuje je dalej. Ale komu? Gdyby sam liczył żołd, sprawdzał zapasy i pisał do skarbca, przestałby być centurionem, a stałby się jednoosobową kohortą - i tę samą pracę trzeba by powtórzyć w każdym innym oddziale.

Legion ma na to specjalistów: kowala, medyka, kwatermistrza. Każdy zna jedną rzecz i robi ją dla wszystkich. W NestJS takim specjalistą jest serwis.

Dekorator, który czyni specjalistę

Serwis to zwykła klasa z jednym dekoratorem:

1@Injectable()
2export class LegionService {
3  private legions: Legion[] = [];
4  private nextId = 1;
5
6  findAll(): Legion[] {
7    return this.legions;
8  }
9
10  findById(id: number): Legion {
11    return this.legions.find((legion) => legion.id === id);
12  }
13
14  recruit(data: CreateLegionDto): Legion {
15    const legion = { id: this.nextId++, ...data };
16    this.legions.push(legion);
17    return legion;
18  }
19
20  dismiss(id: number): void {
21    this.legions = this.legions.filter((legion) => legion.id !== id);
22  }
23}

@Injectable() oznacza klasę jako provider, który może być wstrzykiwany. To jedyne, co robi - i warto to odgraniczyć od sąsiadów, bo dekoratory w NestJS wyglądają podobnie: kontroler oznacza @Controller(), moduł @Module(), a middleware to klasa implementująca NestMiddleware. @Injectable() nie czyni z klasy żadnego z nich - mówi tylko, że NestJS ma umieć ją komuś podać.

Cztery metody powyżej to typowy zestaw: findAll() zwraca wszystko, findById(id) jedną pozycję, recruit(data) tworzy nową, dismiss(id) usuwa. Nazwy opisują czynność w dziedzinie, nie czasownik HTTP - bo serwis nie wie, że istnieje jakiś HTTP.

Wstrzyknięcie do kontrolera

Gotowego specjalistę kontroler dostaje przez konstruktor:

1@Controller('legions')
2export class LegionController {
3  constructor(private readonly legionService: LegionService) {}
4
5  @Get()
6  findAll() {
7    return this.legionService.findAll();
8  }
9}

Kolejność zapisu jest stała: constructor(, potem private readonly legionService: , dalej LegionService, na końcu ) {}.

Ten skrót zasługuje na wyjaśnienie, bo wygląda dziwnie. Słowo private przed parametrem konstruktora to skrót TypeScriptu: deklaruje pole klasy i przypisuje mu wartość w jednym miejscu. Bez niego musiałbyś napisać private legionService: LegionService osobno i this.legionService = legionService w ciele konstruktora. readonly dokłada gwarancję, że nikt tego pola później nie podmieni.

Sam nie tworzysz instancji przez new - robi to NestJS. Patrzy na typ parametru, znajduje pasujący provider i podaje go gotowego. To jest wstrzykiwanie zależności: klasa mówi, czego potrzebuje, a nie skąd to wziąć.

Serwis w serwisie

Specjaliści korzystają nawzajem ze swoich usług tak samo. Droga ma cztery kroki i warto ją znać w kolejności:

  1. Dodanie @Injectable() do nowego serwisu.
  2. Import modułu z eksportowanym serwisem - jeśli pochodzi z innego modułu.
  3. Wstrzyknięcie serwisu przez konstruktor.
  4. Wywołanie metody wstrzykniętego serwisu.
1@Injectable()
2export class PayrollService {
3  constructor(private readonly legionService: LegionService) {}
4
5  calculateTotalPay(): number {
6    const legions = this.legionService.findAll();
7
8    return legions.reduce((sum, legion) => sum + legion.pay, 0);
9  }
10}

Krok drugi jest tym, o którym najczęściej się zapomina. Provider zadeklarowany w module nie jest automatycznie widoczny gdzie indziej - musi być wypisany w exports swojego modułu, a moduł, który go potrzebuje, musi go zaimportować. Pominięcie tego daje przy starcie błąd o nierozwiązanej zależności.

Oddzielenie dostępu do danych

Nasz LegionService trzyma dane w tablicy. Gdy przyjdzie prawdziwa baza, każda metoda będzie musiała się zmienić - a razem z nią wszystko, co jej używa.

Rozwiązaniem jest Repository Pattern - wzorzec, który oddziela logikę dostępu do danych od logiki biznesowej. Serwis mówi wtedy, co chce zrobić, a repozytorium wie, jak dane pobrać. Zmiana tablicy na bazę dotyka wyłącznie repozytorium.

Nie myl go z innymi wzorcami o podobnie brzmiących nazwach: Singleton pilnuje, by istniała jedna instancja, Observer rozsyła powiadomienia o zdarzeniach, Proxy podstawia zastępczy obiekt w miejsce prawdziwego. Tylko Repository dotyczy rozdzielenia danych od logiki.

Provider z fabryki

Zwykle wystarczy podać klasę i NestJS sam ją utworzy. Czasem jednak instancja zależy od czegoś, co znasz dopiero przy starcie - wtedy podajesz przepis zamiast klasy:

1@Module({
2  providers: [
3    {
4      provide: 'STORAGE_SERVICE',
5      useFactory: (config: ConfigService) => {
6        return config.get('STORAGE') === 'cloud'
7          ? new CloudStorageService()
8          : new LocalStorageService();
9      },
10      inject: [ConfigService],
11    },
12  ],
13})
14export class StorageModule {}

Trzy pola opisują ten przepis. provide to nazwa, pod którą provider będzie dostępny. useFactory to funkcja tworząca instancję - tu wybiera między dwiema implementacjami zależnie od konfiguracji. inject wylicza, czego potrzebuje sama fabryka; NestJS poda jej to jako argumenty, w tej samej kolejności.

Zauważ, po co to całe zamieszanie: reszta aplikacji prosi o 'STORAGE_SERVICE' i nie wie, którą implementację dostała. Podmiana chmury na dysk lokalny to zmiana w jednym miejscu - a nie w każdym serwisie, który zapisuje pliki.

Podsumowanie

Specjaliści na miejscu, każdy zna swoją rzecz:

  • serwis trzyma logikę, kontroler tylko kieruje żądania,
  • @Injectable() oznacza klasę jako provider, który może być wstrzykiwany - nie czyni jej kontrolerem, modułem ani middleware,
  • nazwy metod opisują czynność w dziedzinie (findAll, findById, recruit, dismiss), nie czasowniki HTTP,
  • wstrzyknięcie w kolejności: constructor(, private readonly nazwa: , TypSerwisu, ) {},
  • private przed parametrem konstruktora deklaruje pole i przypisuje wartość w jednym miejscu,
  • instancji nie tworzysz przez new - NestJS dobiera provider po typie,
  • serwis w serwisie: @Injectable() → import modułu z exports → wstrzyknięcie przez konstruktor → wywołanie metody,
  • provider nie jest widoczny poza swoim modułem, dopóki nie trafi do exports,
  • Repository Pattern oddziela logikę dostępu do danych od logiki biznesowej - Singleton, Observer i Proxy rozwiązują zupełnie inne problemy,
  • provider z fabryki opisują provide (nazwa), useFactory (funkcja tworząca) i inject (zależności fabryki),
  • dzięki fabryce reszta aplikacji nie wie, którą implementację dostała.

W następnej lekcji zbudujesz z tych elementów pierwsze REST API - komplet operacji CRUD na jednym zasobie. A na razie zapamiętaj: kontroler wie kto ma coś zrobić, serwis wie jak - i tylko dzięki temu tę samą wiedzę wywołasz z dowolnego miejsca aplikacji.

Kod do tej lekcji: src/legion.service.ts
1// Serwisy i Providers - Specjaliści w Imperium
2import { Injectable, NotFoundException } from '@nestjs/common';
3
4console.log("Serwisy NestJS - specjaliści wykonujący prawdziwą pracę!");
5
6// ===========================================
7// 1. Podstawowy serwis z @Injectable
8// ===========================================
9
10interface Legionary {
11  id: number;
12  name: string;
13  rank: string;
14  experience: number;
15  legion: string;
16}
17
18@Injectable()
19export class LegionService {
20  private legionaries: Legionary[] = [
21    { id: 1, name: 'Marcus Aurelius', rank: 'Centurio', experience: 10, legion: 'Legio X' },
22    { id: 2, name: 'Julia Domna', rank: 'Optio', experience: 8, legion: 'Legio X' },
23    { id: 3, name: 'Titus Flavius', rank: 'Miles', experience: 3, legion: 'Legio III' },
24  ];
25
26  findAll(): Legionary[] {
27    return this.legionaries;
28  }
29
30  findById(id: number): Legionary {
31    const legionary = this.legionaries.find(l => l.id === id);
32    if (!legionary) {
33      throw new NotFoundException('Legionary non inventus!');
34    }
35    return legionary;
36  }
37
38  create(data: Omit<Legionary, 'id'>): Legionary {
39    const newLegionary: Legionary = {
40      id: Math.max(...this.legionaries.map(l => l.id)) + 1,
41      ...data,
42    };
43    this.legionaries.push(newLegionary);
44    return newLegionary;
45  }
46
47  update(id: number, data: Partial<Legionary>): Legionary {
48    const legionary = this.findById(id);
49    Object.assign(legionary, data);
50    return legionary;
51  }
52
53  remove(id: number): void {
54    const index = this.legionaries.findIndex(l => l.id === id);
55    if (index === -1) throw new NotFoundException('Legionary non inventus!');
56    this.legionaries.splice(index, 1);
57  }
58
59  // Logika biznesowa - obliczanie siły legionu
60  calculateLegionStrength(legionName: string): number {
61    const soldiers = this.legionaries.filter(l => l.legion === legionName);
62    return soldiers.reduce((sum, s) => sum + s.experience * 10, 0);
63  }
64}
65
66// ===========================================
67// 2. Serwis z wstrzykniętym innym serwisem
68// ===========================================
69
70@Injectable()
71export class RankService {
72  private rankHierarchy: Record<string, number> = {
73    Miles: 1,
74    Optio: 2,
75    Centurio: 3,
76    Tribunus: 4,
77    Legatus: 5,
78  };
79
80  getRankLevel(rank: string): number {
81    return this.rankHierarchy[rank] || 0;
82  }
83
84  canPromote(currentRank: string, targetRank: string): boolean {
85    return this.getRankLevel(targetRank) === this.getRankLevel(currentRank) + 1;
86  }
87}
88
89@Injectable()
90export class PromotionService {
91  constructor(
92    private readonly legionService: LegionService,
93    private readonly rankService: RankService,
94  ) {}
95
96  promote(legionaryId: number, newRank: string): Legionary {
97    const legionary = this.legionService.findById(legionaryId);
98    if (!this.rankService.canPromote(legionary.rank, newRank)) {
99      throw new Error('Awans niemożliwy - niewłaściwa ranga!');
100    }
101    return this.legionService.update(legionaryId, { rank: newRank });
102  }
103}
104
105console.log("\n=== PODSUMOWANIE SERWISÓW ===");
106console.log("@Injectable() - oznacza klasę jako provider");
107console.log("Serwisy zawierają logikę biznesową");
108console.log("DI pozwala wstrzykiwać serwisy do siebie nawzajem");
109console.log("Kontrolery delegują pracę do serwisów");
110

Sprawdź się

Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.

  1. 1. Do czego służy dekorator @Injectable() w NestJS?

  2. 2. Jaki wzorzec projektowy oddziela logikę dostępu do danych od logiki biznesowej?

Zadania praktyczne w grze

  • Układanie w pionie

    Uporządkuj kroki tworzenia serwisu współpracującego z innym serwisem:

  • Edytor kodu

    Napisz serwis LegionService z prywatną tablicą legionistów i metodami: findAll() zwraca tablicę, findById(id) zwraca legionistę o tym id (albo undefined), recruit(data) dodaje legionistę z nowym, niepowtarzalnym id i go zwraca, dismiss(id) usuwa legionistę o tym id.

  • Klikanie w kolejności

    Ułóż elementy wstrzykiwania serwisu przez konstruktor kontrolera:

  • Edytor kodu

    Zdefiniuj stałą storageProvider z provide: 'STORAGE_SERVICE', inject: [ConfigService] i useFactory, która dostaje ConfigService i zwraca new CloudStorageService(), gdy config.get('STORAGE') === 'cloud', a w przeciwnym razie new LocalStorageService(). Obie klasy napisz sam, a storageProvider dodaj do providers modułu StorageModule.

Przydatne artykuły