Inversion of Control and Dependency Injection

Learning Objectives

Distinguish inversion of control (IoC) from dependency injection (DI).

Use constructor, setter and method injection appropriately.

Explain DI containers, lifetimes and the service-locator anti-pattern.

Use test doubles through injection.

Prerequisites: Modules 19, 21.

Intuition and Definition

Inversion of control: a framework calls *your* code (callbacks, event handlers, template methods) instead of your code calling the library. "Don't call us, we'll call you."

Dependency injection: an object receives its collaborators from outside instead of creating or looking them up itself.

Service(Logger& log) Required dependencies (default choice)

Optional dependencies with a sensible default report(Printer& p) The dependency varies per call

A DI container (Spring, .NET IServiceCollection, Angular, InversifyJS) maps abstractions to implementations and creates object graphs with lifetimes: *transient* (new each time), *scoped* (one per request), *singleton* (one per container).

Service locator (ServiceLocator.get<Logger>() inside classes) hides dependencies, so they are not visible in the constructor — considered an anti-pattern for application code.

Why It Matters

Explicit dependencies make classes testable (swap real I/O for fakes), configurable (choose implementations at start-up) and honest about what they need.

Syntax and Language Notes

class Logger {
public:
    virtual void log(const std::string& message) = 0;
    virtual ~Logger() = default;
};
class Service {
    Logger& logger;
public:
    explicit Service(Logger& log) : logger(log) {}
    void run() { logger.log("Service started"); }
};
// .NET built-in container
var services = new ServiceCollection();
services.AddSingleton<ILogger, ConsoleLogger>();
services.AddScoped<OrderService>();
class Service:
    def __init__(self, logger):      # duck-typed dependency, injected
        self._logger = logger

Complete Example

A composition root in main wires the object graph by hand (pure DI) — the same thing a container automates. The service never creates its dependencies.

The same wiring with a real container, the .NET ServiceCollection, including lifetimes:

#include <chrono>
#include <iostream>
#include <memory>
#include <string>
#include <vector>

struct Clock { virtual ~Clock() = default; virtual int hour() const = 0; };
struct Logger { virtual ~Logger() = default; virtual void log(const std::string&) = 0; };

struct FixedClock : Clock { int h; explicit FixedClock(int hh) : h(hh) {} int hour() const override { return h; } };
struct ConsoleLogger : Logger { void log(const std::string& m) override { std::cout << "[log] " << m << "\n"; } };
struct MemoryLogger : Logger {                               // test double
    std::vector<std::string> lines;
    void log(const std::string& m) override { lines.push_back(m); }
};

class WorkshopScheduler {
    const Clock& clock_;      // required: constructor injection
    Logger& log_;
public:
    WorkshopScheduler(const Clock& c, Logger& l) : clock_(c), log_(l) {}
    bool book(const std::string& plate) {
        int h = clock_.hour();
        bool open = h >= 8 && h < 18;
        log_.log(plate + (open ? " booked at " : " rejected at ") + std::to_string(h) + ":00");
        return open;
    }
};

int main() {
    // production-like wiring
    FixedClock nineAm(9);
    ConsoleLogger console;

…
[log] KA-01-1234 booked at 9:00
test: booked=0, captured log: "MH-12-9999 rejected at 22:00"
#:package Microsoft.Extensions.DependencyInjection@10.0.0
using Microsoft.Extensions.DependencyInjection;

var services = new ServiceCollection();
services.AddSingleton<IClock>(new FixedClock(9));
services.AddTransient<ILogSink, ConsoleSink>();
services.AddScoped<Scheduler>();
using var provider = services.BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true });

Scheduler first;
using (var scope = provider.CreateScope())
{
    var a = scope.ServiceProvider.GetRequiredService<Scheduler>();
    var b = scope.ServiceProvider.GetRequiredService<Scheduler>();
    Console.WriteLine($"same scheduler within one scope: {ReferenceEquals(a, b)}");
    a.Book("KA-01-1234");
    first = a;
}
using (var scope = provider.CreateScope())
{
    var c = scope.ServiceProvider.GetRequiredService<Scheduler>();
    Console.WriteLine($"new scope gives a new scheduler: {!ReferenceEquals(first, c)}");
    Console.WriteLine($"...but the singleton clock is shared: {ReferenceEquals(first.Clock, c.Clock)}");
}
try
{
    provider.GetRequiredService<Scheduler>();          // scoped service from the root: a captive-dependency risk
}
catch (InvalidOperationException e)
{
    Console.WriteLine($"
…
same scheduler within one scope: True
[log] KA-01-1234 booked at 9:00
new scope gives a new scheduler: True
...but the singleton clock is shared: True
root resolution refused: Cannot resolve scoped service 'Scheduler' from root provider

Step-by-Step Walkthrough

WorkshopScheduler declares exactly what it needs: a clock and a logger.

main is the composition root: the only place that knows concrete types.

In the test wiring the clock is fixed at 22:00, so the rejection path is deterministic — no waiting for night time — and the log is captured for assertions.

In .NET, AddScoped gives one Scheduler per scope; AddSingleton shares the clock everywhere; AddTransient creates a new sink each time it's requested. With ValidateScopes = true the container refuses to resolve a scoped service from the root provider — the check that catches captive dependencies.

flowchart TD
  Root[Composition root: main] --> S[WorkshopScheduler]
  Root --> C[FixedClock]
  Root --> L[ConsoleLogger / MemoryLogger]
  S -. depends on .-> CI[Clock interface]
  S -. depends on .-> LI[Logger interface]

Common Mistakes

Service locator calls inside business classes — hidden dependencies, hard tests.

Captive dependencies: a singleton holding a scoped service (e.g. a per-request DB context) — keeps it alive too long, often across threads. .NET can validate scopes and throw.

Constructor over-injection (10+ parameters) — a sign the class has too many responsibilities.

Circular dependencies (A needs B needs A) — redesign; containers can't (and shouldn't) resolve them.

Injecting everything, including stable value types and pure functions.

Trade-offs and Alternatives

Pure DI (manual wiring) is explicit and compile-time checked, but verbose for large graphs. Containers reduce boilerplate and manage lifetimes but move errors to start-up time and add "magic". In C++, templates/policies provide compile-time injection (Module 58).

Real-World Use

ASP.NET Core, Spring Boot, Angular and NestJS are built around containers. Embedded and game code usually wires dependencies by hand at start-up for predictability.

Practice

Q1. (Easy) Which injection style should be the default for required dependencies?

Answer: Constructor injection.

Q2. (Medium) Why is ServiceLocator.get<Db>() inside OrderService::place() problematic?

Answer: The dependency is invisible in the class's API, tests must configure a global locator, and missing registrations fail at run time deep inside business logic.

Q3. (Advanced) A singleton cache depends on a scoped DbContext. What goes wrong and how do you fix it?

Answer: The singleton captures the first scope's context forever ("captive dependency"): it outlives its scope, may be used concurrently by many requests, and holds stale state. Fix by depending on a factory (IDbContextFactory) and creating a short-lived context per operation, or by making the cache scoped.

Interview Question

Q1. "Is dependency injection the same as inversion of control?"

Answer: DI is one *form* of IoC. IoC is the general principle that control flow is inverted — a framework calls your code (event loops, template methods, callbacks, plugin hosts). DI inverts the control of *dependency creation*: instead of an object constructing its collaborators, something outside supplies them. You can use DI without a framework (manual wiring), and you can have IoC without DI (an event-driven UI).

Quick Quiz

Q1. What is a composition root?

Answer: The single place (usually start-up) where the object graph is wired with concrete types.

Q2. Name three common container lifetimes.

Answer: Transient, scoped, singleton.

Q3. Why is DI good for testing?

Answer: Tests can inject fakes (clocks, loggers, repositories) to make behaviour deterministic and observable.

Summary

IoC: the framework calls you; DI: dependencies come from outside.

Prefer constructor injection and a single composition root; avoid service locators and captive dependencies.

Related: Module 19 (DIP), Module 65 (test doubles), Module 72 (ports and adapters).