Dependency Injection Internals

Reviewed & published by Brayan K

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.

Part of the free C# course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

What You'll Learn

💡 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

LifetimeRegistered withCreatedUse for
SingletonAddSingletonOnce, shared for the whole appCaches, config, stateless loggers
ScopedAddScopedOnce per scope (e.g. one web request)DbContext, per-request state
TransientAddTransientA brand-new one every time it's resolvedLightweight, 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.

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 constructor ===
// 'Dependency injection' just means: the class is GIVEN its collaborators
// from outside instead of creating them itself.
interface INotifier
{
    void Notify(string message);            // a contract — the "what", not the "how"
}

class EmailNotifier : INotifier
{
    public void Notify(string message) =>
        Console.WriteLine($"  [Email] {message}");
}

class OrderService
{
    private readonly INotifier _notifier;   // depends on the INTERFACE, not a concrete class

    // The dependency is INJECTED here. OrderService no longer says 'new'.
    public OrderService(INotifier notifier) => _notifier = notifier;

    public void Place(string item)
    {
        Console.WriteLine($"Order placed: {item}");
        _notifier.Notify($"We shipped your {item}");
    }
}

class Program
{
    static void Main()
    {
        Console.WriteLine("=== Tight coupling (cannot swap the notifier) ===");
        var tight = new TightOrderService();
        tight.Place("Laptop");

        Console.WriteLine();
        Console.WriteLine("=== Constructor injection (you choose the notifier) ===");
        INotifier notifier = new EmailNotifier();   // the CALLER decides which one
        var service = new OrderService(notifier);   // wire it by hand — no container needed
        service.Place("Laptop");

        // ✅ Expected output:
        //    === Tight coupling (cannot swap the notifier) ===
        //    Order placed: Laptop
        //      [Email] We shipped your Laptop
        //
        //    === Constructor injection (you choose the notifier) ===
        //    Order placed: Laptop
        //      [Email] We shipped your Laptop
    }
}

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.

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 Greeter(IMessageService ___)      // 👉 name the parameter, e.g. messages
    {
        _messages = ___;                     // 👉 assign the parameter to _messages
    }

    public void Greet(string name)
    {
        Console.WriteLine(_messages.Format($"Hello, {name}!"));
    }
}

class Program
{
    static void Main()
    {
        // 2) Wire it by hand: create the service, then inject it.
        IMessageService service = new PlainMessageService();
        Greeter greeter = new Greeter(___);  // 👉 pass the service you just made

        greeter.Greet("Sam");

        // ✅ Expected output:
        //    Hello, Sam!
    }
}

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.

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() + "!!!";  // "HELLO, SAM!!!!"
}

class Greeter
{
    private readonly IMessageService _messages;
    public Greeter(IMessageService messages) => _messages = messages;
    public void Greet(string name) => Console.WriteLine(_messages.Format($"Hello, {name}"));
}

class Program
{
    static void Main()
    {
        // 1) Inject the SHOUTING service instead of the plain one.
        IMessageService service = new ___();   // 👉 use ShoutingMessageService

        // 2) Give Greeter that service.
        Greeter greeter = new Greeter(___);    // 👉 pass 'service'

        greeter.Greet("Sam");

        // ✅ Expected output:
        //    HELLO, SAM!!!!
    }
}

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.

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); }

class ConsoleLogger : ILogger
{
    public void Log(string msg) => Console.WriteLine($"  [log] {msg}");
}

class GreetingStore : IGreetingStore
{
    public string Get() => "Hello";          // pretend this reads from a database
}

class Greeter : IGreeter
{
    private readonly ILogger _logger;
    private readonly IGreetingStore _store;

    // The container sees these constructor parameters and supplies them.
    public Greeter(ILogger logger, IGreetingStore store)
    {
        _logger = logger;
        _store = store;
    }

    public void Greet(string name)
    {
        _logger.Log($"Greeting {name}");
        Console.WriteLine($"{_store.Get()}, {name}!");
    }
}

class Program
{
    static void Main()
    {
        // 1) REGISTER each service against its interface, picking a lifetime.
        var services = new ServiceCollection();
        services.AddSingleton<ILogger, ConsoleLogger>();        // one instance, shared
        services.AddScoped<IGreetingStore, GreetingStore>();    // one per scope
        services.AddTransient<IGreeter, Greeter>();             // a fresh one each request

        // 2) BUILD the provider — the thing that resolves services.
        ServiceProvider provider = services.BuildServiceProvider();

        // 3) RESOLVE: ask for IGreeter. The container constructs Greeter AND
        //    its ILogger and IGreetingStore for you — the full graph, no 'new'.
        IGreeter greeter = provider.GetService<IGreeter>();
        greeter.Greet("Ada");

        // ✅ Expected output:
        //      [log] Greeting Ada
        //    Hello, Ada!
    }
}

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.

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 ServiceCollection();
        services.AddSingleton<SingletonService>();   // ONE for the whole app
        services.AddScoped<ScopedService>();         // ONE per scope
        services.AddTransient<TransientService>();    // NEW every resolve

        var provider = services.BuildServiceProvider();

        // A "scope" models a unit of work — e.g. one web request.
        using (var scope1 = provider.CreateScope())
        {
            var sp = scope1.ServiceProvider;
            var t1 = sp.GetService<TransientService>();
            var t2 = sp.GetService<TransientService>();
            // Transient: two resolves => two DIFFERENT instances.
            Console.WriteLine($"Transient same in scope?  {t1.Id == t2.Id}"); // False

            var sc1 = sp.GetService<ScopedService>();
            var sc2 = sp.GetService<ScopedService>();
            // Scoped: same scope => SAME instance.
            Console.WriteLine($"Scoped same in scope?     {sc1.Id == sc2.Id}"); // True
        }

        // Compare a value across TWO different scopes.
        Guid singletonA, scopedA, singletonB, scopedB;
        using (var s = provider.CreateScope())
        {
            singletonA = s.ServiceProvider.GetService<SingletonService>().Id;
            scopedA = s.ServiceProvider.GetService<ScopedService>().Id;
        }
        using (var s = provider.CreateScope())
        {
            singletonB = s.ServiceProvider.GetService<SingletonService>().Id;
            scopedB = s.ServiceProvider.GetService<ScopedService>().Id;
        }

        // Singleton: same across scopes. Scoped: different per scope.
        Console.WriteLine($"Singleton same across scopes? {singletonA == singletonB}"); // True
        Console.WriteLine($"Scoped same across scopes?    {scopedA == scopedB}");       // False

        // ✅ Expected output:
        //    Transient same in scope?  False
        //    Scoped same in scope?     True
        //    Singleton same across scopes? True
        //    Scoped same across scopes?    False
    }
}

🔎 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.

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 what happened, no network at all.
class FakeGateway : IPaymentGateway
{
    public decimal LastAmount { get; private set; }
    public bool Charge(decimal amount)
    {
        LastAmount = amount;            // remember it so the test can check it
        return true;                    // pretend it always succeeds
    }
}

class Checkout
{
    private readonly IPaymentGateway _gateway;
    public Checkout(IPaymentGateway gateway) => _gateway = gateway;   // injected!

    public bool Buy(decimal price) => _gateway.Charge(price);
}

class Program
{
    static void Main()
    {
        // Production wiring: inject the real gateway.
        var live = new Checkout(new StripeGateway());
        live.Buy(19.99m);

        // Test wiring: inject the fake — no real charge happens.
        var fake = new FakeGateway();
        var underTest = new Checkout(fake);
        underTest.Buy(42.00m);

        // Because we injected the fake, the test can inspect what it saw.
        Console.WriteLine($"Fake recorded amount: £{fake.LastAmount}");

        // ✅ Expected output:
        //      [Stripe] charging £19.99
        //    Fake recorded amount: £42.00
    }
}

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

Common Errors (and the fix)

📋 Quick Reference

TaskCodeNotes
Create the containervar services = new ServiceCollection();Holds registrations
Register a singletonservices.AddSingleton<IFoo, Foo>();One, shared forever
Register a scopedservices.AddScoped<IFoo, Foo>();One per scope
Register a transientservices.AddTransient<IFoo, Foo>();New each resolve
Build the providervar 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 scopeusing 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.

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
//    [Notify] Order received: Keyboard

interface IRepository { void Save(string item); }
interface INotifier   { void Send(string item); }

class InMemoryRepository : IRepository
{
    public void Save(string item) => Console.WriteLine($"Saved order: {item}");
}

class ConsoleNotifier : INotifier
{
    public void Send(string item) => Console.WriteLine($"[Notify] Order received: {item}");
}

class OrderService
{
    private readonly IRepository _repo;
    private readonly INotifier _notifier;

    // Both dependencies arrive through the constructor.
    public OrderService(IRepository repo, INotifier notifier)
    {
        _repo = repo;
        _notifier = notifier;
    }

    public void Place(string item)
    {
        _repo.Save(item);
        _notifier.Send(item);
    }
}

class Program
{
    static void Main()
    {
        // your code here — build the two dependencies and inject them
    }
}

🎉 Lesson Complete

Practice quiz

What does 'dependency injection' mean?

  • A class creates its own dependencies with new
  • A class is given its dependencies from outside instead of creating them
  • All dependencies are static fields
  • Dependencies are resolved with reflection at compile time

Answer: A class is given its dependencies from outside instead of creating them. DI means a class receives its collaborators from outside (e.g. via its constructor) rather than new-ing them itself.

Why depend on an interface instead of a concrete class?

  • It runs faster
  • It uses less memory
  • You can swap implementations without changing the dependent class
  • Interfaces are required by C#

Answer: You can swap implementations without changing the dependent class. Programming to an interface lets you substitute a different implementation (real, fake, upgraded) without editing the code that uses it.

How many instances does AddSingleton create for the whole application?

  • One per scope
  • A new one each resolve
  • One, shared for the entire app
  • One per thread

Answer: One, shared for the entire app. A Singleton is created once and shared for the entire application lifetime.

When is a Scoped service created?

  • Once per scope (e.g. one web request)
  • Once for the whole app
  • A fresh one every resolve
  • Never — it must be created manually

Answer: Once per scope (e.g. one web request). AddScoped gives one instance per scope; in a web app that scope is typically one HTTP request.

What does AddTransient do?

  • Shares one instance app-wide
  • Creates a brand-new instance every time it's resolved
  • Creates one per scope
  • Caches the instance for 5 minutes

Answer: Creates a brand-new instance every time it's resolved. A Transient service is built fresh every single time it's resolved — the safest default because it can't hold stale state.

What is the captive dependency trap?

  • Two services depending on each other
  • A Singleton holding a reference to a shorter-lived Scoped service
  • A service registered twice
  • Forgetting to call BuildServiceProvider

Answer: A Singleton holding a reference to a shorter-lived Scoped service. A Singleton that takes a Scoped service traps that scoped instance for the whole app lifetime — it's never refreshed per request.

A service may only depend on something with which lifetime?

  • A shorter lifetime
  • An equal or longer lifetime
  • Only the same lifetime
  • Any lifetime — it never matters

Answer: An equal or longer lifetime. The rule: a service may only depend on something with an equal or longer lifetime. Singleton → Scoped is the captive-dependency trap.

What's the difference between GetService<T>() and GetRequiredService<T>()?

  • They are identical
  • GetService throws if missing; GetRequiredService returns null
  • GetService returns null if missing; GetRequiredService throws
  • GetRequiredService is async

Answer: GetService returns null if missing; GetRequiredService throws. GetService<T>() returns null for an unregistered service, while GetRequiredService<T>() throws a clear exception — prefer the latter so failures are loud.

Which injection style does the lesson recommend?

  • Property injection
  • Method injection
  • Service-locator pattern
  • Constructor injection

Answer: Constructor injection. Constructor injection makes a class's required dependencies obvious and impossible to forget, so it's preferred over property/method injection.

Why is the service-locator pattern considered an anti-pattern?

  • It's slower than constructor injection
  • It hides a class's real dependencies, making it harder to test and reason about
  • It only works with singletons
  • It can't resolve interfaces

Answer: It hides a class's real dependencies, making it harder to test and reason about. Calling GetService deep inside business classes hides their dependencies, defeating the point of DI. Inject through the constructor instead.

Continue this course