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.
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:
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.
IServiceCollection)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:
OrderService will send a real SMTP email, because there's no way to substitute a fake sender.OrderService's source code.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.
OrderServicenew SmtpEmailSender()OrderServiceIEmailSenderZoomed out, this is the flow of a DI-powered app:
IServiceCollectionIEmailSender, give it a SmtpEmailSender"IEmailSender and ILogger<OrderService>OrderService, passing them invar builder = Host.CreateApplicationBuilder(args);
Host.CreateApplicationBuilder is the modern, unified entry point for console apps, worker services, and (under the hood) web apps.builder.Services, an IServiceCollection — a list of service registrations.builder.Services.AddSingleton<IEmailSender, SmtpEmailSender>();
builder.Services.AddTransient<OrderService>();
IEmailSender) to a concrete type (SmtpEmailSender).Add<Lifetime> also controls how often a new instance is created — covered fully in lesson 125.using var host = builder.Build();
Build() compiles all your registrations into a working IServiceProvider — the actual container that can construct objects on demand.var orderService = host.Services.GetRequiredService<OrderService>();
orderService.PlaceOrder(new Order(1, "customer@example.com"));
GetRequiredService<T>() asks the container for a fully-built OrderService.OrderService's constructor, sees it needs IEmailSender, resolves that first, then constructs OrderService and hands it back.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).
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.
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.
GetRequiredService<OrderService>() triggers resolution.OrderService's constructor parameters: IEmailSender, ILogger<OrderService>.OrderService, it must first have a real IEmailSender instance — so it resolves SmtpEmailSender (which itself might have dependencies, resolved the same way, all the way down).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.
These are two different, related ideas, and beginners routinely conflate them:
new SmtpEmailSender() into a constructor that takes IEmailSender) without ever touching a container. The container is a convenience that automates it at scale.In short: Dependency Inversion is the "what and why," Dependency Injection is one common "how."
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.
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.
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.
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.
new at one composition point) is simple enough and the app will never grow.IServiceCollection, resolve anywhere via the container.Microsoft.Extensions.DependencyInjection is .NET's built-in IoC container, exposed through IServiceCollection (for registering) and IServiceProvider (for resolving).Host.CreateApplicationBuilder(args) is the standard modern entry point for wiring up services in console apps, worker services, and web apps alike.IServiceProvider itself.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?
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?
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)?
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?
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?
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.