Dependency injection
Let classes receive their dependencies instead of creating them — for flexible, testable code.
14 min read
You will learn
- Explain the problem DI solves
- Refactor a class to use constructor injection with interfaces
- Register services with the right lifetime (singleton, scoped, transient)
- Avoid the captive dependency pitfall
The problem: classes that build their own world
public class OrderService
{
private readonly SqlOrderRepository repository = new SqlOrderRepository("Server=prod;...");
private readonly SmtpEmailSender email = new SmtpEmailSender("smtp.example.com");
public void Place(Order order)
{
repository.Save(order);
email.Send(order.CustomerEmail, $"Order {order.Id} confirmed");
}
}This works, but:
- You can't test
OrderServicewithout a real database and mail server. - You can't swap SMTP for SendGrid without editing
OrderService. - Configuration (connection strings) is buried inside the class.
The fix: ask for dependencies
Dependency injection (DI) means a class receives what it needs — usually through its constructor — instead of creating it:
public class OrderService(IOrderRepository repository, IEmailSender email)
{
public void Place(Order order)
{
repository.Save(order);
email.Send(order.CustomerEmail, $"Order {order.Id} confirmed");
}
}OrderService now depends only on interfaces. Production passes the real implementations; tests pass fakes. This is the dependency inversion principle — depend on abstractions, not concrete details (Track 12).
The container wires it up
Creating object graphs by hand gets tedious (new OrderService(new SqlOrderRepository(new DbContext(...)), new SmtpEmailSender(...))). A DI container does it for you. In ASP.NET Core, you register services at startup:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();
builder.Services.AddSingleton<IEmailSender, SmtpEmailSender>();
builder.Services.AddScoped<OrderService>();
builder.Services.AddDbContext<ShopDb>(o => o.UseSqlServer(connectionString)); // registered as scoped…and the framework resolves them: when an endpoint or controller needs an OrderService, the container sees its constructor wants an IOrderRepository and an IEmailSender, creates those (and their dependencies), and passes them in.
app.MapPost("/orders", (Order order, OrderService orders) => // injected automatically
{
orders.Place(order);
return Results.Created($"/orders/{order.Id}", order);
});Lifetimes
| Lifetime | Instances | Typical use |
|---|---|---|
| Singleton | One for the whole app | Stateless, thread-safe services; caches; HttpClient factories; configuration |
| Scoped | One per scope — in ASP.NET Core, per HTTP request | DbContext, unit-of-work, per-request user context |
| Transient | A new one every time it's resolved | Lightweight, stateless helpers |
The container also disposes the IDisposable services it created, at the end of their lifetime.
Captive dependencies
A service must not depend on something with a shorter lifetime. A singleton that receives a scoped DbContext keeps that one context alive for the app's whole life — shared by every request and thread. ASP.NET Core's scope validation throws in Development to catch this. If a singleton truly needs scoped services, inject IServiceScopeFactory and create a scope when needed.
How a container works
There's no magic — at heart, a container is a dictionary from types to factory functions. Here's a toy one:
C# runtime not loaded
Real containers add lifetimes, disposal, open generics and constructor discovery via reflection — but the idea is the same.
Good DI habits
- Prefer constructor injection; avoid calling
serviceProvider.GetService<T>()all over the code (the service locator anti-pattern). - Keep constructors simple — just store dependencies.
- Many constructor parameters (say, more than 5) suggest a class doing too much.
- Wrap time, randomness, file system and network behind interfaces (
TimeProvideris built in since .NET 8) so they can be faked in tests.
Key takeaways
- DI: classes receive their dependencies (usually interfaces) instead of creating them.
- Register services in
builder.Serviceswith a lifetime; the container resolves whole object graphs. - Singleton = one per app, scoped = one per request, transient = new each time.
- Never let a long-lived service capture a shorter-lived one.
Exercise
WelcomeService creates its own dependencies, so it can't be tested. Refactor it to receive an IClock and an IEmailSender through its constructor. Greet returns "Good morning, <name>!" before 12:00, "Good afternoon, <name>!" before 18:00 and "Good evening, <name>!" otherwise. SendWelcome emails the greeting. FakeClock and FakeEmailSender are used by the checks — don't change them.
C# runtime not loaded
Hint 1
Add a constructor: public WelcomeService(IClock clock, IEmailSender email) and store both in private readonly fields (or use a primary constructor: class WelcomeService(IClock clock, IEmailSender email)).
Hint 2
Replace DateTime.Now with clock.Now and new ConsoleEmailSender() with the injected sender.
Hint 3
Update the first line of the program too: new WelcomeService(new SystemClock(), new ConsoleEmailSender()).
Show solution
var service = new WelcomeService(new SystemClock(), new ConsoleEmailSender());
Console.WriteLine(service.Greet("Ada"));
interface IClock { DateTime Now { get; } }
interface IEmailSender { void Send(string to, string body); }
class SystemClock : IClock { public DateTime Now => DateTime.Now; }
class ConsoleEmailSender : IEmailSender
{
public void Send(string to, string body) => Console.WriteLine($"Email to {to}: {body}");
}
// Used by the checks — don't change these two.
class FakeClock : IClock { public DateTime Now { get; set; } }
class FakeEmailSender : IEmailSender
{
public List<string> Sent { get; } = new();
public void Send(string to, string body) => Sent.Add($"{to}: {body}");
}
class WelcomeService(IClock clock, IEmailSender email)
{
public string Greet(string name) => clock.Now.Hour switch
{
< 12 => $"Good morning, {name}!",
< 18 => $"Good afternoon, {name}!",
_ => $"Good evening, {name}!",
};
public void SendWelcome(string address, string name) => email.Send(address, Greet(name));
}Quiz
4 questions · 3 correct to pass1.What's the main benefit of injecting an IEmailSender instead of writing `new SmtpEmailSender()` inside a class?
2.In ASP.NET Core, how long does a Scoped service live?
3.Which lifetime fits a stateless, thread-safe service like a pricing calculator with no dependencies on scoped services?
4.What's a captive dependency?
To complete this lesson
- ○ Solve the exercise
- ○ Pass the quiz (3/4 correct)