October 5, 2026 · .NET · C# · Dependency Injection
Dependency injection in .NET, explained with a counter
Scoped, transient, and singleton, explained with a simple counter — and how to swap an implementation by changing one line.
I recently started building a .NET API as a reference for someone, so I thought it would be a good idea to share it here too. It gives me an excuse to explain some concepts that some people might not know yet, and I always enjoy the opportunity to teach.
Please take a look at my GitHub repository, where I go more into depth and you can see the actual implementation, not just code snippets. The code for this post can be found in these places:
Program.cs, under the Services region, to see the registrations- The
Servicesfolder, to see the three implementation types CounterController.cs, in the Controllers folder, to see the usages
One thing to keep in mind: the code in this post is simplified. The repository has full working examples, so the names and some details differ. For example, each lifetime has its own interface (IScopedCounterService, ITransientCounterService, and ISingletonCounterService) instead of a single ICounterService, and the methods print to the console so you can watch the counts change as you call the endpoints. The concepts are exactly the same.
This time around, I decided to dive into services and dependency injection. For those who don’t know, Dependency Injection (DI) in .NET is when you pass an object into a class and let .NET automatically handle creating and delivering it at runtime. This means you don’t have to hardcode dependencies or constantly rewrite your classes later. If you want to change how a class works internally—like switching from sending emails to sending text messages—you can do it simply by changing a single line of configuration.
If this sounds a little complicated, don’t worry! I’ll break it down right now.
The problem
Take this class, for example:
public class CounterServiceA
{
private int Count;
public Task<string> GetClassName()
{
return Task.FromResult(nameof(CounterServiceA));
}
public Task AddToCount()
{
Count++;
return Task.CompletedTask;
}
public Task SubtractFromCount()
{
Count--;
return Task.CompletedTask;
}
} It’s a pretty basic class: GetClassName() returns the name of the class, AddToCount() adds one to the running count, and SubtractFromCount() subtracts one from the count. If I wanted to use it in any other class, I would just type:
var counterServiceA = new CounterServiceA();Pretty simple thing to do. But what if I wanted to change the implementation?
public class CounterServiceB
{
private int Count;
public Task<string> GetClassName()
{
return Task.FromResult(nameof(CounterServiceB));
}
public Task AddToCount()
{
Count += 2;
return Task.CompletedTask;
}
} Now GetClassName() returns a different name and AddToCount() adds by two. To make the switch without DI, I would need to manually go everywhere CounterServiceA is used and change it to CounterServiceB.
By now you might have noticed that we run into another problem: what if CounterServiceB doesn’t implement all of the methods or signatures of CounterServiceA? CounterServiceB is missing SubtractFromCount(), which is a massive headache if it is already used in a lot of places where we still need that logic.
Enter dependency injection
This is where DI comes in. With DI, we register our class via an interface in Program.cs, tell .NET which concrete implementation to inject, and let the framework resolve it at runtime. Here is how it’s done.
Step 1: create an interface for the classes to implement.
public interface ICounterService
{
Task<string> GetClassName();
Task AddToCount();
} (Note: I removed SubtractFromCount() for simplicity so both of our classes can share this same interface.)
This interface acts as a strict contract. It tells our classes exactly what methods need to be implemented, including their inputs and outputs.
Step 2: apply the interface to the classes.
public class CounterServiceA : ICounterService // Contract applied here
{
private int Count;
public Task<string> GetClassName()
{
return Task.FromResult(nameof(CounterServiceA));
}
public Task AddToCount()
{
Count++;
return Task.CompletedTask;
}
} When you first add : ICounterService to your class, your IDE will throw a compiler error saying that the required interface methods have not been implemented yet. Once you implement the matching methods, the error goes away. You would then do the exact same thing for CounterServiceB : ICounterService.
Step 3: register it, and swap it with one line. In Program.cs, we tell .NET which implementation to hand out whenever something asks for an ICounterService (I’ll explain AddScoped in a moment):
builder.Services.AddScoped<ICounterService, CounterServiceA>(); Want CounterServiceB everywhere instead? Change that one line:
builder.Services.AddScoped<ICounterService, CounterServiceB>(); Every class that asks for an ICounterService now receives CounterServiceB, and nothing else has to change.
It really is as simple as this to get a class ready to be injected. But why does this matter? Why would we want to switch classes in a real application?
A real-world example: payments
Let’s imagine a scenario where we have been using Payment Processing Company A. When processing payments, we can think of two things we must absolutely do at the very minimum: capture a payment and process a refund. Without DI, our class might look like this:
public class PaymentProcessingCompanyA
{
public void CapturePayment()
{
// Payment capture logic for Company A
}
public void ProcessRefund()
{
// Refund process logic for Company A
}
}To use it in a controller or another service, you would instantiate it manually:
var paymentProcessingCompanyA = new PaymentProcessingCompanyA();Now, what happens if your business decides to move from Payment Processing Company A to Payment Processing Company B because they offer better transaction rates?
You still need to capture payments and process refunds. But if you hardcoded new PaymentProcessingCompanyA() across dozens of controllers and services throughout your entire codebase, you now have to manually find and rewrite each and every one of them. Even worse, if Company B’s SDK uses entirely different method names or signatures, your code breaks everywhere.
Instead of coupling your application to a specific vendor, you create an interface contract:
public interface IPaymentService
{
void CapturePayment();
void ProcessRefund();
} Each vendor’s SDK gets wrapped in its own class that implements IPaymentService. Company B’s SDK might name everything differently, but that difference stays inside its wrapper class instead of leaking into your controllers.
By making both PaymentProcessingCompanyA and PaymentProcessingCompanyB implement IPaymentService, your application stops caring which company is handling the money. It only cares that the object handed to it knows how to execute CapturePayment() and ProcessRefund().
Now let’s take a look at how to register and use them. This heavily depends on what you need your service to do, because there are three distinct service lifetimes in .NET: Scoped, Transient, and Singleton.
Scoped
builder.Services.AddScoped<ICounterService, CounterServiceA>();Scoped is the one you’ll probably reach for most in a web API. A scoped lifetime means that every single HTTP request gets its own fresh instance of the implementation. This instance persists throughout the entire duration of that specific HTTP request. No matter how many different places or levels of the stack it gets injected into during that request, they will all share the exact same instance.
Let’s look at what this means in practice using a primary constructor in a controller. (Primary constructors are a C# 12 feature, so this needs .NET 8 or newer. On older versions, the same thing works with a regular constructor.) Let’s assume the starting count is zero and AddToCount() increases the count by one:
[Route("api/[controller]/[action]")]
[ApiController]
public class CounterController(
ICounterService counterService1,
ICounterService counterService2
) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> AddToCount()
{
await counterService1.AddToCount(); // Count becomes 1
await counterService2.AddToCount(); // Count becomes 2
return Ok();
}
} Because we registered this as Scoped, counterService1 and counterService2 point to the exact same object in memory during this HTTP request. When counterService1 increments the count by one, counterService2 sees that updated value and increments it again by one.
However, because it is a fresh instance per HTTP request, isolation is guaranteed between users. If John and Jane hit this API endpoint at the exact same time, .NET creates two entirely separate HTTP request scopes. John’s actions will never accidentally bleed into or alter Jane’s counter data.
Transient
builder.Services.AddTransient<ICounterService, CounterServiceA>();Transient registration works almost completely opposite to Scoped, although it still maintains isolation between separate users. In a transient lifetime, .NET spins up a brand-new instance every single time the service is requested, even if they are injected side by side within the exact same HTTP request.
Let’s look at the exact same controller, assuming the count starts at zero and each call adds one:
[Route("api/[controller]/[action]")]
[ApiController]
public class CounterController(
ICounterService counterService1,
ICounterService counterService2
) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> AddToCount()
{
await counterService1.AddToCount(); // counterService1 count becomes 1
await counterService2.AddToCount(); // counterService2 count becomes 1
return Ok();
}
} Given that the registration is transient, the two services are completely isolated from each other inside the constructor. When counterService1 increments the count, it is modifying its own unique instance. When counterService2 executes next, it modifies an entirely separate instance. As a result, both of their individual internal counts only reach 1.
Singleton
builder.Services.AddSingleton<ICounterService, CounterServiceA>(); Singleton registration creates one single instance for the entire API, and every HTTP request gets that exact same instance regardless of who calls it or where it is located in the stack. Let’s go back to the controller example one more time, but this time with two different HTTP methods. Let’s once again assume that the count starts at zero and each call adds one. This time, John calls JohnAddToCount(), followed immediately by Jane calling JaneAddToCount():
[Route("api/[controller]/[action]")]
[ApiController]
public class CounterController(
ICounterService counterService1,
ICounterService counterService2
) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> JohnAddToCount()
{
await counterService1.AddToCount(); // count becomes 1
await counterService2.AddToCount(); // count becomes 2
return Ok();
}
[HttpGet]
public async Task<IActionResult> JaneAddToCount()
{
await counterService1.AddToCount(); // count becomes 3
await counterService2.AddToCount(); // count becomes 4
return Ok();
}
} You’ll notice that the count went up in sequential order. Why is that? Since it is a singleton service, when John called JohnAddToCount() he interacted with the one shared instance that was resolved by .NET, even though it was injected into two variables. Then, Jane called JaneAddToCount() and interacted with that exact same shared instance. Essentially, a Singleton is NOT isolated between HTTP calls or even different users.
The thread-safety catch
This presents a new problem, though. For AddToCount() to work, it has to read the value of Count, add to it, and then write it back. What happens when John and Jane call their endpoints at around the same time? Both requests can read the same starting value, both write back the same result, and one of the increments is lost. That is a race condition, and it’s what we mean by being “not thread-safe.”
I think I’ll be creating a separate post on how to handle thread safety across different scenarios, but for this specific counter case, we can use an atomic operation (Interlocked) to make it completely safe across multiple threads:
public Task AddToCount()
{
// Atomically increments the value in memory, safely across all threads
Interlocked.Increment(ref Count);
// If you need to increment by a different number, like 2, use:
// Interlocked.Add(ref Count, 2);
return Task.CompletedTask;
}Where each lifetime fits
Now that we have covered the three basic service lifetimes, let’s take a really quick look at how they are used and why. Note that how you use these is not law, and you can use them to best serve your application’s needs.
Entity Framework Core (EF Core) is a good example of Scoped. When you register your DbContext with AddDbContext, it is Scoped by default. Why? When you execute database queries and updates during an API call, you want all of your changes tracked under a single, isolated instance, ensuring your database actions do not interfere with other users. Under the hood, EF Core tracks everything you do. When you query a model and update a field, all you have to do is save the changes at the end of that specific request.
Furthermore, because it is Scoped, .NET automatically disposes of the database context when the HTTP request finishes, and the database connection goes back to the connection pool. (I’ll get much deeper into EF Core in a later post!)
Transient is used for lightweight, stateless implementations. For example, data validators and data sanitizers that can be called on demand and immediately thrown away without anything else in your application being affected. They hold zero persistent state or local data, meaning they get created, do their quick job, and get disposed of with zero consequences.
Lastly, a Singleton is used for data that needs to be shared across an entire application globally. For example, you can use a Singleton as an in-memory cache: instead of querying your database thousands of times for the exact same static records, you can hold that data in memory via a Singleton and read it instantly from there, freeing up your database from excessive reads. Furthermore, you can use it to hold global application settings or API configurations loaded at startup. You can even use a Singleton queue to feed a background worker, such as a service that processes and sends out SMS or emails: any request can drop a task into the queue, and a hosted background service picks tasks up and processes them.
One trap to watch out for: a Singleton shouldn’t depend on a Scoped service like a DbContext. A Singleton lives for the entire life of the app, so it would hold on to a single request’s scoped instance long after that request is over. In Development, .NET validates this and throws an error to warn you.
The takeaway
That’s dependency injection in a nutshell: program against an interface, register the implementation in one place, and pick the lifetime that fits what the service does. Swapping implementations later becomes a one-line change instead of a search-and-replace across your whole codebase.