Dependency Injection in .NET

The built-in DI container, constructor injection, and the real difference between Singleton, Scoped and Transient.

What dependency injection solves

Dependency injection (DI) means a class receives the objects it depends on from the outside (typically via its constructor) instead of creating them itself with new. This keeps classes decoupled from concrete implementations, and makes swapping a real implementation for a test double trivial.

C#
// Without DI — tightly coupled, hard to test
public class OrderService
{
    private readonly SqlOrderRepository _repository = new SqlOrderRepository();
}

// With DI — depends only on an abstraction, supplied from outside
public class OrderService
{
    private readonly IOrderRepository _repository;

    public OrderService(IOrderRepository repository)
    {
        _repository = repository;
    }
}

Unlike some ecosystems where DI is a third-party add-on (e.g., Spring in the Java world), .NET ships a built-in DI container as part of the BCL — Microsoft.Extensions.DependencyInjection. Every ASP.NET Core app uses it by default, and it's easy to use standalone in a console app too.

The three pieces: register, build, resolve

C#
using Microsoft.Extensions.DependencyInjection;

var services = new ServiceCollection();          // 1. register services
services.AddSingleton<IClock, SystemClock>();
services.AddScoped<IOrderRepository, SqlOrderRepository>();
services.AddTransient<OrderService>();

var provider = services.BuildServiceProvider();   // 2. build the container

var orderService = provider.GetRequiredService<OrderService>(); // 3. resolve
  • IServiceCollection is a registry you configure at startup — "when something asks for IOrderRepository, give it a SqlOrderRepository."
  • IServiceProvider (built from that collection) is the actual container you resolve instances from at runtime.
  • Resolution is recursive: asking the container for an OrderService automatically supplies whatever its constructor needs too, as long as those are also registered.

Constructor injection in practice

C#
public interface IClock
{
    DateTime UtcNow { get; }
}

public class SystemClock : IClock
{
    public DateTime UtcNow => DateTime.UtcNow;
}

public class OrderService
{
    private readonly IClock _clock;
    private readonly IOrderRepository _repository;

    // The container inspects this constructor and supplies both arguments
    public OrderService(IClock clock, IOrderRepository repository)
    {
        _clock = clock;
        _repository = repository;
    }

    public Order PlaceOrder(string product)
    {
        var order = new Order(product, _clock.UtcNow);
        _repository.Save(order);
        return order;
    }
}

Nothing inside OrderService ever calls new SystemClock() or new SqlOrderRepository() — it only knows about the interfaces. In tests, you construct it with fakes instead:

C#
var fakeClock = new FakeClock(new DateTime(2026, 1, 1));
var service = new OrderService(fakeClock, new InMemoryOrderRepository());

Service lifetimes: Singleton, Scoped, Transient

This is the single most commonly misunderstood — and most commonly interview-tested — part of .NET's DI system:

Lifetime Instance created Typical use
Singleton Once, for the whole application's lifetime. Same instance every time, for every caller. Stateless services, in-memory caches, configuration objects
Scoped Once per scope (in ASP.NET Core, one scope = one HTTP request). Same instance within that request, a new one for the next. DbContext, per-request state, anything tied to "one unit of work"
Transient Every single time it's requested — never reused. Lightweight, stateless, cheap-to-construct services

A concrete demonstration — register the same interface three different ways and observe the difference:

C#
public interface IOperationId
{
    Guid Id { get; }
}

public class OperationId : IOperationId
{
    public Guid Id { get; } = Guid.NewGuid(); // captured once, at construction
}

services.AddSingleton<IOperationId, OperationId>();
// Every resolution anywhere in the app returns the exact same Guid.

services.AddScoped<IOperationId, OperationId>();
// Same Guid within one scope; a different Guid in the next scope.

services.AddTransient<IOperationId, OperationId>();
// A brand-new Guid every single time it's resolved, even twice in the same scope.

In ASP.NET Core, this matters concretely for DbContext: it's registered Scoped by design (via AddDbContext) so every piece of code handling the same HTTP request shares one context (and one change-tracking session), while the next request gets a fresh one.

A common pitfall: captive dependencies

Injecting a Scoped or Transient service into a Singleton silently captures it for the singleton's entire lifetime, defeating its intended scope:

C#
// DANGEROUS: OrderService is Singleton but depends on a Scoped repository —
// that Scoped repository effectively becomes a singleton too, forever
// reused across every request, even though it was supposed to be per-request.
services.AddSingleton<OrderService>();
services.AddScoped<IOrderRepository, SqlOrderRepository>();

The built-in container detects and throws on this at startup when scope validation is enabled (the default in the ASP.NET Core "Development" environment) — a good reason not to disable that validation.

Common mistakes

  • Registering a DbContext-dependent service as Singleton — it silently pins a single database connection/context for the app's entire lifetime, causing data to go stale and thread-safety failures under load.
  • Forgetting that AddTransient means every resolution is a new instance — using it for something meant to hold shared, mutable state (like a cache) produces surprising bugs.
  • Manually calling new for a type that's registered in the container elsewhere in the same codebase, silently bypassing DI and losing the benefit of swappable implementations.

Interview questions

Q: What's the practical difference between AddScoped and AddTransient? AddScoped returns the same instance for every request within one scope (one HTTP request in ASP.NET Core) but a new instance for the next scope. AddTransient returns a brand-new instance literally every time it's resolved, even multiple times within the same scope.

Q: Why is DbContext registered as Scoped rather than Singleton? A DbContext isn't thread-safe and holds per-unit-of-work state (its change tracker). Scoped ties one instance to one HTTP request, so concurrent requests never share a context, while requests within the same one that resolve it multiple times get a consistent view.

Q: What happens if you inject a Scoped service into a Singleton? It becomes a "captive dependency" — the singleton holds onto that one scoped instance for its entire application lifetime instead of getting a fresh one per scope, which usually causes stale-data or thread-safety bugs. ASP.NET Core's dev-time scope validation throws an exception at startup to catch this early.