Dependency Injection in ASP.NET Core (For Absolute Beginners)
Learn the fundamentals of Dependency Injection in ASP.NET Core. A beginner-friendly guide to DI with real examples and best practices.

Author & Creator · CodeToClarity
There's a moment every .NET developer experiences. You're six months into a project, someone asks you to swap out the email provider, and suddenly you're knee-deep in a tangled mess of new EmailService() calls scattered across fifteen different classes. You groan. You make the changes. You break three things in the process. You silently question your life choices.
If that story sounds familiar, this article is for you.
Dependency Injection (DI) is one of those concepts that sounds intimidating at first glance but genuinely changes the way you think about writing code. Once it clicks, you'll look back at old code and immediately spot what's wrong. More importantly, you'll know how to fix it.
Let's build that understanding from the ground up.
What Problem Does Dependency Injection Actually Solve?
Before we talk about the solution, let's talk about the problem it addresses.
When you write a class in C#, that class often needs other objects to do its job. A UserService might need a database repository. A NotificationService might need something to send emails. These are called dependencies because one class depends on another to function.
The naive approach is to just create those dependencies inside the class directly:
public class CodeToClarityUserService
{
private readonly UserRepository _repo = new UserRepository();
private readonly EmailSender _emailSender = new EmailSender();
public void RegisterUser(string email)
{
_repo.Save(email);
_emailSender.SendWelcome(email);
}
}
This works. Your app runs. You ship it. But here's what you've quietly done:
You've made CodeToClarityUserService responsible for creating its dependencies. That means the class now needs to know how UserRepository and EmailSender are constructed. It knows their internals. It's glued to them.
What happens when you want to write a unit test? You can't easily swap EmailSender for a fake one. What happens when UserRepository needs a connection string passed in? You have to update CodeToClarityUserService to know that too. What happens when you need to switch from SMTP to SendGrid? You're hunting through your entire codebase.
This is called tight coupling, and it's the root of a lot of long-term maintenance headaches.
The Core Idea: Stop Creating, Start Receiving
Dependency Injection flips the model. Instead of a class creating what it needs, it simply asks for what it needs. Something external is responsible for providing it.
Think about how a restaurant kitchen works. The chef doesn't grow vegetables, manufacture pots, or build ovens. They show up and the ingredients and tools are already there, ready to use. The chef's job is to cook, not to source everything from scratch.
DI works the same way. Your class declares what it needs. The framework delivers it. Your class focuses entirely on its own responsibility.
Here's the same example, rewritten with DI in mind:
public class CodeToClarityUserService
{
private readonly IUserRepository _repo;
private readonly IEmailSender _emailSender;
public CodeToClarityUserService(IUserRepository repo, IEmailSender emailSender)
{
_repo = repo;
_emailSender = emailSender;
}
public void RegisterUser(string email)
{
_repo.Save(email);
_emailSender.SendWelcome(email);
}
}
Notice two things. First, the class now depends on interfaces (IUserRepository, IEmailSender) rather than concrete classes. Second, those dependencies are passed in through the constructor rather than created inside the class.
Now CodeToClarityUserService doesn't care whether it's working with a real email sender or a mock one. It doesn't care how the repository connects to the database. It just knows it will receive something that fulfills the contract, and it gets on with its job.

How ASP.NET Core Makes DI a First-Class Citizen
Here's what's great about ASP.NET Core: you don't need a third-party library to use DI. The framework ships with a fully capable built-in DI container, and it's available from the moment you create a new project.
According to the official ASP.NET Core documentation on Dependency Injection, the framework itself is designed around DI. Services like logging, configuration, and HTTP context are all managed through it by default.
When your app starts, it builds a service container, essentially a registry of all the types your application knows how to create. When a class needs a dependency, ASP.NET Core looks it up in this container and injects it automatically.
You configure this container in Program.cs:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped<IUserRepository, SqlUserRepository>();
builder.Services.AddScoped<IEmailSender, SmtpEmailSender>();
var app = builder.Build();
That's it. From this point forward, any class that requests IUserRepository or IEmailSender through its constructor will receive the corresponding implementation automatically.

Understanding Service Lifetimes: Singleton, Scoped, and Transient
When you register a service, you're also telling the container how long each instance should live. ASP.NET Core gives you three options:
Transient: A fresh instance is created every time the service is requested:
builder.Services.AddTransient<ICodeToClarityReportBuilder, ReportBuilder>();
Scoped: One instance is created per HTTP request, shared across all classes that need it within that request:
builder.Services.AddScoped<ICodeToClarityOrderService, OrderService>();
Singleton: One instance is created for the entire lifetime of the application, shared across every request:
builder.Services.AddSingleton<ICodeToClarityCacheService, InMemoryCacheService>();
Choosing the wrong lifetime is one of the most common sources of subtle bugs in .NET apps. For example, injecting a scoped service into a singleton can lead to stale data under concurrent traffic, something that's easy to miss until things go wrong in production.
Since each lifetime has its own nuances and gotchas, I've covered it all in detail in a dedicated post. If you want to understand when to use each one and what pitfalls to watch out for, read Transient vs Scoped vs Singleton in .NET: A Simple Guide for Developers.

Keeping Program.cs Clean With Extension Methods
As your application grows, Program.cs can start to look like a never-ending list of builder.Services.Add... calls. It becomes hard to read and hard to maintain.
The idiomatic way to solve this is with extension methods on IServiceCollection. You group related service registrations together and expose them as a single, readable method call:
public static class CodeToClarityServiceExtensions
{
public static IServiceCollection AddApplicationServices(this IServiceCollection services)
{
services.AddScoped<IUserRepository, SqlUserRepository>();
services.AddScoped<IUserService, UserService>();
services.AddScoped<IEmailSender, SmtpEmailSender>();
return services;
}
public static IServiceCollection AddInfrastructureServices(this IServiceCollection services)
{
services.AddSingleton<ICacheService, RedisCache>();
services.AddScoped<IStorageService, AzureBlobStorage>();
return services;
}
}
And in Program.cs, it reads cleanly:
builder.Services.AddApplicationServices();
builder.Services.AddInfrastructureServices();
This pattern is especially valuable in larger projects following Clean Architecture, where you'd typically have separate extension methods per layer (Application, Infrastructure, Persistence, etc.).
Constructor Injection vs. Method Injection
The most common and recommended way to receive dependencies is through the constructor, as shown throughout this article. It makes dependencies explicit and ensures a class is always in a valid state once constructed.
However, ASP.NET Core also supports method injection natively in Minimal API endpoints:
app.MapGet("/users/{id}", async (int id, ICodeToClarityUserService userService) =>
{
var user = await userService.GetByIdAsync(id);
return user is null ? Results.NotFound() : Results.Ok(user);
});
Here, ICodeToClarityUserService is automatically resolved from the DI container and passed directly into the endpoint handler. This is clean, concise, and works really well for Minimal APIs.
The Microsoft.Extensions.DependencyInjection package, which powers this entire system, is available as a standalone NuGet package for use in non-web scenarios like console apps and worker services too.
Framework Services Are Already There for You
One thing that surprises many beginners is how many services ASP.NET Core registers automatically. You don't have to wire up logging, configuration, or environment detection yourself.
These are ready to inject right out of the box:
public class CodeToClarityAuditService
{
private readonly ILogger<CodeToClarityAuditService> _logger;
private readonly IConfiguration _config;
public CodeToClarityAuditService(
ILogger<CodeToClarityAuditService> logger,
IConfiguration config)
{
_logger = logger;
_config = config;
}
public void LogAction(string action)
{
var env = _config["ASPNETCORE_ENVIRONMENT"];
_logger.LogInformation("Action '{Action}' performed in {Env}", action, env);
}
}
No registration needed for ILogger<T> or IConfiguration. The framework handles all of that. This is a big reason why ASP.NET Core code tends to feel consistent across projects, everyone's working with the same underlying DI-driven infrastructure.
Common Mistakes Worth Knowing About
+-------------------------------------------------------------------------------+
| ASP.NET Core DI Scope Lifecycle |
+-------------------------------------------------------------------------------+
| |
| [Root IServiceProvider] (Lives for entire app lifetime) |
| | |
| +--> Singleton Services (e.g. IMemoryCache, HttpClientFactory) |
| | |
| +--> Incoming HTTP Request 1 -----> [Request Scope 1] |
| | +--> Scoped Services (e.g. DbContext)|
| | +--> Transient Services (Created new)|
| | (Disposed when request completes) |
| | |
| +--> Incoming HTTP Request 2 -----> [Request Scope 2] |
| | +--> Scoped Services (Isolated copy) |
| | +--> Transient Services (Created new)|
| | (Disposed when request completes) |
| | |
| +--> BackgroundWorker (Singleton) |
| +--X Direct Scoped Injection FAILS (Captive Dependency!) |
| +--> Uses IServiceScopeFactory.CreateScope() safely |
| |
+-------------------------------------------------------------------------------+
1. Captive Dependencies (Injecting a Scoped service into a Singleton)
This is a classic and dangerous production trap. If a Singleton holds a reference to a Scoped service (like DbContext), that Scoped service is "captured" and effectively lives for the entire application lifetime, bypassing per-request disposal and causing multi-threading race conditions or memory leaks.
To protect your application from captive dependencies, explicitly enable container validation in Program.cs:
var builder = WebApplication.CreateBuilder(args);
// Enforce strict container validation to detect captive dependencies at startup
builder.Host.UseDefaultServiceProvider((context, options) =>
{
options.ValidateScopes = true; // Throws InvalidOperationException if Scoped is resolved from Root/Singleton
options.ValidateOnBuild = true; // Validates the entire dependency tree at application startup
});
2. The BackgroundService Dilemma: Resolving Scoped Services in Workers
A very common problem occurs when you build an IHostedService or BackgroundService that needs to query a database via EF Core. Because BackgroundService is registered as a Singleton, you cannot directly inject a Scoped service like AppDbContext into its constructor. Doing so violates scope validation and triggers an InvalidOperationException.
The production-proven pattern is to inject IServiceScopeFactory, creating an explicit short-lived execution scope for each unit of work:
public class OrderProcessingWorker : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<OrderProcessingWorker> _logger;
public OrderProcessingWorker(
IServiceScopeFactory scopeFactory,
ILogger<OrderProcessingWorker> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
// Create an isolated request scope for each processing cycle
using (var scope = _scopeFactory.CreateScope())
{
var dbContext = scope.ServiceProvider.GetRequiredService<IOrderRepository>();
var pendingOrders = await dbContext.GetPendingOrdersAsync(stoppingToken);
foreach (var order in pendingOrders)
{
await dbContext.ProcessOrderAsync(order, stoppingToken);
}
} // Scope disposed here -> database connections returned to pool
await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
}
}
}
For deeper exploration of background processing patterns, check out our guide on Background Tasks in .NET: BackgroundService vs IHostedService.
3. Resolving services from IServiceProvider directly (Service Locator Anti-Pattern)
You'll sometimes see code like provider.GetService<ISomething>(). This hides dependencies rather than explicitly declaring them in constructor contracts, making classes fragile and nearly impossible to unit test properly. Stick to explicit constructor injection.
4. Forgetting to register a service
If you inject an unregistered interface, ASP.NET Core throws an InvalidOperationException at runtime ("Unable to resolve service for type..."). Setting options.ValidateOnBuild = true ensures these missing registrations are caught instantly when the application starts, rather than when a real user hits an endpoint in production.
5. Registering everything as a Singleton out of convenience
Singleton is not "safer" or "faster" by default. Using it incorrectly for services that hold request-specific state will introduce concurrency corruptions under load.
When DI Adds Complexity Rather Than Value
DI is a tool, not a religion. There are situations where it introduces unnecessary overhead:
- Pure static utilities: A slug generator or string parsing helper has no state, no dependencies, and no reason to be injected. Call it directly as a pure function.
- Data transfer objects (DTOs) and Domain Entities: Records, models, and request objects hold data, not behavior. They belong in memory, not the DI container.
- Throwaway scripts: A 50-line CLI utility running once a week does not need an entire dependency injection container abstraction.
The golden rule: does using DI make this component easier to understand, test, and adapt? If yes, register it. If not, keep it simple.
Conclusion & Next Architecture Steps
Dependency Injection is the architectural bedrock of modern .NET engineering. By mastering how interfaces, lifetimes, and containers interact, you gain the ability to write modular, testable, and maintainable software that scales gracefully over years of production operation.
To continue advancing your .NET dependency injection mastery:
- Service Lifetimes Demystified: Learn the precise lifecycle differences and memory behaviors in Transient vs Scoped vs Singleton in .NET: Simple Guide for Developers.
- Keyed Services in .NET 8/9/10: Cleanly resolve multiple implementations of the same interface without factory classes using .NET 8 Dependency Injection: Guide to Keyed Services.
- Assembly Scanning with Scrutor: Eliminate tedious manual service registrations using convention-based scanning in our Automate Your .NET DI Setup with Scrutor.
- Strongly Typed Configurations: Stop reading raw string keys from
IConfigurationby implementing the ASP.NET Core Options Pattern.
Dependency Injection is one of those rare concepts that makes your code better in almost every dimension. It loosens coupling, improves testability, makes your architecture more flexible, and forces you to think clearly about what each class actually needs to do its job.
ASP.NET Core's built-in DI container gives you everything you need without any additional setup. Learn the three lifetimes. Favor constructor injection. Use interfaces where flexibility matters. Group your registrations with extension methods as the app grows.
That's the foundation. Future articles in this series will cover advanced topics like keyed services, factory-based registration, and how DI interacts with background services and hosted workers.
For now, the best thing you can do is look at your existing code and ask one question: is this class creating its dependencies, or receiving them? The answer will tell you a lot.

Software engineer passionate about building scalable, production-grade applications with C#, .NET, and modern cloud technologies. Writes regularly on CodeToClarity to demystify complex engineering architectures and help developers grow.
Related Technical Guides
.NET 8 Dependency Injection: A Beginner’s Guide to Keyed Services
Learn how to use Keyed Services in .NET 8 to manage multiple implementations of the same interface cleanly. Discover real-world examples, enum-based keys, and best practices for cleaner Dependency Injection.
Automate Your .NET DI Setup with Scrutor (Step-by-Step Guide)
Learn how to use Scrutor in .NET to automatically register dependencies and simplify your DI setup. This step-by-step guide covers setup, lifetimes, conventions, and best practices for clean architecture.
Clean Architecture: Master Validation in .NET 10 with MediatR Pipeline and FluentValidation
Learn how to build bulletproof APIs in .NET 10 by centralizing validation using MediatR pipeline behaviors, FluentValidation, and IExceptionHandler.