Overview
Dependency Injection, usually called DI, is a design technique where an object receives the objects it depends on from the outside instead of creating them directly. In C# and .NET applications, DI is commonly used with the built-in container from Microsoft.Extensions.DependencyInjection.
DI matters because it reduces tight coupling, improves testability, centralizes object creation, and makes applications easier to configure and maintain. Instead of a class deciding which concrete implementation to use, the class depends on an abstraction, and the DI container provides the concrete implementation at runtime.
DI is widely used in ASP.NET Core, worker services, background jobs, Clean Architecture, CQRS, MediatR pipelines, repositories, application services, logging, configuration, options, HTTP clients, and Entity Framework Core DbContext registration.
For interviews, DI is important because it connects many practical software engineering topics:
- Object-oriented design
- Inversion of Control
- Testability and mocking
- ASP.NET Core request lifetimes
DbContextlifetime management- Background services
- Thread safety
- Common production bugs such as captive dependencies and service lifetime mismatches
- Constructor injection and how the .NET DI container chooses constructors
A strong interview answer should explain not only how to register services with AddScoped, AddTransient, and AddSingleton, but also why the lifetime choice matters and what can go wrong when services are resolved incorrectly.
Core Concepts
Dependency
A dependency is an object that another object needs to do its work.
For example, an order service may depend on a repository, logger, payment gateway, and email sender.
public class OrderService
{
private readonly IOrderRepository _orderRepository;
private readonly ILogger<OrderService> _logger;
public OrderService(
IOrderRepository orderRepository,
ILogger<OrderService> logger)
{
_orderRepository = orderRepository;
_logger = logger;
}
public async Task SubmitAsync(Order order)
{
await _orderRepository.SaveAsync(order);
_logger.LogInformation("Order {OrderId} submitted", order.Id);
}
}
OrderService depends on IOrderRepository and ILogger<OrderService>. It does not create these dependencies directly. They are provided through the constructor.
Dependency Injection
Dependency Injection is the practice of giving an object its dependencies from the outside.
Without DI, a class often creates concrete dependencies itself:
public class OrderService
{
private readonly SqlOrderRepository _repository = new();
public void Submit(Order order)
{
_repository.Save(order);
}
}
This is tightly coupled because OrderService directly depends on SqlOrderRepository.
With DI, the class depends on an abstraction:
public interface IOrderRepository
{
Task SaveAsync(Order order);
}
public class SqlOrderRepository : IOrderRepository
{
public Task SaveAsync(Order order)
{
// Save to database
return Task.CompletedTask;
}
}
public class OrderService
{
private readonly IOrderRepository _repository;
public OrderService(IOrderRepository repository)
{
_repository = repository;
}
public Task SubmitAsync(Order order)
{
return _repository.SaveAsync(order);
}
}
This makes the class easier to test and easier to change.
Inversion of Control
Inversion of Control, or IoC, is a broader design principle where control over object creation is moved away from the class itself.
Without IoC:
public class ReportService
{
private readonly PdfExporter _exporter = new();
}
With IoC:
public class ReportService
{
private readonly IReportExporter _exporter;
public ReportService(IReportExporter exporter)
{
_exporter = exporter;
}
}
The object no longer controls the concrete dependency. The application composition root or DI container controls it.
DI is one way to implement IoC.
DI Container
A DI container is a framework component that knows how to create objects and provide their dependencies.
In .NET, the core concepts are:
IServiceCollection: used to register servicesServiceDescriptor: describes a service type, implementation type, and lifetimeIServiceProvider: used to resolve registered servicesIServiceScope: represents a scope for scoped servicesIServiceScopeFactory: creates scopes manually when needed
Example:
using Microsoft.Extensions.DependencyInjection;
var services = new ServiceCollection();
services.AddScoped<IOrderRepository, SqlOrderRepository>();
services.AddScoped<OrderService>();
using ServiceProvider provider = services.BuildServiceProvider();
using IServiceScope scope = provider.CreateScope();
var orderService = scope.ServiceProvider.GetRequiredService<OrderService>();
In ASP.NET Core, this setup usually happens in Program.cs:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();
builder.Services.AddScoped<OrderService>();
var app = builder.Build();
app.Run();
Composition Root
The composition root is the place where the object graph is assembled.
In an ASP.NET Core app, this is usually Program.cs:
builder.Services.AddControllers();
builder.Services.AddDbContext<AppDbContext>(options =>
{
options.UseSqlServer(builder.Configuration.GetConnectionString("Default"));
});
builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();
builder.Services.AddScoped<IOrderService, OrderService>();
A good design keeps registration and wiring close to the application startup instead of scattering object creation throughout the codebase.
Service Type and Implementation Type
A DI registration usually maps a service type to an implementation type.
builder.Services.AddScoped<IEmailSender, SmtpEmailSender>();
Here:
IEmailSenderis the service typeSmtpEmailSenderis the implementation typeScopedis the lifetime
A class should normally depend on the abstraction:
public class AccountService
{
private readonly IEmailSender _emailSender;
public AccountService(IEmailSender emailSender)
{
_emailSender = emailSender;
}
}
This allows the implementation to be replaced without changing the consumer.
Constructor Injection
Constructor injection is the most common and recommended form of dependency injection in C#.
public class InvoiceService
{
private readonly IInvoiceRepository _repository;
private readonly IClock _clock;
public InvoiceService(IInvoiceRepository repository, IClock clock)
{
_repository = repository;
_clock = clock;
}
}
Benefits:
- Required dependencies are explicit.
- The object cannot be created without its required dependencies.
- Dependencies can be marked
readonly. - Classes are easier to unit test.
- Invalid configuration fails early.
Constructor injection is preferred for required dependencies.
Primary Constructor Injection
Modern C# allows primary constructor syntax for classes.
public class InvoiceService(
IInvoiceRepository repository,
IClock clock)
{
public Task CreateAsync()
{
var now = clock.UtcNow;
return repository.CreateAsync(now);
}
}
This can reduce boilerplate, but interview candidates should still understand that DI is injecting dependencies into the constructor.
Use primary constructors carefully in larger classes because overusing constructor parameters can make a class harder to read.
Property Injection
Property injection means dependencies are assigned through public properties.
public class ReportGenerator
{
public ILogger<ReportGenerator>? Logger { get; set; }
}
This is less common in the built-in .NET DI container.
Property injection can be useful for optional dependencies in some frameworks, but for most application services, constructor injection is clearer and safer.
Common problem:
public class ReportGenerator
{
public IReportFormatter? Formatter { get; set; }
public string Generate()
{
return Formatter.Format();
}
}
This can throw a NullReferenceException if Formatter was not assigned.
For required dependencies, prefer constructor injection.
Method Injection
Method injection means passing a dependency as a method parameter.
public class FileImportService
{
public Task ImportAsync(Stream file, IFileParser parser)
{
return parser.ParseAsync(file);
}
}
This is useful when a dependency is needed only for one operation, or when the dependency varies per call.
Constructor injection is better for dependencies used across the lifetime of the object.
Service Lifetimes
A lifetime controls how long a service instance lives.
The built-in .NET DI container supports three common lifetimes:
- Transient
- Scoped
- Singleton
Choosing the wrong lifetime can cause bugs, performance issues, memory leaks, stale data, or thread-safety problems.
Transient Lifetime
A transient service is created every time it is requested.
builder.Services.AddTransient<IReportFormatter, PdfReportFormatter>();
Use transient for:
- Lightweight stateless services
- Small helpers
- Services that should not be shared
- Short-lived operations
Example:
public class PdfReportFormatter : IReportFormatter
{
public string Format(Report report)
{
return $"PDF: {report.Title}";
}
}
Trade-offs:
- Simple and safe for stateless services
- More allocations because a new instance is created each time
- Not ideal for expensive objects that can be reused safely
Scoped Lifetime
A scoped service is created once per scope.
In ASP.NET Core web applications, a scope is usually created for each HTTP request.
builder.Services.AddScoped<IOrderService, OrderService>();
Use scoped for:
- Request-specific application services
- Unit-of-work style services
- EF Core
DbContext - Services that need consistent state during one request
Example:
builder.Services.AddDbContext<AppDbContext>(options =>
{
options.UseSqlServer(builder.Configuration.GetConnectionString("Default"));
});
builder.Services.AddScoped<IOrderRepository, EfOrderRepository>();
Within one HTTP request, every service that depends on AppDbContext receives the same scoped instance.
This is useful because multiple repository operations can participate in the same unit of work.
Singleton Lifetime
A singleton service is created once and reused for the entire application lifetime.
builder.Services.AddSingleton<ICacheProvider, MemoryCacheProvider>();
Use singleton for:
- Stateless shared services
- Caches
- Configuration readers
- Expensive thread-safe resources
- Services that are safe to share across requests
Example:
public class AppClock : IClock
{
public DateTime UtcNow => DateTime.UtcNow;
}
builder.Services.AddSingleton<IClock, AppClock>();
Singleton services must be thread-safe because the same instance can be used by many requests at the same time.
Avoid storing request-specific state in a singleton.
Bad example:
public class CurrentUserStore
{
public string? UserId { get; set; }
}
builder.Services.AddSingleton<CurrentUserStore>();
This is dangerous because different users could overwrite the same shared instance.
Lifetime Comparison
Captive Dependency
A captive dependency happens when a long-lived service depends on a shorter-lived service.
The most common example is a singleton depending on a scoped service.
Bad example:
builder.Services.AddDbContext<AppDbContext>();
builder.Services.AddSingleton<ReportCache>();
public class ReportCache
{
private readonly AppDbContext _dbContext;
public ReportCache(AppDbContext dbContext)
{
_dbContext = dbContext;
}
}
ReportCache is singleton, but AppDbContext is scoped. This can cause the scoped dependency to effectively live like a singleton, which is incorrect.
Problems:
- Stale data
- Shared request state
- Thread-safety issues
- Disposed object errors
- Hard-to-debug production bugs
Better approach:
public class ReportCache
{
private readonly IServiceScopeFactory _scopeFactory;
public ReportCache(IServiceScopeFactory scopeFactory)
{
_scopeFactory = scopeFactory;
}
public async Task RefreshAsync()
{
using IServiceScope scope = _scopeFactory.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
// Use dbContext inside this scope only
await dbContext.Reports.ToListAsync();
}
}
This is especially relevant in hosted services and background workers.
Resolving Scoped Services in Background Services
BackgroundService and IHostedService are usually singleton services. They should not directly inject scoped services such as DbContext.
Bad example:
public class Worker : BackgroundService
{
private readonly AppDbContext _dbContext;
public Worker(AppDbContext dbContext)
{
_dbContext = dbContext;
}
protected override Task ExecuteAsync(CancellationToken stoppingToken)
{
// Incorrect: scoped service captured by singleton worker
return Task.CompletedTask;
}
}
Better example:
public class Worker : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
public Worker(IServiceScopeFactory scopeFactory)
{
_scopeFactory = scopeFactory;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
using IServiceScope scope = _scopeFactory.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await ProcessAsync(dbContext, stoppingToken);
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
}
}
private static Task ProcessAsync(
AppDbContext dbContext,
CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
}
Scope Validation
Scope validation helps detect lifetime mistakes.
In development, the default .NET service provider can detect common problems such as:
- Resolving scoped services from the root provider
- Injecting scoped services into singleton services
Example problem:
var app = builder.Build();
var dbContext = app.Services.GetRequiredService<AppDbContext>();
This resolves a scoped service from the root provider, which is usually wrong.
Correct approach:
using IServiceScope scope = app.Services.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
Registering Services
Common registration methods:
builder.Services.AddTransient<IEmailSender, SmtpEmailSender>();
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddSingleton<IClock, SystemClock>();
You can also register concrete types:
builder.Services.AddScoped<OrderService>();
This allows OrderService to be injected directly.
However, for business services, depending on interfaces is often better when you need test doubles, multiple implementations, or clean boundaries.
Registering with Factory Functions
A factory registration gives you custom control over object creation.
builder.Services.AddSingleton<IFileStorage>(serviceProvider =>
{
var configuration = serviceProvider.GetRequiredService<IConfiguration>();
string connectionString = configuration.GetConnectionString("Storage")
?? throw new InvalidOperationException("Storage connection string is missing.");
return new AzureBlobFileStorage(connectionString);
});
Use factories when:
- Creation requires configuration
- The constructor needs runtime values
- The implementation depends on environment-specific logic
- You need to validate setup during startup
Avoid doing expensive runtime work inside factories unless it is intentional.
Registering Existing Instances
You can register an already-created instance.
var clock = new SystemClock();
builder.Services.AddSingleton<IClock>(clock);
Use this carefully because the container did not create the instance. In many cases, it is cleaner to let the container construct and manage the singleton.
Multiple Implementations
You can register multiple implementations of the same interface.
builder.Services.AddScoped<INotificationSender, EmailNotificationSender>();
builder.Services.AddScoped<INotificationSender, SmsNotificationSender>();
Inject all implementations with IEnumerable<T>:
public class NotificationService
{
private readonly IEnumerable<INotificationSender> _senders;
public NotificationService(IEnumerable<INotificationSender> senders)
{
_senders = senders;
}
public async Task NotifyAsync(string message)
{
foreach (var sender in _senders)
{
await sender.SendAsync(message);
}
}
}
If you inject a single INotificationSender, the last registration is typically the one resolved.
Keyed Services
Modern .NET supports keyed services, which allow multiple registrations of the same service type to be selected by a key.
builder.Services.AddKeyedScoped<IMessageSender, EmailMessageSender>("email");
builder.Services.AddKeyedScoped<IMessageSender, SmsMessageSender>("sms");
Example consumer:
public class AlertService(
[FromKeyedServices("email")] IMessageSender sender)
{
public Task SendAlertAsync(string message)
{
return sender.SendAsync(message);
}
}
Use keyed services when:
- Multiple named implementations are valid
- The selection is part of application configuration
- You want to avoid large
switchstatements or manual service locators
Do not overuse keyed services when a simpler abstraction or strategy pattern would be clearer.
Open Generic Registrations
Open generic registrations allow one registration to cover many closed generic types.
builder.Services.AddScoped(typeof(IRepository<>), typeof(EfRepository<>));
Then the container can resolve:
IRepository<Customer>
IRepository<Order>
IRepository<Product>
This is common in repository patterns, validation, MediatR pipelines, and generic services.
Options Pattern and DI
The options pattern is often used with DI to inject configuration.
builder.Services.Configure<SmtpOptions>(
builder.Configuration.GetSection("Smtp"));
public class SmtpEmailSender
{
private readonly SmtpOptions _options;
public SmtpEmailSender(IOptions<SmtpOptions> options)
{
_options = options.Value;
}
}
Common options types:
IOptions<T>: basic options, often singleton-friendlyIOptionsSnapshot<T>: scoped, useful in web apps when options may reload per requestIOptionsMonitor<T>: singleton-friendly and supports change notifications
Interviewers often ask about this because configuration, DI, and lifetimes are closely connected.
Constructor Selection
When the .NET service provider creates a service, it uses constructor injection.
Important rules:
- The constructor must be public.
- If there is one public constructor, that constructor is used.
- If there are multiple public constructors, the container selects the public constructor with the most parameters it can resolve.
- If two constructors are equally valid and neither is clearly better, resolution can fail with an ambiguity error.
- Constructor parameters not supplied by DI must have default values when used in supported activation scenarios.
ActivatorUtilitiescan create objects not registered in the container by combining provided arguments and services fromIServiceProvider.
Example:
public class ReportService
{
public ReportService()
{
}
public ReportService(ILogger<ReportService> logger)
{
}
public ReportService(ILogger<ReportService> logger, IReportRepository repository)
{
}
}
If both ILogger<ReportService> and IReportRepository are resolvable, the constructor with two parameters is selected.
If only ILogger<ReportService> is resolvable, the constructor with one parameter is selected.
Constructor Ambiguity
Ambiguous constructors are a common interview topic.
Bad example:
public class PaymentService
{
public PaymentService(ILogger<PaymentService> logger)
{
}
public PaymentService(IOptions<PaymentOptions> options)
{
}
}
If both ILogger<PaymentService> and IOptions<PaymentOptions> are resolvable, the container cannot clearly decide which constructor should be used.
Better approach:
public class PaymentService
{
private readonly ILogger<PaymentService> _logger;
private readonly PaymentOptions _options;
public PaymentService(
ILogger<PaymentService> logger,
IOptions<PaymentOptions> options)
{
_logger = logger;
_options = options.Value;
}
}
Best practice:
- Prefer one public constructor for DI-managed services.
- Make all required dependencies explicit.
- Avoid optional dependencies unless they are truly optional.
- Avoid multiple public constructors unless there is a clear reason.
ActivatorUtilities
ActivatorUtilities can create objects that are not registered as services while still resolving constructor dependencies from the container.
Example:
public class ExportJob
{
private readonly ILogger<ExportJob> _logger;
private readonly string _fileName;
public ExportJob(ILogger<ExportJob> logger, string fileName)
{
_logger = logger;
_fileName = fileName;
}
}
var job = ActivatorUtilities.CreateInstance<ExportJob>(
serviceProvider,
"orders.csv");
The ILogger<ExportJob> comes from DI, and "orders.csv" is provided manually.
This is useful when:
- Some constructor values are runtime values
- The type is not registered in DI
- Framework code needs to activate a type dynamically
ActivatorUtilitiesConstructor Attribute
When using ActivatorUtilities, you can mark the constructor that should be used.
public class ExportJob
{
public ExportJob()
{
}
[ActivatorUtilitiesConstructor]
public ExportJob(ILogger<ExportJob> logger, string fileName)
{
}
}
This is not a replacement for clean constructor design. For most services, prefer a single public constructor.
Service Locator Anti-Pattern
The service locator anti-pattern occurs when a class asks the container for dependencies instead of declaring them.
Bad example:
public class OrderService
{
private readonly IServiceProvider _serviceProvider;
public OrderService(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public async Task SubmitAsync(Order order)
{
var repository = _serviceProvider.GetRequiredService<IOrderRepository>();
await repository.SaveAsync(order);
}
}
Problems:
- Dependencies are hidden.
- The class is harder to test.
- Runtime errors replace compile-time clarity.
- The class is coupled to the DI container.
Better example:
public class OrderService
{
private readonly IOrderRepository _repository;
public OrderService(IOrderRepository repository)
{
_repository = repository;
}
public Task SubmitAsync(Order order)
{
return _repository.SaveAsync(order);
}
}
Inject IServiceProvider only when you truly need dynamic resolution, and prefer IServiceScopeFactory for scope creation scenarios.
Manual Disposal of DI Services
Services created by the DI container should generally be disposed by the container.
Bad example:
public class OrderService
{
private readonly AppDbContext _dbContext;
public OrderService(AppDbContext dbContext)
{
_dbContext = dbContext;
}
public void Dispose()
{
_dbContext.Dispose();
}
}
This is wrong because the container owns the lifetime of AppDbContext.
Correct idea:
- Register disposable services with the correct lifetime.
- Let the container dispose them at the end of the scope or application lifetime.
- Use
usingonly for objects you create manually.
Building ServiceProvider Manually
Avoid calling BuildServiceProvider() inside application service registration.
Bad example:
builder.Services.AddScoped<IOrderService, OrderService>();
var provider = builder.Services.BuildServiceProvider();
var logger = provider.GetRequiredService<ILogger<Program>>();
Problems:
- Creates a second container.
- Can produce duplicate singleton instances.
- Can break disposal behavior.
- Can hide lifetime issues.
Better approach:
builder.Services.AddScoped<IOrderService, OrderService>();
var app = builder.Build();
var logger = app.Services.GetRequiredService<ILogger<Program>>();
For scoped services after building the app, create a scope.
DI and Unit Testing
DI makes testing easier because dependencies can be replaced with test doubles.
public class FakeOrderRepository : IOrderRepository
{
public List<Order> SavedOrders { get; } = [];
public Task SaveAsync(Order order)
{
SavedOrders.Add(order);
return Task.CompletedTask;
}
}
var repository = new FakeOrderRepository();
var logger = NullLogger<OrderService>.Instance;
var service = new OrderService(repository, logger);
await service.SubmitAsync(new Order { Id = 1 });
Assert.Single(repository.SavedOrders);
The test does not need a real database.
DI and Clean Architecture
In Clean Architecture, DI helps enforce direction of dependencies.
Typical setup:
- Domain layer has no DI container dependency.
- Application layer defines interfaces.
- Infrastructure layer implements interfaces.
- API layer wires implementations into the container.
Example:
// Application layer
public interface IProductRepository
{
Task<Product?> GetByIdAsync(Guid id);
}
// Infrastructure layer
public class EfProductRepository : IProductRepository
{
private readonly AppDbContext _dbContext;
public EfProductRepository(AppDbContext dbContext)
{
_dbContext = dbContext;
}
public Task<Product?> GetByIdAsync(Guid id)
{
return _dbContext.Products.FindAsync(id).AsTask();
}
}
// API layer
builder.Services.AddScoped<IProductRepository, EfProductRepository>();
This keeps business logic independent from infrastructure details.
Common DI Mistakes
Common mistakes include:
- Injecting scoped services into singleton services
- Resolving scoped services from the root provider
- Using
IServiceProvidereverywhere instead of constructor injection - Creating services manually with
newwhen they should be DI-managed - Calling
BuildServiceProvider()inside registration code - Registering stateful services as singleton
- Forgetting that singleton services must be thread-safe
- Using too many dependencies in one constructor
- Registering interfaces but injecting concrete types
- Manually disposing DI-created services
- Hiding real dependencies behind factories or service locators
- Using DI to compensate for poor class design
Constructor Over-Injection
Constructor over-injection happens when a class has too many constructor dependencies.
public class CheckoutService
{
public CheckoutService(
ICartRepository cartRepository,
IInventoryService inventoryService,
IPaymentGateway paymentGateway,
IEmailSender emailSender,
IDiscountService discountService,
ITaxCalculator taxCalculator,
IShippingService shippingService,
ILogger<CheckoutService> logger)
{
}
}
This may indicate the class has too many responsibilities.
Possible improvements:
- Split the service into smaller services.
- Introduce domain services.
- Use orchestration carefully.
- Group related behavior behind meaningful abstractions.
- Re-check whether the class violates the Single Responsibility Principle.
Do not hide too many dependencies inside a generic wrapper just to make the constructor look smaller. Fix the design problem.
Best Practices
Good DI practice in C# includes:
- Prefer constructor injection for required dependencies.
- Depend on abstractions where it improves testability and flexibility.
- Keep services small and focused.
- Choose lifetimes intentionally.
- Use scoped lifetime for EF Core
DbContext. - Avoid injecting scoped services into singletons.
- Keep singleton services stateless or thread-safe.
- Let the container dispose services it creates.
- Avoid service locator patterns.
- Avoid unnecessary manual calls to
BuildServiceProvider(). - Use
IServiceScopeFactorywhen a singleton needs to perform scoped work. - Prefer one public constructor for DI-managed services.
- Keep DI registration close to the application composition root.
- Validate configuration and registrations early when possible.