Skip to content

HARRY-6 — GĐ6 — NestJS: DI, guards/pipes/interceptors, auth đầy đủ (Dự án 3: portfolio)

GĐ6 — NestJS: DI, guards/pipes/interceptors, auth đầy đủ (Dự án 3: portfolio)

Ghi chú học cho FE (JS/TS mạnh) chuyển sang BE. Mỗi khái niệm: định nghĩa → tại sao quan trọng → cơ chế → ví dụ code ngắn → pitfall thực tế. Tư duy chính cần "unlearn" từ FE: ở BE bạn không render UI, bạn thiết kế contract (request → validate → business logic → response), và mọi thứ đều là dependency được inject, không phải import trực tiếp.


1. NestJS là gì — vì sao dùng thay Express thuần

Định nghĩa. NestJS là một framework Node.js (TypeScript-first) để xây server-side app. Nó opinionated: áp một kiến trúc chuẩn (module + controller + provider) lấy cảm hứng từ Angular. Nest không tự viết HTTP server — nó chạy trên một adapter: mặc định là Express, có thể đổi sang Fastify (nhanh hơn ~2x throughput).

Tại sao quan trọng. Express thuần cho bạn tự do tuyệt đối → mỗi team tự bịa cấu trúc thư mục, cách wire dependency, cách validate. Sau 6 tháng codebase thành mì spaghetti. Nest ép một khung xương chung: người mới đọc repo Nest nào cũng biết logic nằm ở đâu. So sánh với FE: Express = React thuần với useState khắp nơi; Nest = Next.js có convention rõ ràng.

Cơ chế.

  • Nest build một application graph lúc bootstrap: quét decorators (@Module, @Injectable, @Controller), dựng IoC container, resolve toàn bộ dependency, rồi mount route lên adapter (Express/Fastify).
  • Request đi qua pipeline: Middleware → Guards → Interceptors (pre) → Pipes → Handler → Interceptors (post) → Exception filters.
  • TS-first: dùng decorators (experimentalDecorators) + metadata reflection (reflect-metadata) để đọc type ở runtime → nền tảng của DI và validation.

Ví dụ code.

ts
// main.ts — bootstrap
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule); // mặc định Express
  await app.listen(3000);
}
bootstrap();

Pitfall thực tế. Nhiều FE nghĩ "Nest nặng, dùng Express cho nhanh". Đúng cho script 1 file, nhưng app thật (auth, DB, nhiều team) thì Nest tiết kiệm hơn nhiều. Ngược lại: đừng chọn Fastify adapter chỉ vì "nhanh" nếu bạn phụ thuộc middleware Express (một số lib chỉ support Express) — kiểm tra tương thích trước.


2. Module — @Module

Định nghĩa. Module là đơn vị đóng gói một domain/feature. Mỗi Nest app có ít nhất một root module (AppModule); các feature (users, auth, payment...) thành feature module riêng. @Module() nhận 4 mảng:

  • controllers: khai báo controller thuộc module.
  • providers: service/repository... module này tự tạo & dùng nội bộ.
  • imports: module khác mà module này cần (để dùng provider chúng export).
  • exports: provider mà module này cho phép module khác dùng.

Tại sao quan trọng. Module định nghĩa ranh giới encapsulation của DI. Một provider không tự động dùng được ở mọi nơi — nó chỉ khả dụng trong module khai báo, trừ khi được export và module kia import. Đây là cơ chế chống "mọi thứ nối với mọi thứ".

Cơ chế. Khi bootstrap, Nest duyệt cây module bắt đầu từ root, dựng scope DI cho từng module. Provider trong exports được "nâng" lên để module import thấy được. Import mang tính transitive qua re-export chứ không tự lan: A export X, B import A và muốn cho C dùng X thì B phải re-export A/X.

Ví dụ code.

ts
// users/users.module.ts
@Module({
  controllers: [UsersController],
  providers: [UsersService],
  exports: [UsersService], // cho AuthModule dùng
})
export class UsersModule {}

// auth/auth.module.ts
@Module({
  imports: [UsersModule], // giờ AuthService inject được UsersService
  providers: [AuthService],
})
export class AuthModule {}

Pitfall thực tế. Lỗi kinh điển: Nest can't resolve dependencies of AuthService (?). Please make sure UsersService is available... → nguyên nhân 90% là quên exports: [UsersService] trong UsersModule, hoặc quên imports: [UsersModule]. Đừng "sửa" bằng cách khai báo UsersService lại trong providers của AuthModule — làm vậy sẽ tạo 2 instance khác nhau, state không share.


3. Controller — routing layer

Định nghĩa. Controller nhận HTTP request và trả response. @Controller('users') gắn prefix path; các method decorator (@Get, @Post, @Patch, @Delete) map HTTP verb + sub-path. Param decorators trích dữ liệu: @Param, @Query, @Body, @Headers, @Req.

Tại sao quan trọng. Controller là lớp mỏng: chỉ nhận input, gọi service, trả kết quả. Nó là "adapter" giữa HTTP và business logic. Giữ nó mỏng giúp business logic tái dùng được (từ CLI, cron, queue... không chỉ HTTP).

Cơ chế. Nest đọc metadata từ decorator để build routing table lúc bootstrap. Return value của handler tự động được serialize thành JSON + status 200 (201 cho @Post). Trả Promise/Observable cũng được — Nest tự await.

Ví dụ code.

ts
@Controller('users')
export class UsersController {
  constructor(private readonly users: UsersService) {}

  @Get()                       // GET /users?limit=10
  findAll(@Query('limit') limit: string) {
    return this.users.findAll(Number(limit));
  }

  @Get(':id')                  // GET /users/42
  findOne(@Param('id') id: string) {
    return this.users.findOne(id);
  }

  @Post()                      // POST /users -> 201
  create(@Body() dto: CreateUserDto) {
    return this.users.create(dto);
  }
}

Pitfall thực tế. Nhồi business logic (query DB, gọi API ngoài) thẳng vào controller → không test được, không tái dùng. Thứ hai: thứ tự route matter@Get('me') phải đặt trước @Get(':id'), nếu không me bị nuốt bởi param :id.


4. Provider & Service

Định nghĩa. Provider là bất cứ class nào Nest có thể inject (@Injectable()): service, repository, factory, helper, thậm chí một value. Service là loại provider phổ biến nhất — nơi chứa business logic.

Tại sao quan trọng. Tách logic khỏi controller cho ba lợi ích: (1) test độc lập không cần HTTP; (2) tái dùng logic ở nhiều controller/queue/cron; (3) một chỗ duy nhất để sửa rule nghiệp vụ. Đây là "Single Responsibility" ở tầng kiến trúc.

Cơ chế. @Injectable() đánh dấu class là provider để IoC container quản lý. Khai báo nó trong providers của module → container tạo một instance (singleton) và inject vào bất cứ ai khai báo nó ở constructor.

Ví dụ code.

ts
@Injectable()
export class UsersService {
  constructor(private readonly repo: UserRepository) {}

  async findOne(id: string) {
    const user = await this.repo.findById(id);
    if (!user) throw new NotFoundException('User not found');
    return user;
  }
}

Pitfall thực tế. Quên @Injectable() trên service → Nest vẫn có thể chạy nếu nó chỉ được inject (decorator chủ yếu cần khi class dependency), nhưng để nhất quán luôn gắn. Pitfall lớn hơn: God Service — một UsersService 2000 dòng làm cả auth, email, billing. Tách theo domain.


5. Dependency Injection (QUAN TRỌNG — khái niệm mới với FE)

Định nghĩa. DI là pattern: class không tự tạo dependency của nó (new UserRepository()), mà nhận chúng từ bên ngoài (thường qua constructor). "IoC container" (Inversion of Control) là bộ máy của Nest chịu trách nhiệm tạo và cung cấp các instance đó.

Tại sao quan trọng (đọc kỹ nếu từ FE). Ở FE bạn hầu như luôn import { api } from './api' rồi gọi trực tiếp — dependency bị hard-code. Khi test, bạn phải hack module mock. Với DI:

  • Test/mock dễ: trong test bạn inject một fake repo, không đụng DB thật.
  • Đổi implementation không sửa consumer: đổi EmailService từ SendGrid sang SES chỉ cần đổi provider binding, code service dùng nó không đổi.
  • Lifecycle tập trung: container quản lý singleton, tránh tạo trùng kết nối DB.

Cơ chế.

  1. Constructor injection: Nest đọc type của tham số constructor qua reflect-metadata.
  2. Provider token: mỗi provider có một "token" — mặc định chính là class. Container tra token → trả instance. Token cũng có thể là string/symbol (dùng @Inject('TOKEN')).
  3. Scope: mặc định SINGLETON (một instance dùng chung toàn app). Còn REQUEST (mỗi request một instance — chậm hơn, dùng khi cần state theo request như tenant/user hiện tại) và TRANSIENT (mỗi lần inject một instance mới).

Ví dụ code.

ts
// custom provider token + useClass (đổi implementation dễ)
@Module({
  providers: [
    { provide: 'MAILER', useClass: SendgridMailer }, // đổi sang SesMailer chỉ ở đây
  ],
})
export class MailModule {}

@Injectable()
export class NotifyService {
  constructor(@Inject('MAILER') private mailer: Mailer) {}
}

// Test — inject mock, không đụng SendGrid thật
const service = new NotifyService({ send: jest.fn() } as any);

Pitfall thực tế.

  • Circular dependency: A cần B, B cần A → dùng forwardRef(() => B). Tốt hơn là refactor tách phần chung ra module thứ ba.
  • Inject provider REQUEST-scoped vào một singleton sẽ "nâng" cả chuỗi lên request-scoped (bubbling) → tụt performance mà không nhận ra.
  • FE hay dùng new Service() trong constructor theo thói quen — làm vậy phá DI, container không quản lý được, mất test-ability.

6. DTO + validation

Định nghĩa. DTO (Data Transfer Object) là class mô tả shape của dữ liệu vào/ra. Kết hợp class-validator (decorator validate) + class-transformer (chuyển plain object → instance class) + ValidationPipe để tự động kiểm tra body/query.

Tại sao quan trọng. "Never trust the client." Request là dữ liệu ngoài tầm kiểm soát → phải validate ở boundary. DTO cho bạn một nguồn sự thật vừa là type TS (compile-time) vừa là rule runtime. FE quen zod — đây là tương đương ở Nest (class-based).

Cơ chế. ValidationPipe (bật global) chạy trước handler: dùng class-transformer biến @Body() thành instance của DTO, rồi class-validator chạy các decorator. Fail → tự ném 400 Bad Request kèm chi tiết lỗi.

  • whitelist: true: strip field không khai báo trong DTO (chống mass-assignment).
  • forbidNonWhitelisted: true: có field thừa thì báo lỗi 400 thay vì strip.
  • transform: true: cho phép ép kiểu (vd "42"42 theo type param).

Ví dụ code.

ts
// create-user.dto.ts
export class CreateUserDto {
  @IsEmail() email: string;
  @IsString() @MinLength(8) password: string;
  @IsOptional() @IsInt() @Min(0) age?: number;
}

// main.ts — bật global
app.useGlobalPipes(new ValidationPipe({
  whitelist: true,
  forbidNonWhitelisted: true,
  transform: true,
}));

Pitfall thực tế.

  • Quên transform: true@Query('limit') vẫn là string, so sánh số sai âm thầm.
  • Nested object cần @ValidateNested() @Type(() => ChildDto) (class-transformer), nếu không validation nested bị bỏ qua.
  • Không bật whitelist → client gửi thêm { isAdmin: true } và nếu bạn spread thẳng vào DB → privilege escalation.

7. Pipes

Định nghĩa. Pipe là class biến đổi hoặc validate input của handler ngay trước khi handler chạy. Hai nhiệm vụ: transform (đổi kiểu/format) và validation (chặn input xấu). ValidationPipe ở mục 6 chính là một pipe.

Tại sao quan trọng. Đẩy việc parse/validate ra khỏi handler → handler chỉ nhận dữ liệu đã sạch, đúng kiểu. Tái dùng logic validate qua nhiều route.

Cơ chế. Pipe implement PipeTransform, có method transform(value, metadata). Return value được truyền vào handler; throw → request bị chặn (thường 400). Áp ở cấp param, handler, controller, hoặc global.

Ví dụ code.

ts
// built-in ParseIntPipe: ép & validate là số nguyên
@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number) { // id chắc chắn là number
  return this.users.findOne(id);
}

// custom pipe
@Injectable()
export class TrimPipe implements PipeTransform {
  transform(value: any) {
    return typeof value === 'string' ? value.trim() : value;
  }
}

Pitfall thực tế. ParseIntPipe ném 400 khi param không phải số (/users/abc) — đúng ý muốn, nhưng nếu bạn muốn 404 thì phải xử lý khác. Custom pipe đừng nhét business logic (query DB) vào — pipe nên thuần transform/validate; truy vấn DB để kiểm tra tồn tại nên ở service/guard.


8. Guards — authorization (RBAC)

Định nghĩa. Guard quyết định request có được phép vào handler hay không, trả true/false (hoặc Promise/Observable của boolean). Dùng cho authentication (đã login chưa) và authorization (có quyền không).

Tại sao quan trọng. Tách "ai được làm gì" khỏi business logic. Không phải rải if (!user) throw... trong mỗi handler. Guard chạy trước interceptor & pipe → chặn sớm, tiết kiệm.

Cơ chế. Guard implement CanActivate với canActivate(context: ExecutionContext). ExecutionContext cho phép lấy request. Kết hợp custom decorator (@Roles) + Reflector (đọc metadata gắn trên handler) để làm RBAC (Role-Based Access Control).

Ví dụ code.

ts
// roles.decorator.ts
export const ROLES_KEY = 'roles';
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);

// roles.guard.ts
@Injectable()
export class RolesGuard implements CanActivate {
  constructor(private reflector: Reflector) {}
  canActivate(ctx: ExecutionContext): boolean {
    const required = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
      ctx.getHandler(), ctx.getClass(),
    ]);
    if (!required) return true;               // route không yêu cầu role
    const { user } = ctx.switchToHttp().getRequest();
    return required.some((r) => user?.roles?.includes(r));
  }
}

// dùng
@UseGuards(AuthGuard('jwt'), RolesGuard)
@Roles('admin')
@Delete(':id')
remove(@Param('id') id: string) { return this.users.remove(id); }

Pitfall thực tế.

  • Thứ tự guard: AuthGuard phải chạy trước RolesGuard (RolesGuard cần req.user do AuthGuard gắn). @UseGuards chạy theo thứ tự khai báo.
  • reflector.get chỉ đọc metadata trên handler; nếu bạn @Roles ở cấp controller, phải dùng getAllAndOverride([handler, class]) để không sót.
  • Guard trả false → Nest ném 403 Forbidden; nếu muốn 401 khi chưa login, để AuthGuard tự ném 401.

9. Interceptors

Định nghĩa. Interceptor bọc quanh handler: chạy trướcsau khi handler thực thi. Dùng để: transform response, logging, đo thời gian, cache, timeout, map exception. Dựa trên RxJS (Observable).

Tại sao quan trọng. Cross-cutting concern (áp cho mọi route) không nên copy-paste. Ví dụ: bọc mọi response thành { data, timestamp } — làm một lần bằng interceptor global.

Cơ chế. Implement NestInterceptor với intercept(ctx, next). next.handle() trả Observable của kết quả handler. Bạn dùng RxJS operator (map, tap, timeout, catchError) để can thiệp luồng post-handler.

Ví dụ code.

ts
@Injectable()
export class TransformInterceptor implements NestInterceptor {
  intercept(ctx: ExecutionContext, next: CallHandler): Observable<any> {
    const now = Date.now();
    return next.handle().pipe(
      map((data) => ({ data, took: Date.now() - now })), // reshape response
      timeout(5000),                                       // hủy nếu > 5s
    );
  }
}

Pitfall thực tế. Wrap response thành { data: ... } global sẽ phá contract những endpoint đã publish (FE đang đọc field cũ) — thống nhất format sớm hoặc dùng @SkipInterceptor cho route ngoại lệ. timeout() chỉ hủy Observable phía Nest, không hủy query DB đang chạy → cần cấu hình timeout ở tầng DB nữa.


10. Exception filters

Định nghĩa. Filter bắt exception ném ra từ handler/guard/pipe và biến thành HTTP response chuẩn hoá. Nest có sẵn HttpException và các subclass (NotFoundException, BadRequestException, UnauthorizedException...).

Tại sao quan trọng. Cần một error contract nhất quán để FE parse. Không để lộ stack trace/thông tin DB ra client. Một chỗ tập trung để log + format lỗi.

Cơ chế. Ném HttpException(message, status) → default filter trả JSON { statusCode, message }. Custom filter implement ExceptionFilter + @Catch(...), truy cập response để tự định dạng.

Ví dụ code.

ts
@Catch()                       // bắt mọi exception
export class AllExceptionsFilter implements ExceptionFilter {
  catch(err: unknown, host: ArgumentsHost) {
    const res = host.switchToHttp().getResponse();
    const status = err instanceof HttpException ? err.getStatus() : 500;
    res.status(status).json({
      statusCode: status,
      message: err instanceof HttpException ? err.getResponse() : 'Internal error',
      timestamp: new Date().toISOString(),
    });
  }
}
// service ném lỗi nghiệp vụ:
throw new ConflictException('Email already exists'); // -> 409

Pitfall thực tế. Bắt hết bằng @Catch() mà quên log lỗi 500 → mất dấu bug production. Đừng trả err.message của lỗi không phải HttpException ra client (có thể lộ path DB, SQL). Với lỗi Prisma/TypeORM (vd unique constraint) nên map sang ConflictException trong service, đừng để rơi xuống 500.


11. Auth đầy đủ — JWT access + refresh, hashing

Định nghĩa. Xác thực (authentication) + phát hành JWT access token (ngắn hạn) và refresh token (dài hạn), có rotation, mật khẩu hash bằng argon2/bcrypt. Dùng Passport (thư viện auth chuẩn Node) qua @nestjs/passport.

Tại sao quan trọng. Đây là "cửa vào" của app — sai sót = rò rỉ tài khoản. Access token ngắn hạn giảm thiệt hại khi bị lộ; refresh token cho UX không phải login lại liên tục; hashing bảo vệ password khi DB bị dump.

Cơ chế.

  • Password hashing: KHÔNG bao giờ lưu plaintext. argon2 (khuyến nghị hiện đại, chống GPU) hoặc bcrypt. Hash có salt tự sinh; verify bằng hàm compare (constant-time).
  • Login: verify password → phát accessToken (~15m) + refreshToken (~7d, ký secret khác).
  • Passport strategy: JwtStrategy extract token từ Authorization: Bearer, verify chữ ký, validate() trả về user → Nest gắn vào req.user.
  • Refresh rotation: mỗi lần dùng refresh token → cấp cặp mới và vô hiệu token cũ. Phát hiện tái dùng token đã revoke = dấu hiệu bị đánh cắp → thu hồi cả session.
  • Lưu refresh token ở đâu: lưu hash của refresh token trong DB (vd cột hashedRt trên user, hoặc bảng sessions) để có thể revoke server-side. Client giữ refresh token trong httpOnly secure cookie (chống XSS đọc) — không lưu localStorage.

Ví dụ code.

ts
// hashing
const hash = await argon2.hash(dto.password);
const ok   = await argon2.verify(user.passwordHash, dto.password);

// jwt.strategy.ts
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
  constructor(cfg: ConfigService) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      secretOrKey: cfg.get('JWT_ACCESS_SECRET'),
    });
  }
  validate(payload: { sub: string; roles: string[] }) {
    return { id: payload.sub, roles: payload.roles }; // -> req.user
  }
}

// auth.service.ts — cấp token + lưu hash refresh
async issueTokens(userId: string, roles: string[]) {
  const [at, rt] = await Promise.all([
    this.jwt.signAsync({ sub: userId, roles }, { secret: this.atSecret, expiresIn: '15m' }),
    this.jwt.signAsync({ sub: userId },        { secret: this.rtSecret, expiresIn: '7d'  }),
  ]);
  await this.users.setHashedRt(userId, await argon2.hash(rt)); // để revoke được
  return { accessToken: at, refreshToken: rt };
}

Pitfall thực tế.

  • Ký access và refresh bằng cùng secret → không phân biệt được token loại nào, mở đường lạm dụng. Dùng 2 secret khác nhau.
  • Lưu refresh token plaintext trong DB → DB leak = mất hết session. Luôn hash.
  • Không rotate refresh → token bị đánh cắp dùng mãi. Rotate + phát hiện reuse.
  • Để access token quá dài hạn (vd 30 ngày) → mất token = mất tài khoản lâu; giữ ngắn, dựa vào refresh.
  • Đưa dữ liệu nhạy cảm vào JWT payload — nhớ JWT chỉ ký, không mã hoá, ai cũng decode đọc được payload.

12. Config — @nestjs/config + validate env

Định nghĩa. @nestjs/config load biến môi trường (.env) và cung cấp ConfigService để đọc có type. Validate env lúc khởi động bằng Joi hoặc Zod.

Tại sao quan trọng. Tách config khỏi code (12-factor). Validate sớm → app fail fast ngay lúc boot nếu thiếu DATABASE_URL, thay vì crash lúc 3h sáng khi request đầu tiên chạm biến undefined.

Cơ chế. ConfigModule.forRoot({ isGlobal, validationSchema }) đọc .env, chạy schema validate; sai → throw ngay, app không start. ConfigService.get('KEY') trả value đã validate.

Ví dụ code.

ts
@Module({
  imports: [ConfigModule.forRoot({
    isGlobal: true,
    validationSchema: Joi.object({
      NODE_ENV: Joi.string().valid('development','production').required(),
      DATABASE_URL: Joi.string().uri().required(),
      JWT_ACCESS_SECRET: Joi.string().min(32).required(),
    }),
  })],
})
export class AppModule {}

// dùng
constructor(private cfg: ConfigService) {}
const url = this.cfg.get<string>('DATABASE_URL');

Pitfall thực tế. Không validate env → typo DATABSE_URL cho undefined âm thầm, DB connect fail mơ hồ. Commit .env lên git = lộ secret (đưa vào .gitignore, dùng .env.example). isGlobal: true để khỏi import ConfigModule ở mọi feature module.


13. Database integration + repository pattern

Định nghĩa. Tích hợp DB qua ORM: Prisma (schema-first, type-safe, DX tốt) hoặc TypeORM (decorator entity, quen với người từ Java/.NET). Repository pattern: một lớp trung gian đóng gói mọi truy vấn cho một entity, service không viết query trực tiếp.

Tại sao quan trọng. Repository cô lập tầng dữ liệu → đổi ORM/DB dễ hơn, test service bằng cách mock repo, và tránh rải câu query khắp codebase. Prisma cho type an toàn end-to-end (kết quả query có type chuẩn).

Cơ chế. Bọc client DB trong một provider (PrismaService extends PrismaClient, connect trong onModuleInit). Repository là @Injectable() inject PrismaService, expose method domain (findById, create...). Service inject repository.

Ví dụ code.

ts
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
  async onModuleInit() { await this.$connect(); }
}

@Injectable()
export class UserRepository {
  constructor(private prisma: PrismaService) {}
  findById(id: string) { return this.prisma.user.findUnique({ where: { id } }); }
  create(data: Prisma.UserCreateInput) { return this.prisma.user.create({ data }); }
}

Pitfall thực tế.

  • N+1 query: loop qua danh sách rồi query từng phần tử. Dùng include/join hoặc batch.
  • Quên onModuleInit/pool config → cạn connection pool dưới tải.
  • Với TypeORM, synchronize: trueproduction = tự đổi schema, có thể mất data. Luôn dùng migration ở prod.
  • Đừng leak type ORM (Prisma.User) ra tận controller/response nếu chứa field nhạy cảm (passwordHash) — map sang DTO output hoặc dùng @Exclude().

14. Swagger auto-doc — @nestjs/swagger

Định nghĩa. Sinh tài liệu OpenAPI (Swagger UI) tự động từ decorator. @nestjs/swagger đọc DTO, controller, response type để dựng spec + trang UI tương tác.

Tại sao quan trọng. API doc luôn đồng bộ với code (sinh từ chính DTO/controller) → FE và team ngoài tự thử API, giảm hỏi đáp. Đây là "hợp đồng sống" giữa BE và consumer.

Cơ chế. SwaggerModule.setup() quét metadata. Decorator: @ApiTags, @ApiProperty (trên field DTO), @ApiResponse, @ApiBearerAuth (đánh dấu route cần token). Plugin CLI của swagger có thể tự suy field từ type để bớt phải viết @ApiProperty.

Ví dụ code.

ts
// main.ts
const doc = SwaggerModule.createDocument(app,
  new DocumentBuilder().setTitle('Portfolio API').addBearerAuth().build());
SwaggerModule.setup('docs', app, doc); // GET /docs

// dto
export class CreateUserDto {
  @ApiProperty({ example: 'a@b.com' }) @IsEmail() email: string;
}

Pitfall thực tế. Không bật swagger CLI plugin → phải viết @ApiProperty cho mọi field, hay quên → doc thiếu field. Đừng để Swagger UI public ở production nếu API nội bộ (đặt sau auth hoặc chỉ bật ở non-prod).


15. Testing — unit + e2e

Định nghĩa. Unit test: test một service cô lập, mock dependency. E2E test: chạy app thật (hoặc gần thật) và bắn HTTP request bằng supertest, kiểm tra toàn pipeline (guard, pipe, controller, service).

Tại sao quan trọng. Unit test bắt lỗi logic nhanh, chạy mili-giây, không cần DB. E2E xác nhận các mảnh ghép đúng với nhau (auth guard có chặn không, validation có chạy không). DI của Nest làm cả hai dễ vì có thể override provider.

Cơ chế. Test.createTestingModule({...}) dựng một DI container test; .overrideProvider(X).useValue(mock) thay dependency. Unit: lấy service ra module.get(UsersService). E2E: app = module.createNestApplication() rồi request(app.getHttpServer()).

Ví dụ code.

ts
// unit — mock repo
const module = await Test.createTestingModule({
  providers: [UsersService, { provide: UserRepository, useValue: { findById: jest.fn() } }],
}).compile();
const service = module.get(UsersService);

// e2e — supertest
it('GET /users/:id 404 khi không tồn tại', () => {
  return request(app.getHttpServer()).get('/users/nope').expect(404);
});

Pitfall thực tế. E2E dùng DB thật mà không reset giữa test → test phụ thuộc thứ tự, flaky. Dùng DB test riêng + truncate/transaction rollback mỗi test. Đừng mock quá sâu ở unit test đến mức test chỉ "kiểm tra mock" mà không kiểm tra logic thật.


Dự án 3 (portfolio chính)

Xây một REST API app thật để làm portfolio — thể hiện toàn bộ GĐ6. Gợi ý: "Mini SaaS / Digital asset store API".

Feature bắt buộc (chứng minh năng lực BE):

  1. Auth + role đầy đủ
    • Register/login, argon2 hashing, JWT access (15m) + refresh (7d) có rotation.
    • RolesGuard + @Roles('admin'|'user') cho RBAC (vd chỉ admin xoá user, xem tất cả order).
    • Refresh token lưu hash trong DB, logout = revoke.
  2. Upload file lên S3 / Cloudflare R2
    • Endpoint nhận file (multipart), validate type/size bằng pipe, đẩy lên R2/S3, lưu key + trả presigned URL khi cần tải.
    • Không lưu file vào server local (không scale).
  3. Payment webhook
    • Tích hợp Stripe/SePay: tạo checkout, nhận webhook báo thanh toán thành công.
    • Verify signature webhook (chống giả mạo), idempotency (webhook có thể gửi lại — không cộng tiền 2 lần).
  4. Background job
    • Queue (BullMQ + Redis): sau khi thanh toán → job gửi email biên nhận, xử lý ảnh, hoặc cleanup.
    • Xử lý retry + dead-letter khi job fail.

Kèm theo (chất lượng production):

  • ValidationPipe global (whitelist), exception filter chuẩn hoá lỗi, interceptor logging + transform response.
  • @nestjs/config + Joi/Zod validate env; secrets qua env, không hardcode.
  • Prisma + repository pattern + migration (không synchronize prod).
  • Swagger /docsaddBearerAuth.
  • Unit test cho service auth/payment + e2e cho luồng login → tạo order → webhook.
  • Dockerfile + docker-compose (app + Postgres + Redis) để chạy 1 lệnh.

Kiến trúc module gợi ý:

AppModule
├── ConfigModule (global)      ├── PrismaModule (global)
├── AuthModule (JwtStrategy, guards, refresh rotation)
├── UsersModule                ├── UploadModule (S3/R2)
├── PaymentModule (checkout + webhook + idempotency)
└── JobsModule (BullMQ processors)

Done khi

  • [ ] Hiểu & giải thích được DI/IoC: vì sao inject qua constructor giúp test/mock, phân biệt singleton vs request scope, xử lý được circular dependency.
  • [ ] Dựng được app Nest có ≥4 feature module, import/export provider đúng, không lỗi "can't resolve dependencies".
  • [ ] ValidationPipe global chạy: request sai → 400 có message rõ; whitelist strip field lạ.
  • [ ] RolesGuard + @Roles chặn đúng: user thường bị 403, chưa login 401; guard order đúng (Auth trước Roles).
  • [ ] Auth hoàn chỉnh: argon2 hash, access + refresh 2 secret khác nhau, refresh rotation + lưu hash, logout revoke được.
  • [ ] Interceptor transform/log response; exception filter chuẩn hoá lỗi + không lộ stack/SQL ra client.
  • [ ] Config validate env lúc boot (thiếu biến → fail fast); không commit .env.
  • [ ] Prisma/TypeORM qua repository pattern, có migration, không N+1 rõ ràng, không leak passwordHash ra response.
  • [ ] Upload S3/R2 hoạt động (presigned URL), payment webhook verify signature + idempotent, background job chạy + retry được.
  • [ ] Swagger /docs mô tả đủ endpoint + bearer auth.
  • [ ] Có unit test service (mock provider) + e2e (supertest) cho ít nhất luồng auth và một luồng nghiệp vụ; test xanh.
  • [ ] Chạy được bằng docker-compose up (app + Postgres + Redis).

Nguyên tắc xuyên suốt để nhớ: Controller mỏng → Service chứa logic → Repository chạm DB. Validate ở boundary. Mọi dependency đều inject, không new. Không tin client. Fail fast ở boot.


🔑 Bổ sung — OAuth 2.0 & Social Login (đăng nhập Google/GitHub)

Bổ sung theo mục tiêu AI SaaS. JWT/Passport ở trên lo xác thực nội bộ; phần này lo đăng nhập bằng nhà cung cấp thứ 3 — gần như bắt buộc cho SaaS.

OAuth 2.0 là gì

  • Định nghĩa: giao thức uỷ quyền — user cho phép app bạn truy cập thông tin của họ ở provider (Google/GitHub) mà KHÔNG đưa mật khẩu cho bạn.
  • Tại sao quan trọng: SaaS cần "Sign in with Google" để giảm ma sát đăng ký; bạn không phải tự lưu/bảo vệ mật khẩu.
  • Phân biệt: OAuth 2.0 = authorization (cấp quyền); OpenID Connect (OIDC) = lớp authentication xây trên OAuth, trả về id_token (JWT) chứa danh tính. Social login thực chất dùng OIDC.

Authorization Code Flow (flow chuẩn cho web server)

Cơ chế từng bước:

  1. FE bấm "Login with Google" → redirect tới accounts.google.com kèm client_id, redirect_uri, scope, state.
  2. User đồng ý → Google redirect về redirect_uri?code=...&state=....
  3. Backend đổi code + client_secret lấy access_token + id_token (gọi server-to-server, secret không lộ ra FE).
  4. Backend verify id_token, đọc email/sub → tìm hoặc tạo user trong DB → phát JWT của hệ thống bạn (như GĐ trên).
  5. Từ đây user dùng JWT nội bộ; không cần giữ token Google.
ts
// NestJS + passport-google-oauth20 (rút gọn)
@Injectable()
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
  constructor() {
    super({
      clientID: process.env.GOOGLE_CLIENT_ID,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET,
      callbackURL: '/auth/google/callback',
      scope: ['email', 'profile'],
    });
  }
  async validate(_at: string, _rt: string, profile: Profile) {
    // profile.emails[0].value -> upsert user -> return user
    return { email: profile.emails[0].value, provider: 'google' };
  }
}

Điểm phải nắm

  • state chống CSRF: sinh random, lưu session/cookie, so lại ở callback. Bỏ qua = lỗ hổng.
  • PKCE: bắt buộc cho SPA/mobile (public client) — chống đánh cắp code. Web server có secret thì ít cần nhưng nên bật.
  • Account linking: user login Google rồi sau login GitHub cùng email → gộp về 1 user (dựa email đã verify), tránh tạo trùng.
  • Chỉ tin email đã email_verified từ provider.
  • Đừng lưu access_token provider nếu không cần gọi API của họ tiếp; chỉ cần danh tính là đủ.

Pitfall thực tế

  • Redirect URI phải khớp tuyệt đối với cấu hình trên console provider (kể cả trailing slash) → lỗi redirect_uri_mismatch.
  • Nhầm id_token (danh tính) với access_token (gọi API).
  • Không verify chữ ký id_token → giả mạo.

Done bổ sung khi

  • Dự án 3 có "Login with Google/GitHub" → tạo user → phát JWT nội bộ, có state, xử lý account linking cơ bản.

Học bằng cách build. Chứng minh, đừng tin.