Dependency injection is built into .NET — there is no extra package to install and no third-party container to learn. This tutorial shows why it matters and how to use it, by building a small products API where swapping a whole implementation takes exactly one line.

Why should you use dependency injection?
Assume you own GeekShop, an online store. Your website calls an ASP.NET Core Web API; the API controller uses a ProductService that returns product information.
Without dependency injection, the controller creates the service itself:
[HttpGet]
public List<Product> GetProducts()
{
ProductService products = new ProductService();
return products.GetProducts();
}
During the holiday season you run a three-day Prime Day sale. You write a new PrimeDayProductService with discounted prices — and now you have to edit the controller to use it, and edit it again when the sale ends. Every class that news up ProductService needs the same treatment. That is tight coupling: the controller depends on one concrete class and cannot work with any other.
Dependency injection turns this around. The controller declares what it needs (an interface), and the .NET IoC container decides which implementation it gets. Switching to the Prime Day service becomes a one-line change in Program.cs.
The IoC container
The Inversion of Control (IoC) container manages object creation, lifetime, and wiring. It has three jobs:
- Registration — you map an interface to a class (
IProductService→ProductService), so the container knows what to build. - Resolution — when a controller (or any registered class) asks for
IProductServicein its constructor, the container creates the right instance and passes it in. - Disposal — the container disposes instances according to the lifetime you chose, so you rarely write
Disposecalls yourself.
Implement dependency injection step by step
Create the Web API project
dotnet new webapi --use-controllers -n GeeksShop.DI
In Visual Studio: File → New → Project → ASP.NET Core Web API, project name GeeksShop.DI. Delete the sample WeatherForecast files — we won't use them.
Add the Product model
Create a Models folder with Product.cs:
namespace GeeksShop.DI.Models;
public class Product
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public decimal ListPrice { get; set; }
}
Define the contract — IProductService
The interface is what makes the swap possible: the controller will depend on this contract, never on a concrete class. Create Services/Interfaces/IProductService.cs:
using GeeksShop.DI.Models;
namespace GeeksShop.DI.Services.Interfaces;
public interface IProductService
{
List<Product> GetProducts();
}
Implement the regular service
Services/ProductService.cs returns the everyday catalog:
using GeeksShop.DI.Models;
using GeeksShop.DI.Services.Interfaces;
namespace GeeksShop.DI.Services;
public class ProductService : IProductService
{
public List<Product> GetProducts() =>
[
new() { Id = 1, Name = "Galaxy A13 Mobile", ListPrice = 100 },
new() { Id = 2, Name = "Air Pods", ListPrice = 200 },
new() { Id = 3, Name = "Pen drive", ListPrice = 300 },
];
}
Implement the Prime Day service
Services/PrimeDayProductService.cs implements the same interface with sale pricing:
using GeeksShop.DI.Models;
using GeeksShop.DI.Services.Interfaces;
namespace GeeksShop.DI.Services;
public class PrimeDayProductService : IProductService
{
private const decimal PrimeDayDiscount = 10;
public List<Product> GetProducts() =>
[
new() { Id = 4, Name = "Galaxy A15 Mobile", ListPrice = 400 - PrimeDayDiscount },
new() { Id = 5, Name = "Laptop", ListPrice = 500 - PrimeDayDiscount },
];
}
Inject the service into the controller
The controller asks for IProductService in its constructor and never mentions a concrete class. With C# primary constructors this is one line of ceremony:
using GeeksShop.DI.Models;
using GeeksShop.DI.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace GeeksShop.DI.Controllers;
[Route("api/[controller]")]
[ApiController]
public class ProductsController(IProductService products) : ControllerBase
{
[HttpGet]
public List<Product> GetProducts() => products.GetProducts();
}
If you run the app now you will hit:
InvalidOperationException: Unable to resolve service for type 'GeeksShop.DI.Services.Interfaces.IProductService' while attempting to activate 'ProductsController'.
The container does not know about IProductService yet — that is the registration step.
Register the dependency in Program.cs
using GeeksShop.DI.Services;
using GeeksShop.DI.Services.Interfaces;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddScoped<IProductService, ProductService>();
var app = builder.Build();
app.MapControllers();
app.Run();
Run it and call the endpoint:
curl http://localhost:5000/api/products
[
{ "id": 1, "name": "Galaxy A13 Mobile", "listPrice": 100 },
{ "id": 2, "name": "Air Pods", "listPrice": 200 },
{ "id": 3, "name": "Pen drive", "listPrice": 300 }
]
Prime Day arrives — change one line
builder.Services.AddScoped<IProductService, PrimeDayProductService>();
Same controller, same endpoint, different behavior:
[
{ "id": 4, "name": "Galaxy A15 Mobile", "listPrice": 390 },
{ "id": 5, "name": "Laptop", "listPrice": 490 }
]
When the sale ends, change the line back. No controller edits, nothing else touched — that is the payoff of depending on the interface.
Choosing a service lifetime
AddScoped is one of three lifetimes the container offers:
AddTransient— a new instance every time the service is requested. Safest for lightweight, stateless services.AddScoped— one instance per HTTP request. The right choice for services that touch a database (this is whyAddDbContextregisters your EF Core context as scoped).AddSingleton— one instance for the whole application lifetime. Use for caches and configuration-style services, and make sure they are thread-safe.
The differences are easy to see in code — .NET dependency injection object lifetimes walks through them with examples.
Going further: keyed services
Since .NET 8 you can also register both implementations at once under names and pick one per consumer — handy when regular and sale pricing must coexist:
builder.Services.AddKeyedScoped<IProductService, ProductService>("regular");
builder.Services.AddKeyedScoped<IProductService, PrimeDayProductService>("primeday");
public class ProductsController(
[FromKeyedServices("primeday")] IProductService products) : ControllerBase
For most applications the plain one-registration approach above is all you need — reach for keyed services only when two implementations genuinely have to live side by side.
Comments (0)
No comments yet — be the first to share your thoughts.