.NET & C# Development · Lesson 7 of 92

Lab: Break Your App With Wrong Lifetimes — Then Fix It

The Three Lifetimes — Plain English First

Think of a coffee shop:

| Lifetime | Analogy | Created | Disposed | |---|---|---|---| | Transient | Paper cup — one per drink | Every time requested | When done | | Scoped | Your table — shared for your visit | Once per HTTP request | End of request | | Singleton | The espresso machine — everyone shares it | App startup | App shutdown |


Transient — New Every Time

C#
// Registration
builder.Services.AddTransient<IOrderValidator, OrderValidator>();

// Two injections in the same request = two different instances
public class OrderService
{
    private readonly IOrderValidator _validator1;
    private readonly IOrderValidator _validator2;

    public OrderService(IOrderValidator v1, IOrderValidator v2)
    {
        // v1 != v2 — different objects even in the same scope
        _validator1 = v1;
        _validator2 = v2;
    }
}

Use Transient for:

  • Lightweight, stateless services
  • Services that should not share state between calls
  • Validators, mappers, formatters

Scoped — Once Per Request

C#
// Registration
builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();
builder.Services.AddScoped<OrderFlowDbContext>();

// Every injection within the same HTTP request gets the same instance
public class OrderService
{
    private readonly IOrderRepository _orders;
    private readonly ICustomerRepository _customers;

    // _orders and _customers share the same DbContext in the same request
    public OrderService(
        IOrderRepository orders,
        ICustomerRepository customers) { ... }
}

// SqlOrderRepository and CustomerRepository both inject DbContext
// They get the SAME DbContext instance within one request
// This is intentional — one transaction per request
public class SqlOrderRepository
{
    private readonly OrderFlowDbContext _db;
    public SqlOrderRepository(OrderFlowDbContext db) => _db = db;
}

public class SqlCustomerRepository
{
    private readonly OrderFlowDbContext _db;
    public SqlCustomerRepository(OrderFlowDbContext db) => _db = db;
    // Same _db instance as SqlOrderRepository within this request
}

Use Scoped for:

  • DbContext (always — this is the rule)
  • Unit of Work
  • Request-specific state (current user, tenant)
  • Anything that should share state within one request but not between requests

Singleton — One for the App

C#
// Registration
builder.Services.AddSingleton<ICacheService, MemoryCacheService>();
builder.Services.AddSingleton<IFeatureFlags, LaunchDarklyFeatureFlags>();
builder.Services.AddSingleton<HttpClient>(); // shared, but be careful

// You can also pass a pre-built instance
var settings = new AppSettings { Name = "OrderFlow" };
builder.Services.AddSingleton<IAppSettings>(settings);

// Singleton with factory
builder.Services.AddSingleton<ICacheService>(sp =>
{
    var config = sp.GetRequiredService<IConfiguration>();
    return new RedisCacheService(config["Redis:ConnectionString"]!);
});

Use Singleton for:

  • Expensive-to-create services (HTTP clients, cache providers, connection pools)
  • Stateless services with shared configuration
  • Services that hold app-wide state (feature flags, config snapshots)

Never use Singleton for:

  • Anything that holds request-specific state
  • Anything that wraps DbContext
  • Anything with mutable state that isn't thread-safe

The Captive Dependency Bug

This is the most common lifetime mistake. A longer-lived service holds a reference to a shorter-lived service, freezing it in time.

C#
// THE BUG
public class OrderProcessor  // registered as Singleton
{
    private readonly IOrderRepository _repo;  // registered as Scoped!

    public OrderProcessor(IOrderRepository repo)
    {
        // This repo was created for request #1
        // It will be used for request #2, #3, ... forever
        // The scoped repo is now "captive" in the singleton
        _repo = repo;
    }
}

// Registration that causes the bug
builder.Services.AddSingleton<OrderProcessor>();  // <-- singleton
builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();  // <-- scoped

// What goes wrong:
// Request 1: OrderProcessor is created, captures repo for request 1
// Request 2: Same OrderProcessor, same repo — which may have a disposed DbContext
// Result: ObjectDisposedException, stale data, or thread-safety violations

Detect It — ValidateScopes

Turn on scope validation in development so the app throws on startup instead of silently corrupting data.

C#
// Program.cs
var builder = WebApplication.CreateBuilder(args);

// Enable scope validation in development
builder.Host.UseDefaultServiceProvider((context, options) =>
{
    options.ValidateScopes  = context.HostingEnvironment.IsDevelopment();
    options.ValidateOnBuild = context.HostingEnvironment.IsDevelopment();
});

// This will throw InvalidOperationException at startup:
// "Cannot consume scoped service 'IOrderRepository' from singleton 'OrderProcessor'"
builder.Services.AddSingleton<OrderProcessor>();
builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();

ValidateOnBuild catches the problem when builder.Build() is called, before a single request is served.


Fix Pattern 1 — Change the Singleton's Lifetime

C#
// If OrderProcessor doesn't need to be singleton, just make it scoped
builder.Services.AddScoped<OrderProcessor>();    // fixed
builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();

Fix Pattern 2 — Use IServiceScopeFactory

When you genuinely need a singleton that occasionally resolves scoped services (e.g. background jobs):

C#
public class OrderCleanupJob  // Singleton background service
{
    private readonly IServiceScopeFactory _scopeFactory;
    private readonly ILogger<OrderCleanupJob> _logger;

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

    public async Task RunAsync(CancellationToken ct)
    {
        // Create a fresh scope for each run — scoped services are safe here
        await using var scope = _scopeFactory.CreateAsyncScope();
        var repo = scope.ServiceProvider.GetRequiredService<IOrderRepository>();

        var staleOrders = await repo.GetOlderThanAsync(TimeSpan.FromDays(90), ct);
        _logger.LogInformation("Archiving {Count} stale orders", staleOrders.Count);

        foreach (var order in staleOrders)
            await repo.ArchiveAsync(order.Id, ct);
    }
    // scope.DisposeAsync() called here — DbContext and repo are properly disposed
}

DbContext Is Always Scoped — Here Is Why

C#
// CORRECT
builder.Services.AddDbContext<OrderFlowDbContext>(opts =>
    opts.UseSqlServer(connectionString));
// AddDbContext registers it as Scoped by default

// WHY NOT SINGLETON?
// DbContext tracks entity state (change tracking)
// If shared across requests, User A's changes bleed into User B's request
// DbContext is not thread-safe — concurrent requests = race conditions

// WHY NOT TRANSIENT?
// You lose the unit-of-work pattern — multiple repos get different contexts
// Can't commit a multi-repository operation in one transaction

// CORRECT MULTI-REPO TRANSACTION (both repos share one DbContext)
public class OrderService
{
    private readonly IOrderRepository _orders;
    private readonly IInventoryRepository _inventory;
    private readonly OrderFlowDbContext _db;

    public OrderService(
        IOrderRepository orders,
        IInventoryRepository inventory,
        OrderFlowDbContext db)
    {
        _orders    = orders;
        _inventory = inventory;
        _db        = db;
    }

    public async Task<Order> CreateAsync(CreateOrderRequest req, CancellationToken ct)
    {
        await using var tx = await _db.Database.BeginTransactionAsync(ct);
        try
        {
            var order = await _orders.AddAsync(req, ct);
            await _inventory.ReserveAsync(req.Items, ct);
            await _db.SaveChangesAsync(ct);
            await tx.CommitAsync(ct);
            return order;
        }
        catch
        {
            await tx.RollbackAsync(ct);
            throw;
        }
    }
}

Lifetime Reference Table

C#
// TRANSIENT
builder.Services.AddTransient<IOrderValidator, OrderValidator>();
builder.Services.AddTransient<IEmailFormatter, HtmlEmailFormatter>();

// SCOPED
builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddScoped<ICurrentUserService, HttpContextCurrentUserService>();
builder.Services.AddDbContext<OrderFlowDbContext>(...);  // scoped by default

// SINGLETON
builder.Services.AddSingleton<ICacheService, RedisCacheService>();
builder.Services.AddSingleton<IFeatureFlags, LaunchDarklyFeatureFlags>();
builder.Services.AddSingleton<IConfiguration>(...);  // already registered by host
builder.Services.AddHttpClient<IPaymentClient, StripePaymentClient>();  // IHttpClientFactory is singleton

The Captive Dependency Matrix

| Consumer \ Dependency | Transient | Scoped | Singleton | |---|---|---|---| | Transient | OK | OK | OK | | Scoped | OK | OK | OK | | Singleton | OK (new each call anyway) | BUG | OK |

Only one cell is a bug: a Singleton consuming a Scoped service.


Verifying Lifetimes in Tests

C#
public class ServiceLifetimeTests
{
    [Fact]
    public void Transient_ReturnsDifferentInstances()
    {
        var services = new ServiceCollection();
        services.AddTransient<IOrderValidator, OrderValidator>();
        var sp = services.BuildServiceProvider();

        var v1 = sp.GetRequiredService<IOrderValidator>();
        var v2 = sp.GetRequiredService<IOrderValidator>();

        Assert.NotSame(v1, v2);
    }

    [Fact]
    public void Scoped_ReturnsSameInstanceWithinScope()
    {
        var services = new ServiceCollection();
        services.AddScoped<IOrderRepository, SqlOrderRepository>();
        var sp = services.BuildServiceProvider();

        using var scope = sp.CreateScope();
        var r1 = scope.ServiceProvider.GetRequiredService<IOrderRepository>();
        var r2 = scope.ServiceProvider.GetRequiredService<IOrderRepository>();

        Assert.Same(r1, r2);  // same within scope

        using var scope2 = sp.CreateScope();
        var r3 = scope2.ServiceProvider.GetRequiredService<IOrderRepository>();
        Assert.NotSame(r1, r3);  // different across scopes
    }

    [Fact]
    public void Singleton_ReturnsSameInstanceAlways()
    {
        var services = new ServiceCollection();
        services.AddSingleton<ICacheService, MemoryCacheService>();
        var sp = services.BuildServiceProvider();

        var c1 = sp.GetRequiredService<ICacheService>();
        using var scope = sp.CreateScope();
        var c2 = scope.ServiceProvider.GetRequiredService<ICacheService>();

        Assert.Same(c1, c2);
    }
}

What to Learn Next

  • Keyed Services: Register multiple implementations of the same interface cleanly
  • Scrutor: Auto-register services by convention with a single Scan() call
  • Background Services: Hosted services and how they interact with DI lifetimes