Dependency Injection in C#: Stop Writing Hard-Coded Code¶
Open any modern ASP.NET Core project. Look at a controller. Notice how the controller never calls new SomeService(). Instead, it declares ISomeService as a constructor parameter, and the framework somehow provides an instance. That pattern is Dependency Injection (DI), and it is one of the most important paradigm shifts in modern .NET development.
DI is not a clever trick. It is a fundamental rethinking of how objects get their collaborators. Instead of classes constructing their dependencies (tightly coupling them), they receive their dependencies from outside (loosely coupling them). The result is code that is easier to test, easier to change, easier to extend, and easier to reason about.
If you have read about SOLID principles, DI is what the “D” (Dependency Inversion) actually looks like in practice. If you have written unit tests and found yourself trying to mock static calls and concrete dependencies, DI is the escape. If you have built systems that started simple and became unmaintainable, DI is one of the things that stops that slide.
This article walks through DI from the ground up: why it exists, how it works in .NET, the built-in container, service lifetimes, and the common patterns that actually come up in production code.
Dependency Injection is the pattern that makes you realize every new keyword in your codebase is a tiny act of betrayal against your future self.
The Problem: Tight Coupling¶
Consider a simple scenario: an OrderService that processes orders and sends a confirmation email.
The Tightly Coupled Version¶
public class OrderService
{
private readonly EmailSender _emailSender = new EmailSender();
private readonly SqlDatabase _database = new SqlDatabase("Server=prod;Database=orders;");
public void PlaceOrder(Order order)
{
_database.Save(order);
_emailSender.Send(order.CustomerEmail, "Order confirmed!");
}
}
This code works. It is simple. It also has serious problems:
Problem 1: Hard to Test¶
You want to unit test PlaceOrder. But every call creates a real EmailSender (which actually sends emails) and a real SqlDatabase connection (which needs a database). Your tests either hit the real infrastructure (slow, fragile, sends real emails) or you cannot test at all.
Problem 2: Hard to Change¶
Want to switch from SQL Server to PostgreSQL? You edit OrderService. Want to add a second email provider as a fallback? You edit OrderService. Want to mock email sending in development? You edit OrderService. Every change requires modifying the class that should not care about these implementation details.
Problem 3: Hard to Reason About¶
The class’s dependencies are hidden inside its body. You have to read the whole class to figure out what it actually needs. When you later add logging, caching, authentication, feature flags, and metrics, the constructor body becomes incomprehensible.
Problem 4: Violates Dependency Inversion¶
The class depends on concrete implementations (SqlDatabase, EmailSender), not on abstractions. The “D” in SOLID says high-level modules should not depend on low-level modules; both should depend on abstractions. This class flagrantly violates that.
Key Insight: The fundamental issue is that
OrderServiceis doing two things: defining business logic AND choosing its dependencies. Good design separates these concerns. A class should focus on what it does, not on where its collaborators come from.
Hard-coding new statements is like a chef growing his own vegetables, raising his own cattle, and mining his own salt. Admirable, but at that point he is not really a chef.
The Solution: Inversion of Control¶
The principle behind DI is Inversion of Control (IoC). Instead of a class controlling its dependencies, an external component hands the class its dependencies. Control of the wiring is inverted.
Step 1: Extract Interfaces¶
Define abstractions for each dependency:
public interface IEmailSender
{
void Send(string to, string message);
}
public interface IDatabase
{
void Save(Order order);
}
Step 2: Depend on Abstractions¶
Change OrderService to receive its dependencies through the constructor:
public class OrderService
{
private readonly IEmailSender _emailSender;
private readonly IDatabase _database;
public OrderService(IEmailSender emailSender, IDatabase database)
{
_emailSender = emailSender;
_database = database;
}
public void PlaceOrder(Order order)
{
_database.Save(order);
_emailSender.Send(order.CustomerEmail, "Order confirmed!");
}
}
Now OrderService does not know or care about concrete types. It just knows it has “something that can send emails” and “something that can save orders.”
Step 3: Someone Else Wires It Up¶
// At application startup (composition root)
IEmailSender emailSender = new SmtpEmailSender();
IDatabase database = new SqlDatabase("Server=prod;Database=orders;");
var orderService = new OrderService(emailSender, database);
orderService.PlaceOrder(order);
This may look like just shuffling the new keywords around. It is. But the shuffle is the whole point. Concretizing dependencies happens exactly once, at the top of the application, in a single well-known location called the composition root.
Constructor Injection: The Canonical Form¶
There are several ways to inject dependencies:
- Constructor injection: pass dependencies through the constructor (preferred)
- Property injection: set dependencies through public properties
- Method injection: pass dependencies as method parameters
Constructor injection is the dominant pattern in .NET for good reason:
- Dependencies are explicit (visible in the constructor signature)
- Dependencies are required (cannot construct the object without them)
- Dependencies are immutable (set once, cannot be swapped out later)
- Works well with readonly fields
The Canonical Pattern¶
public class InvoiceService
{
private readonly IEmailSender _emailSender;
private readonly ILogger<InvoiceService> _logger;
private readonly IInvoiceRepository _repository;
public InvoiceService(
IEmailSender emailSender,
ILogger<InvoiceService> logger,
IInvoiceRepository repository)
{
_emailSender = emailSender ?? throw new ArgumentNullException(nameof(emailSender));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
}
public async Task GenerateInvoiceAsync(int orderId)
{
_logger.LogInformation("Generating invoice for order {OrderId}", orderId);
var invoice = await _repository.CreateInvoiceAsync(orderId);
await _emailSender.SendAsync(invoice.CustomerEmail, "Your invoice is ready");
}
}
Every dependency is clear. Null checks guarantee invariants. The class is now trivially testable by passing mocks.
If you find yourself writing a constructor with 12 parameters, your class is doing too much. DI makes this smell visible. That is a feature, not a bug.
Testing With DI¶
The single biggest practical benefit of DI is testability. Let us write a unit test for OrderService.
using Moq;
using Xunit;
public class OrderServiceTests
{
[Fact]
public void PlaceOrder_SavesOrderAndSendsEmail()
{
// Arrange
var mockDatabase = new Mock<IDatabase>();
var mockEmailSender = new Mock<IEmailSender>();
var service = new OrderService(mockEmailSender.Object, mockDatabase.Object);
var order = new Order
{
Id = 42,
CustomerEmail = "customer@example.com"
};
// Act
service.PlaceOrder(order);
// Assert
mockDatabase.Verify(d => d.Save(order), Times.Once);
mockEmailSender.Verify(
e => e.Send("customer@example.com", "Order confirmed!"),
Times.Once);
}
}
The test runs in milliseconds. It does not touch a database. It does not send an email. It verifies that the logic is correct without any infrastructure. This is why we invented DI.
The .NET Built-In Container¶
ASP.NET Core ships with a built-in DI container (Microsoft.Extensions.DependencyInjection). It is simpler than full-featured containers like Autofac or StructureMap but covers 95% of real-world needs.
Registering Services¶
var builder = WebApplication.CreateBuilder(args);
// Register services
builder.Services.AddScoped<IEmailSender, SmtpEmailSender>();
builder.Services.AddSingleton<IConfiguration, Configuration>();
builder.Services.AddTransient<IInvoiceRepository, SqlInvoiceRepository>();
var app = builder.Build();
Each registration maps an abstraction (interface) to a concrete implementation (class) and specifies a lifetime.
Consuming Services¶
In a controller:
public class OrdersController : ControllerBase
{
private readonly IOrderService _orderService;
public OrdersController(IOrderService orderService)
{
_orderService = orderService;
}
[HttpPost]
public IActionResult Create(Order order)
{
_orderService.PlaceOrder(order);
return Ok();
}
}
ASP.NET Core creates the controller, sees it needs IOrderService, looks up the registration, creates an instance, and injects it. You never write new anywhere in your application code.
Service Lifetimes¶
When you register a service, you choose its lifetime. This decides when instances are created and when they are disposed.
Transient¶
A new instance is created every time the service is requested.
services.AddTransient<IEmailValidator, EmailValidator>();
Use for: lightweight, stateless services. If in doubt, default here.
Scoped¶
One instance per HTTP request (in web apps) or per scope (in console apps).
services.AddScoped<IOrderService, OrderService>();
Use for: services that should share state within a single request (database contexts, user context, business logic).
Singleton¶
One instance for the entire application lifetime.
services.AddSingleton<IConfiguration, Configuration>();
Use for: expensive-to-create, stateless services; caches; configuration; thread-safe shared state.
Comparison Table¶
| Lifetime | When Created | When Disposed | Use Case |
|---|---|---|---|
| Transient | Every injection | Scope end or immediately | Stateless services |
| Scoped | Once per scope/request | Scope end | Per-request state |
| Singleton | First use, once | Application shutdown | Shared state, expensive resources |
The Captive Dependency Problem¶
If a singleton depends on a scoped service, you have a bug. The singleton holds a reference to the scoped instance forever, bypassing its intended lifetime.
// BAD: singleton captures scoped dependency
services.AddSingleton<IBackgroundProcessor, BackgroundProcessor>();
services.AddScoped<IDbContext, AppDbContext>();
public class BackgroundProcessor
{
public BackgroundProcessor(IDbContext db) { _db = db; }
// _db will never be disposed, reused forever
}
The DI container will throw an error at startup if you try this. The fix: use IServiceScopeFactory to create a scope manually inside the singleton.
public class BackgroundProcessor
{
private readonly IServiceScopeFactory _scopeFactory;
public BackgroundProcessor(IServiceScopeFactory scopeFactory)
{
_scopeFactory = scopeFactory;
}
public void ProcessJob()
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<IDbContext>();
// Use db...
}
}
Key Insight: Lifetime mismatches are the #1 source of DI bugs in real applications. The order from most restrictive to least: Transient → Scoped → Singleton. A service can safely depend on services of the same or more restrictive lifetime, but not less restrictive.
Real Example: A Finance App¶
Let us build a complete example: a service that fetches market data, computes a P&L, and writes an audit log.
The Abstractions¶
public interface IMarketDataProvider
{
Task<decimal> GetCurrentPriceAsync(string ticker);
}
public interface IPositionRepository
{
Task<IReadOnlyList<Position>> GetPositionsAsync(string trader);
}
public interface IAuditLogger
{
Task LogAsync(string action, string details);
}
The Implementations¶
public class BloombergMarketDataProvider : IMarketDataProvider
{
private readonly HttpClient _http;
public BloombergMarketDataProvider(HttpClient http)
{
_http = http;
}
public async Task<decimal> GetCurrentPriceAsync(string ticker)
{
var response = await _http.GetFromJsonAsync<PriceData>(
$"https://bloomberg.api/v1/price/{ticker}");
return response.Price;
}
}
public class SqlPositionRepository : IPositionRepository
{
private readonly IDbConnection _db;
public SqlPositionRepository(IDbConnection db)
{
_db = db;
}
public async Task<IReadOnlyList<Position>> GetPositionsAsync(string trader)
{
var positions = await _db.QueryAsync<Position>(
"SELECT * FROM positions WHERE trader = @trader",
new { trader });
return positions.ToList();
}
}
public class FileAuditLogger : IAuditLogger
{
private readonly string _logPath;
public FileAuditLogger(IConfiguration config)
{
_logPath = config["AuditLog:Path"];
}
public async Task LogAsync(string action, string details)
{
var entry = $"{DateTime.UtcNow:o} | {action} | {details}\n";
await File.AppendAllTextAsync(_logPath, entry);
}
}
The Business Service¶
public class PnLService
{
private readonly IMarketDataProvider _marketData;
private readonly IPositionRepository _positions;
private readonly IAuditLogger _audit;
public PnLService(
IMarketDataProvider marketData,
IPositionRepository positions,
IAuditLogger audit)
{
_marketData = marketData;
_positions = positions;
_audit = audit;
}
public async Task<decimal> ComputePnLAsync(string trader)
{
var positions = await _positions.GetPositionsAsync(trader);
decimal totalPnL = 0;
foreach (var position in positions)
{
var currentPrice = await _marketData.GetCurrentPriceAsync(position.Ticker);
totalPnL += (currentPrice - position.EntryPrice) * position.Quantity;
}
await _audit.LogAsync(
"ComputePnL",
$"Trader: {trader}, Positions: {positions.Count}, PnL: {totalPnL:C}");
return totalPnL;
}
}
The Registration¶
// Program.cs
var builder = WebApplication.CreateBuilder(args);
// Register dependencies with appropriate lifetimes
builder.Services.AddHttpClient<IMarketDataProvider, BloombergMarketDataProvider>();
builder.Services.AddScoped<IPositionRepository, SqlPositionRepository>();
builder.Services.AddSingleton<IAuditLogger, FileAuditLogger>();
builder.Services.AddScoped<PnLService>();
builder.Services.AddScoped<IDbConnection>(sp =>
new SqlConnection(builder.Configuration.GetConnectionString("Default")));
var app = builder.Build();
app.MapGet("/pnl/{trader}", async (string trader, PnLService service) =>
{
var pnl = await service.ComputePnLAsync(trader);
return Results.Ok(new { trader, pnl });
});
app.Run();
The Test¶
public class PnLServiceTests
{
[Fact]
public async Task ComputePnL_SumsAllPositionsCorrectly()
{
// Arrange
var mockMarketData = new Mock<IMarketDataProvider>();
mockMarketData.Setup(m => m.GetCurrentPriceAsync("AAPL")).ReturnsAsync(150m);
mockMarketData.Setup(m => m.GetCurrentPriceAsync("MSFT")).ReturnsAsync(380m);
var mockPositions = new Mock<IPositionRepository>();
mockPositions.Setup(r => r.GetPositionsAsync("Alice")).ReturnsAsync(new List<Position>
{
new() { Ticker = "AAPL", EntryPrice = 140m, Quantity = 100 },
new() { Ticker = "MSFT", EntryPrice = 370m, Quantity = 50 }
});
var mockAudit = new Mock<IAuditLogger>();
var service = new PnLService(
mockMarketData.Object,
mockPositions.Object,
mockAudit.Object);
// Act
var pnl = await service.ComputePnLAsync("Alice");
// Assert
// AAPL: (150-140)*100 = 1000
// MSFT: (380-370)*50 = 500
Assert.Equal(1500m, pnl);
mockAudit.Verify(
a => a.LogAsync("ComputePnL", It.IsAny<string>()),
Times.Once);
}
}
Everything testable. Everything swappable. Everything clear.
Advanced Patterns¶
Named/Keyed Services¶
When you need multiple implementations of the same interface:
// .NET 8+
services.AddKeyedScoped<IEmailSender, SmtpSender>("smtp");
services.AddKeyedScoped<IEmailSender, SendGridSender>("sendgrid");
public class NotificationService
{
public NotificationService([FromKeyedServices("smtp")] IEmailSender sender)
{
_sender = sender;
}
}
Factory Pattern¶
Sometimes you need to create instances with parameters only known at runtime:
public interface IOrderServiceFactory
{
IOrderService Create(string region);
}
public class OrderServiceFactory : IOrderServiceFactory
{
private readonly IServiceProvider _provider;
public OrderServiceFactory(IServiceProvider provider)
{
_provider = provider;
}
public IOrderService Create(string region)
{
var baseService = _provider.GetRequiredService<IOrderService>();
// Customize based on region
return new RegionalOrderServiceWrapper(baseService, region);
}
}
Options Pattern¶
For configuration, use IOptions<T> instead of raw IConfiguration:
public class SmtpOptions
{
public string Host { get; set; }
public int Port { get; set; }
public string Username { get; set; }
}
// Registration
services.Configure<SmtpOptions>(configuration.GetSection("Smtp"));
// Usage
public class SmtpEmailSender : IEmailSender
{
private readonly SmtpOptions _options;
public SmtpEmailSender(IOptions<SmtpOptions> options)
{
_options = options.Value;
}
}
Decorator Pattern¶
Wrap a service with additional behavior:
public class CachingOrderService : IOrderService
{
private readonly IOrderService _inner;
private readonly IMemoryCache _cache;
public CachingOrderService(IOrderService inner, IMemoryCache cache)
{
_inner = inner;
_cache = cache;
}
public async Task<Order> GetOrderAsync(int id)
{
return await _cache.GetOrCreateAsync(id, async entry =>
{
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5);
return await _inner.GetOrderAsync(id);
});
}
}
Alternative DI Containers¶
The built-in .NET container is great, but alternatives exist:
| Container | Best For | Notable Features |
|---|---|---|
| Microsoft DI | Default ASP.NET Core apps | Simple, fast, integrated |
| Autofac | Complex applications | Modules, assembly scanning, property injection |
| Lamar | StructureMap successor | More flexibility than built-in |
| Ninject | Legacy apps | Older, less maintained |
| SimpleInjector | Correctness-focused | Strict diagnostics, fast |
For 90% of applications, the built-in container is sufficient. Reach for alternatives only when you need specific features.
Common Pitfalls¶
Using the service locator anti-pattern. Instead of injecting dependencies, some code calls
serviceProvider.GetService<T>()directly. This hides dependencies and is an anti-pattern. Use constructor injection.Circular dependencies. If A depends on B and B depends on A, the container will throw. Fix by introducing a new abstraction or restructuring the code.
Too many dependencies. A class with 10+ constructor parameters is doing too much. Split it. DI reveals the smell; do not paper over it.
Captive dependencies. Singletons capturing scoped services. Catches you at startup (newer .NET versions validate) but older versions just silently misbehave.
Overusing singletons. Easy to register
AddSingleton“because it’s faster”. But singletons share state across all requests, which can cause subtle concurrency bugs and memory leaks.Not disposing properly. If your services implement
IDisposable, the container handles disposal. If they hold unmanaged resources, make sure you implementIDisposablecorrectly.Injecting
IServiceProvidereverywhere. This is the service locator anti-pattern in disguise. Prefer specific interface injection.
Wrapping Up¶
Dependency Injection is one of the most important patterns in modern software engineering. It transforms code from a tangle of hard-wired relationships into a well-structured graph of abstractions. It makes testing practical, refactoring safe, and composition trivial.
The mechanics are simple: depend on abstractions, inject them through constructors, register implementations at startup, let the container wire everything up. The payoff is enormous: testable code, swappable implementations, clear dependencies, and a codebase that stays manageable as it grows.
If you are writing modern C# and not using DI, you are fighting the framework and writing code that does not compose well. Learn the built-in container, understand service lifetimes, and use DI for every non-trivial class. Your future self, and anyone who joins the team, will thank you.
Dependency Injection is the pattern that turns “I’ll refactor it later” into “I can actually refactor this.” And that is what makes the difference between a codebase that scales and one that does not.
Cheat Sheet¶
Key Questions & Answers¶
What is Dependency Injection?¶
A pattern where a class receives its dependencies from outside (usually through its constructor) rather than creating them itself. This separates “what the class does” from “where its collaborators come from,” making code testable, flexible, and loosely coupled.
What is the difference between Transient, Scoped, and Singleton?¶
Transient creates a new instance every time requested. Scoped creates one instance per scope (per HTTP request in web apps). Singleton creates one instance for the entire application lifetime. Use Transient for stateless, Scoped for per-request state, and Singleton for shared expensive resources.
Why is DI better than just calling new?¶
Three main reasons: (1) testability: you can inject mocks in tests; (2) flexibility: you can swap implementations without changing the consumer; (3) clarity: dependencies are explicit in the constructor signature, not hidden in the code.
What is the service locator anti-pattern?¶
Calling
serviceProvider.GetService<T>()inside a class instead of injecting dependencies through the constructor. This hides dependencies from the class signature and makes code harder to test and reason about. Always prefer constructor injection.
Key Concepts at a Glance¶
| Concept | Summary |
|---|---|
| Dependency Injection | Receiving dependencies from outside instead of creating them |
| Inversion of Control | An external component controls the wiring |
| Constructor injection | Dependencies passed through the constructor (preferred) |
| Composition root | The single place where dependencies are wired up |
| Transient lifetime | New instance every time requested |
| Scoped lifetime | One instance per HTTP request/scope |
| Singleton lifetime | One instance for the whole application |
| Captive dependency | Singleton holding a shorter-lived service (bug) |
| Service locator | Anti-pattern: calling container directly inside classes |
| AddScoped | Register Foo as implementation of IFoo |
| Mock | Moq library for unit testing with fake dependencies |
| Options pattern | IOptions |
| Factory pattern | For runtime construction with parameters |
| Decorator pattern | Wrap existing service with extra behavior |
| Keyed services | Multiple implementations of same interface (.NET 8+) |
Sources & Further Reading¶
- Seemann, M. & van Deursen, S., Dependency Injection Principles, Practices, and Patterns, Manning
- Freeman, S. & Pryce, N., Growing Object-Oriented Software, Guided by Tests, Addison-Wesley
- Martin, R.C., Clean Architecture, Prentice Hall
- Microsoft Docs, Dependency Injection in ASP.NET Core
- Microsoft Docs, Service lifetimes
- Fowler, M., Inversion of Control Containers and the Dependency Injection pattern
- Seemann, M., Blog on DI and software design
- Microsoft Docs, Options pattern
- Autofac Documentation, autofac.org
- Moq Documentation, github.com/moq/moq