Lesson 0004 · Backend · ±14 menit

Transient, Scoped, Singleton, dan jebakan captive dependency

Berapa lama DI container menyimpan sebuah instance, kenapa DbContext Scoped, dan cara .NET menangkap kesalahan lifetime

Target lesson ini: Anda bisa menjawab pertanyaan interview "Apa beda Transient, Scoped, dan Singleton, dan apa itu captive dependency?" dalam 60–90 detik, lengkap dengan contoh DbContext dan cara mendeteksinya.

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]

LifetimeInstance dibuat…Di-dispose…Contoh di Product CatalogPadanan 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]

Request masuk→ scope dibuat→ Controller → Service → Repository→ satu DbContext yang sama→ SaveChanges→ scope + DbContext di-dispose

Ada tiga alasan yang bisa Anda sebut di interview:

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 →TransientScopedSingleton
Transientamanamanaman
Scopedamanamanaman
Singletonhati-hati (ikut hidup selamanya) [2]captive dependencyaman

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

  1. Turunkan lifetime konsumen (paling sederhana): jadikan PriceCache Scoped kalau memang tidak perlu Singleton.
  2. 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 with IServiceScopeFactory." [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.

Bacaan utama (±8 menit) Microsoft Learn: Service lifetimes (pendek, tiga bagian). Lalu baca bagian "Captive dependency" di halaman Dependency injection guidelines.

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