NestJS và TypeORM năm 2026: Migration, Relation và Câu hỏi Phỏng vấn

Hướng dẫn toàn diện về NestJS kết hợp TypeORM trong năm 2026: cấu hình DataSource, migration, các loại relation (OneToMany, ManyToMany), Repository pattern, transaction và câu hỏi phỏng vấn kỹ thuật.

NestJS và TypeORM: Migrations, Relations và Câu hỏi Phỏng vấn

NestJS và TypeORM tiếp tục là một trong những bộ đôi được sử dụng rộng rãi nhất trong hệ sinh thái Node.js backend năm 2026. NestJS mang lại kiến trúc module hóa với Dependency Injection mạnh mẽ, trong khi TypeORM cung cấp công cụ thao tác cơ sở dữ liệu an toàn kiểu dữ liệu bằng TypeScript. Tuy nhiên, nhiều dự án gặp khó khăn khi thiết lập migration đúng cách, mô hình hóa các mối quan hệ phức tạp giữa entity, và đảm bảo tính nhất quán dữ liệu trong các thao tác nhiều bước.

Bài viết này trình bày các khái niệm cốt lõi về cấu hình TypeORM trong dự án NestJS, quy trình migration chuyên nghiệp, các loại relation phổ biến, Repository pattern và transaction. Phần cuối bao gồm những câu hỏi thường xuất hiện trong phỏng vấn kỹ thuật dành cho lập trình viên backend.

API DataSource của TypeORM

TypeORM đã thay đổi cơ bản API cấu hình. Các dự án còn sử dụng ormconfig.json hoặc createConnection cần chuyển sang API DataSource. Tất cả ví dụ trong bài viết này sử dụng API hiện tại của TypeORM.

Cấu hình TypeORM trong dự án NestJS

Bước đầu tiên là tạo một file cấu hình DataSource độc lập. File này được TypeORM CLI sử dụng để thực hiện các thao tác migration. Việc tách biệt cấu hình đảm bảo CLI có thể hoạt động độc lập với ngữ cảnh 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,
});

Thuộc tính synchronize: false cần được đặc biệt lưu ý. Trong môi trường phát triển, việc đặt synchronize: true có vẻ tiện lợi vì TypeORM tự động đồng bộ schema cơ sở dữ liệu với các entity. Tuy nhiên, trong môi trường production, điều này có thể dẫn đến mất dữ liệu và các hành vi không dự đoán trước. Thay vào đó, migration là phương pháp chuẩn để quản lý thay đổi schema.

Trong AppModule, TypeORM được tích hợp thông qua cấu hình bất đồng bộ, trong đó ConfigService cung cấp các biến môi trường:

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,
        synchronize: false,
      }),
    }),
  ],
})
export class AppModule {}

Sự khác biệt giữa config.get()config.getOrThrow() tuy tinh tế nhưng có ý nghĩa thực tiễn quan trọng. Phương thức getOrThrow() ném exception khi biến môi trường không tồn tại. Đối với các giá trị cấu hình quan trọng như port cơ sở dữ liệu, điều này đảm bảo lỗi cấu hình được phát hiện ngay lập tức thay vì sử dụng giá trị mặc định một cách âm thầm.

Migration: Tạo, thực thi và hoàn tác

Migration là nền tảng của mọi chiến lược quản lý cơ sở dữ liệu chuyên nghiệp. Chúng ghi lại các thay đổi schema theo phiên bản, có tính tái tạo và có thể hoàn tác. Các npm script sau giúp đơn giản hóa việc tương tác với TypeORM CLI:

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"
}

Quy trình làm việc trong thực tế diễn ra như sau: trước tiên, cập nhật các entity tương ứng. Sau đó, lệnh npm run migration:generate --name=AddUserTable tự động tạo một file migration chứa sự khác biệt giữa schema hiện tại và định nghĩa entity. Lệnh npm run migration:run áp dụng migration vào cơ sở dữ liệu, và npm run migration:revert hoàn tác migration gần nhất.

Một lỗi thường gặp trong các dự án là viết migration thủ công khi migration:generate đã tự động hóa việc so sánh schema. Migration thủ công chỉ cần thiết khi cần chuyển đổi dữ liệu, chẳng hạn khi đổi tên cột kèm theo việc di chuyển dữ liệu từ cột cũ sang cột mới.

Luôn kiểm tra migration được tạo tự động

Khi đổi tên một cột, TypeORM tạo lệnh DROP + ADD thay vì ALTER RENAME, điều này sẽ xóa toàn bộ dữ liệu trong cột đó. Các câu lệnh SQL trong phương thức up() cần được kiểm tra kỹ lưỡng và điều chỉnh thủ công khi cần thiết.

Entity và các loại Relation

TypeORM sử dụng decorator để ánh xạ cấu trúc cơ sở dữ liệu trực tiếp vào các lớp TypeScript. Ba loại relation quan trọng nhất bao gồm OneToMany/ManyToOne, ManyToMany và OneToOne.

OneToMany và ManyToOne

Mối quan hệ cổ điển giữa người dùng và đơn hàng minh họa cách @OneToMany@ManyToOne phối hợp với nhau:

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[];

  @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' })
  user: User;
}

Điểm then chốt ở đây là: phía @OneToMany không tạo bất kỳ cột nào trong cơ sở dữ liệu. Khóa ngoại luôn nằm ở phía @ManyToOne. Decorator @JoinColumn chỉ định rõ tên cột khóa ngoại, giúp tăng tính dễ đọc của cơ sở dữ liệu và tránh xung đột tên. Tùy chọn onDelete: 'CASCADE' đảm bảo các đơn hàng tự động bị xóa khi người dùng tương ứng bị loại bỏ khỏi hệ thống.

ManyToMany

Đối với sản phẩm và danh mục, quan hệ ManyToMany là lựa chọn phù hợp. TypeORM tự động tạo bảng trung gian (junction table):

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' })
  categories: Category[];
}

Decorator @JoinTable đánh dấu phía sở hữu (owning side) của relation. Chỉ một phía được phép sử dụng decorator này. Trong trường hợp bảng trung gian cần chứa thêm các cột bổ sung (ví dụ thứ tự sắp xếp), cần tạo một entity riêng với hai relation @ManyToOne thay vì sử dụng @ManyToMany trực tiếp.

Sẵn sàng chinh phục phỏng vấn Node.js / NestJS?

Luyện tập với mô phỏng tương tác, flashcards và bài kiểm tra kỹ thuật.

Repository Pattern và Feature Module

NestJS khuyến khích việc module hóa thông qua feature module. Mỗi module đăng ký các entity của mình thông qua TypeOrmModule.forFeature() và từ đó có quyền truy cập vào các repository tương ứng:

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 {}

Trong service, repository được inject thông qua @InjectRepository. Đối với các truy vấn đơn giản, Repository API là đủ. Khi gặp các tình huống phức tạp hơn, QueryBuilder sẽ được sử dụng:

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'],
      order: { createdAt: 'DESC' },
    });
  }

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

Phương thức findByUser minh họa cách lọc relation thông qua các điều kiện Where lồng nhau. Mảng relations kiểm soát những liên kết nào được tải cùng. Nếu không chỉ định, TypeORM chỉ trả về các đơn hàng mà không kèm theo dữ liệu người dùng.

QueryBuilder trong findWithFilters cung cấp nhiều quyền kiểm soát hơn: leftJoinAndSelect tải relation và hiển thị trong kết quả, trong khi các truy vấn tham số hóa ngăn chặn SQL Injection một cách đáng tin cậy.

Transaction: Đảm bảo tính nhất quán dữ liệu

Khi nhiều thao tác cơ sở dữ liệu cần diễn ra một cách nguyên tử (atomic), transaction là giải pháp bắt buộc. TypeORM cung cấp phương thức DataSource.transaction() cho mục đích này:

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) => {
    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);
  });
}

Bên trong transaction, tất cả các thao tác đều phải sử dụng manager được cung cấp. Nếu sử dụng repository thông thường, các thao tác đó sẽ chạy bên ngoài transaction và khi xảy ra lỗi ở một bước sau, dữ liệu không thể được hoàn tác, dẫn đến trạng thái không nhất quán. Khi bất kỳ lỗi nào xảy ra bên trong callback, toàn bộ transaction sẽ tự động được rollback.

QueryRunner và transaction()

DataSource.transaction() phù hợp cho các thao tác nhiều bước đơn giản. QueryRunner cung cấp khả năng kiểm soát thủ công các điểm Commit/Rollback và Savepoint, hữu ích trong các trường hợp cần rollback từng phần khi xử lý hàng loạt (batch processing).

Câu hỏi phỏng vấn thường gặp về NestJS và TypeORM

Các buổi phỏng vấn kỹ thuật về NestJS và TypeORM thường tập trung vào ba lĩnh vực: hiểu biết kiến trúc, khả năng giải quyết vấn đề thực tế và khả năng tránh các lỗi phổ biến.

Tại sao cần tắt synchronize trong môi trường production?

Tùy chọn synchronize: true tự động đồng bộ schema cơ sở dữ liệu với định nghĩa entity mỗi khi ứng dụng khởi động. Điều này có thể xóa cột, thay đổi kiểu dữ liệu hoặc loại bỏ bảng mà không có bất kỳ ghi chép nào. Trong môi trường production, hậu quả là mất dữ liệu. Migration cung cấp giải pháp thay thế: các thay đổi schema được phiên bản hóa, có tính tái tạo và có thể hoàn tác.

Sự khác biệt giữa migration:generatemigration:create là gì?

Lệnh migration:generate so sánh định nghĩa entity hiện tại với schema cơ sở dữ liệu và tự động tạo ra các câu lệnh SQL cần thiết. Lệnh migration:create tạo một file migration trống, trong đó các câu lệnh SQL được viết thủ công. Trường hợp thứ hai hữu ích cho các thao tác chuyển đổi dữ liệu hoặc seed dữ liệu.

Khóa ngoại nằm ở phía nào trong quan hệ OneToMany/ManyToOne?

Khóa ngoại luôn nằm ở phía @ManyToOne. Phía @OneToMany không tạo bất kỳ cột nào trong cơ sở dữ liệu -- nó chỉ phục vụ cho việc biểu diễn mối quan hệ ngược (inverse relation) trong TypeScript.

Khi nào nên sử dụng QueryBuilder thay vì Repository API?

Repository API (find, findOne, save) phù hợp cho các thao tác CRUD đơn giản và truy vấn với điều kiện lọc trực tiếp. QueryBuilder được sử dụng khi cần các phép JOIN phức tạp, subquery, hàm tổng hợp hoặc các truy vấn được xây dựng động tại runtime.

Làm thế nào để giải quyết Circular Dependency giữa các module?

NestJS cung cấp forwardRef() để xử lý các phụ thuộc vòng giữa các module. Tuy nhiên, việc sử dụng nó thường là dấu hiệu của vấn đề kiến trúc. Giải pháp tốt hơn là trích xuất logic dùng chung vào một module riêng biệt.

Điều gì xảy ra khi sử dụng repository thông thường thay vì manager trong một transaction?

Các thao tác thông qua repository thông thường sẽ chạy bên ngoài transaction. Điều này có nghĩa là những thay đổi đó được commit ngay lập tức và không thể rollback khi có lỗi xảy ra. Kết quả là dữ liệu rơi vào trạng thái không nhất quán.

Sẵn sàng chinh phục phỏng vấn Node.js / NestJS?

Luyện tập với mô phỏng tương tác, flashcards và bài kiểm tra kỹ thuật.

Kết luận

Sự kết hợp giữa NestJS và TypeORM mang lại một hệ sinh thái toàn diện cho việc phát triển backend an toàn kiểu dữ liệu với Node.js. Những điểm chính có thể tổng kết như sau:

  • Migration thay thế synchronize trong mọi môi trường ngoài phát triển cục bộ
  • Relation đòi hỏi sự hiểu biết rõ ràng về phía nào sở hữu khóa ngoại
  • Transaction phải sử dụng manager được cung cấp, không bao giờ dùng repository thông thường
  • QueryBuilder bổ sung cho Repository API tại những nơi các phương thức đơn giản không đáp ứng được
  • Trong phỏng vấn kỹ thuật, không chỉ cú pháp mà lý do đằng sau các quyết định kiến trúc mới là yếu tố quyết định

Bắt đầu luyện tập!

Kiểm tra kiến thức với mô phỏng phỏng vấn và bài kiểm tra kỹ thuật.

Chia sẻ

Bài viết liên quan