Skip to main content

πŸ“ 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 OrderService itself.
  • 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 OrderService needs.

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 touching OrderService.
  • 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);
graph TD R["Register:
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:

LifetimeA new instance is created…Use for
AddTransientevery time it's requestedlightweight, stateless services
AddScopedonce per "scope" (e.g. per web request)per-request state, DB contexts
AddSingletononce for the whole applicationshared, 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:

  1. Create a new project called DI (add Microsoft.Extensions.DependencyInjection).
  2. Define interface IGreeter { string Greet(string name); } and interface ILogger { void Log(string msg); }.
  3. Implement ConsoleGreeter : IGreeter and ConsoleLogger : ILogger.
  4. Write class WelcomeService(IGreeter greeter, ILogger logger) (constructor injection) with void Welcome(string name) that logs and prints the greeting.
  5. Register everything in a ServiceCollection, resolve WelcomeService, and call Welcome("Ada").
  6. Bonus: write a FakeLogger : ILogger that 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 new causes 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!