Why DI knowledge matters in .NET interviews
Dependency injection is the backbone of every ASP.NET Core application. Interviewers test it
because the wrong approach — hardcoding new, injecting IServiceProvider, or mixing
lifetimes incorrectly — produces mysterious production bugs that are hard to reproduce and
expensive to fix. This article walks through how the built-in container works and which
patterns to use (and avoid).
What dependency injection actually is
Dependency injection means an object receives its dependencies from outside rather than creating them itself. The container handles wiring.
// WITHOUT DI — tight coupling; can't test or swap implementations:
public class OrderService
{
private readonly EmailSender _emailSender = new EmailSender(); // hardcoded
}
// WITH DI — the container creates and injects the dependency:
public class OrderService
{
private readonly IEmailSender _emailSender;
public OrderService(IEmailSender emailSender)
{
_emailSender = emailSender; // injected; implementation is external
}
}
// Wire-up:
builder.Services.AddScoped<IEmailSender, SmtpEmailSender>();
// Test: inject FakeEmailSender — OrderService doesn't change
The four benefits are testability, loose coupling, lifetime management, and single responsibility.
IServiceCollection vs IServiceProvider
IServiceCollection is the builder — a mutable list of service descriptors you populate
during app startup. builder.Build() converts it into an immutable IServiceProvider — the
resolver that creates instances on demand.
var builder = WebApplication.CreateBuilder(args);
// IServiceCollection — add entries during startup:
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddTransient<IEmailSender, SmtpEmailSender>();
builder.Services.AddSingleton<ICache, MemoryCache>();
// Factory delegate — for complex construction:
builder.Services.AddSingleton<IConnectionFactory>(sp =>
{
var config = sp.GetRequiredService<IConfiguration>();
return new SqlConnectionFactory(config["ConnectionStrings:Default"]);
});
var app = builder.Build(); // IServiceCollection is frozen; IServiceProvider is ready
// IServiceProvider — resolve instances (mostly in infrastructure code):
var svc = app.Services.GetRequiredService<IOrderService>(); // throws if not registered
Prefer GetRequiredService<T>() over GetService<T>() — the latter returns null silently.
Constructor injection — the right way
Constructor injection is the default and preferred style. The container inspects the public constructor, resolves each parameter type, and passes it in.
public class CheckoutController : ControllerBase
{
private readonly IOrderService _orders;
private readonly IPaymentGateway _payments;
private readonly ILogger<CheckoutController> _logger;
// All three resolved by the container — no manual wiring:
public CheckoutController(
IOrderService orders,
IPaymentGateway payments,
ILogger<CheckoutController> logger)
{
_orders = orders;
_payments = payments;
_logger = logger;
}
}
// In a unit test — no container required:
var sut = new CheckoutController(
new FakeOrderService(),
new FakePaymentGateway(),
NullLogger<CheckoutController>.Instance);
Why it beats property injection and method injection: all dependencies are explicit in the signature, required at construction time (no partial state), and testable without a container.
Registration methods — Add, TryAdd, Replace
Choosing the right registration method matters especially in library code and test setup:
// Add* — always appends; multiple for the same type is valid:
services.AddSingleton<ICache, MemoryCache>();
services.AddSingleton<ICache, RedisCache>();
// GetService<ICache>() → RedisCache (last wins)
// GetServices<ICache>() → [MemoryCache, RedisCache] (composite)
// TryAdd* — only registers if the type has no existing descriptor:
services.TryAddSingleton<ICache, MemoryCache>();
services.TryAddSingleton<ICache, RedisCache>(); // ignored — MemoryCache already present
// Best for library/extension code: "register a default, let apps override"
// Replace — remove existing, then add:
services.AddSingleton<ICache, MemoryCache>();
services.Replace(ServiceDescriptor.Singleton<ICache, RedisCache>()); // MemoryCache gone
// Test pattern — swap a real service with a fake:
services.RemoveAll<IEmailSender>();
services.AddSingleton<IEmailSender, FakeEmailSender>();
Open generic registrations
One line covers all closed type combinations at resolve time:
public interface IRepository<T> where T : class { }
public class EfRepository<T> : IRepository<T> where T : class { }
// One open generic registration:
builder.Services.AddScoped(typeof(IRepository<>), typeof(EfRepository<>));
// Resolves automatically for every entity type:
// IRepository<Order> → EfRepository<Order>
// IRepository<Product> → EfRepository<Product>
// A closed registration overrides the open one:
builder.Services.AddScoped<IRepository<AuditLog>, ReadOnlyAuditRepository>();
Use open generics for repository, validator, and handler patterns where the same shape applies to many types.
Keyed services (.NET 8)
Before .NET 8, using multiple implementations of the same interface required Func<string, T>
factory workarounds. .NET 8 adds first-class keyed service support:
// Register with keys:
builder.Services.AddKeyedSingleton<ICache, MemoryCache>("memory");
builder.Services.AddKeyedSingleton<ICache, RedisCache>("redis");
// Inject by key:
public class ProductService
{
public ProductService([FromKeyedServices("redis")] ICache cache) { }
}
// Or resolve programmatically:
var cache = sp.GetRequiredKeyedService<ICache>("memory");
Prefer enum keys over strings for type safety.
Multiple implementations with IEnumerable
builder.Services.AddScoped<IOrderValidator, StockValidator>();
builder.Services.AddScoped<IOrderValidator, PriceValidator>();
builder.Services.AddScoped<IOrderValidator, FraudValidator>();
public class OrderService
{
// All three are injected:
public OrderService(IEnumerable<IOrderValidator> validators)
{
_validators = validators;
}
public async Task<bool> ValidateAsync(Order order)
{
foreach (var v in _validators)
if (!await v.ValidateAsync(order)) return false;
return true;
}
}
Use IEnumerable<T> injection for composite, pipeline, and notification patterns.
Extension methods — keeping Program.cs clean
Group related registrations into named extensions:
public static class OrderingServiceExtensions
{
public static IServiceCollection AddOrderingServices(
this IServiceCollection services, IConfiguration config)
{
services.AddScoped<IOrderService, OrderService>();
services.AddScoped<IOrderRepository, EfOrderRepository>();
services.AddScoped<IOrderValidator, StockValidator>();
services.AddScoped<IOrderValidator, PriceValidator>();
return services;
}
}
// Program.cs — a readable feature index:
builder.Services.AddOrderingServices(builder.Configuration);
builder.Services.AddCatalogServices(builder.Configuration);
The three DI anti-patterns
These are the patterns interviewers specifically ask about because they produce subtle, hard-to-debug production failures.
1. Service Locator — hides dependencies
// Hidden deps — not testable without a real container:
public class OrderService
{
private readonly IServiceProvider _sp;
public OrderService(IServiceProvider sp) => _sp = sp;
public void Process()
{
var repo = _sp.GetRequiredService<IOrderRepository>(); // hidden
}
}
// Fix: inject the dependency directly:
public class OrderService
{
public OrderService(IOrderRepository repo) { }
}
IServiceProvider belongs only in infrastructure code (factory delegates, hosted services,
extension methods) — not in application classes.
2. Captive dependency — wrong lifetime hierarchy
// Singleton holds a Scoped service — scoped service lives forever:
public class ProductCache // Singleton
{
public ProductCache(AppDbContext db) { } // AppDbContext is Scoped — BUG
// db is shared across ALL requests — EF change tracker corrupts
}
// Fix: inject IServiceScopeFactory:
public class ProductCache
{
private readonly IServiceScopeFactory _factory;
public ProductCache(IServiceScopeFactory factory) => _factory = factory;
public async Task RefreshAsync()
{
using var scope = _factory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
_products = await db.Products.ToListAsync();
}
}
Enable ValidateScopes = true in development to catch this at startup.
3. Bastard injection — hidden fallback
// Misleading — looks injectable but silently uses a hardcoded default:
public class EmailService
{
private readonly ILogger _logger;
public EmailService(ILogger logger = null)
{
_logger = logger ?? new ConsoleLogger(); // hides the real dependency
}
}
// Fix: require the dependency; provide NullLogger in tests:
public EmailService(ILogger<EmailService> logger) => _logger = logger;
ValidateOnBuild and ValidateScopes
builder.Host.UseDefaultServiceProvider((ctx, options) =>
{
bool isDev = ctx.HostingEnvironment.IsDevelopment();
options.ValidateOnBuild = isDev; // catch missing registrations at startup
options.ValidateScopes = isDev; // catch captive dependencies at startup
});
Without these, misconfigured containers fail silently on the first production request that exercises the broken dependency path — usually at 3 AM.
Recap
The .NET DI container wires your application at startup via IServiceCollection and resolves
services at runtime via IServiceProvider. Constructor injection is the default and preferred
style — explicit, required, testable. Register interfaces against implementations; use
TryAdd* in library code. Use open generics for uniform patterns across many types. Use keyed
services (.NET 8+) for named variants. Avoid Service Locator, captive dependencies, and bastard
injection. Enable ValidateOnBuild and ValidateScopes in development to catch misconfiguration
at startup, not under production load.