Here is a class you cannot unit test without a live database and a working SMTP server:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
public class OrderService
{
    private readonly SqlOrderRepository _repository = new SqlOrderRepository("Server=prod-db;Database=Orders;...");
    private readonly SmtpEmailSender _emailSender = new SmtpEmailSender("smtp.company.com");

    public void PlaceOrder(Order order)
    {
        _repository.Save(order);
        _emailSender.Send(order.CustomerEmail, "Order confirmed");
    }
}

OrderService builds its own SqlOrderRepository and SmtpEmailSender with new, connection string and all. Every test that touches PlaceOrder needs a real Orders database and a real mail server, or it fails before your assertion runs. The logic you want to test is two lines: save the order, send the confirmation. It’s welded to two concrete classes you can’t swap out.

What dependency injection means in practice

Dependency injection is handing a class the objects it needs instead of letting it construct them. The class declares what it depends on. Code outside the class decides which concrete implementation it gets. Containers and lifetimes are tooling built around that one move.

Rewrite OrderService to take its dependencies through the constructor, as interfaces rather than concrete classes:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
public class OrderService
{
    private readonly IOrderRepository _repository;
    private readonly IEmailSender _emailSender;

    public OrderService(IOrderRepository repository, IEmailSender emailSender)
    {
        _repository = repository;
        _emailSender = emailSender;
    }

    public void PlaceOrder(Order order)
    {
        _repository.Save(order);
        _emailSender.Send(order.CustomerEmail, "Order confirmed");
    }
}

OrderService no longer knows whether the repository writes to SQL Server or to an in-memory list, or whether the email goes out over SMTP or nowhere at all. It knows two interfaces and calls their methods. Whoever creates the OrderService decides what gets wired in: a test passes fakes, the web app passes the real thing.

This is constructor injection, and it’s the form you’ll use for almost everything. Property injection (setting a public property after construction) and method injection (passing a dependency to the one method that needs it) exist for optional dependencies and one-off values. ASP.NET Core’s built-in container doesn’t support property injection at all. The rule is short: if a class can’t do its job without something, that something goes in the constructor.

How dependency injection makes a class easy to test

With the dependencies arriving as interface parameters, a test can hand OrderService whatever it wants:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class FakeOrderRepository : IOrderRepository
{
    public List<Order> SavedOrders { get; } = new();
    public void Save(Order order) => SavedOrders.Add(order);
}

public class FakeEmailSender : IEmailSender
{
    public List<string> SentTo { get; } = new();
    public void Send(string to, string subject) => SentTo.Add(to);
}

[Fact]
public void PlaceOrder_SavesOrderAndSendsConfirmation()
{
    var repository = new FakeOrderRepository();
    var emailSender = new FakeEmailSender();
    var service = new OrderService(repository, emailSender);

    service.PlaceOrder(new Order { CustomerEmail = "[email protected]" });

    Assert.Single(repository.SavedOrders);
    Assert.Equal("[email protected]", emailSender.SentTo.Single());
}

The test runs in milliseconds and touches no database or network. It sits at the unit end of the spectrum described in Unit vs Integration vs End-to-End Tests Explained: one class, collaborators replaced, one behavior checked. These hand-written classes are fakes, small working implementations with their own state. A mocking library like Moq or NSubstitute generates the same kind of stand-in for you, so you don’t write a class per interface.

What a DI container does, and why you don’t need one

Nothing above required a framework. You can wire the real dependencies by hand in your application’s entry point:

1
2
3
var repository = new SqlOrderRepository(connectionString);
var emailSender = new SmtpEmailSender(smtpHost);
var orderService = new OrderService(repository, emailSender);

This is called “pure DI” (or “poor man’s DI”), and for a small app it’s enough. The pattern is passing dependencies in. A container automates the wiring, and it starts paying for itself once you have dozens of classes with overlapping dependencies and one changed constructor means editing the assembly code in five places.

ASP.NET Core has a container built in. You register each interface against an implementation once, at startup, and the framework resolves the graph whenever it builds a controller, a minimal API handler, or any other registered service:

1
2
3
4
5
6
7
8
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddScoped<IOrderRepository>(sp =>
    new SqlOrderRepository(builder.Configuration.GetConnectionString("Orders")));
builder.Services.AddTransient<IEmailSender>(sp =>
    new SmtpEmailSender(builder.Configuration["Smtp:Host"]));

var app = builder.Build();

SqlOrderRepository and SmtpEmailSender take a connection string and a host name, not a parameterless constructor, so each registration is a factory: a lambda that receives the container (sp) and returns the instance. When neither implementation needs a value like that, the two-type-argument form is shorter: AddScoped<IOrderRepository, SqlOrderRepository>().

Register OrderService itself the same way (builder.Services.AddScoped<OrderService>()) and you can ask for it in a controller constructor. The container builds it, resolves IOrderRepository and IEmailSender from these registrations, and you never call new.

ASP.NET Core service lifetimes: singleton, scoped, transient

AddScoped, AddTransient, and AddSingleton register the same way; the method name is the lifetime, and picking the wrong one causes most ASP.NET Core DI bugs.

LifetimeNew instance createdTypical use
TransientEvery time it’s requestedStateless, cheap services (a formatter, a validator)
ScopedOnce per HTTP requestAnything tied to the current request: a DbContext, a unit of work
SingletonOnce, for the app’s lifetimeShared, thread-safe state: configuration, a connection pool, a cache

The AddScoped registration above gives every request its own SqlOrderRepository. If the repository wraps a DbContext, two concurrent requests can never share a half-finished transaction. The AddTransient registration creates a new SmtpEmailSender at every injection point. That’s fine, because the sender holds no per-request state.

Captive dependencies: a scoped service trapped in a singleton

This is the lifetime rule that bites. A service can safely depend only on things that live as long as it does, or longer. A singleton can take other singletons. A scoped service can take scoped or singleton. A transient can take anything. The failure is a singleton taking a scoped dependency in its constructor:

1
2
3
4
5
6
7
8
9
public class ReportCache // registered as a singleton
{
    private readonly AppDbContext _db; // registered as scoped

    public ReportCache(AppDbContext db)
    {
        _db = db;
    }
}

The container builds ReportCache once and the AppDbContext it received stays in that field for the life of the application. That’s a captive dependency. Every later request shares one DbContext, a class that is neither thread-safe nor meant to live long. You see it as stale reads, or as InvalidOperationExceptions complaining that a second operation started on this context before a previous one completed.

ASP.NET Core catches this in development. With scope validation on, which is the default in the Development environment, resolving a singleton that needs a scoped service throws an InvalidOperationException at startup instead of corrupting data in production. If a singleton really does need a scoped service now and then, inject IServiceScopeFactory, create a scope where you need it, and resolve the service from that scope instead of keeping it in a field.

Dependency injection anti-patterns: service locator and bloated constructors

Service locator in disguise. Injecting the container itself and pulling dependencies out of it inside a method looks like DI, but it throws away what DI gives you:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
public class OrderService
{
    private readonly IServiceProvider _provider;

    public OrderService(IServiceProvider provider)
    {
        _provider = provider;
    }

    public void PlaceOrder(Order order)
    {
        var repository = _provider.GetService<IOrderRepository>();
        repository.Save(order);
    }
}

The constructor no longer says what OrderService needs. You have to read every method to find out, and a missing registration surfaces as a null in the middle of PlaceOrder instead of a failure when the class is built. Inject the specific interfaces you use, not the object that hands them out.

The twelve-parameter constructor. A class that needs IOrderRepository, IEmailSender, IInventoryService, IPaymentGateway, IAuditLogger and seven more is doing too many jobs. DI didn’t cause that; it made it visible. It’s the same symptom described in What Is Spaghetti Code and How to Refactor It. Split the class by responsibility and the constructor shrinks on its own. Bundling the twelve dependencies into one “services” object hides the problem without fixing it.

Both of these slip through code review easily. They stop slipping the first time someone tries to write a unit test for the class.

Where to start with dependency injection

Pick one class in your codebase that calls new on a database client, an HTTP client or a mail sender. Change its constructor to take an interface, wire the real implementation by hand in Program.cs, and write one test that passes a fake. In an ASP.NET Core app, skip the hand-wiring and register it in builder.Services, since the container is already there. Choose the lifetime deliberately for anything that holds state, and leave scope validation on so the framework catches a captive dependency before your users do.