Dependency Injection Scopes
Dependency Inversion Principle, Dependency Injection, Singleton.... What?

I had a discussion with a colleague recently about fundamentals of software engineering. If you know me, you know I come back to SOLID constantly. This conversation touched on dependency injection, inversion of control, and the Singleton pattern and whether it violates SOLID. There is a lot buried in those topics, but I want to narrow the focus here to service lifetimes and scopes, using Microsoft.Extensions.DependencyInjection as the entry point.
We're going to explore those lifetimes through a concrete proof of concept. Something anyone can download, run, and modify to see the behavior for themselves. My experience lately is that Microsoft has made dependency injection so central to modern .NET development that if you are not using it, or do not understand how it actually behaves, you are leaving capability and correctness on the table.
Before getting there, we need to align on a few foundational ideas, specifically how SOLID, inversion of control, and dependency injection relate to one another.
The D in SOLID stands for the Dependency Inversion Principle. At a high level, it says that high level modules should not depend on low level modules. Both should depend on abstractions. Indirectly, it also means that changes in low level implementation details should not force changes in higher level policy or orchestration code. Abstraction exists to reduce coupling and to localize change.
Without inversion of control, a class typically constructs its own dependencies. The class will new up instances directly, decide which concrete implementation to use, and binds itself tightly to that decision. That works in trivial cases, but it quickly becomes a maintenance problem. Construction logic spreads, and replacing behavior requires invasive changes. Good luck writing automated unit tests against that!
With inversion of control, the class still declares what it needs, but it does not decide how those needs are satisfied. Something external takes responsibility for choosing and supplying the implementation. The class depends on an abstraction and trusts that the system will provide a valid instance.
Dependency injection is the most common and most approachable way of achieving inversion of control. If you have ever injected a concrete implementation into a constructor that expects an interface, you have used dependency injection. That is how most of us first learn it. The consuming class does not know or care which implementation it receives, it is coded to the contract. Dependency injection is not the principle itself. It is a mechanism that enables inversion of control.
If dependency injection and inversion of control are so closely related, are there other ways to achieve inversion of control. Yes, but they depend on the shape of the system. Callbacks, events, and message based systems all invert control in different ways. In distributed systems especially, control often flows through asynchronous messaging rather than direct calls. That is a deeper topic and one I plan to write about separately. What keeps pulling me back here is that when you strip those systems down to their fundamentals, it all comes back to SOLID.
With that foundational understanding in place, we can move into dependency injection in modern .NET and how little friction Microsoft has introduced to make it usable. To do that, I built a simple WebAPI that exposes a few endpoints. Each endpoint demonstrates how service lifetimes behave when resolved under different scopes. The services themselves are intentionally trivial so the lifecycle behavior is the only thing you are observing.
What are scopes?
In the Microsoft dependency injection model, scopes describe the lifetime of a service instance. Choosing the correct lifetime is not a stylistic preference. It is a correctness decision. Using the wrong lifetime can introduce subtle bugs, state leakage, and concurrency issues.
There are three lifetimes, Singleton, Scoped, and Transient.
Singleton - A single instance is created and shared for the lifetime of the application. This is appropriate when the service holds immutable or read only state that is safe to share across all consumers. A common example is a service that loads reference data that never changes. The critical requirement is that the service must be thread safe. Don't you dare hold request specific information in here either!
Scoped - A single instance is created per logical scope. In a Web API, the default scope is an HTTP request. All resolutions within that request receive the same instance. This is useful for units of work that span multiple services, such as transactional operations. Scoped services are isolated per request and are not shared across concurrent requests.
Transient - A new instance is created every time the service is resolved. This is appropriate for stateless services or short lived operations such as validators or formatters. Because no instance is shared, there is no risk of state bleeding across resolutions.
Understanding these lifetimes and their implications is essential. Many of the most common dependency injection bugs come from violating lifetime boundaries, especially when a longer lived service depends on a shorter lived one.
Let's take a look at some code!
For the proof of concept, I created a Web API using Microsoft.Extensions.DependencyInjection. The service itself is a simple utility that produces a timestamp when constructed and when invoked. The controller, named ScopeDemonstrationController, accepts multiple instances of the same service through constructor injection. Each instance is keyed with a different lifetime.

In Program.cs, the services are registered using keyed registrations, all pointing to the same concrete implementation. This makes it explicit which lifetime is being resolved at each injection point. Inside the controller methods, I return the timestamps along with labels so you can clearly see when each instance was created and whether instances are shared.

The repository is available on GitHub. To be honest, it is probably easier to explore there. You can clone it, run the API locally, modify the registrations, and observe how the behavior changes. In fact, I encourage you to do just that.
Let's Run It!
I started the API and hit the endpoints using Postman. The screenshots below show the results in the order the calls were executed. Keep the definition of each lifetime in mind as you look at the timestamps of these screen captures.
Transient

Transient is the default lifetime. Each resolution produces a new instance. In the output, you can see that each injected service has a distinct creation timestamp. Even within the same request, no instances are shared. This confirms that transient services are never reused.

Scoped

Scoped behavior is more subtle. All resolutions within the same request reference the same instance. The timestamps show that the service was created once, then reused across injections. When a new request is made, a new instance is created. This aligns with the idea of a request scoped unit of work.

Singleton

Singleton behavior is the most visually distinct. The creation timestamp predates all request execution. The instance exists before any controller action is invoked and persists across all requests. This confirms that the instance is created once at application startup and reused everywhere.

Going Back to Fundamentals
This isn't a ground breaking idea, and that's why I call it fundamental. Dependency injection, inversion of control, and service lifetimes are not advanced tricks. They sit right next to SOLID in your mind, and they quietly shape whether a system stays understandable or slowly turns into something brittle and surprising. Most bugs caused by lifetimes are not dramatic. They are subtle. They show up months later, under load, during a refactor, or when someone says, “This worked when I tested it.” I love how .NET has basically forced good practices into us to do this. The container is there. The lifetimes are explicit. The behavior is observable. You can prove it to yourself with a few endpoints and some timestamps. That is not magic. That is good engineering.
If you take nothing else away from this post, take this - master the lifetime of your services. They are architectural decisions. Treat them with the same respect you give public APIs, threading models, and data ownership.
And if you ever find yourself saying, “It is fine, I will just make it a singleton and it will go faster," understand that you may have just introduced numerous design flaws into your system that will have you playing whack-a-mole while debugging.



