← Open in the full interactive course (progress tracking, search & more)

Stop building your own dependencies. Let someone hand them to you.

Imagine every time you wanted a coffee, you had to grow the beans yourself, build a roaster, and manufacture a coffee machine before you could brew a cup. That's insane — you just want the coffee. You walk into a café, and the barista hands you a finished cup. You didn't build the machine; someone else provided it, and you simply used it.

A lot of C# code is written the "grow your own beans" way. A class needs a logger, so it does new ConsoleLogger() right inside itself. It needs to send email, so it does new SmtpEmailSender(). Every class becomes responsible for manufacturing everything it touches — which means every class is welded to specific, concrete implementations, and none of it can be swapped, mocked, or reconfigured without editing that class's source code.

In this lesson, you'll learn what Dependency Injection actually is, why the .NET runtime ships a built-in container for it, and how to register and resolve services using the modern generic host — the foundation every real .NET application (web app, worker service, or console tool) is built on.

What Is It?

The Simple Explanation

Dependency Injection (DI) is a technique where a class doesn't create the objects it depends on — it receives them from the outside, usually through its constructor. The class declares "I need an ILogger," and something else — a framework, a container — figures out which concrete logger to hand it and builds it for it.

Break the phrase into its two words:

The Technical Definition

Dependency Injection is a specific application of the Dependency Inversion Principle — the "D" in SOLID, covered in lesson 081. Dependency Inversion says: high-level code should depend on abstractions (interfaces), not on concrete, low-level implementations. Dependency Injection is how you make that happen at runtime: an external mechanism constructs the concrete implementation and passes it into the class that depends on the abstraction, typically through the constructor.

In .NET, this external mechanism is usually the built-in Microsoft.Extensions.DependencyInjection package — a lightweight Inversion of Control (IoC) container. You register your services with it once, at startup, and it takes responsibility for constructing them (and everything they depend on) whenever they're needed.

Dependency Inversion (the principle)

Dependency Injection (the mechanism)

Why Does It Exist?

The Problem — Classes That Build Their Own Dependencies

Picture an OrderService that needs to log activity and send confirmation emails:

public class OrderService
{
    private readonly ConsoleLogger _logger = new ConsoleLogger();
    private readonly SmtpEmailSender _emailSender = new SmtpEmailSender("smtp.mycorp.com");

    public void PlaceOrder(Order order)
    {
        _logger.Log($"Placing order {order.Id}");
        _emailSender.Send(order.CustomerEmail, "Order placed!");
    }
}

This looks harmless, but it quietly creates several real problems:

The Solution — Let Something Else Supply the Dependencies

Dependency Injection flips the responsibility. OrderService stops knowing how to build a logger or an email sender — it only knows it needs something that satisfies ILogger and IEmailSender. A container, configured once at startup, is responsible for building the right concrete objects and wiring them together.

The payoff: Classes become small, focused, and easy to test in isolation. Swapping an implementation (real email sender → fake test double) becomes a one-line change at the composition point, not a hunt through your codebase.

Big Picture

WITHOUT DI vs WITH DI
Without DI
OrderService
↓ creates
new SmtpEmailSender()

tightly coupled
With DI
OrderService
↓ receives
IEmailSender
↓ supplied by container
loosely coupled

Zoomed out, this is the flow of a DI-powered app:

THE DI PICTURE
Application starts
You register services
Container builds the service provider
Something asks for OrderService

How It Works

SETTING UP THE HOST AND CONTAINER
1. CREATE THE APPLICATION BUILDER
var builder = Host.CreateApplicationBuilder(args);
2. REGISTER SERVICES
builder.Services.AddSingleton<IEmailSender, SmtpEmailSender>();
builder.Services.AddTransient<OrderService>();
3. BUILD THE HOST
using var host = builder.Build();
4. RESOLVE AND RUN
var orderService = host.Services.GetRequiredService<OrderService>();
orderService.PlaceOrder(new Order(1, "customer@example.com"));

Simple Example

Here's the full, runnable picture — an abstraction, an implementation, a class that depends on the abstraction via constructor injection (using a primary constructor), and the wiring at startup.

// ─── The abstraction ───
public interface IEmailSender
{
    void Send(string to, string message);
}

// ─── The concrete implementation ───
public class SmtpEmailSender : IEmailSender
{
    public void Send(string to, string message) =>
        Console.WriteLine($"[SMTP] To: {to} — {message}");
}

// ─── A class that DEPENDS on the abstraction ───
// Primary constructor: the parameter list *is* the constructor.
public class OrderService(IEmailSender emailSender, ILogger<OrderService> logger)
{
    public void PlaceOrder(int orderId, string customerEmail)
    {
        logger.LogInformation("Placing order {OrderId}", orderId);
        emailSender.Send(customerEmail, $"Your order {orderId} has been placed!");
    }
}

// ─── Program.cs — composing the app ───
var builder = Host.CreateApplicationBuilder(args);

builder.Services.AddSingleton<IEmailSender, SmtpEmailSender>();
builder.Services.AddTransient<OrderService>();

using var host = builder.Build();

var orderService = host.Services.GetRequiredService<OrderService>();
orderService.PlaceOrder(1001, "sam@example.com");

What just happened: OrderService never wrote new SmtpEmailSender() anywhere. It simply declared, through its constructor, "I need an IEmailSender and a logger." The container matched that request against the registration and supplied the concrete object. OrderService doesn't even know SmtpEmailSender exists.

Note also that logger was never explicitly registered — ILogger<T> is provided automatically by the generic host's built-in logging infrastructure (lesson 128 covers this in depth).

Real-World Example

Consider a background worker service that polls a payment provider every minute, checks for failed payments, and notifies a support channel. It needs several collaborators: a payment API client, a notification service, and a repository to track which payments it has already handled.

public interface IPaymentClient
{
    Task<IReadOnlyList<FailedPayment>> GetFailedPaymentsAsync();
}

public interface INotificationService
{
    Task NotifyAsync(string message);
}

public class PaymentMonitorWorker(
    IPaymentClient paymentClient,
    INotificationService notifications,
    ILogger<PaymentMonitorWorker> logger) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            var failures = await paymentClient.GetFailedPaymentsAsync();
            foreach (var failure in failures)
            {
                logger.LogWarning("Payment {PaymentId} failed", failure.Id);
                await notifications.NotifyAsync($"Payment {failure.Id} failed for {failure.Amount:C}");
            }
            await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
        }
    }
}

// ─── Composition root ───
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddHttpClient<IPaymentClient, StripePaymentClient>();
builder.Services.AddSingleton<INotificationService, SlackNotificationService>();
builder.Services.AddHostedService<PaymentMonitorWorker>();

using var host = builder.Build();
await host.RunAsync();

PaymentMonitorWorker is completely decoupled from Stripe and Slack. In a test project, you register fake implementations of IPaymentClient and INotificationService instead, and the worker's logic runs against them without a single change to PaymentMonitorWorker itself. That's the entire point.

Analogy

The Restaurant Kitchen

A chef (your class) needs ingredients (dependencies) to cook a dish. A bad kitchen makes the chef leave the restaurant, drive to a farm, and grow the vegetables personally every single time. A well-run kitchen has a supplier (the DI container) that delivers exactly the ingredients ordered, already prepped, right when the chef needs them.

The chef doesn't care whether the tomatoes came from Farm A or Farm B — just that they satisfy "tomato." Swap suppliers, and the chef's recipe doesn't change at all. That's constructor injection: the class specifies what it needs (the interface), and the container decides which supplier fulfills it.

Under the Hood

RESOLVING A DEPENDENCY GRAPH
1. YOU ASK FOR OrderService
2. CONTAINER INSPECTS THE CONSTRUCTOR VIA REFLECTION
3. IT RECURSIVELY RESOLVES EACH DEPENDENCY FIRST
4. IT CONSTRUCTS THE OBJECT AND (FOR SCOPED/SINGLETON) CACHES IT

This constructor-reflection approach means the container is largely unopinionated about your code — it just needs a public constructor whose parameter types it can resolve. If it can't find or build a registration for some required type, it throws an InvalidOperationException at resolution time (usually app startup), not silently returning null.

Common Confusion

"Dependency Injection" ≠ "Dependency Inversion"

These are two different, related ideas, and beginners routinely conflate them:

In short: Dependency Inversion is the "what and why," Dependency Injection is one common "how."

"The container" isn't magic — it's a dictionary and reflection

It's tempting to think of IServiceProvider as some mysterious black box. It isn't — it's a lookup table mapping types to instructions for building them, plus a bit of reflection to read constructors. Understanding this demystifies most DI errors: if resolution fails, it's because a type wasn't registered, or one of its dependencies wasn't.

Common Mistakes

Mistake 1 — The "Service Locator" anti-pattern

Injecting the whole IServiceProvider and pulling services out of it manually inside a class, instead of declaring real constructor dependencies.

public class OrderService(IServiceProvider provider)
{
    public void PlaceOrder()
    {
        var emailSender = provider.GetRequiredService<IEmailSender>(); // hides the real dependency
    }
}

Declare IEmailSender directly as a constructor parameter. This keeps the class's true dependencies visible and honest, and lets the compiler (and the reader) verify them at a glance.

Mistake 2 — Forgetting to register a type and only finding out at runtime

DI resolution failures happen at runtime, not compile time. Forgetting builder.Services.AddTransient<OrderService>() compiles fine and blows up the moment you try to resolve it.

Resolve your top-level services (like hosted services or entry points) as early as possible in Program.cs/startup so registration mistakes surface immediately in development, not in production.

Mistake 3 — Injecting concrete classes instead of interfaces

public OrderService(SmtpEmailSender sender) — this defeats the whole purpose. You can still register and resolve it, but now OrderService is coupled to SMTP specifically, and you've lost the ability to swap implementations or mock it cleanly in tests.

Depend on the interface (IEmailSender), and let the registration decide which concrete type fulfills it.

When Should I Use It?

Use DI when:

You may not need a full container when:

Rule of thumb: Every ASP.NET Core and worker-service template already wires up a DI container for you — in modern .NET, you're almost always using it, even without deciding to. Learning to register and resolve services deliberately is what turns that default plumbing into a tool you control.

Mental Model

Dependency = something my class needs
Injection = someone else provides it

Dependency Injection = my class receives what it needs through its constructor, instead of creating it itself

Remember:
· Register once with IServiceCollection, resolve anywhere via the container.
· The class declares an interface; the registration decides the implementation.
· Constructor injection is the default and preferred style — dependencies are visible, honest, and required.

Key Takeaway


Check Your Understanding

You've seen how Dependency Injection lets a class receive what it needs instead of building it. Let's check whether the mental model has stuck.

1. What is the core difference between the Dependency Inversion Principle and Dependency Injection?

Show answer

Correct: B

Why B is correct: Dependency Inversion is the design rule from SOLID — depend on abstractions, not concretions. Dependency Injection is one practical way to make that happen at runtime: something external supplies the concrete implementation.

Why A is incorrect: They're related but distinct — one is a principle, the other a technique.

Why C is incorrect: DI is used across console apps, worker services, and web apps — it's not ASP.NET Core-specific.

Why D is incorrect: It's actually backwards — you can follow Dependency Inversion with zero tooling, but a container is a convenience for doing DI at scale.

Reinforcement: Principle vs. mechanism — Inversion is the "why," Injection is a "how."

2. In builder.Services.AddSingleton<IEmailSender, SmtpEmailSender>();, what does this line actually do?

Show answer

Correct: B

Why B is correct: Registration is just adding an entry to the container's lookup table. No object is built yet — construction happens later, on demand, when something actually resolves IEmailSender.

Why A is incorrect: Registration is lazy — construction is deferred until resolution (and for singletons, until first resolution).

Why C is incorrect: Nothing is deleted; this simply adds a new registration.

Why D is incorrect: SmtpEmailSender must already implement IEmailSender in code — DI doesn't rewrite type relationships, it just wires already-compatible types together.

Reinforcement: Registration is declarative wiring, not immediate construction.

3. Why is constructor injection preferred over having a class pull dependencies out of an injected IServiceProvider (the service locator pattern)?

Show answer

Correct: B

Why B is correct: With constructor injection, anyone reading the class signature immediately knows what it depends on. With service locator, dependencies are hidden inside method bodies and only discoverable by reading every line of implementation — and it becomes easy to accidentally resolve a service that was never intended to be a real dependency.

Why A is incorrect: Performance is not the deciding factor here; clarity and testability are.

Why C is incorrect: IServiceProvider is automatically available for injection — the problem isn't that it can't be injected, it's that using it to fetch dependencies manually is an anti-pattern.

Why D is incorrect: The DI community and Microsoft's own guidance strongly favor constructor injection as the default.

Reinforcement: Explicit, visible dependencies beat hidden ones every time.

4. You call host.Services.GetRequiredService<OrderService>() but never registered OrderService with the container. What happens?

Show answer

Correct: C

Why C is correct: DI registration mistakes are a runtime concern, not a compile-time one — GetRequiredService throws an InvalidOperationException if the type (or one of its dependencies) has no registration.

Why A is incorrect: The compiler has no visibility into your DI registrations; it type-checks code, not container configuration.

Why B is incorrect: That's what GetService<T>() (not GetRequiredService) would do — return null instead of throwing. GetRequiredService is explicit about requiring a result.

Why D is incorrect: The container never auto-registers types you didn't explicitly register.

Reinforcement: This is exactly why resolving key services early in Program.cs catches registration mistakes fast, during startup.

5. A PaymentMonitorWorker depends on IPaymentClient. In your unit tests, you want to verify the worker's retry logic without calling a real payment API. What does using DI make possible here?

Show answer

Correct: B

Why B is correct: Because PaymentMonitorWorker only knows about the IPaymentClient abstraction, you can construct it directly with a test double in unit tests — no container even required for the test itself, just pass a fake implementation to the constructor.

Why A is incorrect: Testability is one of the biggest practical benefits of DI — it's precisely what makes isolated unit testing possible.

Why C is incorrect: The entire point is to avoid calling the real API in tests.

Why D is incorrect: DI is an architectural technique, not a test-generation tool.

Reinforcement: Depending on abstractions is what makes swapping in test doubles trivial — this is the payoff of everything this lesson covered.

You now understand what Dependency Injection is, why it exists, and how to wire it up with the modern generic host — the foundation for everything else in this module.


dotnetmadeeasy.com — Learn C# and .NET, the right way.