jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

Node.js Production Engineering 14 — NestJS dưới Góc nhìn Kiến trúc

Thiết kế NestJS 11 như một runtime kiến trúc: module boundary, DI scope, request lifecycle, validation, policy guard, interceptor, error contract, Prisma 7, testing và graceful shutdown.

Một codebase NestJS có thể có đủ module, controller, service và decorator nhưng vẫn là một khối coupling lớn: module import vòng tròn, forwardRef() khắp nơi, business rule nằm trong guard và request-scoped provider làm latency tăng mà không ai biết.

NestJS giải quyết wiring và lifecycle. Framework không tự tìm bounded context, transaction boundary hay ownership của dữ liệu. Bài này đọc NestJS 11 như một runtime kiến trúc để những cơ chế của framework phục vụ thiết kế, thay vì trở thành thiết kế.

Sau bài này, bạn có thể:

  • dùng module như explicit boundary và composition root;
  • chọn provider scope dựa trên lifetime/cost;
  • giải thích chính xác request lifecycle;
  • đặt validation, authentication, authorization, transaction và error mapping đúng tầng;
  • tích hợp Prisma 7 mà không tạo nhiều pool hoặc rò connection;
  • test use case qua port, test HTTP qua module thật;
  • shutdown có readiness/drain và telemetry đầy đủ.

Baseline: NestJS 11, Node.js 24 LTS, TypeScript và Prisma 7. NestJS 11 dùng Express 5 theo mặc định; route syntax và một số behavior khác NestJS 10/Express 4, vì vậy luôn đọc migration guide khi nâng major.


Mental model: Nest là ba hệ thống chồng lên nhau

compile/startup
  module graph → provider graph → singleton construction

request
  adapter → middleware → guards → interceptors(in)
          → pipes → controller → application service
          → interceptors(out) → adapter

shutdown
  signal → readiness off → HTTP drain → lifecycle hooks → process exit

Ba trục này tạo ra ba loại lỗi khác nhau:

  • graph lỗi: circular dependency, provider không export/import đúng, token trùng;
  • request lỗi: guard/pipe/interceptor sai thứ tự hoặc xử lý nhầm concern;
  • lifetime lỗi: request scope lan truyền, resource không đóng, shutdown mất job/trace.

Module là boundary, không phải folder gom file

Một cấu trúc theo capability giúp ownership rõ hơn cấu trúc chỉ theo technical layer:

src/modules/orders/
├── domain/           # entity, value object, invariant; không import Nest
├── application/      # use case + port
├── infrastructure/   # Prisma repository, broker adapter
├── presentation/     # controller, DTO, guard binding
└── orders.module.ts  # composition root của capability

Module chỉ export thứ capability khác thực sự được phép dùng:

// application/order-repository.port.ts
export const ORDER_REPOSITORY = Symbol('ORDER_REPOSITORY');

export interface OrderRepository {
  findById(id: string): Promise<Order | null>;
  save(order: Order): Promise<void>;
}
// application/place-order.use-case.ts
import { Inject, Injectable } from '@nestjs/common';

@Injectable()
export class PlaceOrder {
  constructor(
    @Inject(ORDER_REPOSITORY)
    private readonly orders: OrderRepository
  ) {}

  async execute(command: PlaceOrderCommand): Promise<PlacedOrder> {
    const order = Order.place(command); // domain giữ invariant
    await this.orders.save(order);
    return { id: order.id, status: order.status };
  }
}
// orders.module.ts
import { Module } from '@nestjs/common';

@Module({
  imports: [DatabaseModule],
  controllers: [OrdersController],
  providers: [
    PlaceOrder,
    { provide: ORDER_REPOSITORY, useClass: PrismaOrderRepository },
  ],
  exports: [PlaceOrder],
})
export class OrdersModule {}

Decorator @Injectable() làm application service biết DI container của Nest. Đây là trade-off thực dụng. Nếu application core phải chạy độc lập khỏi Nest, bỏ decorator và tạo factory/provider ở module; đừng tuyên bố “framework-agnostic” khi source vẫn import framework.

Invariant của module graph

  • domain không import presentation/infrastructure;
  • module A không đọc table/private provider của module B;
  • cross-module call đi qua exported use case/port hoặc event contract;
  • một provider có một owner module rõ ràng;
  • @Global() là ngoại lệ cho capability thật sự toàn cục như config/telemetry, không phải cách tránh import.

forwardRef() có thể tháo một vòng lặp kỹ thuật, nhưng nhiều forwardRef() thường báo boundary sai. Tách contract, đổi chiều dependency hoặc dùng domain event trước khi chấp nhận vòng tròn.


Provider scope là quyết định hiệu năng

ScopeLifetimeDùng choRủi ro
Singleton (default)một instance/applicationstateless service, pool, configkhông giữ mutable request state
Requestmột instance/requestdependency thực sự gắn requestallocation tăng; scope lan lên consumer
Transientmỗi lần injectobject có state ngắnkhó quan sát số instance

Default singleton phù hợp cho phần lớn service. PrismaClient, Redis client và SDK connection phải là singleton/process.

Không dùng request scope chỉ để lấy request id. AsyncLocalStorage hoặc OpenTelemetry context thường rẻ và ít làm provider graph “bubbling” thành request-scoped hơn.

import { AsyncLocalStorage } from 'node:async_hooks';
import { Injectable } from '@nestjs/common';

interface RequestContext {
  requestId: string;
  traceId?: string;
}

@Injectable()
export class RequestContextStore {
  private readonly storage = new AsyncLocalStorage<RequestContext>();

  run<T>(context: RequestContext, callback: () => T): T {
    return this.storage.run(context, callback);
  }

  get(): RequestContext | undefined {
    return this.storage.getStore();
  }
}

Context id từ client phải được validate độ dài/ký tự hoặc thay bằng UUID mới để tránh log injection và cardinality không kiểm soát.


Request lifecycle: đặt concern đúng chỗ

Thứ tự tổng quát của Nest:

1. middleware
2. guards
3. interceptors — trước controller
4. pipes
5. controller
6. service/use case
7. interceptors — sau controller, theo thứ tự ngược
8. exception filters khi có exception chưa được bắt
9. response adapter

Ánh xạ concern:

ConcernPrimitive phù hợp
request id, raw header normalizationmiddleware/interceptor
authentication/authorizationguard
parse + validate DTO/parameterpipe
timing, tracing, response envelope, cacheinterceptor
HTTP error serializationexception filter
business invariantdomain/application service

Guard không nên parse body business phức tạp; pipe không nên query database để authorize; interceptor không nên nuốt exception rồi trả 200.


Controller mỏng và DTO runtime rõ ràng

import { Body, Controller, HttpCode, Param, Post } from '@nestjs/common';
import { IsArray, IsInt, IsString, Min, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';

class OrderLineDto {
  @IsString()
  productId!: string;

  @Type(() => Number)
  @IsInt()
  @Min(1)
  quantity!: number;
}

class PlaceOrderDto {
  @IsArray()
  @ValidateNested({ each: true })
  @Type(() => OrderLineDto)
  lines!: OrderLineDto[];
}

@Controller('orders')
export class OrdersController {
  constructor(private readonly placeOrder: PlaceOrder) {}

  @Post()
  @HttpCode(201)
  create(@Body() dto: PlaceOrderDto) {
    return this.placeOrder.execute({ lines: dto.lines });
  }
}

Global validation:

app.useGlobalPipes(
  new ValidationPipe({
    whitelist: true,
    forbidNonWhitelisted: true,
    transform: true,
    transformOptions: { enableImplicitConversion: false },
    stopAtFirstError: false,
  })
);

DTO phải là class vì validation cần runtime metadata; TypeScript interface biến mất khi compile. transform: true không có nghĩa mọi coercion đều an toàn: dùng @Type rõ ràng, giới hạn array/string/number và vẫn validate domain invariant trong use case.

Không trả entity/Prisma model trực tiếp. Response DTO/view model ngăn vô tình lộ passwordHash, field nội bộ hoặc thay contract khi schema đổi.


Authentication và authorization là hai guard khác nhau

Authentication tạo principal hoặc trả 401. Authorization đánh giá principal trên resource/policy và trả 403 hoặc 404 theo disclosure policy.

export interface Principal {
  subject: string;
  tenantId: string;
  permissions: ReadonlySet<string>;
}

@Injectable()
export class AccessTokenGuard implements CanActivate {
  constructor(private readonly verifier: AccessTokenVerifier) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const request = context
      .switchToHttp()
      .getRequest<Request & { principal?: Principal }>();
    const token = readBearerToken(request.headers.authorization);
    if (!token) throw new UnauthorizedException();

    request.principal = await this.verifier.verify(token).catch(() => {
      throw new UnauthorizedException();
    });
    return true;
  }
}
export const REQUIRE_PERMISSION = 'require_permission';
export const RequirePermission = (permission: string) =>
  SetMetadata(REQUIRE_PERMISSION, permission);

@Injectable()
export class PermissionGuard implements CanActivate {
  constructor(private readonly reflector: Reflector) {}

  canActivate(context: ExecutionContext): boolean {
    const permission = this.reflector.getAllAndOverride<string>(
      REQUIRE_PERMISSION,
      [context.getHandler(), context.getClass()]
    );
    if (!permission) return true;

    const request = context
      .switchToHttp()
      .getRequest<{ principal?: Principal }>();
    if (!request.principal) throw new UnauthorizedException();
    if (!request.principal.permissions.has(permission))
      throw new ForbiddenException();
    return true;
  }
}

Permission route-level chưa đủ cho ownership/tenant isolation. Repository query phải scope theo tenantId/owner hoặc policy service phải kiểm resource. Không nhận tenantId từ body rồi tin nó.

Global guard dùng APP_GUARD để vẫn tham gia DI; route public cần metadata explicit và được security test.


Interceptor cho cross-cutting behavior có giới hạn

@Injectable()
export class MetricsInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
    const request = context.switchToHttp().getRequest<Request>();
    const response = context.switchToHttp().getResponse<Response>();
    const started = performance.now();

    return next.handle().pipe(
      finalize(() => {
        httpDuration.observe(
          {
            method: request.method,
            route: request.route?.path ?? 'unmatched',
            status_class: `${Math.floor(response.statusCode / 100)}xx`,
          },
          (performance.now() - started) / 1_000
        );
      })
    );
  }
}

Không label metric bằng raw URL, user id hay order id. Interceptor response cache chỉ dành cho query an toàn và phải hiểu auth/tenant/freshness; cache một response cá nhân theo URL chung là data leak.

RxJS observable đi ra interceptor theo thứ tự LIFO. Nếu team không quen RxJS, giữ interceptor nhỏ và test error/cancellation path thay vì xây business workflow trong operator chain.


Error contract: map domain error một lần

export class DomainError extends Error {
  constructor(
    public readonly code: string,
    message: string,
    options?: ErrorOptions
  ) {
    super(message, options);
  }
}

@Catch(DomainError)
export class DomainErrorFilter implements ExceptionFilter {
  catch(error: DomainError, host: ArgumentsHost): void {
    const response = host.switchToHttp().getResponse<Response>();
    const mapping = domainErrorToHttp(error.code);

    response.status(mapping.status).json({
      error: {
        code: error.code,
        message: mapping.publicMessage,
      },
    });
  }
}

Giữ code ổn định cho client; message có thể đổi/ngôn ngữ hóa. Unknown error phải log nguyên cause/stack ở server nhưng trả 500 chung. Không biến mọi exception thành 400, không lộ SQL/Prisma/JWT detail.

Filter route/controller chạy trước filter global và một exception đã được filter bắt không tự đi tiếp qua filter khác. Tránh nhiều filter chồng responsibility không rõ.


Prisma 7 trong Nest: một pool, lifecycle rõ

import {
  Injectable,
  OnApplicationShutdown,
  OnModuleInit,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '../generated/prisma/client.js';

@Injectable()
export class PrismaService
  extends PrismaClient
  implements OnModuleInit, OnApplicationShutdown
{
  constructor(config: ConfigService) {
    const connectionString = config.getOrThrow<string>('DATABASE_URL');
    const adapter = new PrismaPg({
      connectionString,
      max: 10,
      connectionTimeoutMillis: 5_000,
    });
    super({ adapter });
  }

  async onModuleInit(): Promise<void> {
    await this.$connect();
  }

  async onApplicationShutdown(): Promise<void> {
    await this.$disconnect();
  }
}
@Global()
@Module({
  providers: [PrismaService],
  exports: [PrismaService],
})
export class DatabaseModule {}

Chỉ global hóa database adapter khi team chấp nhận coupling đó; kiến trúc chặt hơn có thể chỉ inject repository port vào capability module.

Transaction boundary thuộc use case, không thuộc controller và không bị chia nhỏ qua nhiều repository tự mở transaction. External publish/email đi qua outbox như phần 11–12.


Bootstrap, health và shutdown

async function bootstrap() {
  const app = await NestFactory.create(AppModule, { bufferLogs: true });

  app.enableShutdownHooks();
  app.setGlobalPrefix('api');
  app.useLogger(app.get(AppLogger));
  app.useGlobalPipes(productionValidationPipe());

  await app.listen(process.env.PORT ?? 3000);
}

Shutdown production không chỉ gọi app.close():

SIGTERM
  → readiness=false
  → load balancer ngừng route
  → HTTP server ngừng nhận mới, drain in-flight
  → worker/subscriber ngừng nhận mới và drain
  → Prisma/Redis/telemetry flush + close
  → exit trước orchestrator deadline

Liveness phải rẻ và không phụ thuộc database. Readiness có thể phản ánh dependency bắt buộc nhưng cần timeout/cache để probe không tự DDoS database. Thêm startup probe cho app migration/warm-up lâu.


Express hay Fastify adapter

Nest tách phần lớn API khỏi HTTP platform, nhưng không phải mọi package đều portable:

  • middleware nhận Request/Response Express;
  • file upload, compression, raw body, streaming;
  • plugin lifecycle và error behavior;
  • benchmark phụ thuộc route/payload/plugin chứ không chỉ adapter.

Chọn Fastify khi workload và ecosystem phù hợp, sau benchmark đại diện. Nếu code gọi trực tiếp res của Express ở controller, migration adapter không còn là thay một dòng config.


Testing theo boundary

Unit test use case qua port fake, không boot cả Nest application:

describe('PlaceOrder', () => {
  it('persists an order that satisfies domain invariants', async () => {
    const orders: OrderRepository = {
      findById: vi.fn(),
      save: vi.fn(),
    };
    const useCase = new PlaceOrder(orders);

    const result = await useCase.execute(validCommand());

    expect(result.status).toBe('PLACED');
    expect(orders.save).toHaveBeenCalledOnce();
  });
});

Integration test kiểm DI graph và override adapter:

const moduleRef = await Test.createTestingModule({
  imports: [OrdersModule],
})
  .overrideProvider(ORDER_REPOSITORY)
  .useValue(fakeOrderRepository)
  .compile();

E2E test boot app thật với cùng global pipe/guard/filter như production, dùng PostgreSQL/Redis disposable. Đừng tạo test app thiếu global config rồi kết luận production route đã được bảo vệ.

Các contract cần test:

  • DTO thừa field bị từ chối;
  • unauthenticated là 401, thiếu quyền là 403/policy 404;
  • tenant A không đọc resource tenant B;
  • domain error map đúng code/status;
  • unknown error không lộ stack;
  • shutdown hoàn tất in-flight request và đóng resource.

Failure modes và trade-off

Failure modeDấu hiệuCách xử lý
fat controller/serviceHTTP, SQL, rule trộn cùng fileuse case + port theo capability
circular modulenhiều forwardRef()sửa dependency direction/boundary
request-scope cascadeallocation/latency tăngsingleton + ALS/context
global everythingtest/coupling khó thấyexplicit import/export
guard chỉ kiểm roleIDOR/tenant leakresource policy + scoped query
filter nuốt unknown errormất signal, trả sai statusmap known error, log unknown
decorator che side effecttransaction/cache khó thấyexplicit use case orchestration
test chỉ mockgraph/config/migration lỗi khi deployintegration + E2E disposable deps

Nest phù hợp khi team cần convention, DI, lifecycle và ecosystem thống nhất. Với service nhỏ hoặc team ưu tiên explicit functions/minimal abstraction, Fastify/Express/Hono có thể ít ceremony hơn. Đánh giá bằng cost thay đổi, onboarding, runtime và ownership, không bằng số decorator.


Lab và acceptance criteria

Xây OrdersModule trên Prisma 7 và Redis/BullMQ.

  1. Domain không import Nest/Prisma; application phụ thuộc repository port.
  2. Module chỉ export use case công khai; dependency graph không có forwardRef().
  3. Global ValidationPipe reject unknown field và không implicit-coerce ngoài DTO chỉ định.
  4. Auth guard trả 401; permission/resource policy chặn cross-tenant access.
  5. Place order mở một transaction, ghi order + outbox; không publish trong transaction.
  6. PrismaService chỉ có một instance/process và disconnect khi shutdown.
  7. Metrics interceptor dùng route template/status class, không dùng raw URL/user id.
  8. Unit test use case không boot Nest; integration test override port; E2E dùng config global giống production.
  9. SIGTERM test chứng minh readiness off, request đang chạy hoàn tất, worker/client đóng.
  10. Kiểm tra heap/allocation cho thấy không có request-scoped provider ngoài danh sách đã giải trình.

Checklist production

  • Module map theo capability và có public API rõ.
  • Provider scope được chọn theo lifetime, không theo tiện lợi.
  • Controller chỉ dịch transport; invariant nằm trong domain/use case/database.
  • Validation whitelist input và coercion explicit.
  • Authentication, permission và resource ownership tách rõ.
  • Error contract có stable code; unknown error không lộ detail.
  • Prisma/Redis/SDK là singleton và có lifecycle shutdown.
  • Transaction bao trọn use case; external side effect qua outbox.
  • Metric/trace dùng low-cardinality attribute.
  • Unit, integration và E2E mỗi lớp chứng minh một boundary khác nhau.

Tài liệu chính thức

Phần tiếp theo đặt identity vào đúng boundary này: access token không phải ID token, OAuth không tự là login, refresh rotation là state machine và authorization phải kiểm audience, tenant lẫn resource.