.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
// 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
// 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
// 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.
// 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 violationsDetect It — ValidateScopes
Turn on scope validation in development so the app throws on startup instead of silently corrupting data.
// 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
// 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):
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
// 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
// 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 singletonThe 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
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