Dependency injection has a reputation for being an enterprise ceremony, interfaces everywhere and a container full of registrations. It is actually a very small idea with a large payoff, and .NET has a container built in. This post explains what problem it solves, what the three lifetimes mean, and the mistake that causes the strangest bugs in ASP.NET Core applications. It builds on interfaces and reflection.
The problem: new is a hard-coded decision
public class BookService
{
private readonly SqlBookRepository _repository = new SqlBookRepository("Server=...");
private readonly SmtpEmailSender _email = new SmtpEmailSender();
public void Purchase(int bookId)
{
var book = _repository.Get(bookId);
_email.Send($"You bought {book.Title}");
}
}
That word new looks harmless and it makes four decisions permanent:
BookServicecannot be tested. Constructing one opens a database connection and sends real email. There is no seam to substitute.BookServiceowns the connection string. A business logic class now knows about infrastructure configuration.- You cannot swap implementations. Changing to Postgres, or to a queue-based email sender, means editing this class and every other class that does the same thing.
- Lifetime is out of your control. Every
BookServicecreates its own repository, even when one shared instance would be correct.
The dependency points the wrong way: high-level business logic depends on low-level infrastructure detail.
The fix: ask for what you need
public class BookService
{
private readonly IBookRepository _repository;
private readonly IEmailSender _email;
public BookService(IBookRepository repository, IEmailSender email)
{
_repository = repository;
_email = email;
}
public void Purchase(int bookId)
{
var book = _repository.Get(bookId);
_email.Send($"You bought {book.Title}");
}
}
Nothing clever happened. The class stopped creating its dependencies and started declaring them. Everything follows from that:
- The constructor is now honest documentation: this class needs a repository and an email sender, and nothing else.
- Tests pass fakes:
new BookService(new FakeRepository(), new FakeEmailSender()), no database, no SMTP, no container. - Swapping implementations is a registration change, not a code change.
- Whoever constructs
BookServicedecides the lifetimes.
That is dependency injection: dependencies are supplied from outside rather than constructed inside. Inversion of control is the broader principle: the decision about which implementation moved from the class to the composition root.
Note that DI does not require a container. Passing dependencies into a constructor by hand is dependency injection. A container just automates it when the graph gets deep.
The container
.NET has one built in. Registration goes in Program.cs, which is the composition root: the one place that knows how everything fits together:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped<IBookRepository, SqlBookRepository>();
builder.Services.AddScoped<BookService>();
builder.Services.AddSingleton<IEmailSender, SmtpEmailSender>();
builder.Services.AddTransient<IPriceCalculator, PriceCalculator>();
builder.Services.AddDbContext<AppDbContext>(o => o.UseSqlServer(cs)); // scoped by default
builder.Services.AddHttpClient<IIsbnLookup, IsbnLookup>();
At runtime, when something needs a BookService, the container reflects over its constructor, sees it needs an IBookRepository and an IEmailSender, resolves those (constructing their dependencies too, recursively), and hands back a finished object. That is the reflection post doing real work.
Those AddScoped/AddSingleton calls are extension methods on IServiceCollection, the pattern from the extension methods post, which is how every NuGet package adds its own one-line registration.
The three lifetimes
This is the part that matters most in practice, and the part interviews focus on.
Transient: a new instance every time it is requested. Even twice within the same request. Use for lightweight, stateless services. Safest default; also the most allocation.
Scoped: one instance per scope, which in ASP.NET Core means per HTTP request. Every class in one request that asks for IBookRepository gets the same object; the next request gets a fresh one. This is the right default for anything touching a database, and it is why DbContext is scoped: one unit of work per request, disposed at the end.
Singleton: one instance for the lifetime of the application. Use for genuinely stateless services, caches, and expensive-to-create objects like configuration or an HttpClient factory.
The decision rule:
- Holds per-request state, or a database connection → Scoped
- Expensive to create and safe to share → Singleton
- Neither → Transient or Scoped; it rarely matters
And the rule that actually prevents bugs: a singleton must be thread-safe. One instance serves every concurrent request, so any mutable field in it falls straight under the rule from the threading post. Scoped and transient services are usually touched by one request at a time, which is why most application code never needs a lock.
The captive dependency
Here is the bug worth memorising:
builder.Services.AddSingleton<BookService>(); // singleton
builder.Services.AddScoped<IBookRepository, SqlBookRepository>(); // scoped
BookService lives forever. It is constructed once, at which point it is handed a repository, and that repository is captured for the lifetime of the singleton. The scoped registration is now a lie: every request shares one repository that was created during the first request, along with its DbContext, its change tracker, and its database connection.
The symptoms are horrible: stale data that never refreshes, DbContext threading exceptions under load, and objects from one user’s request appearing in another’s. Nothing in the code looks wrong.
The rule: a service may only depend on services with a lifetime at least as long as its own.
- Singleton → may only inject singletons
- Scoped → may inject scoped and singletons
- Transient → may inject anything
.NET’s container detects the scoped-into-singleton case at startup when scope validation is on, which it is by default in the Development environment. Do not turn it off, and make sure your CI runs in an environment where it is enabled.
When a singleton genuinely needs a scoped service (a background service reading the database, typically), create a scope explicitly:
public class RestockService : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
public RestockService(IServiceScopeFactory scopeFactory) => _scopeFactory = scopeFactory;
protected override async Task ExecuteAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
using var scope = _scopeFactory.CreateScope(); // a scope per iteration
var repo = scope.ServiceProvider.GetRequiredService<IBookRepository>();
await repo.RestockAsync(ct);
await Task.Delay(TimeSpan.FromMinutes(5), ct);
}
}
}
BackgroundService is a singleton, so this is the standard and correct pattern: one scope per unit of work, disposed at the end.
Disposal, and who owns what
The container calls Dispose() on any service it created that implements IDisposable, at the end of that service’s lifetime: end of request for scoped, application shutdown for singletons. That connects directly to the IDisposable post: you generally should not dispose an injected dependency yourself, because you do not own it.
One caveat: transient disposables resolved from a scope are held by that scope until it ends, so a transient IDisposable resolved repeatedly within a long-lived scope accumulates. It is a known leak shape; prefer scoped for disposables.
Things people get wrong
Service locator. Injecting IServiceProvider and resolving from it inside a class:
public class BookService
{
private readonly IServiceProvider _provider;
public void Purchase(int id)
{
var repo = _provider.GetRequiredService<IBookRepository>(); // anti-pattern
}
}
This undoes the benefit: the constructor no longer declares dependencies, so nobody can tell what the class needs, and tests must configure a whole container. Ask for what you need in the constructor. The exceptions are genuine infrastructure, factories, background services, middleware.
An interface for every class. As the interfaces post argued, a one-implementation IThing with no test seam is indirection with no payoff. It is fine to register a concrete class: AddScoped<BookService>() works, and BookService can be constructed directly in tests. Add the interface where you have a boundary worth faking.
Constructors that do work. A constructor should assign fields, nothing else. The container may create the object earlier than you expect, and constructors cannot be async. Do I/O in a method or in a hosted service’s startup.
Too many constructor parameters. Seven dependencies is the container telling you the class does seven things. That is a design signal from the SOLID post, not a reason to switch to a service locator.
The same idea inside Java, Angular and Go
Java (Netflix, everywhere). Spring is the origin of most modern DI thinking: @Autowired, @Component, @Service, and singleton/prototype/request scopes matching .NET’s lifetimes almost exactly. Spring resolves by scanning the classpath with reflection at startup, which is why large Spring applications start slowly, and why Micronaut and Quarkus rebuilt it with compile-time injection, the same source-generator direction .NET is moving in.
Angular (Google). DI is the framework’s backbone, with constructor injection and providers scoped to a module or component, which is Angular’s version of scoped versus singleton.
Go (Google). No container in the standard library and little enthusiasm for one. Dependencies are struct fields passed to constructor functions by hand, and Google’s wire tool generates that wiring at compile time rather than resolving it at runtime. It is a useful reminder that the container is optional: the pattern is what matters.
Rust. Also no DI framework in common use. Generics and traits provide substitution at compile time with no runtime resolution at all.
Python. FastAPI’s Depends() is a neat function-level take on the same idea, and it makes the connection to testing explicit: overriding a dependency is a one-line change in the test.
The through-line: everyone agreed on constructor injection; the disagreement is only about whether the wiring happens at runtime or at build time.
Summary
- Calling
newinside a class hard-codes an implementation, a configuration and a lifetime, and removes the seam tests need. - Dependency injection means a class declares its dependencies in its constructor instead of creating them. Inversion of control means the choice of implementation moves to the composition root.
- DI does not require a container; a container just automates constructing deep object graphs.
- Register in
Program.cswithAddSingleton,AddScopedorAddTransientonIServiceCollection. - Transient is per resolution, scoped is per HTTP request, singleton is per application.
- Singletons must be thread-safe; scoped is the right default for anything touching a database.
- A captive dependency, a scoped service injected into a singleton, silently makes that dependency a singleton too. Never depend on something shorter-lived than yourself; use
IServiceScopeFactorywhen a singleton needs scoped work. - The container disposes what it created; do not dispose injected dependencies yourself.
- Avoid the service locator pattern, avoid interfaces with no seam, and treat a long constructor as a design signal.
Interview questions and answers
1. What is dependency injection?
A pattern in which a class receives the objects it depends on from outside, usually through its constructor, rather than creating them itself. It makes dependencies explicit in the type’s signature and lets the caller decide which implementation and which lifetime to supply.
2. What is the difference between dependency injection and inversion of control?
Inversion of control is the general principle that a component does not decide which concrete collaborators it uses; that decision is made elsewhere. Dependency injection is one specific technique for achieving it, by supplying dependencies from the outside. A DI container is a tool that automates the supplying.
3. What are the three service lifetimes in .NET, and when do you use each?
Transient creates a new instance on every resolution, for lightweight stateless services. Scoped creates one per scope, which in ASP.NET Core is one per HTTP request, and is right for DbContext and repositories. Singleton creates one for the application’s lifetime, for caches, configuration and expensive stateless services, which must therefore be thread-safe.
4. What is a captive dependency?
A shorter-lived service injected into a longer-lived one, most commonly a scoped service captured by a singleton. The singleton holds that instance forever, so the scoped registration no longer means what it says: every request shares one DbContext, producing stale data and concurrency errors. .NET’s scope validation detects this at startup in Development.
5. How does a singleton use a scoped service correctly?
By injecting IServiceScopeFactory, creating a scope for each unit of work, resolving the scoped service from that scope’s provider, and disposing the scope when finished. This is the standard pattern inside a BackgroundService, which is itself a singleton.
6. Why is constructor injection preferred over property or method injection?
Because it makes the dependency mandatory and visible: the object cannot be constructed in an invalid state, the fields can be readonly, and the constructor signature documents exactly what the class needs. Property injection permits half-initialised objects and hides requirements from callers.
7. What is the service locator anti-pattern?
Injecting the container itself and resolving dependencies from it inside a class. It hides what the class actually depends on, so callers and tests cannot tell from the signature, and it turns a missing registration into a runtime failure deep in the code rather than a resolution error at construction.
8. Who disposes services created by the container?
The container. Services it constructed that implement IDisposable are disposed at the end of their lifetime, end of scope for scoped services, application shutdown for singletons. Consuming code should not dispose an injected dependency. Transient disposables resolved within a scope are held until that scope ends, which is why disposables are better registered as scoped.
9. Do you need an interface for every injected service?
No. You can register and inject a concrete class, and doing so is appropriate when there is only one implementation and no boundary to fake. Introduce an interface where you need substitution: multiple implementations, an external system to stub in tests, or a package boundary you cannot break.
10. What does it mean if a class has eight constructor dependencies?
That it almost certainly has too many responsibilities. The container is surfacing a single-responsibility problem rather than causing one. The fix is to split the class, or to group related collaborators behind a coherent higher-level abstraction, not to hide the dependencies behind a service locator.