Section 3 of 6

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

Registration C#
builder.Services.AddTransient<IEmailService, EmailService>();

Behavior

  • A new instance is created every time it's requested
  • No sharing between consumers
  • Lightweight and stateless
Example C#
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

Good Transient Services C#
builder.Services.AddTransient<IEmailSender, EmailSender>();
builder.Services.AddTransient<IPdfGenerator, PdfGenerator>();
builder.Services.AddTransient<IDateTimeProvider, DateTimeProvider>();
⚠️ Avoid for Expensive Services

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

Registration C#
builder.Services.AddScoped<IProductService, ProductService>();

Behavior

  • Created once per HTTP request
  • Shared within the same request
  • Disposed at the end of the request
Example C#
// 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

Good Scoped Services C#
builder.Services.AddScoped<IProductRepository, ProductRepository>();
builder.Services.AddScoped<IProductService, ProductService>();
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddDbContext<InvenTrackDbContext>(); // Scoped by default
πŸ’‘ Why Scoped for DbContext?

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

Registration C#
builder.Services.AddSingleton<ICacheService, CacheService>();

Behavior

  • Created once for the application lifetime
  • Shared across all requests and users
  • Disposed when the application shuts down
Example C#
// 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

Good Singleton Services C#
builder.Services.AddSingleton<ICacheService, RedisCacheService>();
builder.Services.AddSingleton<IConfiguration>(builder.Configuration);
builder.Services.AddSingleton<IHttpClientFactory, HttpClientFactory>();
⚠️ Must Be Thread-Safe!

Singleton services are accessed by multiple threads simultaneously. They must be thread-safe or you'll have race conditions and data corruption!

Lifetime Comparison

Visual Comparison Text
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.

❌ Bad: Singleton β†’ Scoped C#
// 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.
βœ… Good: Use IServiceProvider C#
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

❌ Bad: IDisposable Singleton C#
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

❌ Bad: Stateful Transient C#
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

Program.cs (Lifetime Choices) C#
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 IServiceScopeFactory to resolve scoped services from singletons
🎯 Next Steps

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.