Dependency Injection in .NET: A Practical Guide for C# and ASP.NET Core Developers
Dependency Injection is one of the most important concepts to understand when building modern applications with .NET and ASP.NET Core.
You encounter it almost immediately:
builder.Services.AddScoped<IProductService, ProductService>();Then somewhere else:
public ProductsController(IProductService productService)
{
_productService = productService;
}The syntax is simple.
Understanding what actually happens behind that syntax is more important.
Why should ProductService be registered?
Who creates it?
How does .NET choose a constructor?
What does Scoped actually mean?
When should you use Singleton or Transient?
What happens when a singleton depends on a scoped service?
How should DbContext be handled?
What are open generic registrations?
How do IOptions, IOptionsSnapshot, and IOptionsMonitor relate to Dependency Injection?
And why should you avoid resolving everything manually from IServiceProvider?
This guide answers those questions by building a practical mental model of Dependency Injection in .NET.
What Is Dependency Injection?
Dependency Injection, commonly called DI, is a design technique where an object receives the dependencies it needs instead of creating those dependencies itself.
Consider a product service that needs to send notifications.
Without Dependency Injection:
public class ProductService
{
private readonly EmailService _emailService;
public ProductService()
{
_emailService = new EmailService();
}
}ProductService creates its own EmailService.
That creates tight coupling:
ProductService
↓
Creates EmailService itself
↓
Controls dependency creationNow imagine that later you want to:
Replace email with another provider
Mock notifications during testing
Change configuration
Add logging
Use different implementations
Manage object lifetime centrally
Direct construction becomes restrictive.
With Dependency Injection:
public class ProductService
{
private readonly IEmailService _emailService;
public ProductService(IEmailService emailService)
{
_emailService = emailService;
}
}Now ProductService does not decide how IEmailService is created.
It simply declares:
I need something that implements
IEmailService.
The DI container handles the construction.
The Core Idea Behind Dependency Injection
Without DI:
ProductService
↓
new EmailService()With DI:
DI Container
↓
Creates EmailService
↓
Injects it into ProductServiceThe class focuses on using its dependencies instead of constructing and managing them.
That separation is the foundation of Dependency Injection.
What Is a Dependency?
A dependency is something another class needs to perform its work.
Suppose:
public class OrderService
{
private readonly IPaymentService _paymentService;
private readonly IEmailService _emailService;
public OrderService(
IPaymentService paymentService,
IEmailService emailService)
{
_paymentService = paymentService;
_emailService = emailService;
}
}OrderService has two dependencies:
IPaymentService
IEmailServiceThe class does not need to know exactly how those objects were constructed.
It only needs objects that satisfy the required contracts.
Dependency Injection vs Dependency Inversion
These terms are related, but they are not identical.
Dependency Injection describes how dependencies are supplied to an object.
Dependency Inversion Principle is one of the SOLID principles. It encourages high-level application logic to avoid unnecessary coupling to low-level implementation details.
For example:
OrderService
↓
IPaymentService
↑
StripePaymentServiceOrderService depends on:
IPaymentServicerather than being permanently tied to:
StripePaymentServiceDependency Injection is one technique that can help implement this type of design.
Dependency Injection in ASP.NET Core
ASP.NET Core includes a built-in Dependency Injection container.
A typical application begins with:
var builder = WebApplication.CreateBuilder(args);The service collection is available through:
builder.ServicesApplication services can be registered there:
builder.Services.AddScoped<IProductService, ProductService>();Then:
var app = builder.Build();During application execution, .NET can resolve registered services and construct their dependency graphs.
Registration vs Resolution
Two concepts are important:
Registration
↓
Tell the container what services are available
Resolution
↓
Ask the container to build a required serviceFor example:
builder.Services.AddScoped<IProductService, ProductService>();is registration.
Later, when the application needs:
IProductServicethe DI container determines that it should create:
ProductServiceThat is part of dependency resolution.
The Three Main Parts of Dependency Injection
A useful way to understand DI is to divide it into three steps.
1. Define the Dependency
public interface IProductService
{
Task<List<Product>> GetProductsAsync();
}2. Implement the Dependency
public class ProductService : IProductService
{
public async Task<List<Product>> GetProductsAsync()
{
// Load products
return new List<Product>();
}
}3. Register the Dependency
builder.Services.AddScoped<
IProductService,
ProductService>();Then request it:
public class ProductsController : ControllerBase
{
private readonly IProductService _productService;
public ProductsController(
IProductService productService)
{
_productService = productService;
}
}The flow becomes:
ProductsController
↓ needs
IProductService
↓ registered as
ProductService
↓ created by
.NET DI ContainerConstructor Injection
Constructor injection is the most common DI pattern in .NET applications.
Example:
public class OrderService
{
private readonly IOrderRepository _repository;
private readonly ILogger<OrderService> _logger;
public OrderService(
IOrderRepository repository,
ILogger<OrderService> logger)
{
_repository = repository;
_logger = logger;
}
}The dependencies are explicit.
Anyone reading the constructor can immediately see what OrderService requires.
How Does .NET Resolve Constructor Dependencies?
Understanding this process makes DI much easier to reason about.
Suppose the container needs to create:
ProductServiceand the class has:
public ProductService(
IProductRepository repository,
ILogger<ProductService> logger)
{
}The container inspects the constructor and discovers two dependencies:
IProductRepository
ILogger<ProductService>It then attempts to resolve both.
For example:
ProductService
│
├── IProductRepository
│ ↓
│ ProductRepository
│ ↓
│ AppDbContext
│
└── ILogger<ProductService>
↓
Logging InfrastructureIf a required constructor dependency cannot be resolved, service creation fails.
For example, if this registration is missing:
builder.Services.AddScoped<
IProductRepository,
ProductRepository>();the container cannot successfully construct ProductService.
This is why errors such as:
Unable to resolve service for type ...usually indicate that a required dependency has not been registered or the dependency graph cannot be constructed.
Constructor Selection Matters
Classes used with DI should have constructors that the container can resolve without ambiguity.
A simple service is easy to understand:
public class ProductService
{
public ProductService(
IProductRepository repository)
{
}
}When multiple public constructors exist, constructor selection can become less obvious and may create ambiguity depending on which parameters the container can resolve.
For DI-managed services, prefer a clear constructor that expresses the dependencies required by the class.
The constructor should answer:
What does this class need in order to work correctly?
Why Constructor Injection Is Useful
Constructor injection provides several practical benefits.
Dependencies Are Visible
You can immediately see:
public OrderService(
IOrderRepository repository,
ILogger<OrderService> logger)Required Dependencies Are Explicit
A service cannot normally be created correctly without supplying what it requires.
Testing Becomes Easier
Tests can provide alternative implementations.
Business Classes Do Not Need the DI Container
The class does not need to know how dependencies are resolved.
That keeps business logic separate from dependency-resolution infrastructure.
Service Lifetimes in .NET
The three commonly used service lifetimes are:
AddTransient
AddScoped
AddSingletonFor example:
builder.Services.AddTransient<
IEmailFormatter,
EmailFormatter>();
builder.Services.AddScoped<
IProductService,
ProductService>();
builder.Services.AddSingleton<
ICacheService,
CacheService>();The lifetime controls how instances are created and reused.
Transient Lifetime
A transient service is created when it is requested from the container.
Register:
builder.Services.AddTransient<
IEmailFormatter,
EmailFormatter>();Conceptually:
Resolve service
↓
Instance A
Resolve again
↓
Instance BTransient services can be appropriate for lightweight, stateless operations.
Examples might include:
Formatters
Small calculation services
Stateless transformation services
Do not choose transient simply because the service looks small.
Its dependencies and usage pattern still matter.
Scoped Lifetime
A scoped service is created once within a Dependency Injection scope.
In a typical ASP.NET Core HTTP application, one request normally corresponds to one request scope.
Register:
builder.Services.AddScoped<
IProductService,
ProductService>();Conceptually:
HTTP Request A
↓
ProductService A
↓
Reused inside Request A
HTTP Request B
↓
ProductService BScoped services are common for request-oriented application operations.
Entity Framework Core DbContext is an important example of a dependency commonly configured with a scoped lifetime in traditional ASP.NET Core request processing.
Singleton Lifetime
A singleton service is shared from the application's root service provider.
Register:
builder.Services.AddSingleton<
ICacheService,
CacheService>();Conceptually:
Application
↓
Singleton Instance
↓
Request A
Request B
Request CBecause the service can be accessed across multiple requests and threads, shared mutable state requires particular care.
A singleton should be designed for its long lifetime.
Transient vs Scoped vs Singleton
| Lifetime | Instance Behavior | Common Consideration |
|---|---|---|
| Transient | Created when resolved | Lightweight/stateless operations |
| Scoped | Shared within one DI scope | Request-oriented services |
| Singleton | Shared from root container | Long-lived/shared infrastructure |
A simple mental model:
Transient
→ Short-lived
Scoped
→ Scope-lived
Singleton
→ Application-livedBut the table is not a rule for choosing lifetimes.
The correct lifetime depends on the responsibility and dependency graph of the service.
How Should You Choose a Service Lifetime?
Instead of memorizing:
Repository = Scoped
Utility = Transient
Cache = Singletonask practical questions.
Does the service contain mutable state?
If yes, determine who should own that state and how long it should exist.
Does it depend on DbContext?
That often influences the lifetime because a normal DbContext is commonly scoped.
Will multiple requests access the same instance?
If yes, thread safety and shared state matter.
Does it represent one unit of work?
A scoped lifetime may be appropriate.
Is it lightweight and stateless?
Transient may be reasonable.
Does it represent shared application infrastructure?
Singleton may be appropriate if it is designed for concurrent, long-lived use.
The key principle is:
Choose the lifetime from the service's behavior and dependencies, not from its class name.
Why Service Lifetime Matters
Consider:
public class ShoppingCartService
{
public List<int> ProductIds { get; } = new();
}If registered as:
builder.Services.AddSingleton<ShoppingCartService>();the same mutable instance may be shared across users.
That can produce serious bugs.
Changing it to:
builder.Services.AddScoped<ShoppingCartService>();can isolate the service by request scope.
But that still does not automatically make it a proper persistent shopping cart.
A real cart might need:
Database
Distributed Cache
Session
Persistent User StorageDI lifetime and business-data lifetime are different concepts.
The Captive Dependency Problem
One of the most important DI lifetime problems occurs when a long-lived service captures a shorter-lived dependency.
Suppose:
builder.Services.AddScoped<
IOrderRepository,
OrderRepository>();
builder.Services.AddSingleton<
IReportService,
ReportService>();Then:
public class ReportService : IReportService
{
private readonly IOrderRepository _repository;
public ReportService(
IOrderRepository repository)
{
_repository = repository;
}
}The dependency graph becomes:
Singleton ReportService
↓
Scoped OrderRepositoryThe singleton can effectively retain something that was intended to live only within a scope.
This is commonly called a captive dependency.
Scope validation can detect many lifetime mismatches during development.
The general principle is:
A long-lived service should not directly capture a shorter-lived scoped dependency.
Scoped Services Inside Background Services
Background services are a common place where developers encounter this issue.
Hosted services are typically singleton services.
Suppose a worker needs a scoped IOrderService.
Create a scope for each appropriate unit of work:
public class OrderProcessingWorker : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
public OrderProcessingWorker(
IServiceScopeFactory scopeFactory)
{
_scopeFactory = scopeFactory;
}
protected override async Task ExecuteAsync(
CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
using var scope =
_scopeFactory.CreateScope();
var orderService =
scope.ServiceProvider
.GetRequiredService<IOrderService>();
await orderService
.ProcessPendingOrdersAsync(
stoppingToken);
await Task.Delay(
TimeSpan.FromMinutes(1),
stoppingToken);
}
}
}The architecture is:
Singleton BackgroundService
↓
Create Scope
↓
Resolve Scoped Service
↓
Perform Work
↓
Dispose ScopeHere, using IServiceProvider inside the intentionally created scope is infrastructure behavior rather than hidden business-service dependency resolution.
Dependency Injection and Entity Framework Core
Entity Framework Core integrates naturally with .NET DI.
For example:
builder.Services.AddDbContext<AppDbContext>(
options =>
{
options.UseSqlServer(
builder.Configuration
.GetConnectionString(
"DefaultConnection"));
});Then:
public class ProductRepository
{
private readonly AppDbContext _dbContext;
public ProductRepository(
AppDbContext dbContext)
{
_dbContext = dbContext;
}
}The repository does not create the context itself.
The application configures the dependency centrally.
DbContext Lifetime Requires Special Attention
DbContext represents a unit of work and should not be treated as an indefinitely shared, thread-safe database object.
This becomes especially important in:
Background processing
Parallel operations
Long-running workflows
Interactive server-side UI
Services that live longer than an HTTP request
For scenarios that need independently created contexts, IDbContextFactory<TContext> can be useful.
Register:
builder.Services.AddDbContextFactory<AppDbContext>(
options =>
{
options.UseSqlServer(
builder.Configuration
.GetConnectionString(
"DefaultConnection"));
});Inject:
public class ProductService
{
private readonly IDbContextFactory<AppDbContext>
_dbContextFactory;
public ProductService(
IDbContextFactory<AppDbContext>
dbContextFactory)
{
_dbContextFactory = dbContextFactory;
}
public async Task<List<Product>>
GetProductsAsync()
{
await using var dbContext =
await _dbContextFactory
.CreateDbContextAsync();
return await dbContext.Products
.AsNoTracking()
.ToListAsync();
}
}Now each operation can create and dispose its own context.
Interface Registration
A common registration is:
builder.Services.AddScoped<
IProductService,
ProductService>();This means:
Request IProductService
↓
Create/return ProductServiceThen:
public ProductsController(
IProductService productService)
{
}The controller depends on the abstraction rather than a particular implementation.
Do You Always Need an Interface?
No.
A concrete service can be registered directly:
builder.Services.AddScoped<ProductService>();Then injected:
public ProductsController(
ProductService productService)
{
}Interfaces become particularly useful when:
Multiple implementations exist
Implementations may change
Infrastructure should be isolated
The abstraction represents a meaningful capability
Tests benefit from substitution
Creating an interface for every class does not automatically produce better architecture.
Use abstractions intentionally.
Open Generic Dependency Injection
Generics become especially useful when the same abstraction applies to many types.
Consider:
public interface IRepository<T>
where T : class
{
Task<T?> GetByIdAsync(int id);
Task<List<T>> GetAllAsync();
}Implementation:
public class Repository<T> : IRepository<T>
where T : class
{
private readonly AppDbContext _dbContext;
public Repository(
AppDbContext dbContext)
{
_dbContext = dbContext;
}
public async Task<T?> GetByIdAsync(int id)
{
return await _dbContext
.Set<T>()
.FindAsync(id);
}
public async Task<List<T>> GetAllAsync()
{
return await _dbContext
.Set<T>()
.AsNoTracking()
.ToListAsync();
}
}Instead of registering every closed type separately:
builder.Services.AddScoped<
IRepository<Product>,
Repository<Product>>();
builder.Services.AddScoped<
IRepository<Customer>,
Repository<Customer>>();you can register the open generic types:
builder.Services.AddScoped(
typeof(IRepository<>),
typeof(Repository<>));Now the container can resolve:
IRepository<Product>
IRepository<Customer>
IRepository<Order>using the same generic implementation.
Conceptually:
IRepository<Product>
↓
Repository<Product>
IRepository<Customer>
↓
Repository<Customer>Open generic registration is powerful when a genuinely reusable generic abstraction exists.
Do not create generic repositories merely because DI supports them.
The abstraction should still provide architectural value.
Multiple Implementations of the Same Interface
Suppose:
public interface INotificationService
{
Task SendAsync(string message);
}Two implementations:
public class EmailNotificationService
: INotificationService
{
public Task SendAsync(string message)
{
return Task.CompletedTask;
}
}and:
public class SmsNotificationService
: INotificationService
{
public Task SendAsync(string message)
{
return Task.CompletedTask;
}
}Register:
builder.Services.AddScoped<
INotificationService,
EmailNotificationService>();
builder.Services.AddScoped<
INotificationService,
SmsNotificationService>();When you need all implementations:
public class NotificationManager
{
private readonly IEnumerable<INotificationService>
_services;
public NotificationManager(
IEnumerable<INotificationService> services)
{
_services = services;
}
public async Task NotifyAsync(
string message)
{
foreach (var service in _services)
{
await service.SendAsync(message);
}
}
}This is useful when every registered implementation should participate.
Keyed Services
Sometimes several implementations exist, but only one should be selected for a particular operation.
For example:
builder.Services.AddKeyedScoped<
INotificationService,
EmailNotificationService>("email");
builder.Services.AddKeyedScoped<
INotificationService,
SmsNotificationService>("sms");Then:
public class OrderNotificationService
{
private readonly INotificationService
_notificationService;
public OrderNotificationService(
[FromKeyedServices("email")]
INotificationService notificationService)
{
_notificationService =
notificationService;
}
}Keys can represent explicit strategies such as:
email
sms
stripe
paypal
primary
secondaryWhen selection becomes complex business logic, however, a strategy or factory abstraction may communicate the intent more clearly.
Dependency Injection and the Options Pattern
Configuration is another area closely connected to DI.
Suppose:
{
"Email": {
"Host": "smtp.example.com",
"Port": 587
}
}Create an options class:
public class EmailOptions
{
public string Host { get; set; }
= string.Empty;
public int Port { get; set; }
}Register it:
builder.Services.Configure<EmailOptions>(
builder.Configuration
.GetSection("Email"));.NET provides several ways to consume these options.
IOptions<T>
Inject:
public class EmailService
{
private readonly EmailOptions _options;
public EmailService(
IOptions<EmailOptions> options)
{
_options = options.Value;
}
}IOptions<T> provides straightforward access to configured options.
It is useful when configuration does not need to be refreshed for each request or dynamically observed by the consuming service.
IOptionsSnapshot<T>
Example:
public class ProductService
{
private readonly EmailOptions _options;
public ProductService(
IOptionsSnapshot<EmailOptions> options)
{
_options = options.Value;
}
}IOptionsSnapshot<T> is scoped.
It is designed to provide options within a scope and can reflect updated configuration on subsequent requests when the underlying configuration provider supports reload.
Because it is scoped, it should not be injected directly into a singleton.
IOptionsMonitor<T>
For long-lived services that need access to current option values, IOptionsMonitor<T> is often the relevant abstraction.
Example:
public class NotificationService
{
private readonly IOptionsMonitor<EmailOptions>
_options;
public NotificationService(
IOptionsMonitor<EmailOptions> options)
{
_options = options;
}
public void Send()
{
var host = _options.CurrentValue.Host;
}
}It can also observe option changes:
_options.OnChange(options =>
{
// React to updated configuration
});A useful mental model is:
| Option Type | DI Characteristic | Typical Use |
|---|---|---|
IOptions<T> | Singleton-friendly access | Basic configuration |
IOptionsSnapshot<T> | Scoped | Per-scope refreshed options |
IOptionsMonitor<T> | Singleton-friendly monitoring | Current values / change notifications |
The correct choice depends on whether configuration needs to change while the application is running and on the lifetime of the consuming service.
Dependency Injection with Logging
Logging is integrated with DI.
Inject:
public class ProductService
{
private readonly ILogger<ProductService>
_logger;
public ProductService(
ILogger<ProductService> logger)
{
_logger = logger;
}
public void ProcessProduct()
{
_logger.LogInformation(
"Processing product");
}
}The framework supplies the logger.
Your business service does not need to construct logging infrastructure.
Dependency Injection with HttpClient
External APIs are another common dependency.
Instead of manually creating and configuring HTTP clients throughout application code, .NET provides IHttpClientFactory.
For example:
builder.Services.AddHttpClient<
PaymentApiClient>(client =>
{
client.BaseAddress =
new Uri("https://api.example.com/");
});Then:
public class PaymentApiClient
{
private readonly HttpClient _httpClient;
public PaymentApiClient(
HttpClient httpClient)
{
_httpClient = httpClient;
}
public async Task<string>
GetStatusAsync()
{
return await _httpClient
.GetStringAsync("status");
}
}This keeps HTTP configuration centralized and allows .NET to manage the underlying HTTP infrastructure appropriately.
Extension Methods Keep Program.cs Organized
As applications grow, service registration can make Program.cs difficult to read.
Instead of placing every registration there:
builder.Services.AddScoped<
IProductService,
ProductService>();
builder.Services.AddScoped<
IOrderService,
OrderService>();
builder.Services.AddScoped<
ICustomerService,
CustomerService>();create an extension:
public static class DependencyInjection
{
public static IServiceCollection
AddApplicationServices(
this IServiceCollection services)
{
services.AddScoped<
IProductService,
ProductService>();
services.AddScoped<
IOrderService,
OrderService>();
services.AddScoped<
ICustomerService,
CustomerService>();
return services;
}
}Then:
builder.Services.AddApplicationServices();Larger solutions can organize registration by responsibility:
AddApplicationServices()
AddInfrastructureServices()
AddPersistenceServices()This keeps the composition root understandable without hiding where dependencies are configured.
Dependency Injection in Clean Architecture
Dependency Injection becomes particularly useful when application responsibilities are separated.
A simplified structure might look like:
Web
↓
Application
↓
Domain
Infrastructure
↓
implements external concernsSuppose Application defines:
public interface IEmailSender
{
Task SendAsync(
string recipient,
string message);
}Infrastructure provides:
public class SmtpEmailSender
: IEmailSender
{
public Task SendAsync(
string recipient,
string message)
{
// SMTP implementation
return Task.CompletedTask;
}
}Registration connects them:
builder.Services.AddScoped<
IEmailSender,
SmtpEmailSender>();Conceptually:
Application Logic
↓
IEmailSender
↑
SmtpEmailSender
↑
InfrastructureThe application can express the capability it needs without containing SMTP implementation details.
Dependency Injection and Unit Testing
One major advantage of explicit dependencies is easier testing.
Suppose:
public class OrderService
{
private readonly IPaymentService
_paymentService;
public OrderService(
IPaymentService paymentService)
{
_paymentService = paymentService;
}
public async Task<bool>
CheckoutAsync(decimal amount)
{
return await _paymentService
.ChargeAsync(amount);
}
}Production may use a real payment provider.
A unit test can provide a fake:
public class FakePaymentService
: IPaymentService
{
public Task<bool> ChargeAsync(
decimal amount)
{
return Task.FromResult(true);
}
}Test:
var paymentService =
new FakePaymentService();
var orderService =
new OrderService(paymentService);
var result =
await orderService.CheckoutAsync(100);
Assert.True(result);Notice that the unit test does not require the DI container.
It simply creates the class with the dependency it needs.
DI-friendly design and container-dependent design are not the same thing.
Ideally, your business classes remain easy to construct outside the container.
What Is the Service Locator Anti-Pattern?
Consider:
public class ProductService
{
private readonly IServiceProvider
_serviceProvider;
public ProductService(
IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public void Process()
{
var emailService =
_serviceProvider
.GetRequiredService<IEmailService>();
}
}The real dependency is hidden.
From the constructor, you cannot easily tell that ProductService requires IEmailService.
Compare:
public ProductService(
IEmailService emailService)
{
_emailService = emailService;
}The dependency is now explicit.
Resolving services manually is appropriate in some infrastructure scenarios, such as deliberately creating a scope in a background service.
Using IServiceProvider throughout normal business code, however, often turns DI into the Service Locator pattern.
Avoid Static Service Access
Another problematic approach is exposing the service provider globally:
public static IServiceProvider Services
{
get;
set;
}Then:
var service =
Global.Services
.GetRequiredService<IProductService>();This creates hidden global dependencies.
It can make:
Testing harder
Lifetime management harder
Dependencies invisible
Code more tightly coupled to infrastructure
Prefer explicit dependencies.
Too Many Constructor Dependencies Are Useful Feedback
Suppose:
public OrderService(
IOrderRepository orders,
ICustomerRepository customers,
IProductRepository products,
IPaymentService payments,
IEmailService emails,
IInventoryService inventory,
IDiscountService discounts,
IShippingService shipping,
ILogger<OrderService> logger)
{
}Dependency Injection did not create this complexity.
It exposed it.
A large dependency list may indicate that the class has too many responsibilities.
Instead of hiding those dependencies, reconsider the design.
For example:
Order Checkout
↓
OrderCheckoutService
PaymentProcessor
InventoryReservationService
OrderNotificationServiceConstructor size is not a perfect metric, but it can provide useful architectural feedback.
Circular Dependencies
Consider:
ProductService
↓
InventoryService
↓
ProductServiceFor example:
public ProductService(
IInventoryService inventoryService)
{
}while:
public InventoryService(
IProductService productService)
{
}The dependency graph loops back on itself.
This commonly indicates a design problem.
Possible causes include:
Responsibilities are mixed
Services know too much about each other
Shared behavior belongs somewhere else
Application boundaries are unclear
Rather than forcing the container to resolve the cycle, reconsider the dependency direction.
Dependency Injection Does Not Automatically Create Good Architecture
You can use DI everywhere and still build a poorly structured application.
For example:
Controller
↓
HugeService
↓
15 Dependencies
↓
Everything ElseTechnically, DI is being used.
Architecturally, the application may still have poor separation of concerns.
Dependency Injection manages dependencies.
It does not decide what your application architecture should be.
Common Dependency Injection Mistakes in .NET
1. Choosing Lifetimes Randomly
Do not use Scoped everywhere simply because it works.
Understand how the service is used.
2. Injecting Scoped Services into Singletons
This creates a lifetime mismatch and can produce captive dependencies.
3. Keeping DbContext Alive Too Long
A long-lived context can create tracking, concurrency, memory, and stale-data problems.
4. Using IServiceProvider Everywhere
Prefer constructor injection for ordinary application dependencies.
5. Creating Interfaces Without a Reason
Interfaces are useful abstractions, not mandatory decorations.
6. Making Everything Singleton
Singletons introduce shared-state and concurrency considerations.
7. Constructing Infrastructure Dependencies Manually
Avoid scattering:
new EmailService()
new PaymentClient()
new ProductRepository()through business code when those dependencies should be centrally configured.
8. Hiding Circular Dependencies
Circular dependencies usually deserve an architectural fix.
9. Ignoring Large Constructors
Too many dependencies can indicate that a class has accumulated too many responsibilities.
10. Confusing DI Lifetime with Business Data Lifetime
A service lifetime describes dependency instance management.
It does not automatically define how long business data should exist.
11. Using IOptionsSnapshot Inside a Singleton
IOptionsSnapshot<T> is scoped and should not be directly captured by singleton services.
Consider IOptions<T> or IOptionsMonitor<T> according to the configuration requirements.
12. Using Open Generics Without a Real Abstraction
Generic registration is powerful, but it should solve an actual design problem rather than introduce unnecessary abstraction.
A Practical Dependency Injection Structure
A typical application might contain:
API
│
├── Controllers
│
Application
│
├── Services
├── Interfaces
│
Domain
│
├── Entities
│
Infrastructure
│
├── Persistence
├── Repositories
├── Email
├── Payments
└── External APIsDependencies might flow like:
Controller
↓
Application Service
↓
Abstraction
↓
Infrastructure Implementation
↓
Database / External SystemService registrations connect these pieces at application startup.
Example: Product Application
Imagine:
ProductsController
↓
IProductService
↓
ProductService
↓
IProductRepository
↓
ProductRepository
↓
AppDbContext
↓
DatabaseRegistrations:
builder.Services.AddScoped<
IProductService,
ProductService>();
builder.Services.AddScoped<
IProductRepository,
ProductRepository>();Controller:
public class ProductsController
: ControllerBase
{
private readonly IProductService
_productService;
public ProductsController(
IProductService productService)
{
_productService = productService;
}
}Application service:
public class ProductService
: IProductService
{
private readonly IProductRepository
_repository;
public ProductService(
IProductRepository repository)
{
_repository = repository;
}
}Repository:
public class ProductRepository
: IProductRepository
{
private readonly AppDbContext
_dbContext;
public ProductRepository(
AppDbContext dbContext)
{
_dbContext = dbContext;
}
}The DI container constructs the graph:
ProductsController
↓
ProductService
↓
ProductRepository
↓
AppDbContextEach class declares what it needs.
The composition root determines how those dependencies are supplied.
How .NET Resolves the Full Dependency Graph
Suppose ASP.NET Core needs to create:
ProductsControllerIt discovers:
ProductsController
↓ needs
IProductServiceThe registration says:
IProductService
↓
ProductServiceThe constructor of ProductService requires:
IProductRepositoryThe container finds:
IProductRepository
↓
ProductRepositoryProductRepository requires:
AppDbContextEF Core registration provides it.
The final graph becomes:
ProductsController
↓
IProductService
↓
ProductService
↓
IProductRepository
↓
ProductRepository
↓
AppDbContextThis recursive construction is the central idea behind DI resolution.
Once you understand the graph, many DI errors become much easier to diagnose.
A Practical Checklist for Choosing DI Lifetimes
Before registering a custom service, ask:
Does it hold mutable state?
↓
Who should share that state?
Does it depend on a scoped service?
↓
Avoid making it longer-lived accidentally.
Does it perform one request/unit of work?
↓
Scoped may fit.
Is it stateless and lightweight?
↓
Transient may fit.
Must one shared instance exist?
↓
Consider Singleton carefully.
Does it perform parallel operations?
↓
Check thread safety and dependency safety.This is more reliable than choosing lifetimes from class names.
Dependency Injection Best Practices
A practical set of principles is:
Prefer explicit constructor injection
Keep constructors clear and unambiguous
Select service lifetimes intentionally
Keep dependency graphs understandable
Avoid long-lived services capturing scoped dependencies
Treat
DbContextlifetime as a unit-of-work concernConsider
IDbContextFactory<TContext>when independent contexts are requiredUse abstractions where they provide real architectural value
Use open generic registrations only when the generic abstraction is meaningful
Choose
IOptions<T>,IOptionsSnapshot<T>, orIOptionsMonitor<T>according to lifetime and reload requirementsAvoid using
IServiceProvideras a general-purpose service locatorUse
IHttpClientFactorypatterns for managed HTTP clientsCreate explicit scopes in background infrastructure when required
Treat oversized constructors as possible design feedback
Organize registrations as the application grows
Keep business classes easy to construct in tests
Most importantly:
Dependency Injection should make dependencies clearer, not hide them.
Frequently Asked Questions
What Is Dependency Injection in .NET?
Dependency Injection is a technique where a class receives the dependencies it requires rather than creating those dependencies itself.
.NET includes a built-in DI container for registering and resolving services.
Does ASP.NET Core Have Built-In Dependency Injection?
Yes.
ASP.NET Core uses Dependency Injection throughout the framework.
Services are commonly registered through:
builder.ServicesWhat Is AddTransient?
AddTransient registers a transient service.
An instance is created when the service is resolved.
What Is AddScoped?
AddScoped registers a service for a DI scope.
In typical ASP.NET Core HTTP request processing, a scoped service is normally reused within the same request scope.
What Is AddSingleton?
AddSingleton registers a service with a lifetime associated with the root service provider.
The instance can be shared across requests, so concurrency and shared mutable state require careful consideration.
How Does .NET Know Which Implementation to Inject?
You register the relationship:
builder.Services.AddScoped<
IProductService,
ProductService>();When IProductService is requested, the container can resolve ProductService and recursively resolve its constructor dependencies.
What Does “Unable to Resolve Service” Mean?
It usually means the DI container cannot construct part of the requested dependency graph.
A required service may be missing from registration, or the dependency structure may be invalid.
What Is an Open Generic Registration?
An open generic registration connects generic service definitions.
For example:
builder.Services.AddScoped(
typeof(IRepository<>),
typeof(Repository<>));This allows the container to construct closed versions such as:
IRepository<Product>
IRepository<Customer>What Is a Captive Dependency?
A captive dependency occurs when a longer-lived service captures a shorter-lived dependency.
A common example is a singleton directly depending on a scoped service.
Which Lifetime Should I Use for DbContext?
AddDbContext commonly configures DbContext as scoped.
However, the appropriate pattern depends on the application.
Background work, long-running operations, parallel processing, and interactive server-side UI can require different context-management strategies such as IDbContextFactory<TContext>.
What Is the Difference Between IOptions, IOptionsSnapshot, and IOptionsMonitor?
IOptions<T> provides basic options access.
IOptionsSnapshot<T> is scoped and is useful when updated configuration should be available in later scopes.
IOptionsMonitor<T> can provide current option values and change notifications and can be used by singleton services.
Should Every Service Have an Interface?
No.
Use an interface when it represents a useful abstraction or substitution point.
Can a Singleton Use a Scoped Service?
A singleton should not directly capture a scoped service.
If singleton infrastructure legitimately needs scoped work, create an explicit scope for that operation.
Why Is Dependency Injection Useful for Testing?
Explicit dependencies can be replaced with fakes, mocks, or other test implementations.
That allows business logic to be tested without requiring real external infrastructure.
What Is IServiceProvider?
IServiceProvider is used to resolve services from the DI container.
It is useful for specific infrastructure scenarios, but ordinary application classes should generally request their dependencies explicitly.
What Are Keyed Services?
Keyed services allow several implementations of the same service type to be registered under different keys.
This is useful when the application needs explicit implementation selection.
Final Thoughts
Dependency Injection in .NET is not primarily about memorizing:
builder.Services.AddScoped<
IProductService,
ProductService>();The registration syntax is the easy part.
The important part is understanding the dependency graph:
Controller
↓
Application Service
↓
Repository / Infrastructure Abstraction
↓
Infrastructure
↓
Database or External ServiceThen understanding how the container manages that graph:
Registration
↓
Constructor Resolution
↓
Dependency Graph
↓
Service Lifetime
↓
Scope
↓
DisposalOnce those concepts are clear, many ASP.NET Core patterns become easier to understand.
Start with constructor injection.
Learn the difference between transient, scoped, and singleton lifetimes.
Understand captive dependencies.
Pay special attention to DbContext and background services.
Learn when open generic registrations are useful.
Understand how configuration options interact with DI lifetimes.
Use abstractions when they provide real value.
Avoid hiding dependencies behind global service access or service locators.
And remember:
Dependency Injection is not your application architecture.
It is infrastructure that helps you build software where dependencies are explicit, manageable, replaceable, testable, and easier to maintain.
Comments 0