Understanding Dependency Injection Lifetimes: Transient vs Scoped vs Singleton
Dependency injection is a fundamental concept in modern .NET development, and understanding service lifetimes is crucial for building robust applications. Microsoft.Extensions.DependencyInjection provides three main lifetime options: Transient
, Scoped
, and Singleton
. Let’s explore how these work and see them in action using LINQPad.
The Three Service Lifetimes
When you register services in the ServiceCollection
dependency injection container, you need to choose how long each service should live. This decision affects everything from memory usage to how your services behave across different parts of your application.
Transient services are created fresh every time they’re requested. Think of them like disposable items - you get a new one each time you ask for it. This is perfect for lightweight, stateless services that don’t need to maintain any state between uses.
Scoped services live for the duration of a specific scope, typically a web request in ASP.NET Core applications. Within that scope, you’ll always get the same instance when you request the service. This is great for services that need to share state within a single operation but shouldn’t persist beyond it.
Singleton services are created once and live for the entire lifetime of the application. They’re like global variables that everyone shares. This is ideal for services that are expensive to create or need to maintain state across the entire application.
Seeing It All in Action
Let’s look at a practical example that demonstrates how these lifetimes work. This code shows exactly when each service gets created and reused:
void Main()
{
ServiceCollection services = new();
services.AddTransient<TransientService>();
services.AddScoped<ScopedService>();
services.AddSingleton<SingletonService>();
using ServiceProvider sp1 = services.BuildServiceProvider();
Console.WriteLine($"begin: scope1");
using IServiceScope scope1 = sp1.CreateScope();
// Transient services - new instance every time
var ts1 = scope1.ServiceProvider.GetService<TransientService>(); // prints ctor TransientService
var ts2 = scope1.ServiceProvider.GetService<TransientService>(); // prints ctor TransientService
// Scoped services - one instance per scope
var ss1 = scope1.ServiceProvider.GetService<ScopedService>(); // prints ctor ScopedService
var ss2 = scope1.ServiceProvider.GetService<ScopedService>(); // *nothing*
// Singleton services - one instance per service provider
var sngs1 = scope1.ServiceProvider.GetService<SingletonService>(); // prints ctor SingletonService
var sngs2_2 = scope1.ServiceProvider.GetService<SingletonService>(); // *nothing*
Console.WriteLine($"\nbegin: scope2");
using IServiceScope scope2 = sp1.CreateScope();
var s2_ss1 = scope2.ServiceProvider.GetService<ScopedService>(); // prints ctor ScopedService
var s2_ss2 = scope2.ServiceProvider.GetService<ScopedService>(); // *nothing*
var s2_sngs1 = scope2.ServiceProvider.GetService<SingletonService>(); // *nothing*
}
public class TransientService
{
public TransientService() { Console.WriteLine($"ctor TransientService"); }
}
public class ScopedService
{
public ScopedService() { Console.WriteLine($"ctor ScopedService"); }
}
public class SingletonService
{
public SingletonService() { Console.WriteLine($"ctor SingletonService"); }
}
What Happens When You Run This Code
When you run this example in LINQPad, you’ll see exactly how each lifetime behaves. The output tells the story:
begin: scope1
ctor TransientService
ctor TransientService
ctor ScopedService
ctor SingletonService
begin: scope2
ctor ScopedService
Notice that TransientService gets created twice in the first scope - once for each request. ScopedService gets created once per scope, so you see it created in both scope1 and scope2. SingletonService only gets created once, in the first scope, and then gets reused in the second scope.
Download LINQPad Today
Get started with the most powerful .NET Rapid Progress Tool (RPT) available.
Get LINQPad PremiumPowerful .NET acceleration. Code at the speed of thought.
When to Use Each Lifetime
Choosing the right lifetime for your services is crucial for both performance and correctness. Use Transient for lightweight, stateless services that don’t need to share any data. This is often the safest choice when you’re unsure, as it prevents accidental state sharing between different parts of your application.
Scoped services are perfect for services that need to maintain state within a single operation. In web applications, this typically means within a single HTTP request. Database contexts, user sessions, and request-specific caches are great candidates for scoped services.
Singleton services should be used carefully, but they’re perfect for services that are expensive to create or need to maintain global state. Configuration services, logging services, and application-wide caches are common examples. Just remember that singletons need to be thread-safe since they can be accessed from multiple threads simultaneously.
Common Pitfalls to Avoid
One of the most common mistakes is using the wrong lifetime for your services. Using a singleton when you meant to use scoped can lead to memory leaks and unexpected behavior. Similarly, using transient for expensive services can hurt performance.
Another common issue is circular dependencies. These can be tricky to spot, but they’re especially problematic with singleton services since they’re created once and live forever. Always think about the dependencies between your services when choosing lifetimes.
Testing with LINQPad
LINQPad makes it incredibly easy to experiment with different service lifetimes. You can quickly change the registration and see how it affects the behavior of your services. This immediate feedback helps you understand the implications of your choices before you commit them to your actual application.
The ability to see exactly when constructors are called makes it much easier to debug dependency injection issues. You can quickly spot if services are being created more often than expected or if they’re not being created when they should be.
Download LINQPad Today
Get started with the most powerful .NET Rapid Progress Tool (RPT) available.
Get LINQPad PremiumPowerful .NET acceleration. Code at the speed of thought.
Best Practices
Always start with the most restrictive lifetime that meets your needs. If a service doesn’t need to maintain state, use transient. If it needs state within a request, use scoped. Only use singleton when you’re sure the service can be safely shared across the entire application.
Consider the performance implications of your choices. Transient services are created and destroyed frequently, which can impact performance if they’re expensive to create. Singleton services use less memory but can cause issues if they’re not thread-safe.
Document your service lifetimes clearly. It’s easy to forget why you chose a particular lifetime, especially when you come back to the code months later. Clear comments or documentation help prevent confusion and bugs.
Conclusion
Understanding service lifetimes in dependency injection is essential for building maintainable .NET applications. The choice between Transient, Scoped, and Singleton affects not just performance but also the correctness of your application. LINQPad provides the perfect environment for experimenting with these concepts and seeing exactly how they work in practice.
Want to learn more about LINQPad and how it can supercharge your development workflow? Check out my comprehensive LINQPad course where I cover everything from basic queries to advanced scenarios like the ones shown here.