OpenAPI-First #4: NestJS Interface Contract thay vì Class kế thừa
Tại sao không nên dùng extends để kế thừa controller trong NestJS khi làm OpenAPI-First? Bài này giải thích cơ chế metadata của NestJS và cách dùng Interface đúng cách.
Nghe bài viết này dưới dạng podcastKhi làm OpenAPI-First với NestJS, câu hỏi tự nhiên là: nếu đã generate controller base class từ spec, tại sao không dùng extends để tái sử dụng decorators?
Lý do: NestJS decorators không hoạt động đúng khi kế thừa. Đây là điểm khác biệt cốt lõi so với Spring Boot trong Java.
Tại sao Java làm được nhưng TypeScript thì không?
Trong Spring Boot:
@RestController
public abstract class UsersControllerBase {
@GetMapping("/users")
public abstract List<UserResponse> listUsers();
}
// Subclass tự động kế thừa @GetMapping
public class UsersController extends UsersControllerBase {
@Override
public List<UserResponse> listUsers() {
return svc.findAll();
}
}
Spring đọc được @GetMapping trên subclass vì JVM spec bắt buộc java.lang.reflect phải leo lên inheritance chain khi tìm annotations. Đây là đặc tả của ngôn ngữ.
Trong TypeScript/NestJS:
@Controller('users')
export abstract class UsersControllerBase {
@Get() // metadata gắn vào UsersControllerBase.prototype
abstract listUsers(): Promise<UserListResponse>;
}
export class UsersController extends UsersControllerBase {
override listUsers() { return this.svc.findAll(); }
}
NestJS scanner làm thế này:
// NestJS chỉ đọc own properties — không leo lên prototype chain
const methods = Object.getOwnPropertyNames(UsersController.prototype);
for (const method of methods) {
const httpMethod = Reflect.getMetadata('method', UsersController.prototype, method);
// → undefined! Metadata nằm ở UsersControllerBase.prototype
// không phải UsersController.prototype
}
Kết quả: route /users không được đăng ký, API trả về 404 silent failure.
Giải pháp đúng: Interface + DTO từ package
Package chỉ export DTO (generate từ spec) và Interface (viết tay, enforce contract). Không export controller.
// packages/nestjs-contracts/src/users/users.interface.ts
import { CreateUserDto, UpdateUserDto, UserResponse, UserListResponse } from './dto';
export interface IUsersController {
listUsers(page: number, limit: number): Promise<UserListResponse>;
createUser(dto: CreateUserDto): Promise<UserResponse>;
getUser(id: string): Promise<UserResponse>;
updateUser(id: string, dto: UpdateUserDto): Promise<UserResponse>;
deleteUser(id: string): Promise<void>;
}
NestJS repo implements interface và tự viết decorators:
// src/features/users/users.controller.ts
import { Controller, Get, Post, Patch, Delete,
Body, Param, Query, HttpCode } from '@nestjs/common';
import { IUsersController, CreateUserDto, UpdateUserDto }
from '@your-org/api-contracts-nestjs';
import { UsersService } from './users.service';
@Controller('users')
export class UsersController implements IUsersController {
constructor(private svc: UsersService) {}
@Get()
listUsers(@Query('page') page = 1, @Query('limit') limit = 20) {
return this.svc.findAll({ page: +page, limit: +limit });
}
@Post()
createUser(@Body() dto: CreateUserDto) {
return this.svc.create(dto);
}
@Get(':id')
getUser(@Param('id') id: string) {
return this.svc.findById(id);
}
@Patch(':id')
updateUser(@Param('id') id: string, @Body() dto: UpdateUserDto) {
return this.svc.update(id, dto);
}
@Delete(':id')
@HttpCode(204)
deleteUser(@Param('id') id: string) {
return this.svc.delete(id);
}
}
TypeScript bắt lỗi khi contract thay đổi
Khi package thêm method mới vào interface:
export interface IUsersController {
// ... existing methods
bulkDeleteUsers(ids: string[]): Promise<void>; // ← thêm mới
}
TypeScript ngay lập tức báo lỗi:
Class 'UsersController' incorrectly implements interface 'IUsersController'.
Property 'bulkDeleteUsers' is missing in type 'UsersController'
but required in type 'IUsersController'.
Lỗi xảy ra ở compile time — không cần chờ runtime hay QA. Đây chính xác là cơ chế mà tsc --noEmit khai thác ở bài tiếp theo.
Tại sao decorators viết tay không phải vấn đề?
@Get(), @Post(), @Param()... gần như không bao giờ thay đổi theo thời gian. Thứ thay đổi thường xuyên là DTO shape — và đó chính xác là thứ package quản lý.
Decorators viết tay 1 lần. DTO được sync tự động qua package version.
Kết luận
- NestJS metadata không tự kế thừa qua
extendsvì TypeScript dùngReflect.defineMetadatathay vì JVM reflection. - Cố dùng
extendsđể kế thừa decorators sẽ dẫn đến 404 silent failure. - Giải pháp đúng: package export DTO + Interface, consumer tự viết decorators.
- TypeScript
implementsenforce contract ở compile time — không cần tool thêm. - Bài tiếp theo: cách dùng
tsc --noEmitđể tự động hóa việc check này trong pre-commit và CI.
Chưa có bình luận
Để lại bình luận
Bình luận sẽ được phê duyệt trước khi hiển thị.