π Lesson 5.2: Dependency Injection
Dependency injection (DI) is the architectural technique that keeps large applications loosely coupled, testable, and flexible. It's built on interfaces you already know β and, under the hood, the reflection you just learned.
π― Learning Objectives
By the end of this lesson, you will be able to:
- Explain the problem of tight coupling and the Dependency Inversion Principle
- Apply constructor injection with interfaces
- Register and resolve services with an IoC container
- Choose the right service lifetime (transient, scoped, singleton)
- Explain why DI makes code easy to test
Estimated Time: 75 minutes
Project: Refactor a tightly-coupled class to use DI and a container.
In This Lesson
The Coupling Problem
When a class creates its own dependencies with new, it becomes tightly coupled to those concrete types. Consider an order service that news-up its collaborators:
public class OrderService
{
private readonly SmtpEmailSender _email = new SmtpEmailSender(); // hard-wired
private readonly SqlOrderRepository _repo = new SqlOrderRepository();
public void PlaceOrder(Order order)
{
_repo.Save(order);
_email.Send(order.CustomerEmail, "Order confirmed");
}
}
β οΈ Why this hurts
- Rigid: to switch to a different email provider or database, you must edit
OrderServiceitself. - Untestable: every test would send a real email and hit a real database β you can't substitute fakes.
- Hidden dependencies: nothing in the public surface tells you what
OrderServiceneeds.
The class does two jobs: its real work and constructing its dependencies. Separating those is the heart of DI.
Dependency Inversion
The fix follows the Dependency Inversion Principle: depend on abstractions (interfaces, from the intro course), not concrete types. Define what you need as interfaces:
public interface IEmailSender { void Send(string to, string message); }
public interface IOrderRepository { void Save(Order order); }
π Definition
Dependency Injection: instead of a class creating its dependencies, they are provided (injected) from the outside β typically through its constructor. The class depends only on interfaces and is handed concrete implementations by someone else.
"Inversion" refers to flipping who's in control: the class no longer decides which concrete types to use β the caller (or a container) does. The class just declares "I need an IEmailSender."
Constructor Injection
The dominant DI pattern is constructor injection: the class accepts its dependencies as constructor parameters and stores them. It never uses new for them:
public class OrderService
{
private readonly IEmailSender _email;
private readonly IOrderRepository _repo;
// Dependencies are injected β the class depends only on abstractions
public OrderService(IEmailSender email, IOrderRepository repo)
{
_email = email;
_repo = repo;
}
public void PlaceOrder(Order order)
{
_repo.Save(order);
_email.Send(order.CustomerEmail, "Order confirmed");
}
}
β What we gained
- Flexible: pass any
IEmailSenderβ SMTP in production, a fake in tests, a different provider tomorrow β without touchingOrderService. - Honest: the constructor signature documents exactly what the class needs.
- Testable: inject fakes to test the logic in isolation.
Someone still has to supply the concrete types. In a tiny app you could do it by hand (new OrderService(new SmtpEmailSender(), new SqlOrderRepository())), but as the graph grows, wiring it manually gets unwieldy. That's the container's job.
The IoC Container
An Inversion of Control (IoC) container is a registry that knows how to build your objects and their dependencies. You register which implementation to use for each interface; then you resolve a type and the container constructs the whole graph. .NET's built-in container is Microsoft.Extensions.DependencyInjection.
dotnet add package Microsoft.Extensions.DependencyInjection
using Microsoft.Extensions.DependencyInjection;
var services = new ServiceCollection();
// Register: "when someone needs IEmailSender, give them SmtpEmailSender"
services.AddSingleton<IEmailSender, SmtpEmailSender>();
services.AddScoped<IOrderRepository, SqlOrderRepository>();
services.AddTransient<OrderService>();
// Build the provider
using ServiceProvider provider = services.BuildServiceProvider();
// Resolve: the container constructs OrderService AND its dependencies
var orderService = provider.GetRequiredService<OrderService>();
orderService.PlaceOrder(order);
interface β implementation"] --> C["Container"] Q["Resolve OrderService"] --> C C -->|"reflects the constructor"| N["Sees it needs
IEmailSender + IOrderRepository"] N --> B["Builds those, then OrderService"] B --> O["Fully-wired OrderService"] style Q fill:#eff6ff,stroke:#3b82f6,stroke-width:2px style O fill:#e8f5e9,stroke:#4CAF50,stroke-width:2px
π‘ This is reflection at work
The container uses the reflection from Lesson 5.1: it inspects OrderService's constructor, sees the parameter types, resolves an implementation for each (recursively building their dependencies too), and invokes the constructor. You register the pieces; the container assembles the graph.
Service Lifetimes
When you register a service, you choose its lifetime β how long an instance lives and how often the container creates a new one:
| Lifetime | A new instance is created⦠| Use for |
|---|---|---|
AddTransient | every time it's requested | lightweight, stateless services |
AddScoped | once per "scope" (e.g. per web request) | per-request state, DB contexts |
AddSingleton | once for the whole application | shared, stateless, or cached services |
β οΈ The captive dependency trap
A longer-lived service must not depend on a shorter-lived one. If a singleton injects a scoped service, the scoped instance gets "captured" and lives forever inside the singleton β defeating its per-scope semantics and often causing subtle bugs (e.g. a shared DB context across requests). Rule: don't inject shorter-lived services into longer-lived ones.
π‘ Thread-safety and singletons
A singleton is shared across the whole app β potentially by many threads at once. So a singleton with mutable state must be thread-safe (Module 2!). Stateless or immutable singletons are safest.
DI and Testing
The biggest everyday payoff of DI is testability. Because OrderService depends on interfaces, a unit test (Intermediate) can inject fake implementations and verify behavior without a real database or email server:
// A hand-written fake (a "test double")
public class FakeEmailSender : IEmailSender
{
public List<string> SentTo { get; } = new();
public void Send(string to, string message) => SentTo.Add(to);
}
[Fact]
public void PlaceOrder_SendsConfirmationEmail()
{
// Arrange β inject fakes, no real infrastructure
var email = new FakeEmailSender();
var repo = new FakeOrderRepository();
var service = new OrderService(email, repo);
// Act
service.PlaceOrder(new Order { CustomerEmail = "a@b.com" });
// Assert
Assert.Contains("a@b.com", email.SentTo);
}
β The virtuous cycle
DI, interfaces, and unit testing reinforce each other: depending on abstractions makes classes easy to test, and the desire to test pushes you toward clean, decoupled design. This is why DI is standard in professional .NET β and why ASP.NET Core has a container built in and injects your services automatically.
π‘ Fakes vs. mocking libraries
Here we hand-wrote a fake. For complex dependencies, mocking libraries (e.g. Moq, NSubstitute) generate test doubles on the fly and let you assert on interactions. Both rely on the same foundation: the code under test depends on an interface it can be handed a substitute for.
Exercise & Quiz
ποΈ Exercise: Refactor to DI
Objective: Decouple a class with constructor injection, wire it with a container, and test it with a fake.
Instructions:
- Create a new project called
DI(addMicrosoft.Extensions.DependencyInjection). - Define
interface IGreeter { string Greet(string name); }andinterface ILogger { void Log(string msg); }. - Implement
ConsoleGreeter : IGreeterandConsoleLogger : ILogger. - Write
class WelcomeService(IGreeter greeter, ILogger logger)(constructor injection) withvoid Welcome(string name)that logs and prints the greeting. - Register everything in a
ServiceCollection, resolveWelcomeService, and callWelcome("Ada"). - Bonus: write a
FakeLogger : ILoggerthat records messages, inject it directly, and assert the log was written.
Starter Code:
using Microsoft.Extensions.DependencyInjection;
var services = new ServiceCollection();
// TODO: register IGreeter, ILogger, WelcomeService
using var provider = services.BuildServiceProvider();
// TODO: resolve WelcomeService and call Welcome("Ada")
interface IGreeter { string Greet(string name); }
interface ILogger { void Log(string msg); }
// TODO: ConsoleGreeter, ConsoleLogger, WelcomeService(IGreeter, ILogger)
π‘ Hint
Register with services.AddSingleton<IGreeter, ConsoleGreeter>(); etc., and services.AddTransient<WelcomeService>();. Resolve with provider.GetRequiredService<WelcomeService>(). The constructor stores the two dependencies in readonly fields.
β Solution
using Microsoft.Extensions.DependencyInjection;
var services = new ServiceCollection();
services.AddSingleton<IGreeter, ConsoleGreeter>();
services.AddSingleton<ILogger, ConsoleLogger>();
services.AddTransient<WelcomeService>();
using var provider = services.BuildServiceProvider();
var welcome = provider.GetRequiredService<WelcomeService>();
welcome.Welcome("Ada");
// Bonus: test with a fake logger (no container needed)
var fake = new FakeLogger();
var tested = new WelcomeService(new ConsoleGreeter(), fake);
tested.Welcome("Grace");
Console.WriteLine($"Logged {fake.Messages.Count} message(s).");
interface IGreeter { string Greet(string name); }
interface ILogger { void Log(string msg); }
class ConsoleGreeter : IGreeter
{
public string Greet(string name) => $"Hello, {name}!";
}
class ConsoleLogger : ILogger
{
public void Log(string msg) => Console.WriteLine($"[log] {msg}");
}
class WelcomeService
{
private readonly IGreeter _greeter;
private readonly ILogger _logger;
public WelcomeService(IGreeter greeter, ILogger logger)
{
_greeter = greeter;
_logger = logger;
}
public void Welcome(string name)
{
_logger.Log($"Welcoming {name}");
Console.WriteLine(_greeter.Greet(name));
}
}
class FakeLogger : ILogger
{
public List<string> Messages { get; } = new();
public void Log(string msg) => Messages.Add(msg);
}
π― Quick Quiz
Question 1: What problem does dependency injection primarily solve?
Question 2: With AddScoped, when is a new instance created?
Question 3: Why does DI make code easier to test?
Summary
π Key Takeaways
- Creating dependencies with
newcauses tight coupling β rigid, untestable, with hidden requirements. - Dependency Inversion: depend on interfaces, not concrete types; the class declares what it needs.
- Constructor injection is the standard pattern β dependencies are passed in and stored, never
new-ed internally. - An IoC container registers interfaceβimplementation mappings and builds the whole object graph (using reflection, Lesson 5.1).
- Choose lifetimes (transient/scoped/singleton) carefully β avoid the captive-dependency trap; DI's biggest win is testability.
π Additional Resources
π What's Next?
You now have every advanced tool β and the architectural mindset to combine them. In the final lesson, Lesson 5.3: Capstone Project, you'll build a high-performance, concurrent, well-architected application that ties the whole course together.
π Architecture unlocked!
You can design decoupled, testable systems. One lesson to go β the capstone!