Transient, Scoped, Singleton, dan jebakan captive dependency
Berapa lama DI container menyimpan sebuah instance, kenapa DbContext Scoped, dan cara .NET menangkap kesalahan lifetime
1. Lifetime = berapa lama instance hidup
Di lesson 0001 Anda menulis
builder.Services.AddScoped<IProductService, ProductService>() di composition root.
Kata Scoped di situ adalah Service Lifetime: aturan kapan container membuat instance baru dan kapan memakai ulang yang lama.
"Services can be registered with a transient, scoped, or singleton lifetime."
[1]
| Lifetime | Instance dibuat… | Di-dispose… | Contoh di Product Catalog | Padanan Node |
|---|---|---|---|---|
AddTransient |
"each time it's requested from the service container" [1] | akhir request (scope) | PriceFormatter yang ringan, tanpa state |
new X() setiap dipakai |
AddScoped |
"once per client request (connection)" [1] | akhir request | DbContext, Repository, Service, CurrentUser |
objek yang dibuat middleware lalu ditempel ke req / res.locals |
AddSingleton |
sekali, saat pertama diminta [1] | saat aplikasi shutdown | CategoryCache, ILogger<T>, IOptions<T> |
export const cache = new Cache() di level module |
Dua aturan yang sering ditanyakan untuk Singleton: "Singleton services must be thread safe" [1], dan pakai Singleton hanya untuk "services with their own state that is expensive to create or globally shared". Service tanpa state cukup Transient, karena "a stateless transient service is very cheap to create and destroy." [2]
Padanan terdekat ada di NestJS: Scope.DEFAULT ("A single instance of the provider is shared across the entire application"),
Scope.REQUEST, dan Scope.TRANSIENT.
[5]
Bedanya: di NestJS default-nya Singleton, sedangkan di .NET Anda selalu memilih lifetime sendiri lewat AddTransient / AddScoped / AddSingleton.
Bukti dari dokumentasi: dua konsumen, satu request
builder.Services.AddTransient<IOperationTransient, Operation>();
builder.Services.AddScoped<IOperationScoped, Operation>();
builder.Services.AddSingleton<IOperationSingleton, Operation>();
// Operation membuat GUID baru di constructor-nya.
// Controller dan middleware sama-sama meng-inject ketiganya, lalu mencetak ID-nya.
Hasilnya: "Transient objects are always different", "Scoped objects are the same for a given request but differ across new requests", dan "Singleton objects are the same for every request." [3]
2. Kenapa DbContext Scoped
"A DbContext instance is designed to be used for a single unit-of-work. This means that the lifetime of a DbContext instance is usually very short."
[4]
Di web app, "each HTTP request corresponds to a single unit-of-work", jadi AddDbContext mendaftarkan DbContext sebagai Scoped.
[4]
SaveChanges→
scope + DbContext di-dispose
Ada tiga alasan yang bisa Anda sebut di interview:
- Berbagi dalam satu request. Service dan Repository memakai DbContext (dan change tracker) yang sama, jadi satu
SaveChangesmenyimpan semua perubahan. - Terpisah antar request. "DbContext is Not thread-safe. Don't share contexts between threads." Scoped aman karena "each request gets a separate dependency injection scope (and therefore a separate
DbContextinstance)." [4] - Dibersihkan otomatis. Scoped service "are disposed at the end of the request". [1]
Jembatan dari Node: GLOSSARY menyebut DbContext ≈ instance Prisma client, tapi soal lifetime keduanya berbeda. Prisma client biasanya satu instance global; DbContext lebih mirip "satu transaksi kerja per request", karena ia menyimpan state (Entity yang dilacak). Detail change tracking dibahas di lesson 0006.
Latihan 1 — Pilih lifetime untuk setiap skenario
Jawab tanpa scroll ke atas. Soal yang sama akan muncul lagi, diacak, di review campuran 0007.
Kuis di lesson ini adalah feedback cepat, bukan bukti penguasaan; yang dicatat hanya hasil di review campuran 0007.
3. Captive dependency
Istilah dari Mark Seemann yang dipakai Microsoft: "the misconfiguration of service lifetimes, where a longer-lived service holds a shorter-lived service captive." [2]
builder.Services.AddDbContext<AppDbContext>(...); // Scoped
builder.Services.AddSingleton<PriceCache>(); // Singleton
public class PriceCache(AppDbContext db) { ... } // ✗ Singleton memegang Scoped
PriceCache dibuat sekali, jadi DbContext yang ia terima juga hidup selamanya.
Microsoft: resolving a scoped service from a singleton "causes the scoped service to behave like a singleton, which can lead to incorrect state when processing subsequent requests."
[1]
Untuk DbContext, akibatnya dobel: change tracker menumpuk data dari semua request, dan satu instance yang tidak thread-safe dipakai paralel.
Aturan praktis: sebuah dependensi harus hidup sama lama atau lebih lama dari konsumennya.
| Konsumen ↓ menerima → | Transient | Scoped | Singleton |
|---|---|---|---|
| Transient | aman | aman | aman |
| Scoped | aman | aman | aman |
| Singleton | hati-hati (ikut hidup selamanya) [2] | captive dependency | aman |
Cara mendeteksi: scope validation
"By default, in the development environment, resolving a service from another service with a longer lifetime throws an exception."
[1]
Container memeriksa bahwa "Scoped services aren't resolved from the root service provider" dan "Scoped services aren't injected into singletons."
[6]
Pesannya mirip: InvalidOperationException: Cannot consume scoped service 'AppDbContext' from singleton 'PriceCache'.
[2]
Jebakan interview: pemeriksaan ini default-nya hanya di Development. Kalau ingin selalu aktif, nyalakan ValidateScopes
di ServiceProviderOptions, misalnya builder.Host.UseDefaultServiceProvider(o => o.ValidateScopes = true);.
[7]
Bandingkan dengan NestJS: di sana "The REQUEST scope bubbles up the injection chain", jadi konsumennya otomatis ikut menjadi request-scoped.
[5]
.NET tidak menaikkan scope otomatis; ia menangkap kesalahannya dengan exception.
Cara memperbaiki
- Turunkan lifetime konsumen (paling sederhana): jadikan
PriceCacheScoped kalau memang tidak perlu Singleton. - Buat scope sendiri kalau konsumennya memang harus Singleton (atau
BackgroundService): "It's acceptable to resolve a scoped service within a singleton if you create and use an explicit scope withIServiceScopeFactory." [1]
public class PriceCache(IServiceScopeFactory scopeFactory)
{
public async Task RefreshAsync()
{
using var scope = scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
// ... baca harga, simpan ke cache ...
} // scope (dan DbContext-nya) di-dispose di sini
}
Middleware juga Singleton secara efektif: ia dibuat sekali. Karena itu service Scoped diterima lewat parameter InvokeAsync, bukan constructor;
constructor injection "throws a runtime exception because it forces the scoped service to behave like a singleton."
[3]
Middleware dibahas di lesson 0005.
Latihan 2 — Temukan captive dependency
Latihan 3 — Ucapkan dengan suara keras
Pasang timer 90 detik dan jawab dengan suara keras, seolah sedang interview. Jangan buka jawaban contoh sebelum selesai.
"Apa beda Transient, Scoped, dan Singleton di ASP.NET Core, dan apa itu captive dependency?"
Bandingkan dengan jawaban contoh dan checklist-nya
"Ketiganya menentukan berapa lama DI container menyimpan sebuah instance. Transient dibuat baru setiap kali diminta, cocok untuk service ringan tanpa state.
Scoped dibuat sekali per HTTP request dan di-dispose di akhir request; contoh utamanya DbContext, karena satu request adalah satu unit of work.
Singleton dibuat sekali seumur aplikasi, jadi harus thread-safe, misalnya cache.
Captive dependency terjadi kalau service berumur panjang memegang dependensi berumur lebih pendek, misalnya Singleton yang meng-inject DbContext lewat constructor.
DbContext itu jadi ikut hidup selamanya dan dipakai bersama oleh banyak request, padahal tidak thread-safe.
Di Development, scope validation langsung melempar exception 'Cannot consume scoped service from singleton'.
Perbaikannya: turunkan lifetime konsumennya, atau inject IServiceScopeFactory dan buat scope sendiri.
Bedanya dengan NestJS: di sana request scope merambat ke atas otomatis, di .NET tidak."
Checklist: apakah Anda menyebut…
- Transient = instance baru setiap kali diminta?
- Scoped = satu instance per HTTP request, di-dispose di akhir request?
- Singleton = satu instance seumur aplikasi, harus thread-safe?
- captive dependency = service berumur panjang memegang dependensi berumur lebih pendek (contoh Singleton + DbContext)?
- akibatnya: state/DbContext bocor antar request dan dipakai paralel?
- deteksi lewat scope validation (default di Development); perbaikan dengan menurunkan lifetime atau
IServiceScopeFactory?
Lulus kalau minimal 4 dari 6. Kalau kurang, ulangi besok pagi sebelum lesson berikutnya (spacing). Review 0007 juga bisa menanyakan "Kenapa DbContext Scoped?" secara lisan, dengan checklist yang sama gayanya.
Ringkasan untuk dicetak: Reference: Service Lifetime · Istilah: GLOSSARY.md
Ada yang belum jelas? Tanyakan ke saya (agent) di terminal, misalnya: "kalau Service saya Transient tapi meng-inject DbContext, apakah itu captive?" atau "kapan BackgroundService perlu CreateScope?"
Sumber: [1] Service lifetimes · [2] Dependency injection guidelines · [3] Dependency injection in ASP.NET Core (.NET 8) · [4] DbContext Lifetime, Configuration, and Initialization · [5] NestJS: Injection scopes · [6] Dependency injection in .NET: Scope validation · [7] Configure scope validation