NestJS e TypeORM nel 2026: migrazioni, relazioni e domande da colloquio

Gestire un database relazionale in un progetto NestJS richiede strumenti solidi. Questa guida analizza configurazione TypeORM, migrazioni, relazioni e domande da colloquio.

NestJS e TypeORM nel 2026: migrazioni, relazioni e domande da colloquio

Gestire un database relazionale in un progetto NestJS richiede strumenti solidi e convenzioni chiare. TypeORM resta una delle soluzioni più adottate nell'ecosistema Node.js per mappare tabelle PostgreSQL a classi TypeScript, e la sua integrazione con NestJS risulta particolarmente naturale grazie al sistema di moduli e dependency injection del framework. Questa guida analizza nel dettaglio la configurazione di TypeORM, il workflow delle migrazioni, la gestione delle relazioni tra entità e le transazioni, chiudendo con le domande tecniche più frequenti nei colloqui backend.

Prerequisiti

Per seguire gli esempi servono Node.js 20+, un'istanza PostgreSQL attiva e un progetto NestJS inizializzato con @nestjs/cli. Tutti i frammenti di codice utilizzano TypeORM con il pattern DataSource introdotto nelle versioni recenti.

Configurazione di TypeORM con NestJS

Il primo passo consiste nel creare un file di configurazione DataSource dedicato. Questo file viene utilizzato dalla CLI di TypeORM per generare e eseguire migrazioni, separatamente dal contesto NestJS.

src/config/typeorm.config.tstypescript
import { DataSource } from 'typeorm';
import { config } from 'dotenv';

config(); // Load .env variables

export default new DataSource({
  type: 'postgres',
  host: process.env.DB_HOST,
  port: parseInt(process.env.DB_PORT || '5432'),
  username: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  database: process.env.DB_NAME,
  entities: ['src/**/*.entity.ts'],
  migrations: ['src/migrations/*.ts'],
  synchronize: false,
});

Il parametro synchronize: false merita attenzione particolare. Quando impostato su true, TypeORM modifica automaticamente lo schema del database ad ogni avvio dell'applicazione per allinearlo alle entità definite nel codice. In ambiente di sviluppo può sembrare comodo, ma in produzione rappresenta un rischio concreto: una modifica accidentale a un'entità potrebbe cancellare colonne o tabelle contenenti dati reali. La regola è semplice: le migrazioni esplicite sono l'unico metodo sicuro per modificare lo schema.

La registrazione nel modulo principale di NestJS avviene tramite TypeOrmModule.forRootAsync, che permette di iniettare il ConfigService per leggere le variabili d'ambiente in modo type-safe.

src/app.module.tstypescript
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule, ConfigService } from '@nestjs/config';

@Module({
  imports: [
    ConfigModule.forRoot({ isGlobal: true }),
    TypeOrmModule.forRootAsync({
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        type: 'postgres',
        host: config.get('DB_HOST'),
        port: config.getOrThrow<number>('DB_PORT'),
        username: config.get('DB_USER'),
        password: config.get('DB_PASSWORD'),
        database: config.get('DB_NAME'),
        autoLoadEntities: true, // Auto-register entities from feature modules
        synchronize: false,     // Always false in production
      }),
    }),
  ],
})
export class AppModule {}

L'opzione autoLoadEntities: true evita di elencare manualmente ogni entità nella configurazione globale. Ogni feature module che registra entità tramite TypeOrmModule.forFeature() le rende automaticamente disponibili alla connessione.

Workflow delle migrazioni

Le migrazioni rappresentano il sistema di versioning dello schema del database. Ogni migrazione è un file TypeScript con due metodi: up (applica le modifiche) e down (le annulla). La CLI di TypeORM confronta lo stato attuale del database con le entità definite nel codice e genera automaticamente il codice SQL necessario.

Gli script npm standard per gestire il ciclo di vita delle migrazioni sono tre.

package.json (scripts section)json
{
  "migration:generate": "typeorm-ts-node-commonjs migration:generate src/migrations/$npm_config_name -d src/config/typeorm.config.ts",
  "migration:run": "typeorm-ts-node-commonjs migration:run -d src/config/typeorm.config.ts",
  "migration:revert": "typeorm-ts-node-commonjs migration:revert -d src/config/typeorm.config.ts"
}

Il flusso operativo segue tre fasi. Prima si modifica un'entità nel codice (ad esempio aggiungendo una colonna). Poi si esegue npm run migration:generate --name=AddPhoneToUser per generare il file di migrazione con le istruzioni SQL. Infine, npm run migration:run applica la migrazione al database. Se qualcosa va storto, migration:revert annulla l'ultima migrazione eseguita.

Controllare le migrazioni generate

Una colonna rinominata genera un DROP + ADD invece di un ALTER RENAME, con conseguente perdita dei dati esistenti. Le istruzioni SQL nel metodo up() vanno sempre verificate e, se necessario, corrette manualmente.

Un aspetto spesso trascurato riguarda il versionamento: i file di migrazione vanno sempre committati nel repository. Rappresentano la storia completa dello schema e permettono a qualsiasi sviluppatore del team di ricostruire il database da zero eseguendo tutte le migrazioni in sequenza.

Definire le relazioni tra entità

La modellazione delle relazioni è il cuore di qualsiasi ORM. TypeORM supporta tre tipi principali: OneToMany/ManyToOne, OneToOne e ManyToMany. Ogni tipo ha implicazioni specifiche sulla struttura delle tabelle e sulle query generate.

OneToMany e ManyToOne

La relazione più comune in un'applicazione e-commerce è quella tra utenti e ordini: ogni utente può avere molti ordini, ogni ordine appartiene a un solo utente.

src/entities/user.entity.tstypescript
import { Entity, PrimaryGeneratedColumn, Column, OneToMany, CreateDateColumn } from 'typeorm';
import { Order } from './order.entity';

@Entity('users')
export class User {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ unique: true })
  email: string;

  @Column()
  name: string;

  @OneToMany(() => Order, (order) => order.user)
  orders: Order[];  // No DB column created here; relation lives on the Order side

  @CreateDateColumn()
  createdAt: Date;
}
src/entities/order.entity.tstypescript
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
import { User } from './user.entity';

@Entity('orders')
export class Order {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column('decimal', { precision: 10, scale: 2 })
  total: number;

  @Column({ default: 'pending' })
  status: string;

  @ManyToOne(() => User, (user) => user.orders, { onDelete: 'CASCADE' })
  @JoinColumn({ name: 'user_id' })  // Explicit FK column name
  user: User;
}

Due dettagli fondamentali. Il decoratore @OneToMany sulla classe User non crea alcuna colonna nel database: la foreign key risiede sempre sul lato @ManyToOne. Il decoratore @JoinColumn è facoltativo ma consigliato, perché permette di specificare il nome esatto della colonna FK nel database, evitando nomi generati automaticamente che potrebbero non rispettare le convenzioni del team.

L'opzione onDelete: 'CASCADE' indica che l'eliminazione di un utente comporta l'eliminazione automatica di tutti i suoi ordini a livello di database. Una scelta da valutare caso per caso: per dati finanziari potrebbe essere preferibile SET NULL o un soft delete.

ManyToMany

Le relazioni molti-a-molti richiedono una tabella di giunzione (junction table). TypeORM la gestisce automaticamente tramite il decoratore @JoinTable.

src/entities/product.entity.tstypescript
import { Entity, PrimaryGeneratedColumn, Column, ManyToMany, JoinTable } from 'typeorm';
import { Category } from './category.entity';

@Entity('products')
export class Product {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column()
  name: string;

  @Column('decimal', { precision: 10, scale: 2 })
  price: number;

  @ManyToMany(() => Category, (category) => category.products)
  @JoinTable({ name: 'product_categories' })  // Owns the junction table
  categories: Category[];
}

Il decoratore @JoinTable va posizionato su una sola delle due entità coinvolte: quella che "possiede" la relazione. Quando la tabella di giunzione deve contenere colonne aggiuntive (ad esempio una data di associazione o un ordinamento), la strategia cambia: si crea un'entità esplicita per la tabella intermedia con due relazioni @ManyToOne.

Pronto a superare i tuoi colloqui su Node.js / NestJS?

Pratica con i nostri simulatori interattivi, flashcards e test tecnici.

Registrazione nei moduli e pattern Repository

NestJS organizza il codice in feature module, e ogni modulo dichiara le entità che utilizza tramite TypeOrmModule.forFeature(). Questo pattern registra automaticamente i repository corrispondenti nel container di dependency injection.

src/orders/orders.module.tstypescript
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Order } from '../entities/order.entity';
import { OrdersService } from './orders.service';
import { OrdersController } from './orders.controller';

@Module({
  imports: [TypeOrmModule.forFeature([Order])],
  providers: [OrdersService],
  controllers: [OrdersController],
})
export class OrdersModule {}

Il servizio riceve il repository tramite il decoratore @InjectRepository e lo utilizza per tutte le operazioni di lettura e scrittura. TypeORM offre due approcci per le query: il metodo find con le sue opzioni dichiarative, ideale per query semplici, e il QueryBuilder per query complesse che richiedono join, sottoquery o condizioni dinamiche.

src/orders/orders.service.tstypescript
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Order } from '../entities/order.entity';

@Injectable()
export class OrdersService {
  constructor(
    @InjectRepository(Order)
    private readonly orderRepo: Repository<Order>,
  ) {}

  async findByUser(userId: string): Promise<Order[]> {
    return this.orderRepo.find({
      where: { user: { id: userId } },
      relations: ['user'],          // Eager-load the user relation
      order: { createdAt: 'DESC' }, // Most recent first
    });
  }

  async findWithFilters(status: string, minTotal: number): Promise<Order[]> {
    // QueryBuilder for complex queries
    return this.orderRepo
      .createQueryBuilder('order')
      .leftJoinAndSelect('order.user', 'user')
      .where('order.status = :status', { status })
      .andWhere('order.total >= :minTotal', { minTotal })
      .orderBy('order.total', 'DESC')
      .getMany();
  }
}

Un punto critico riguarda il caricamento delle relazioni. TypeORM adotta il lazy loading per impostazione predefinita: se non si specifica relations: ['user'] nella query, il campo user dell'ordine sarà undefined. Esiste anche la possibilità di attivare l'eager loading a livello di entità con { eager: true }, ma è generalmente sconsigliato perché carica la relazione in ogni query, anche quando non necessaria, con un impatto significativo sulle prestazioni.

Transazioni per operazioni atomiche

Quando un'operazione coinvolge più tabelle e la coerenza dei dati è essenziale, le transazioni garantiscono che tutte le scritture vengano applicate o nessuna. TypeORM espone il metodo DataSource.transaction() che fornisce un EntityManager transazionale.

src/orders/orders.service.ts (additional method)typescript
async createOrderWithItems(
  userId: string,
  items: { productId: string; quantity: number }[],
  dataSource: DataSource,
): Promise<Order> {
  return dataSource.transaction(async (manager) => {
    // All operations use the transactional manager
    const order = manager.create(Order, {
      user: { id: userId },
      total: 0,
      status: 'pending',
    });
    const savedOrder = await manager.save(order);

    let total = 0;
    for (const item of items) {
      const product = await manager.findOneByOrFail(Product, { id: item.productId });
      total += product.price * item.quantity;

      await manager.save(OrderItem, {
        order: savedOrder,
        product,
        quantity: item.quantity,
        unitPrice: product.price,
      });
    }

    savedOrder.total = total;
    return manager.save(savedOrder);
  });
}

L'errore più comune con le transazioni è mescolare il manager transazionale con il repository standard. Tutte le operazioni all'interno della callback devono passare attraverso il parametro manager; utilizzare this.orderRepo vanificherebbe l'isolamento transazionale perché opererebbe su una connessione separata.

QueryRunner vs transaction()

DataSource.transaction() è adatto per operazioni multi-step semplici. Il QueryRunner offre controllo manuale su commit, rollback e savepoint, utile per rollback parziali nelle elaborazioni batch.

Domande frequenti nei colloqui tecnici

I colloqui per posizioni backend che coinvolgono NestJS e TypeORM tendono a concentrarsi su concetti pratici piuttosto che su nozioni teoriche. Ecco le domande più ricorrenti.

Perché synchronize: true non deve essere usato in produzione?

Perché sincronizza automaticamente lo schema ad ogni avvio, rischiando di eliminare colonne, tabelle o dati senza possibilità di rollback. Le migrazioni esplicite sono l'unico approccio sicuro per ambienti di produzione.

Qual è la differenza tra @JoinColumn e @JoinTable?

@JoinColumn si usa nelle relazioni ManyToOne e OneToOne per definire la colonna foreign key sulla tabella corrente. @JoinTable si usa esclusivamente nelle relazioni ManyToMany per creare e configurare la tabella di giunzione intermedia.

Come si gestisce il caricamento delle relazioni?

TypeORM supporta tre strategie: eager loading (automatico ad ogni query, configurato nell'entità), explicit loading (specificando relations nelle opzioni di find), e lazy loading (con proprietà di tipo Promise<T> nell'entità). L'explicit loading è generalmente il più prevedibile e performante.

Quando conviene usare il QueryBuilder rispetto al metodo find?

Il metodo find è sufficiente per query con filtri semplici, ordinamento e paginazione. Il QueryBuilder diventa necessario per join complessi, sottoquery, aggregazioni (GROUP BY, HAVING), e query con condizioni dinamiche costruite a runtime.

Come funzionano le transazioni in TypeORM?

Si utilizza DataSource.transaction() che fornisce un EntityManager isolato. Tutte le operazioni nella callback devono usare questo manager. Se una qualsiasi operazione fallisce, tutte le modifiche vengono annullate automaticamente tramite rollback.

Qual è il ruolo di forFeature e forRoot in TypeOrmModule?

forRoot (o forRootAsync) configura la connessione al database a livello globale nel modulo principale. forFeature registra specifiche entità e i loro repository in un feature module, rendendoli disponibili per l'iniezione nei service di quel modulo.

Pronto a superare i tuoi colloqui su Node.js / NestJS?

Pratica con i nostri simulatori interattivi, flashcards e test tecnici.

Considerazioni finali

L'integrazione tra NestJS e TypeORM offre un'architettura robusta per applicazioni backend con database relazionali. I punti fondamentali da padroneggiare sono:

  • La configurazione corretta della connessione con variabili d'ambiente e una DataSource separata per la CLI
  • Il workflow delle migrazioni disciplinato: mai synchronize in produzione, sempre verificare le migrazioni generate
  • La modellazione delle relazioni con i decoratori appropriati, sapendo che la foreign key risiede sul lato @ManyToOne
  • L'uso consapevole delle transazioni per le operazioni multi-tabella, utilizzando esclusivamente il manager transazionale
  • La capacità di scegliere tra find API e QueryBuilder in base alla complessità della query

Per chi si prepara a un colloquio tecnico, la capacità di spiegare le differenze tra i tipi di relazione, le strategie di caricamento e le implicazioni delle transazioni rappresenta un indicatore solido di esperienza pratica con l'ORM.

Inizia a praticare!

Metti alla prova le tue conoscenze con i nostri simulatori di colloquio e test tecnici.

Condividi

Articoli correlati