Dependency Injection in Depth: Singleton, Scoped and Transient Lifetimes
We cover the lifetime concept at the heart of dependency injection. The difference between singleton, scoped and transient, when to use each, and the subtle bugs a wrong choice causes.
Throughout this series we've used dependency injection casually: we put something in the constructor, .NET gave it to us, and we used it. We registered the service in Program.cs but never got into the details of that registration line. Today we pull back that curtain. Because a small decision you make while registering services can fundamentally change the application's behavior, and when you choose wrong, it can lead to one of the hardest bugs to find. Our topic: lifetimes.
Let's Clarify Dependency Injection First
A quick reminder. Dependency injection is a class receiving the things it needs ready-made from the outside, instead of creating them inside itself. UsersController doesn't new the IUserService it needs itself; it asks for it in its constructor, and .NET comes and hands it over. The mechanism that does this "handing over" is called the DI container.
Think of the container as a central warehouse where services are kept. When a controller says "I need an IUserService," the container looks in the warehouse and gives the suitable one. So does the container produce this service anew every time it's requested, or does it always give the same one? The answer to this question is the lifetime.
What Is a Lifetime?
A lifetime determines how long a service will "live," that is, how often the container will recreate it. When registering a service, you choose one of three options, and this choice dictates how that service will behave throughout the application. There are three lifetimes: singleton, scoped, and transient. Now let's open up each one, one by one, with a concrete analogy.
Singleton: A Single, Shared Instance
A service registered as singleton is created only once for the entire lifetime of the application. Whoever asks for it first, the container produces it, then gives the same instance to everyone. That single instance lives until the application shuts down.
Think of it like a building's main entrance door. There's a single main door in the building; everyone who enters uses the same door. You don't produce a separate door for anyone. Singleton is like this: everyone shares the same instance. You register it like this:
builder.Services.AddSingleton();
Singleton is ideal for stateless services that are expensive to create; for example, a configuration reader or a cache. But be careful: since there's a single shared instance, if you hold user-specific data inside it, that data gets accidentally shared with everyone. When choosing singleton, it's essential to ask "does this service store anything person-specific inside it?"
Scoped: One Instance per Request
A service registered as scoped is created once for each HTTP request. Within the same request, no matter how many times you ask for that service, you always get the same instance. But when a new request arrives, the container produces a brand-new instance.
Think of it like the bill a waiter opens for you at a restaurant. When you sit at the table, a bill is opened for you; whatever you order that evening is all recorded on the same bill. But the bill of the table next to you is completely separate, and when you get up and leave, your bill is closed. Each customer (request) has their own bill (instance). The registration:
builder.Services.AddScoped();
Scoped is the most frequently used lifetime in web applications, and there's a good reason. It provides a consistent state from the beginning to the end of a request; all parts within the same request share the same instance, while different requests don't mix with each other. EF Core's DbContext is registered as scoped exactly for this reason: each request has its own DbContext, and requests don't corrupt each other's data.
Transient: A New Instance Every Time
A service registered as transient is recreated every time it's requested. Even within the same request, if you ask for it twice, you get two different instances. This is the shortest-lived lifetime.
Think of it like paper cups. You give a clean, new cup to everyone who wants to drink water; you don't take the used cup back. A fresh one every time. The registration:
builder.Services.AddTransient();
Transient is suitable for lightweight, stateless little services that need to work independently on each call. But since a new instance is produced every time, making heavy and expensive services transient creates unnecessary cost. This is usually the lifetime you'll need least; if you're not sure, don't let transient be your first choice.
What Does a Wrong Choice Lead To?
Lifetimes seem innocent in theory, but a wrong choice produces one of the most frustrating bugs to find. The most classic trap is this: injecting a scoped service into a singleton service.
Why is it a problem? A singleton is created once for the lifetime of the application, and it captures the scoped service it takes inside at that moment, holding it forever. But the scoped service (for example, the DbContext) was supposed to be refreshed on each request. Since the singleton froze it, the first request's DbContext keeps being used throughout the application. This leads to data corruption and randomly-appearing bugs. Fortunately, .NET usually catches this dangerous combination at startup and warns you, but understanding its logic protects you from this trap from the start.
The general rule is: a service shouldn't directly depend on a service shorter-lived than itself. The long-lived one (singleton) shouldn't imprison the short-lived one (scoped) inside it.
A Practical Compass
A simple guide for which lifetime to choose: singleton for stateless, heavy services that can be shared across the whole application. Scoped for services that need to be consistent throughout a request's life and talk to the database; in web APIs this should be your default choice. Transient for lightweight services that need to be independent and fresh on each call. When you can't be sure, start with scoped; it's the safest and most common choice in web applications.
A Small Experiment
Try this: write a simple service that is produced on each request and holds a random identifier (for example a Guid) inside it. Inject the same service into both the controller and another service that the controller uses. Then register this service as singleton, scoped, and transient in turn, and by looking at the same request a few times, observe when the identifier stays the same and when it changes. You'll see that in singleton it's always the same, in scoped it's the same throughout the request but different across requests, and in transient it's different on each call. This small experiment settles the difference between the three lifetimes far more clearly than any definition.
In the next article we'll move on to global error handling: we'll talk about how to catch an unexpected error occurring anywhere in the application in a single central place and how to return a proper response to the user. The DI logic you grasped today will come right up in front of us while setting up that central structure too.