Provider Request-Scoped di NestJS: Jebakan DI

Foto oleh Original photo: User:Fanghong Derivative work: User:Gnomz007 via Wikimedia Commons (CC BY-SA 3.0)
Provider request-scoped mendapat instance baru untuk setiap request HTTP masuk, dan instance itu di-garbage-collect saat request selesai. Ini berbeda dari scope singleton default, di mana satu instance dibagikan sepanjang umur aplikasi. Anda mengaktifkannya dengan Scope.REQUEST pada dekorator Injectable.
Karena instance tak lagi bisa di-cache dan dibuat sekali saat bootstrap — Nest harus membangunnya, beserta guard, interceptor, dan pipe-nya, di setiap request. Dokumentasi resmi menyebut aplikasi yang dirancang baik seharusnya tetap di bawah sekitar 5% tambahan latensi, tapi salah pakai melipatgandakan alokasi dan tekanan garbage collection saat beban tinggi. Biayanya tumbuh seiring seberapa besar graf yang terinfeksi scope itu.
Scope bubbling adalah saat provider request-scoped memaksa semua yang bergantung padanya ikut menjadi request-scoped. Jika controller menyuntik service request-scoped, controller itu menjadi request-scoped, begitu pula semua yang di atasnya dalam rantai. Satu daun request-scoped bisa diam-diam mengubah sebagian besar graf modul Anda.
Tidak. Singleton dibuat sekali saat bootstrap ketika request belum ada, jadi ia tidak bisa memegang referensi ke sesuatu yang hanya ada per request. Nest akan mempromosikan consumer ke request scope atau gagal me-resolve dependensinya. Batasan ini adalah akar banyak error DI yang membingungkan dan menjadi alasan kuat untuk memilih AsyncLocalStorage bagi konteks bersama.
Gunakan AsyncLocalStorage kapan pun Anda hanya perlu membaca data per-request — user saat ini, tenant id, correlation id untuk logging — dari mana saja dalam kode. Ini menjaga semua provider tetap singleton dan menghindari scope bubbling sepenuhnya. Simpan provider request-scoped untuk kasus yang benar-benar butuh graf objek berbeda per request atau per tenant, seperti durable multi-tenant providers.

Foto oleh Original photo: User:Fanghong Derivative work: User:Gnomz007 via Wikimedia Commons (CC BY-SA 3.0)
Ringkasan Utama
Provider request-scoped di NestJS membuat instance baru per request, dan scope itu menjalar ke atas seluruh rantai injeksi — setiap consumer di atas provider request-scoped ikut menjadi request-scoped. Batas resmi sekitar 5% tambahan latensi, tapi pemakaian ceroboh lebih mahal. Untuk konteks request, pilih AsyncLocalStorage yang menjaga provider tetap singleton.
Secara default setiap provider di NestJS adalah singleton — dibuat sekali saat bootstrap dan dibagikan ke seluruh aplikasi. Itu cepat dan bisa diprediksi. Lalu Anda menemui kasus yang seakan menuntut state per-request: user saat ini, tenant id, correlation id untuk logging. Alat yang paling jelas adalah provider request-scoped, dan NestJS membuatnya cukup satu baris. Satu baris itulah tempat banyak tim diam-diam menukar performa dan kejelasan arsitektur tanpa sadar.
Saya pernah menjalankan service di mana satu logger request-scoped menyeret separuh graf modul ke instansiasi per-request. Tidak ada yang rusak — aplikasinya hanya jadi lebih lambat dan lebih sulit dipahami. Tulisan ini membahas cara kerja scope sebenarnya, dua jebakan yang menggigit di produksi (bubbling dan ketidakcocokan lifecycle), dan kenapa sekarang saya lebih dulu memilih AsyncLocalStorage.
Menetapkan scope sengaja dibuat sepele. Anda mengopernya ke dekorator Injectable, dan di dalam provider request-scoped Anda bisa menyuntik request mentah memakai token REQUEST dari @nestjs/core.
import { Injectable, Scope, Inject } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { Request } from 'express';
@Injectable({ scope: Scope.REQUEST })
export class RequestContextService {
constructor(@Inject(REQUEST) private readonly req: Request) {}
get userId(): string | undefined {
return (this.req.user as { id?: string })?.id;
}
}Ini yang mengejutkan banyak orang. Scope tidak bersifat lokal pada provider yang Anda dekorasi. Jika sebuah controller bergantung pada service request-scoped, controller itu sendiri menjadi request-scoped. Jika service itu disuntik tiga modul dalam, setiap provider di sepanjang jalur yang secara transitif bergantung padanya kini dibuat per request. Satu daun request-scoped bisa diam-diam mengubah sebagian besar graf Anda.
Konsekuensi pertama yang Anda rasakan adalah aturan tegas: provider singleton tidak bisa menyuntik provider request-scoped. Tidak ada request saat singleton dibangun di bootstrap, jadi Nest bisa mempromosikan consumer ke request scope atau menolak me-resolve-nya. Batasan itu merambat keluar dan menjelaskan banyak error DI yang membingungkan.
Anda tidak harus menemukan bubbling saat runtime. Berikan petunjuk durable dan requiredScope, atau audit grafnya, agar perubahan scope yang tidak disengaja gagal keras saat startup, bukan menurunkan throughput di produksi.
Dokumentasi NestJS cukup blak-blakan di sini. Mereka menyatakan aplikasi yang dirancang baik dengan provider request-scoped seharusnya tidak melambat lebih dari sekitar 5% dari sisi latensi — tapi mereka juga menekankan tetap ada dampak, karena instance tak lagi bisa di-cache dan harus dikonstruksi di setiap request. Lima persen adalah kasus yang berperilaku baik, bukan yang terburuk.
Untuk kasus multi-tenant sungguhan NestJS menawarkan durable providers: tandai sebuah provider durable bersama Scope.REQUEST dan Nest akan memakai ulang sub-tree DI antar request yang berbagi atribut umum seperti tenant id, alih-alih membangunnya ulang setiap kali. Itu jalan keluar yang tepat saat Anda benar-benar butuh instance per-tenant — tapi ini pisau bedah, bukan default.
Sebagian besar waktu Anda sebenarnya tidak butuh instance per-request. Anda butuh data per-request yang bisa dibaca dari mana saja. Itulah persis yang diberikan AsyncLocalStorage milik Node: sebuah store yang tetap terasosiasi dengan satu konteks eksekusi async. NestJS secara eksplisit memposisikannya sebagai alternatif provider request-scoped di recipe resminya, dan ini membuat provider Anda tetap singleton.
Polanya sederhana: bungkus request lebih awal di middleware dengan als.run(store, next), lalu baca store dari service singleton mana pun setelahnya dalam rantai async yang sama. Tanpa bubbling, tanpa konstruksi per-request, tanpa batasan DI.
// als-context.ts
import { AsyncLocalStorage } from 'node:async_hooks';
export interface RequestStore {
userId?: string;
correlationId: string;
}
export const requestContext = new AsyncLocalStorage<RequestStore>();
// context.middleware.ts
import { Injectable, NestMiddleware } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { Request, Response, NextFunction } from 'express';
import { requestContext } from './als-context';
@Injectable()
export class ContextMiddleware implements NestMiddleware {
use(req: Request, _res: Response, next: NextFunction) {
const store = {
userId: (req.user as { id?: string })?.id,
correlationId: randomUUID(),
};
// run() keeps the store bound to this request's async context
requestContext.run(store, () => next());
}
}
// any singleton service, anywhere downstream
const ctx = requestContext.getStore();
const correlationId = ctx?.correlationId;Jika Anda ingin ergonomi ramah-DI di atas AsyncLocalStorage, paket nestjs-cls membungkusnya sebagai ClsService yang injectable dengan ClsModule, guard, interceptor, dan setup middleware siap pakai — singleton sampai ke bawah.
AsyncLocalStorage tidak gratis — ada overhead kecil tapi terukur, dan konteks bisa hilang jika sebuah library memutus rantai async (callback mentah, sebagian event emitter). Benchmark setup Anda dan jaga store tetap kecil.
| Aspek | Provider request-scoped | AsyncLocalStorage |
|---|---|---|
| Lifecycle provider | Instance baru per request | Tetap singleton |
| Bubbling scope | Menjalar ke atas seluruh rantai | Tidak ada — tak ada yang ubah scope |
| Performa | Hingga ~5% latensi, lebih jika salah pakai | Overhead kecil terukur |
| Singleton bisa membacanya | Tidak — memaksa consumer ke request scope | Ya, dari singleton mana pun |
| Paling cocok | Instance per-tenant, durable providers | Correlation id, user saat ini, konteks logging |
Aturan praktis saya: jika Anda hanya perlu membaca data request (siapa pemanggilnya, trace id, locale), pakai AsyncLocalStorage dan biarkan setiap provider tetap singleton. Gunakan request scope hanya ketika Anda benar-benar butuh graf objek berbeda per request atau per tenant — dan saat itu, tambahkan durable providers dan scope guard agar bubble tak sengaja gagal saat startup, bukan di grafik latensi Anda.