Blog Backend & APIs
BUSINESS DOMAIN

Dependency Injection in C#, from First Principles

What dependency injection actually buys a C# codebase beyond fewer 'new' keywords — constructor injection, lifetimes, and testable code explained from the ground up.

Mar 24, 2023

Dependency injection gets sold as an abstract OOP principle, but the day-to-day reason we use it is much more mundane: it is the difference between a class you can unit test in isolation and a class you can only exercise by standing up its entire dependency graph, database included.

The Problem: Hardcoded Dependencies

A class that news up a concrete SqlEmailService inside its constructor or a method body has hardwired itself to that implementation. You cannot substitute a fake for a test, you cannot swap providers without editing the class itself, and every class that news up its own dependencies makes the real object graph invisible from the outside.

csharp
public class OrderService
{
    private readonly SqlEmailService _emailService = new SqlEmailService();

    public void PlaceOrder(Order order)
    {
        // ... persist the order ...
        _emailService.SendConfirmation(order.CustomerEmail, order.Id);
    }
}

Constructor Injection

The fix is to depend on an abstraction and let something else hand the class its dependency, and the constructor is the right place for anything the class cannot function without — it makes required dependencies visible in the type signature and impossible to forget to set.

csharp
public interface IEmailService
{
    void SendConfirmation(string toAddress, int orderId);
}

public class OrderService
{
    private readonly IEmailService _emailService;

    public OrderService(IEmailService emailService)
    {
        _emailService = emailService;
    }

    public void PlaceOrder(Order order)
    {
        // ... persist the order ...
        _emailService.SendConfirmation(order.CustomerEmail, order.Id);
    }
}

// Program.cs
builder.Services.AddScoped<IEmailService, SqlEmailService>();
builder.Services.AddScoped<OrderService>();

Transient, Scoped, and Singleton

ASP.NET Core's built-in container gives you three lifetimes, and picking the wrong one is the most common DI bug we see. Transient creates a new instance every time it is requested — cheap, stateless services are the natural fit. Scoped creates one instance per HTTP request (or per unit of work outside a web request) — this is where most application and repository services belong, especially anything wrapping a DbContext. Singleton creates exactly one instance for the lifetime of the app — reserve it for genuinely stateless or thread-safe shared state, like configuration or an in-memory cache.

The bug worth knowing by name is the captive dependency: injecting a Scoped service into a Singleton silently pins that Scoped instance for the app's entire lifetime, defeating its purpose and often producing confusing DbContext-related exceptions since it is now shared across requests it was never designed to survive.

Why This Makes Testing Easier

Once OrderService depends on IEmailService rather than a concrete SqlEmailService, a unit test can hand it a mock (via Moq, NSubstitute, or a hand-written fake) that records what was called instead of actually sending an email, and assert on that — no test database, no SMTP server, no network call in the way of the thing you are actually testing.

  • Transient — new instance every resolution; stateless helpers and validators
  • Scoped — one instance per request; services and repositories wrapping a DbContext
  • Singleton — one instance for the app's lifetime; configuration, caches, stateless clients

A wrong lifetime usually does not fail at compile time or even at registration time — it fails (or silently misbehaves) at resolution time. After any change to your DI registrations, run the app, or better, write a quick test that resolves every registered service, rather than assuming it compiles means it works.

Wrapping Up

DI is not about the container — it is about writing classes that declare what they need instead of reaching out and grabbing it. The container is just the thing that saves you from wiring that graph together by hand at every call site.

C# Dependency Injection Architecture

More from the blog

View all articles

Read something that fits your problem?

Tell us what you're building. We'll come back with an honest, scoped recommendation — not a sales pitch.

Start a Project