Dependency Injection in .NET: A Beginner-Friendly Guide
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:
- Dependency — something a class needs in order to do its job (a logger, a database repository, an email sender).
- Injection — the act of supplying that dependency from outside, rather than the class constructing it itself.
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)
- A design idea — a rule about which direction dependencies should point
- "Depend on abstractions, not concretions"
- You can follow it even in a five-line script
- Doesn't require any tooling or framework
Dependency Injection (the mechanism)
- A runtime technique that implements the principle
- Something external constructs and supplies dependencies
- Usually powered by a container (like
IServiceCollection) - Makes the principle practical at application scale
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:
- You can't test it in isolation. Every unit test for
OrderServicewill send a real SMTP email, because there's no way to substitute a fake sender. - You can't swap implementations. Want to log to a file instead of the console in production? You have to edit
OrderService's source code. - Configuration is buried. The SMTP server address is hardcoded inside a class that has nothing conceptually to do with SMTP servers.
- Object graphs don't scale. As your app grows, every class ends up wiring up its own tree of dependencies by hand, and that logic gets duplicated everywhere.
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.
Big Picture
OrderService↓ creates
new SmtpEmailSender()↓
tightly coupled
OrderService↓ receives
IEmailSender↓ supplied by container
loosely coupled
Zoomed out, this is the flow of a DI-powered app:
- You build a host and get an
IServiceCollection
- "When something asks for
IEmailSender, give it aSmtpEmailSender"
- The registrations become a resolvable graph
- Container sees its constructor needs
IEmailSenderandILogger<OrderService> - Container builds those first, then builds
OrderService, passing them in
How It Works
var builder = Host.CreateApplicationBuilder(args);
Host.CreateApplicationBuilderis the modern, unified entry point for console apps, worker services, and (under the hood) web apps.- It exposes
builder.Services, anIServiceCollection— a list of service registrations.
builder.Services.AddSingleton<IEmailSender, SmtpEmailSender>();
builder.Services.AddTransient<OrderService>();
- Each call maps an abstraction (
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 workingIServiceProvider— 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-builtOrderService.- The container inspects
OrderService's constructor, sees it needsIEmailSender, resolves that first, then constructsOrderServiceand hands it back.
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
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
GetRequiredService<OrderService>()triggers resolution.
- It reads
OrderService's constructor parameters:IEmailSender,ILogger<OrderService>. - It looks up each parameter type in its internal registration table (a dictionary of service descriptors).
- To build
OrderService, it must first have a realIEmailSenderinstance — so it resolvesSmtpEmailSender(which itself might have dependencies, resolved the same way, all the way down). - This is why it's called a dependency graph — resolving one object can cascade into constructing a whole tree of objects.
- Depending on the registered lifetime, the instance may be cached for reuse within a scope, cached forever, or thrown away after this one use.
- The finished object is handed back to you.
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:
- Dependency Inversion Principle (lesson 081) is a design rule: depend on abstractions, not concrete types. You can follow this rule with zero frameworks — just by typing an interface parameter.
- Dependency Injection is a runtime technique for actually supplying those abstractions — usually via a container. You can technically do DI by hand (passing a concrete
new SmtpEmailSender()into a constructor that takesIEmailSender) 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."
"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're building any real ASP.NET Core app, worker service, or console tool with more than a handful of classes.
- You want your business logic to be unit-testable in isolation, with fakes/mocks standing in for real dependencies.
- You have cross-cutting concerns (logging, configuration, HTTP clients) that many classes need but shouldn't each construct themselves.
- You expect implementations to change per environment (a mock email sender in tests, a real SMTP sender in production).
You may not need a full container when:
- You're writing a tiny script or one-off tool with no meaningful object graph.
- Manual constructor passing (plain "poor man's DI" — just calling
newat one composition point) is simple enough and the app will never grow.
Mental Model
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
- Dependency Injection is the runtime mechanism that implements the Dependency Inversion Principle: dependencies are supplied from outside, not built inside.
Microsoft.Extensions.DependencyInjectionis .NET's built-in IoC container, exposed throughIServiceCollection(for registering) andIServiceProvider(for resolving).Host.CreateApplicationBuilder(args)is the standard modern entry point for wiring up services in console apps, worker services, and web apps alike.- Constructor injection — declaring dependencies as constructor parameters — is the default, preferred style; avoid the service-locator anti-pattern of injecting
IServiceProvideritself. - The container resolves a whole dependency graph recursively via reflection, throwing at resolution time if something can't be satisfied.
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.