StudyComputer ScienceC#

Dependency Injection in C# - Stop Writing Hard-Coded Code

2026-04-15 16 min read Computer Science
Cover image for article: Dependency Injection in C# - Stop Writing Hard-Coded Code

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 OrderService is 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:

  1. Constructor injection: pass dependencies through the constructor (preferred)
  2. Property injection: set dependencies through public properties
  3. 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

LifetimeWhen CreatedWhen DisposedUse Case
TransientEvery injectionScope end or immediatelyStateless services
ScopedOnce per scope/requestScope endPer-request state
SingletonFirst use, onceApplication shutdownShared 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:

ContainerBest ForNotable Features
Microsoft DIDefault ASP.NET Core appsSimple, fast, integrated
AutofacComplex applicationsModules, assembly scanning, property injection
LamarStructureMap successorMore flexibility than built-in
NinjectLegacy appsOlder, less maintained
SimpleInjectorCorrectness-focusedStrict diagnostics, fast

For 90% of applications, the built-in container is sufficient. Reach for alternatives only when you need specific features.


Common Pitfalls

  1. 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.

  2. 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.

  3. Too many dependencies. A class with 10+ constructor parameters is doing too much. Split it. DI reveals the smell; do not paper over it.

  4. Captive dependencies. Singletons capturing scoped services. Catches you at startup (newer .NET versions validate) but older versions just silently misbehave.

  5. 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.

  6. Not disposing properly. If your services implement IDisposable, the container handles disposal. If they hold unmanaged resources, make sure you implement IDisposable correctly.

  7. Injecting IServiceProvider everywhere. 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

ConceptSummary
Dependency InjectionReceiving dependencies from outside instead of creating them
Inversion of ControlAn external component controls the wiring
Constructor injectionDependencies passed through the constructor (preferred)
Composition rootThe single place where dependencies are wired up
Transient lifetimeNew instance every time requested
Scoped lifetimeOne instance per HTTP request/scope
Singleton lifetimeOne instance for the whole application
Captive dependencySingleton holding a shorter-lived service (bug)
Service locatorAnti-pattern: calling container directly inside classes
AddScoped()Register Foo as implementation of IFoo
MockMoq library for unit testing with fake dependencies
Options patternIOptions for strongly-typed configuration
Factory patternFor runtime construction with parameters
Decorator patternWrap existing service with extra behavior
Keyed servicesMultiple implementations of same interface (.NET 8+)

Sources & Further Reading

Browse Articles

C:\KortesHub\Study 31 file(s)
All_Articles31
4 folders 31 files 190 tags
PreviousFX Markets Explained - The World's Biggest Market