Lesson 27 • Advanced Track
Dependency Injection
By the end of this lesson you'll be able to decouple your classes by injecting their dependencies through constructors, wire a service graph by hand and with .NET's built-in container, choose the right service lifetime, and write code that's genuinely easy to test.
What You'll Learn
- The Dependency Inversion principle: depend on interfaces, not concrete classes
- Constructor injection — give a class its collaborators instead of new-ing them
- Why injecting an interface lets you swap implementations without changing callers
- How an IoC container (IServiceCollection) wires whole dependency graphs for you
- Service lifetimes: Singleton, Scoped, and Transient — and when to use each
- Why DI makes code testable by letting you inject fakes in place of real services
interface and implementing it in a class, because dependency injection is built entirely on programming to interfaces rather than concrete types.💡 Real-World Analogy
Dependency injection is like a power socket on the wall. Your kettle, laptop charger, and lamp all plug into the same standard socket — they don't care which power station generated the electricity, whether it's a coal plant, a wind farm, or a battery in your garage. They just depend on the interface (the socket shape and voltage). The grid can switch suppliers behind the wall and your appliances never notice. Constructor injection is exactly this: a class declares the "socket" it needs (an interface in its constructor), and whoever builds the class plugs in whichever implementation they like — the real one in production, a fake one in tests. An IoC container is the building's electrician: register what plugs into what once, and it wires every socket for you.
What "Dependency Injection" Actually Means
A dependency is any object your class needs to do its job — a logger, a database, an email sender. Most beginner code creates those dependencies itself with new, hard-wiring the class to one exact implementation. Dependency injection (DI) flips that around: instead of a class making its collaborators, it is given them from outside.
This is one half of the Dependency Inversion Principle — the "D" in SOLID: high-level code should depend on abstractions (interfaces), not on concrete low-level classes. The benefit is loose coupling: you can replace, mock, or upgrade a dependency without editing the class that uses it.
There's one more idea bundled in: Inversion of Control (IoC). Normally your code controls when objects are created. With an IoC container you hand that control over — the container decides when to build each service and how long it lives. This lesson builds up in exactly that order: inject by hand first, then let a container do it.
📊 Service Lifetimes at a Glance
| Lifetime | Registered with | Created | Use for |
|---|---|---|---|
| Singleton | AddSingleton | Once, shared for the whole app | Caches, config, stateless loggers |
| Scoped | AddScoped | Once per scope (e.g. one web request) | DbContext, per-request state |
| Transient | AddTransient | A brand-new one every time it's resolved | Lightweight, stateless services |
Rule of thumb: when in doubt, choose Transient — it's the safest default because a fresh instance can never hold stale state. Reach for Scoped or Singleton only when you have a concrete reason to share an instance.
1. Constructor Injection — The Core Idea
When a class creates its own dependency with new, it is tightly coupled to that exact implementation — you can't swap it, and you can't test the class in isolation. Constructor injection fixes this: the class declares the interface it needs as a constructor parameter, and the caller passes in a concrete implementation. The class depends on the contract (the interface), never the concrete type. Read this worked example, run it, then you'll do the refactor yourself.
Worked example: tight coupling vs constructor injection
Read every comment, run it, and compare the hard-wired class with the injected one.
using System;
// === The PROBLEM: a class that builds its own dependency with 'new' ===
// EmailNotifier is hard-wired in. You cannot swap it or test around it.
class TightOrderService
{
private readonly EmailNotifier _notifier = new EmailNotifier(); // hard dependency!
public void Place(string item)
{
Console.WriteLine($"Order placed: {item}");
_notifier.Notify($"We shipped your {item}");
}
}
// === The FIX: depend on an INTERFACE, receive it through the const
...Your turn. The Greeter below needs an IMessageService, but the constructor isn't finished. Add the parameter and store it, then wire it by hand in Main. Fill in the three ___ blanks, then run it.
🎯 Your turn: inject a dependency through the constructor
Finish the constructor and wire the service by hand, then check the greeting prints.
using System;
// A tiny messaging contract.
interface IMessageService
{
string Format(string text);
}
class PlainMessageService : IMessageService
{
public string Format(string text) => text;
}
// 🎯 YOUR TURN — Greeter currently builds its OWN service with 'new'.
// Refactor it to receive an IMessageService through its constructor.
class Greeter
{
private readonly IMessageService _messages;
// 1) Add a constructor that takes an IMessageService and stores it.
public Greete
...2. Swapping Implementations — The Payoff
Here's why the effort is worth it. Because Greeter depends on the IMessageService interface and not a concrete class, you can hand it a completely different implementation without changing a single line of Greeter. That's loose coupling in action — and it's the same trick that lets you inject a fake service during testing. Swap the implementation in the two blanks below.
🎯 Your turn: swap in a different implementation
Inject the shouting service instead of the plain one — Greeter stays untouched.
using System;
interface IMessageService
{
string Format(string text);
}
class PlainMessageService : IMessageService
{
public string Format(string text) => text; // "Hello, Sam!"
}
// 🎯 YOUR TURN — a SECOND implementation already exists below.
// Because Greeter depends on the INTERFACE, you can swap it in without
// touching Greeter at all. That is the whole payoff of DI.
class ShoutingMessageService : IMessageService
{
public string Format(string text) => text.ToUpper()
...3. The IoC Container — Wiring Done For You
Wiring two classes by hand is easy. Wiring fifty, each depending on several others, is not — you'd be writing pages of new in the right order. An IoC container solves this: you register each interface against its implementation once with AddSingleton / AddScoped / AddTransient, call BuildServiceProvider(), then ask for a service with GetService<T>(). The container inspects each constructor and supplies the whole dependency graph automatically. .NET ships one built in — ServiceCollection from Microsoft.Extensions.DependencyInjection.
Worked example: register, build, resolve
The container constructs Greeter and its two dependencies for you — no manual new.
using System;
using Microsoft.Extensions.DependencyInjection;
// Wiring graphs by hand is fine for two classes — but a real app has
// dozens of services with nested dependencies. An IoC ("Inversion of
// Control") CONTAINER does the wiring for you: you register each type
// once, then ask the container for a service and it builds the whole graph.
interface ILogger { void Log(string msg); }
interface IGreetingStore { string Get(); }
interface IGreeter { void Greet(string name); }
...Note: GetService<T>() returns null if a service wasn't registered; GetRequiredService<T>() throws a clear exception instead. In real apps, prefer GetRequiredService so a missing registration fails loudly.
4. Service Lifetimes — Singleton, Scoped, Transient
When you register a service you choose how long each instance lives. A Singleton is created once and shared for the entire app. A Scoped service is created once per scope — in a web app, that's typically one HTTP request. A Transient service is built fresh every single time it's resolved. Picking the wrong one causes subtle, hard-to-find bugs, so it's worth seeing the difference. Each service below stamps itself with a unique id so you can tell instances apart.
Worked example: the three lifetimes side by side
Watch which lifetimes reuse an instance and which create a fresh one.
using System;
using Microsoft.Extensions.DependencyInjection;
// Each service stamps itself with a unique Id at creation time, so we can
// SEE whether the container handed us the same instance or a fresh one.
class SingletonService { public Guid Id { get; } = Guid.NewGuid(); }
class ScopedService { public Guid Id { get; } = Guid.NewGuid(); }
class TransientService { public Guid Id { get; } = Guid.NewGuid(); }
class Program
{
static void Main()
{
var services = new ServiceCo
...🔎 Deep Dive: the captive dependency trap
The most common lifetime bug is a captive dependency: a long-lived service that holds a reference to a shorter-lived one. If a Singleton takes a Scoped service in its constructor, the container injects it once at startup — and that scoped instance is now trapped inside the singleton forever, never refreshed per request. The classic disaster is a singleton accidentally pinning a DbContext (which is scoped), leaking data and connections across requests.
The rule: a service may only depend on something with an equal or longer lifetime. Singleton → Singleton is fine; Singleton → Scoped is the trap.
// ❌ Captive dependency — a Scoped service trapped in a Singleton services.AddSingleton<ICache, Cache>(); // lives forever services.AddScoped<IDbContext, DbContext>(); // meant to be per-request // Cache's constructor takes IDbContext -> that DbContext is now a singleton too! // ✅ Fix: don't inject the scoped service directly. Inject a factory // (IServiceScopeFactory) and create a fresh scope when you need it.
.NET can catch many of these for you: build the provider with ValidateScopes = true (and ValidateOnBuild = true) in development and it throws at startup instead of misbehaving at runtime.
5. Why DI Makes Code Testable
This is the payoff that wins teams over. Because a class receives its dependencies through its constructor, a unit test can pass in a fake — an in-memory stand-in you fully control — instead of the real database, payment gateway, or email server. No network, no slow setup, and you can inspect exactly what the class did. Without DI you'd be stuck with whatever the class new-ed internally.
Putting It Together: production vs test wiring
The same Checkout class runs against the real Stripe gateway in production and a fake gateway in a test — purely by injecting a different implementation. You understand every line now.
Worked example: inject a fake for testing
The same class charges a real gateway, then a fake one the test can inspect.
using System;
// DI's biggest practical win: you can swap a real dependency for a FAKE
// one in tests — no database, no network, just a stand-in you control.
interface IPaymentGateway
{
bool Charge(decimal amount);
}
// The REAL gateway would call an external API (slow, costs money).
class StripeGateway : IPaymentGateway
{
public bool Charge(decimal amount)
{
Console.WriteLine($" [Stripe] charging £{amount}");
return true;
}
}
// A FAKE for tests — records w
...In a real test project you'd often use a mocking library like Moq to generate the fake automatically, but a hand-written fake like FakeGateway works exactly the same way — and it only exists because the dependency was injectable.
Pro Tips
- 💡 Depend on interfaces, not concrete classes: a constructor parameter of type
ILoggercan be swapped; one of typeConsoleLoggercannot. - 💡 Prefer constructor injection over property or method injection — it makes a class's required dependencies obvious and impossible to forget.
- 💡 When unsure of a lifetime, use Transient: a fresh instance is the safe default. Only share (Scoped/Singleton) when you have a reason.
- 💡 Use
GetRequiredService<T>()overGetService<T>()so a forgotten registration throws a clear error instead of a confusingnull. - 💡 Turn on
ValidateScopesandValidateOnBuildin development to catch captive dependencies and missing registrations at startup.
Common Errors (and the fix)
- "System.InvalidOperationException: No service for type 'IFoo' has been registered": you asked the container for a service you never registered. Add an
AddTransient<IFoo, Foo>()(or the right lifetime) beforeBuildServiceProvider(). - Captive dependency / lifetime mismatch: a
Singletonthat takes aScopedservice traps that instance for the app's lifetime. Make lifetimes compatible, or injectIServiceScopeFactoryand create a scope when you need the scoped service. - "InvalidOperationException: Cannot consume scoped service ... from singleton": exactly the captive-dependency case above, caught because
ValidateScopesis on. Fix the lifetimes — don't disable the check. - Circular dependency ("A circular dependency was detected"): service A's constructor needs B, and B's needs A. The container can't build either. Break the cycle by extracting a shared interface, or have one side depend on a factory/lazy instead of the object directly.
- Service-locator anti-pattern: calling
provider.GetService<X>()from deep inside your business classes hides their real dependencies and defeats the point of DI. Inject what you need through the constructor instead, and only resolve from the container at the application's entry point.
📋 Quick Reference
| Task | Code | Notes |
|---|---|---|
| Create the container | var services = new ServiceCollection(); | Holds registrations |
| Register a singleton | services.AddSingleton<IFoo, Foo>(); | One, shared forever |
| Register a scoped | services.AddScoped<IFoo, Foo>(); | One per scope |
| Register a transient | services.AddTransient<IFoo, Foo>(); | New each resolve |
| Build the provider | var p = services.BuildServiceProvider(); | Resolves services |
| Resolve (may be null) | p.GetService<IFoo>(); | Returns null if missing |
| Resolve (must exist) | p.GetRequiredService<IFoo>(); | Throws if missing |
| Create a scope | using var s = p.CreateScope(); | For scoped services |
Frequently Asked Questions
Q: Do I always need a container to do dependency injection?
No. DI is just the pattern of passing dependencies in from outside — the first examples in this lesson wire everything by hand with no container at all. A container only automates that wiring once your graph gets big. The pattern is the important bit.
Q: What's the difference between DI and Inversion of Control?
Inversion of Control is the broad idea of handing control of object creation to something else. Dependency injection is one specific way of achieving it — supplying a class's dependencies through its constructor. A DI container is the tool that performs the inversion for you.
Q: Which lifetime should I pick?
Default to Transient for stateless services. Use Scoped for things that should be shared within a single request but not across them (like a database context). Use Singleton only for genuinely shared, thread-safe state such as a cache or configuration.
Q: Why is the service-locator pattern considered bad?
Because pulling services from the container deep inside your classes hides their dependencies — you can't tell what a class needs by looking at its constructor, which makes it harder to test and reason about. Inject dependencies explicitly instead, and only touch the container at your app's composition root.
Q: How exactly does DI make testing easier?
Because a class is given its dependencies, a test can hand it fakes instead of the real database or network service. You control the fake's behaviour and can inspect what the class did with it — fast, isolated, and repeatable.
Mini-Challenge: Wire a Service Graph by Hand
No blanks this time — just a brief and an outline. The contracts and implementations are written for you; your job is to build the two dependencies in Main and inject them into an OrderService through its constructor (no container — wire it by hand). Run it and check your output against the expected lines in the comments.
🎯 Mini-Challenge: an OrderService with two dependencies
Construct the repository and notifier, inject both into OrderService, then place an order.
using System;
// 🎯 MINI-CHALLENGE: Wire an OrderService graph BY HAND (no container).
//
// You are given two contracts and one implementation of each.
// OrderService needs BOTH an IRepository and an INotifier.
//
// 1. In Main, create an InMemoryRepository (an IRepository).
// 2. Create a ConsoleNotifier (an INotifier).
// 3. Construct an OrderService, INJECTING both dependencies via its constructor.
// 4. Call orders.Place("Keyboard").
//
// ✅ Expected output:
// Saved order: Keyboard
//
...🎉 Lesson Complete
- ✅ Dependency injection = a class is given its dependencies instead of
new-ing them - ✅ Depend on interfaces, not concrete classes — that's the Dependency Inversion principle
- ✅ Constructor injection lets you swap implementations without changing the class
- ✅ An IoC container (
ServiceCollection) registers and resolves whole graphs for you - ✅ Lifetimes: Singleton (one, shared), Scoped (one per scope), Transient (new each time)
- ✅ Avoid captive dependencies, circular dependencies, and the service-locator anti-pattern
- ✅ DI makes code testable by letting you inject fakes in place of real services
- ✅ Next lesson: Logging Architecture — structured logging with
ILogger(injected via DI!)
Sign up for free to track which lessons you've completed and get learning reminders.