NestJS per chi viene da Express e Angular

NestJS per chi viene da Express e Angular

TicketHub è scritto con NestJS. Se avete usato Express per il backend e Angular per il frontend, conoscete già quasi tutti i pezzi: questa nota vi fa vedere dove stanno.

Cos'è NestJS

NestJS è un framework per backend in Node.js. Sotto c'è Express (nel nostro progetto, ma può usare anche Fastify): le richieste HTTP le gestisce ancora lui. Sopra, NestJS aggiunge una struttura precisa:

  • i moduli raggruppano le parti dell'applicazione che stanno insieme;
  • i controller ricevono le richieste HTTP e restituiscono le risposte;
  • i provider (service, repository e simili) contengono la logica e i dati;
  • un container di dependency injection crea gli oggetti e li passa a chi ne ha bisogno.

Chi ha lavorato con Angular ritrova decorator, moduli e iniezione nel costruttore. Le idee sono le stesse, cambia il lato dell'applicazione.

Da Express a NestJS

In Express In NestJS
app.get('/events/:id', handler) un metodo con @Get(':id') dentro una classe con @Controller('events')
req.params.id un parametro @Param('id') id: string
req.body un parametro @Body() body: CreateOrderBody
req.headers['x-user-id'] un parametro @Headers('x-user-id') userId?: string
res.status(404).json(...) throw new NotFoundException('...')
res.json(oggetto) return oggetto: NestJS lo trasforma in JSON
app.use(middleware) middleware, guard o interceptor, a seconda di cosa deve fare
un oggetto creato a mano e importato ovunque un provider registrato in un modulo e iniettato dove serve

La stessa rotta, scritta nei due modi:

import express from 'express';

const app = express();
app.use(express.json());

app.get('/events/:id', (req, res) => {
  const event = eventsRepository.findById(req.params.id);
  if (!event) return res.status(404).json({ message: 'evento non trovato' });
  res.json(event);
});
import { Controller, Get, NotFoundException, Param } from '@nestjs/common';
import { EventsRepository } from '../data/events.repository';

@Controller('events')
export class EventsController {
  constructor(private readonly events: EventsRepository) {}

  @Get(':id')
  detail(@Param('id') id: string) {
    const event = this.events.findById(id);
    if (!event) throw new NotFoundException('evento non trovato');
    return event;
  }
}

Due differenze da notare. In Express eventsRepository arriva da un import e il codice dà per scontato che esista. In NestJS lo riceve il costruttore, e a crearlo ci pensa il container. Poi, invece di scrivere la risposta a mano, il metodo restituisce un valore o lancia un'eccezione, e NestJS si occupa di trasformarli in una risposta HTTP con il codice giusto.

Da Angular a NestJS

In Angular In NestJS
@Injectable({ providedIn: 'root' }) @Injectable() più la registrazione nei providers di un modulo
@NgModule({ declarations, imports, providers, exports }) @Module({ controllers, imports, providers, exports })
iniezione nel costruttore iniezione nel costruttore, identica
componenti che mostrano una pagina controller che rispondono a una rotta
HttpClient per chiamare un'API sono le API che altri chiamano
// Angular
@Injectable({ providedIn: 'root' })
export class CartService {
  constructor(private readonly http: HttpClient) {}
}

// NestJS
@Injectable()
export class MailerService {
  send(to: string, subject: string, body: string): void {
    console.log(`[mail] a ${to}: ${subject}`);
  }
}

In Angular providedIn: 'root' basta a rendere il service disponibile in tutta l'app. In NestJS il provider va dichiarato in un modulo, e gli altri moduli lo vedono solo se quel modulo lo esporta.

Moduli

Un modulo è una classe con il decorator @Module, che dichiara quattro elenchi:

  • controllers: i controller che appartengono al modulo;
  • providers: i provider che il modulo crea e può iniettare al suo interno;
  • exports: i provider che il modulo mette a disposizione di chi lo importa;
  • imports: gli altri moduli di cui ha bisogno.

Un provider è privato del suo modulo finché non compare in exports. Chi vuole usarlo deve importare il modulo che lo esporta.

import { Module } from '@nestjs/common';
import { MailerService } from './mailer.service';

@Module({
  providers: [MailerService],
  exports: [MailerService],
})
export class NotificationsModule {}

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

OrdersController può ricevere MailerService nel costruttore perché OrdersModule importa NotificationsModule, che lo esporta.

@Global()

Un modulo con il decorator @Global() rende i suoi provider visibili ovunque, senza bisogno di importarlo. È comodo, ma nasconde le dipendenze tra le parti dell'applicazione: guardando un modulo non si capisce più cosa usa. Va usato con prudenza.

Provider e dependency injection

Quando NestJS deve creare un controller, legge i tipi dei parametri del costruttore, cerca un provider registrato per ciascun tipo e gli passa le istanze. Se una di quelle istanze ha a sua volta delle dipendenze, il container risolve anche quelle. Voi scrivete solo il costruttore.

Tre cose da sapere:

  • Una sola istanza per provider. Per default ogni provider viene creato una volta sola e condiviso in tutta l'applicazione, e il container sa quando crearlo.
  • Il costruttore dice tutto. Le dipendenze di una classe sono quelle del costruttore. Se una classe usa qualcosa che non compare lì, quella è una dipendenza nascosta.
  • Nei test si sostituisce. Siccome è il container a creare gli oggetti, nei test si può chiedergli di usare un'altra implementazione.
const moduleRef = await Test.createTestingModule({ imports: [AppModule] })
  .overrideProvider(MailerService)
  .useValue({ send: () => {} })
  .compile();

In questo test l'app intera funziona come sempre, ma al posto del MailerService vero c'è un oggetto che non manda niente.

Il percorso di una richiesta

Una richiesta HTTP attraversa questi passaggi prima di arrivare al vostro metodo, e poi torna indietro:

  1. Middleware: funzioni come quelle di Express, eseguite per prime (log, CORS, parsing del body).
  2. Guard: decidono se la richiesta può andare avanti, per esempio in base all'utente.
  3. Interceptor, prima: possono fare qualcosa prima della chiamata al metodo.
  4. Pipe: trasformano e validano i parametri (per esempio una stringa che deve diventare un numero).
  5. Il metodo del controller: il vostro codice.
  6. Interceptor, dopo: possono modificare la risposta o misurare quanto ci è voluto.
  7. Filtri delle eccezioni: trasformano un'eccezione in una risposta HTTP.

Per ora basta sapere che esistono. Guard e interceptor torneranno durante il corso.

TypeScript nel progetto

TicketHub è un progetto NestJS 12 generato con la CLI (nest new) scegliendo CommonJS: import relativi senza estensione, test con Jest, lint con oxlint. C'è una regola da conoscere.

Tipi e classi si importano in modo diverso. Interfacce e alias di tipo, che a runtime non esistono, si importano con import type. Le classi che arrivano dal costruttore si importano con un import normale, perché il container ha bisogno della classe vera per sapere cosa iniettare.

import type { Order } from '../data/types';
import { OrdersRepository } from '../data/orders.repository';

Errori comuni

Messaggio o sintomo Causa probabile
Nest can't resolve dependencies of the OrdersController (?, ...) Il provider al posto del ? non è registrato, oppure il suo modulo non lo esporta, oppure la classe è stata importata con import type
A type referenced in a decorated signature must be imported with 'import type' Un'interfaccia usata nella firma di un metodo con decorator è importata con un import normale
La rotta risponde 404 Il controller non è nei controllers del suo modulo, o il modulo non è importato in AppModule

Riferimenti

  • Documentazione ufficiale: https://docs.nestjs.com, in particolare le sezioni Controllers, Providers, Modules e Request lifecycle.
  • Il codice di TicketHub, cartella src/: ogni modulo è un esempio di quello che c'è in questa nota.