Service Lifetimes
π― What You'll Learn
- Understanding the three service lifetimes
- Transient: When and why to use it
- Scoped: The default choice for most services
- Singleton: Shared instances across the application
- Lifetime comparison and decision guide
- Common pitfalls and how to avoid them
- Captive dependencies explained
- Best practices for InvenTrack
The Three Lifetimes
Service lifetime determines how long an instance lives and when it's created:
| Lifetime | Created | Shared | Disposed |
|---|---|---|---|
| Transient | Every time requested | Never | End of scope |
| Scoped | Once per request | Within request | End of request |
| Singleton | Once per application | Everywhere | App shutdown |
Transient Lifetime
builder.Services.AddTransient<IEmailService, EmailService>();
Behavior
- A new instance is created every time it's requested
- No sharing between consumers
- Lightweight and stateless
public class ProductsController : ControllerBase
{
private readonly IEmailService _emailService1;
private readonly IEmailService _emailService2;
public ProductsController(IEmailService emailService1, IEmailService emailService2)
{
_emailService1 = emailService1;
_emailService2 = emailService2;
// These are DIFFERENT instances!
Console.WriteLine(_emailService1.GetHashCode()); // e.g., 12345
Console.WriteLine(_emailService2.GetHashCode()); // e.g., 67890
}
}
When to Use Transient
- Lightweight, stateless services
- Services with no shared state
- Services that are cheap to create
Examples
builder.Services.AddTransient<IEmailSender, EmailSender>();
builder.Services.AddTransient<IPdfGenerator, PdfGenerator>();
builder.Services.AddTransient<IDateTimeProvider, DateTimeProvider>();
Don't use Transient for services that are expensive to create (e.g., database connections, HTTP clients). You'll create too many instances and hurt performance.
Scoped Lifetime
builder.Services.AddScoped<IProductService, ProductService>();
Behavior
- Created once per HTTP request
- Shared within the same request
- Disposed at the end of the request
// Request 1
public class ProductsController : ControllerBase
{
private readonly IProductService _productService;
public ProductsController(IProductService productService)
{
_productService = productService;
Console.WriteLine(_productService.GetHashCode()); // e.g., 11111
}
}
public class InventoryService
{
private readonly IProductService _productService;
public InventoryService(IProductService productService)
{
_productService = productService;
Console.WriteLine(_productService.GetHashCode()); // e.g., 11111 (SAME!)
}
}
// Request 2 - NEW instance created
// ProductService hash code: 22222 (DIFFERENT from Request 1)
When to Use Scoped
- Default choice for most services
- Services that work with databases (DbContext)
- Services that maintain state during a request
- Business logic services
Examples
builder.Services.AddScoped<IProductRepository, ProductRepository>();
builder.Services.AddScoped<IProductService, ProductService>();
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddDbContext<InvenTrackDbContext>(); // Scoped by default
Entity Framework's DbContext is not thread-safe and should
not be shared across requests. Scoped ensures each request gets its own DbContext, which
is disposed after the request completes.
Singleton Lifetime
builder.Services.AddSingleton<ICacheService, CacheService>();
Behavior
- Created once for the application lifetime
- Shared across all requests and users
- Disposed when the application shuts down
// Request 1
public class ProductsController : ControllerBase
{
public ProductsController(ICacheService cacheService)
{
Console.WriteLine(cacheService.GetHashCode()); // e.g., 99999
}
}
// Request 2 - SAME instance!
public class OrdersController : ControllerBase
{
public OrdersController(ICacheService cacheService)
{
Console.WriteLine(cacheService.GetHashCode()); // e.g., 99999 (SAME!)
}
}
When to Use Singleton
- Services that are thread-safe
- Services that are expensive to create
- Services with no state or immutable state
- Configuration services
Examples
builder.Services.AddSingleton<ICacheService, RedisCacheService>();
builder.Services.AddSingleton<IConfiguration>(builder.Configuration);
builder.Services.AddSingleton<IHttpClientFactory, HttpClientFactory>();
Singleton services are accessed by multiple threads simultaneously. They must be thread-safe or you'll have race conditions and data corruption!
Lifetime Comparison
TRANSIENT
Request 1: [Instance A] [Instance B] [Instance C]
Request 2: [Instance D] [Instance E] [Instance F]
β New instance every time
SCOPED
Request 1: [Instance A] [Instance A] [Instance A]
Request 2: [Instance B] [Instance B] [Instance B]
β Same instance within request
SINGLETON
Request 1: [Instance A] [Instance A] [Instance A]
Request 2: [Instance A] [Instance A] [Instance A]
β Same instance always
Decision Guide
| Question | Answer | Lifetime |
|---|---|---|
| Does it work with a database? | Yes | Scoped |
| Is it stateless and lightweight? | Yes | Transient |
| Is it expensive to create? | Yes | Singleton |
| Is it thread-safe? | No | Scoped or Transient |
| Does it maintain state? | Yes | Scoped |
| Not sure? | - | Scoped (safest default) |
Common Pitfalls
1. Captive Dependency
A captive dependency occurs when a longer-lived service depends on a shorter-lived service.
// CacheService is Singleton
public class CacheService : ICacheService
{
private readonly InvenTrackDbContext _context; // DbContext is Scoped!
public CacheService(InvenTrackDbContext context)
{
_context = context; // β CAPTIVE DEPENDENCY!
}
}
// Problem: The DbContext is created once and kept for the app lifetime!
// This causes stale data, memory leaks, and thread-safety issues.
public class CacheService : ICacheService
{
private readonly IServiceScopeFactory _scopeFactory;
public CacheService(IServiceScopeFactory scopeFactory)
{
_scopeFactory = scopeFactory;
}
public async Task RefreshCacheAsync()
{
using var scope = _scopeFactory.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<InvenTrackDbContext>();
// Use context, it will be disposed when scope is disposed
}
}
Captive Dependency Rules
| Service Lifetime | Can Depend On | Cannot Depend On |
|---|---|---|
| Transient | Transient, Scoped, Singleton | - |
| Scoped | Scoped, Singleton | β Transient (wasteful) |
| Singleton | Singleton only | β Scoped, β Transient |
2. Disposing Singleton Services
public class MyService : IDisposable
{
public void Dispose()
{
// This won't be called until app shutdown!
}
}
builder.Services.AddSingleton<MyService>();
Singleton services are only disposed when the application shuts down. If you need timely disposal, use Scoped or Transient.
3. Sharing State in Transient Services
public class CounterService
{
public int Count { get; set; }
public void Increment() => Count++;
}
builder.Services.AddTransient<CounterService>();
// Problem: Each instance has its own Count, so state is not shared!
If you need to share state, use Scoped (per request) or Singleton (application-wide).
Complete InvenTrack Example
var builder = WebApplication.CreateBuilder(args);
// SINGLETON - Shared across app, thread-safe, expensive to create
builder.Services.AddSingleton<ICacheService, RedisCacheService>();
builder.Services.AddSingleton<IConfiguration>(builder.Configuration);
// SCOPED - Per request, works with database
builder.Services.AddDbContext<InvenTrackDbContext>();
builder.Services.AddScoped<IProductRepository, ProductRepository>();
builder.Services.AddScoped<ICustomerRepository, CustomerRepository>();
builder.Services.AddScoped<IOrderRepository, OrderRepository>();
builder.Services.AddScoped<IProductService, ProductService>();
builder.Services.AddScoped<IInventoryService, InventoryService>();
builder.Services.AddScoped<IOrderService, OrderService>();
// TRANSIENT - Lightweight, stateless
builder.Services.AddTransient<IEmailSender, SendGridEmailSender>();
builder.Services.AddTransient<IPdfGenerator, PdfGenerator>();
builder.Services.AddTransient<IDateTimeProvider, DateTimeProvider>();
Key Takeaways
- Transient: New instance every time (lightweight, stateless)
- Scoped: One instance per request (default choice, database services)
- Singleton: One instance for app lifetime (thread-safe, expensive to create)
- Default to Scoped when unsure
- Captive dependencies: Longer-lived services cannot depend on shorter-lived ones
- Singleton β Singleton only
- Scoped β Scoped or Singleton
- Transient β Any lifetime
- DbContext should always be Scoped
- Singleton services must be thread-safe
- Use
IServiceScopeFactoryto resolve scoped services from singletons
You now understand service lifetimes and when to use each! In the next section, we'll explore Registering Servicesβdifferent registration patterns, generic services, open generics, and organizing your service registrations effectively.