Here is a class you cannot unit test without a live database and a working SMTP server:
| |
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:
| |
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:
| |
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:
| |
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:
| |
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.
| Lifetime | New instance created | Typical use |
|---|---|---|
Transient | Every time it’s requested | Stateless, cheap services (a formatter, a validator) |
Scoped | Once per HTTP request | Anything tied to the current request: a DbContext, a unit of work |
Singleton | Once, for the app’s lifetime | Shared, 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:
| |
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:
| |
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.