Skip to content

Service Lifetimes Interview Questions & Answers

15 questions Updated 2026-06-23 Share:

Service lifetime interview questions — Singleton vs Scoped vs Transient, captive dependencies, IServiceScopeFactory, disposal, and ValidateScopes.

Read the in-depth guideService Lifetimes in .NET Core DI(opens in new tab)
15 of 15

The DI container supports three lifetimes that control when a service instance is created and how long it lives.

// Singleton — one instance for the entire application lifetime:
builder.Services.AddSingleton<ICache, MemoryCache>();
// Same instance returned for every request and every constructor injection.
// Use for: stateless or thread-safe services, expensive-to-create objects,
// in-memory caches, configuration wrappers, HttpClient via IHttpClientFactory.

// Scoped — one instance per HTTP request (or per explicit scope):
builder.Services.AddScoped<IOrderService, OrderService>();
// Same instance within a single request; new instance for the next request.
// Use for: DbContext, unit-of-work objects, per-request state like current user.

// Transient — new instance every time the service is resolved:
builder.Services.AddTransient<IEmailValidator, EmailValidator>();
// New instance injected into every constructor and every GetService<T>() call.
// Use for: lightweight stateless services, or services with non-thread-safe state.

Quick reference:

Lifetime Created Disposed Shared across requests?
Singleton App start App shutdown Yes
Scoped Per request End of request Within one request
Transient Per resolve End of scope Never

Rule of thumb: Default to Scoped for application services. Use Singleton only for thread-safe, expensive, or shared state. Use Transient for lightweight stateless helpers.

Singleton services are instantiated once and shared across all requests for the lifetime of the application. Because multiple concurrent requests share the same instance, it must be thread-safe.

// Good singleton — immutable or thread-safe state:
builder.Services.AddSingleton<IConfiguration>(); // already singleton in ASP.NET Core
builder.Services.AddSingleton<IHttpClientFactory>(); // thread-safe, manages pool
builder.Services.AddSingleton<IMemoryCache, MemoryCache>(); // thread-safe

// Good: expensive-to-create object shared across requests:
builder.Services.AddSingleton<IConnectionMultiplexer>(sp =>
    ConnectionMultiplexer.Connect(sp.GetRequiredService<IConfiguration>()["Redis"]));

// Bad singleton — mutable state without synchronization:
public class RequestCounter // registered as Singleton
{
    private int _count; // shared across all requests — race condition!
    public void Increment() => _count++; // not thread-safe
}

// Fix: use thread-safe primitives:
public class RequestCounter
{
    private long _count;
    public void Increment() => Interlocked.Increment(ref _count);
    public long Get() => Interlocked.Read(ref _count);
}

// Bad: singleton holding a scoped dependency (captive dependency):
public class PricingEngine // Singleton
{
    public PricingEngine(AppDbContext db) { } // AppDbContext is Scoped — BUG
}

Rule of thumb: If a singleton holds shared mutable state, protect it with Interlocked, lock, or concurrent collections. Never let a singleton hold a Scoped service — that creates a captive dependency.

Scoped services get one instance per DI scope. In ASP.NET Core, the framework automatically creates a scope for each HTTP request and disposes it when the response completes. Two injections of the same scoped service within one request share the same instance.

// AppDbContext is the canonical scoped service — one connection per request:
builder.Services.AddScoped<AppDbContext>();

// Both inject the same instance within a single request:
public class OrderService
{
    public OrderService(AppDbContext db) { _db = db; }
}

public class InventoryService
{
    public InventoryService(AppDbContext db) { _db = db; }
    // Same AppDbContext instance as OrderService within this request
}

// Creating a scope manually (needed in background services, tests, CLI):
using (var scope = app.Services.CreateScope())
{
    var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
    await db.Database.MigrateAsync(); // own scope, own connection
} // AppDbContext disposed here

// HttpContext.RequestServices exposes the request scope:
public class MyController : ControllerBase
{
    [HttpGet]
    public IActionResult Get()
    {
        // Resolved from the request scope (same as constructor injection):
        var svc = HttpContext.RequestServices.GetRequiredService<IOrderService>();
        return Ok();
    }
}

Rule of thumb: Scope boundaries are your unit-of-work boundary. Register DbContext and anything that wraps a database connection as Scoped to get automatic per-request isolation and disposal.

Transient services are created fresh on every resolve — every constructor injection and every GetService<T>() call yields a new instance. They're disposed at the end of the enclosing scope (usually the HTTP request).

// Transient registration:
builder.Services.AddTransient<IEmailValidator, RegexEmailValidator>();

// Demonstration — each injection gets its own instance:
public class SignupService
{
    private readonly IEmailValidator _v1;
    private readonly IEmailValidator _v2;

    public SignupService(IEmailValidator v1, IEmailValidator v2)
    {
        // v1 and v2 are different RegexEmailValidator instances
        Console.WriteLine(ReferenceEquals(v1, v2)); // false
    }
}

// Trade-offs:
// Safe for non-thread-safe objects — each consumer gets its own copy
// Good for lightweight, stateless helpers
// Can create many instances per request — avoid for expensive-to-construct types
// If the service holds resources (connections, file handles), disposal overhead
// multiplies with each resolve

// IDisposable transients — the scope owns disposal:
public class CsvWriter : IDisposable
{
    private readonly StreamWriter _writer;
    public CsvWriter() => _writer = new StreamWriter("output.csv");
    public void Dispose() => _writer.Dispose();
}
// Disposed at end of request scope — the container tracks IDisposable transients

Rule of thumb: Use Transient for cheap, stateless, non-thread-safe services. Prefer Singleton for stateless thread-safe services — Transient's per-resolve cost adds up fast in high-throughput endpoints.

A captive dependency occurs when a service with a longer lifetime holds a reference to a service with a shorter lifetime. The short-lived service effectively becomes long-lived — living beyond its intended scope and potentially sharing state across requests.

// Captive dependency: Singleton captures a Scoped service
public class ProductCache  // Singleton
{
    private readonly AppDbContext _db; // Scoped — lives per request

    public ProductCache(AppDbContext db)
    {
        _db = db; // db now lives forever — same instance across ALL requests
        // Any request-specific state in db leaks across requests → data corruption
    }
}

// Detection — enable ValidateScopes in development:
builder.Host.UseDefaultServiceProvider(options =>
{
    options.ValidateScopes = app.Environment.IsDevelopment();
});
// Without this, the bug silently causes intermittent data corruption in production.

// Fix 1: resolve scoped service per operation via IServiceScopeFactory:
public class ProductCache
{
    private readonly IServiceScopeFactory _factory;
    public ProductCache(IServiceScopeFactory factory) => _factory = factory;

    public async Task RefreshAsync()
    {
        using var scope = _factory.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
        _products = await db.Products.ToListAsync();
        // scope and db disposed at end of using block
    }
}

// Fix 2: change the lifetime — if the class truly needs Scoped, register it Scoped:
builder.Services.AddScoped<ProductCache>();
// Now it gets a fresh AppDbContext per request automatically

Rule of thumb: Captive dependencies are the most dangerous DI lifetime bug — they corrupt request state silently. Enable ValidateScopes in development to catch them at startup.

IServiceScopeFactory creates explicit DI scopes on demand — essential for any code that runs outside the ASP.NET Core request pipeline (background services, hosted services, console programs) where no automatic scope exists.

// Background service — no HTTP request scope exists:
public class DataSyncHostedService : BackgroundService
{
    private readonly IServiceScopeFactory _scopeFactory;
    private readonly ILogger<DataSyncHostedService> _logger;

    public DataSyncHostedService(
        IServiceScopeFactory scopeFactory,
        ILogger<DataSyncHostedService> logger)
    {
        _scopeFactory = scopeFactory;
        _logger = logger;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            // Create a fresh scope for each sync cycle:
            using (var scope = _scopeFactory.CreateScope())
            {
                var db       = scope.ServiceProvider.GetRequiredService<AppDbContext>();
                var syncSvc  = scope.ServiceProvider.GetRequiredService<ISyncService>();

                await syncSvc.SyncAsync(stoppingToken);
                // db and syncSvc disposed when scope is disposed (end of using)
            }

            await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
        }
    }
}

IServiceScopeFactory is itself registered as a Singleton by the framework, so it can be injected into Singleton services safely. The scopes it creates are independent and properly bounded.

Rule of thumb: Any Singleton that needs a Scoped service should inject IServiceScopeFactory and create a new scope per operation. Never inject Scoped services directly into a Singleton constructor.

The DI container automatically disposes services that implement IDisposable or IAsyncDisposable when their owning scope ends — Singleton at app shutdown, Scoped and Transient at the end of the request scope.

public class DatabaseConnection : IDisposable, IAsyncDisposable
{
    private SqlConnection _conn;

    public DatabaseConnection(string connectionString)
        => _conn = new SqlConnection(connectionString);

    public void Dispose()
    {
        _conn?.Dispose();
        _conn = null;
    }

    public async ValueTask DisposeAsync()
    {
        if (_conn is not null)
        {
            await _conn.DisposeAsync();
            _conn = null;
        }
    }
}

builder.Services.AddScoped<DatabaseConnection>();
// Disposed (DisposeAsync preferred) at the end of each HTTP request automatically.

// Singletons are disposed at app shutdown:
builder.Services.AddSingleton<IConnectionMultiplexer>(sp =>
    ConnectionMultiplexer.Connect("localhost"));
// ConnectionMultiplexer.Dispose() called when app.StopAsync() completes.

// Transients are tracked by the scope and disposed with it:
// If a Transient IDisposable is resolved from the root scope (e.g., app.Services),
// it won't be disposed until app shutdown — this is a memory leak.
// Always resolve Disposable Transients from a child scope.

Rule of thumb: Let the container own disposal — never call Dispose() on injected services yourself. For root-scope transients that are IDisposable, create an explicit child scope so disposal happens promptly.

BackgroundService is registered as a Singleton (it runs for the app's lifetime). Injecting a Scoped service directly into its constructor creates a captive dependency — the Scoped service becomes effectively Singleton and doesn't get disposed per request.

// WRONG — captive dependency; AppDbContext lives for the app lifetime:
public class OrderProcessor : BackgroundService
{
    private readonly AppDbContext _db; // Scoped — wrong lifetime here

    public OrderProcessor(AppDbContext db) // container throws or creates a captive
    {
        _db = db;
    }

    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        // _db is reused across ALL processing cycles — EF change tracker corrupts
        while (!ct.IsCancellationRequested)
            await ProcessNextOrderAsync(_db);
    }
}

// CORRECT — create a scope per work unit:
public class OrderProcessor : BackgroundService
{
    private readonly IServiceScopeFactory _factory;

    public OrderProcessor(IServiceScopeFactory factory) => _factory = factory;

    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        while (!ct.IsCancellationRequested)
        {
            using var scope = _factory.CreateScope();
            var db    = scope.ServiceProvider.GetRequiredService<AppDbContext>();
            var queue = scope.ServiceProvider.GetRequiredService<IOrderQueue>();

            await ProcessNextOrderAsync(db, queue);
            // db and queue properly disposed after each iteration
        }
    }
}

Rule of thumb: BackgroundService constructors should only accept Singleton services plus IServiceScopeFactory. Create a new scope for every unit of work that needs Scoped services (DbContext, unit-of-work, per-operation state).

The root scope is the IServiceProvider created by builder.Build(). It's the top-level container that lives for the entire application lifetime. Resolving Scoped or Transient IDisposable services from the root scope leaks them until app shutdown.

var app = builder.Build();

// Resolving Scoped service from root scope — leaks until app shutdown:
var db = app.Services.GetRequiredService<AppDbContext>();
// db is Scoped, but the root scope never ends → memory + connection leak

// Always create a child scope for one-off operations:
using (var scope = app.Services.CreateScope())
{
    var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
    await db.Database.MigrateAsync(); // db disposed at end of using block
}

// With ValidateScopes = true, resolving a Scoped service from root throws:
builder.Host.UseDefaultServiceProvider(options =>
{
    options.ValidateScopes = app.Environment.IsDevelopment();
});
// InvalidOperationException: Cannot resolve scoped service from root provider.

// Safe: resolving Singleton from root is always fine:
var config = app.Services.GetRequiredService<IConfiguration>(); // OK
var cache  = app.Services.GetRequiredService<IMemoryCache>();   // OK

Rule of thumb: Never call app.Services.GetRequiredService<T>() for Scoped or Transient services. Always wrap one-off resolutions in CreateScope(). Enable ValidateScopes to catch root-scope violations at startup in development.

Blazor's DI model differs from ASP.NET Core's request pipeline because Blazor Server keeps a persistent SignalR circuit per browser tab, and Blazor WebAssembly runs entirely in the browser.

// Blazor Server — "Scoped" = per circuit (browser tab session), not per HTTP request:
builder.Services.AddScoped<IUserState, UserState>();
// Same instance across all component renders within one browser tab.
// A new tab → new circuit → new IUserState instance.
// This is a LONGER lifetime than ASP.NET Core Scoped (which is per-request).

// Singleton in Blazor Server — shared across ALL connected browser tabs!
builder.Services.AddSingleton<ISharedCache, MemoryCache>();
// All users share this — only safe for truly shared, thread-safe state.

// Blazor WebAssembly — no server; runs in browser:
// Singleton = lives until the browser tab is closed or refreshed.
// Scoped    = same as Singleton in WASM (one scope for the whole app lifetime).
// Transient = new instance per injection, just like everywhere else.
builder.Services.AddSingleton<ICartService, CartService>(); // per tab in WASM

// Per-component transient state:
builder.Services.AddTransient<IComponentState, ComponentState>();
// New instance per component that injects it — use [CascadingParameter] instead
// for parent-to-child state sharing in Blazor.

Rule of thumb: In Blazor Server, treat Scoped as per-user-session and Singleton as shared-across-all-users. Never store user-specific state in a Singleton in Blazor Server.

Lifetime mismatches are subtle bugs — they often don't fail immediately but cause data corruption or state leaks in production. .NET provides built-in tooling to catch them.

// 1. Enable ValidateScopes + ValidateOnBuild (development only):
builder.Host.UseDefaultServiceProvider((ctx, options) =>
{
    options.ValidateOnBuild = ctx.HostingEnvironment.IsDevelopment();
    options.ValidateScopes  = ctx.HostingEnvironment.IsDevelopment();
});

// 2. Write integration tests that build the real container:
public class DependencyInjectionTests
{
    [Fact]
    public void AllServicesResolvable()
    {
        // Use the real IServiceCollection from your app:
        var services = new ServiceCollection();
        Startup.ConfigureServices(services);

        using var provider = services.BuildServiceProvider(
            new ServiceProviderOptions
            {
                ValidateOnBuild = true,
                ValidateScopes  = true,
            });

        // If any registration is broken, BuildServiceProvider throws here:
        Assert.NotNull(provider.CreateScope()
            .ServiceProvider.GetRequiredService<IOrderService>());
    }
}

// 3. Use the ASP.NET Core test host (validates real DI graph):
await using var factory = new WebApplicationFactory<Program>()
    .WithWebHostBuilder(builder =>
        builder.UseEnvironment("Development")); // triggers ValidateScopes
var client = factory.CreateClient();
// First request will throw if DI is misconfigured

Rule of thumb: Make DI validation part of CI — either via integration tests that build the real container or by running the app in Development mode with ValidateScopes and ValidateOnBuild enabled.

Singleton is almost always faster for stateless services — allocation, garbage collection, and constructor cost happen once at startup rather than on every resolve. Transient multiplies that cost by every request and every inject site.

// Scenario: a stateless email validator resolved thousands of times per second.

// Transient — new RegexEmailValidator() on every resolution:
builder.Services.AddTransient<IEmailValidator, RegexEmailValidator>();
// Cost per resolution: heap allocation + Regex compilation (if not pre-compiled)
//   + GC pressure accumulates under high throughput.

// Singleton — one instance created at startup, shared for app lifetime:
builder.Services.AddSingleton<IEmailValidator, RegexEmailValidator>();
// Cost per resolution: dictionary lookup only — no allocation.
// Safe because RegexEmailValidator has no mutable state.

// Benchmark illustration (not real numbers — for illustration only):
// 1M resolutions: Transient ~300ms | Singleton ~5ms

// Transient is correct when the service holds non-thread-safe mutable state:
public class CsvBuilder // not thread-safe — each caller needs its own instance
{
    private readonly StringBuilder _sb = new();
    public void AddRow(string[] cells) => _sb.AppendJoin(',', cells);
    public string Build() => _sb.ToString();
}
builder.Services.AddTransient<CsvBuilder>(); // correct: new per injection

// Singleton is wrong for non-thread-safe types:
builder.Services.AddSingleton<CsvBuilder>(); // Bad: shared StringBuilder corrupts

Rule of thumb: For stateless, thread-safe services, prefer Singleton. The allocation cost of Transient is negligible for lightweight types, but it becomes measurable in hot paths. Profile before over-optimizing, but design stateless helpers to be Singleton-safe.

Outside the HTTP request pipeline — CLI tools, unit tests, EF migrations, integration test setup, worker services — no scope exists automatically. You must create one explicitly via IServiceScopeFactory or IServiceProvider.CreateScope().

// Pattern 1: one-off operation after app.Build() (e.g., run EF migrations):
var app = builder.Build();

using (var scope = app.Services.CreateScope())
{
    var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
    await db.Database.MigrateAsync();
    // scope and db disposed at end of using block
}

await app.RunAsync();

// Pattern 2: integration test setup with WebApplicationFactory:
public class OrdersIntegrationTest : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly WebApplicationFactory<Program> _factory;

    public OrdersIntegrationTest(WebApplicationFactory<Program> factory)
        => _factory = factory;

    [Fact]
    public async Task SeedDatabase_ThenQueryViaApi()
    {
        // Create a scope for direct DB access in the test:
        using var scope = _factory.Services.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
        db.Orders.Add(new Order { Id = 1, Total = 99.99m });
        await db.SaveChangesAsync();

        // Now hit the real API endpoint:
        var client   = _factory.CreateClient();
        var response = await client.GetAsync("/orders/1");
        response.EnsureSuccessStatusCode();
    }
}

// Pattern 3: console / worker service (no HTTP pipeline at all):
var host = Host.CreateDefaultBuilder(args)
    .ConfigureServices(services =>
    {
        services.AddScoped<AppDbContext>();
        services.AddScoped<IDataImporter, CsvDataImporter>();
    })
    .Build();

using (var scope = host.Services.CreateScope())
{
    var importer = scope.ServiceProvider.GetRequiredService<IDataImporter>();
    await importer.ImportAsync("data.csv");
}

Rule of thumb: Any code that runs outside a request scope must create its own scope using CreateScope(). Dispose the scope as soon as the unit of work is complete so Scoped services (DbContext, connections) are released promptly.

Singleton services are shared across all concurrent requests, so any mutable shared state must be protected. The right tool depends on the access pattern.

// Pattern 1: immutability — no synchronization needed:
public class RegionConfig // Singleton
{
    // Populated once at construction; never mutated afterward:
    private readonly IReadOnlyDictionary<string, string> _regions;

    public RegionConfig(IConfiguration config)
        => _regions = config.GetSection("Regions")
            .GetChildren()
            .ToDictionary(s => s.Key, s => s.Value ?? "");

    public string? GetEndpoint(string region)
        => _regions.TryGetValue(region, out var url) ? url : null;
}

// Pattern 2: Interlocked for counters and simple values:
public class RequestMetrics // Singleton
{
    private long _totalRequests;
    private long _failedRequests;

    public void RecordSuccess() => Interlocked.Increment(ref _totalRequests);
    public void RecordFailure()
    {
        Interlocked.Increment(ref _totalRequests);
        Interlocked.Increment(ref _failedRequests);
    }
    public long Total   => Interlocked.Read(ref _totalRequests);
    public long Failures => Interlocked.Read(ref _failedRequests);
}

// Pattern 3: ConcurrentDictionary for concurrent read-write maps:
public class InMemoryCache<TKey, TValue> where TKey : notnull // Singleton
{
    private readonly ConcurrentDictionary<TKey, TValue> _store = new();

    public TValue GetOrAdd(TKey key, Func<TKey, TValue> factory)
        => _store.GetOrAdd(key, factory); // thread-safe by design

    public bool TryRemove(TKey key, out TValue? value)
        => _store.TryRemove(key, out value);
}

// Pattern 4: ReaderWriterLockSlim for infrequent writes, frequent reads:
public class RoutingTable // Singleton
{
    private Dictionary<string, string> _routes = new();
    private readonly ReaderWriterLockSlim _lock = new();

    public string? Resolve(string path)
    {
        _lock.EnterReadLock();
        try   { return _routes.TryGetValue(path, out var dest) ? dest : null; }
        finally { _lock.ExitReadLock(); }
    }

    public void Reload(Dictionary<string, string> newRoutes)
    {
        _lock.EnterWriteLock();
        try   { _routes = newRoutes; }
        finally { _lock.ExitWriteLock(); }
    }
}

Rule of thumb: Prefer immutable state for singletons — no locks needed. When you need mutable shared state, use Interlocked for scalars, ConcurrentDictionary for maps, and ReaderWriterLockSlim for rarely-written, frequently-read structures.

gRPC services in ASP.NET Core are treated like controllers — the framework creates a new instance per RPC call (equivalent to per-request). DI lifetime rules apply the same way as in HTTP middleware and controllers.

// gRPC service — one instance per RPC call by default:
public class OrderGrpcService : OrderService.OrderServiceBase
{
    private readonly IOrderRepository _orders; // Scoped — safe; new per RPC call
    private readonly ILogger<OrderGrpcService> _logger;

    public OrderGrpcService(IOrderRepository orders, ILogger<OrderGrpcService> logger)
    {
        _orders = orders;
        _logger = logger;
    }

    public override async Task<OrderReply> GetOrder(
        OrderRequest request, ServerCallContext context)
    {
        _logger.LogInformation("GetOrder called for id={Id}", request.Id);
        var order = await _orders.GetByIdAsync(request.Id);
        return order is null
            ? throw new RpcException(new Status(StatusCode.NotFound, "Order not found"))
            : new OrderReply { Id = order.Id, Total = (double)order.Total };
    }
}

// Registration — same as any ASP.NET Core service:
builder.Services.AddScoped<IOrderRepository, EfOrderRepository>();
builder.Services.AddGrpc();
app.MapGrpcService<OrderGrpcService>();

// Streaming RPCs — the single service instance handles the entire stream:
// For server-streaming methods, the same Scoped IOrderRepository instance is used
// throughout the stream lifetime — which is the duration of the RPC call, not per message.

// Warning: Singleton state shared across all concurrent gRPC calls — same rules as HTTP:
builder.Services.AddSingleton<IGrpcMetrics, GrpcMetrics>(); // must be thread-safe

Rule of thumb: In gRPC services, treat each RPC call as an HTTP request. Scoped services are safe because the framework creates a fresh DI scope per call. Singleton services must still be thread-safe — concurrent streams share them.

More ways to practice

The self-quiz is live. Get notified when mock interviews and new question packs drop.

or
Join our WhatsApp Channel