Updated Jun 23, 2026

Dependency Injection

Here's the mental model, and it's the one thing that makes the rest of this phase click: you register services in one place, and the framework constructs them and hands them to whatever asks. You stop writing new ProductRepository() scattered through your code. Instead you say, once, "when something needs an IProductRepository, give it a ProductRepository," and the framework does the wiring.

That sentence — "you code against interfaces, not new" — is the whole idea. Everything below is the mechanics of making it happen.

📝 The container that does this wiring is built into ASP.NET Core. There's nothing to install, no third-party library to bolt on. The moment you call WebApplication.CreateBuilder(args), you already have a dependency-injection container sitting on builder.Services, waiting for you to register things.

Why bother — the problem DI solves

Imagine your products endpoint reaches straight for the data layer:

app.MapGet("/products", () =>
{
    var repo = new ProductRepository();   // hard-wired to one concrete class
    return repo.All();
});

What just happened: The endpoint creates its own repository with new. It works — but the endpoint is now welded to that exact class. To test it, you'd hit the real data store. To swap implementations (an in-memory one for tests, a cached one in production), you'd edit every place that calls new. The endpoint knows too much about how a repository gets built.

Dependency injection flips this. The endpoint declares what it needs (an IProductRepository) and lets the framework decide what to hand over and how to build it. The endpoint stops caring about construction entirely.

Step 1: program to an interface

DI works best when you depend on an interface, not a concrete class. So define the contract first, then an implementation:

public interface IProductRepository
{
    IEnumerable<Product> All();
    Product? Find(int id);
}

public class ProductRepository : IProductRepository
{
    private readonly List<Product> _products =
    [
        new(1, "Keyboard", 49.99m),
        new(2, "Mouse", 24.99m),
    ];

    public IEnumerable<Product> All() => _products;
    public Product? Find(int id) => _products.FirstOrDefault(p => p.Id == id);
}

public record Product(int Id, string Name, decimal Price);

What just happened: IProductRepository is the contract — the what. ProductRepository is one how. Your endpoints will depend only on the interface, so the concrete class becomes a swappable detail. (This is the in-memory list from earlier phases, now hidden behind an interface.)

Step 2: register the service

Now tell the container about the mapping. You do this on builder.Services, before builder.Build():

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddScoped<IProductRepository, ProductRepository>();

var app = builder.Build();

What just happened: You registered a rule: "whenever someone asks for IProductRepository, construct a ProductRepository and supply it." The two type arguments are the contract and the implementation, in that order. AddScoped is one of three lifetimes — and the lifetime is the next thing you have to understand, because it controls how often the framework builds a fresh instance.

Step 3: the three lifetimes

When you register a service, you're answering one question: how long should a single instance live before the framework throws it away and builds a new one? There are exactly three answers.

Method One instance per… Reach for it when…
AddSingleton the whole app the service is stateless or holds shared, long-lived state (config, an in-memory cache, a clock)
AddScoped one HTTP request the service should be fresh per request — the default for data access like a DbContext or repository
AddTransient every single resolution the service is lightweight and you want a brand-new one each time it's asked for
builder.Services.AddSingleton<IClock, SystemClock>();           // one forever
builder.Services.AddScoped<IProductRepository, ProductRepository>(); // one per request
builder.Services.AddTransient<IPriceFormatter, PriceFormatter>();    // one each time

What just happened: Three registrations, three lifecycles. The IClock is built once and shared by every request for the life of the app. The repository is built once per incoming HTTP request — two simultaneous requests get two separate repositories, but within a single request everyone shares the same one. The formatter is built fresh every time anything asks for it.

💡 When in doubt, AddScoped is the sensible default for application services and anything touching data. It gives each request its own clean instance and lets the framework dispose it when the request ends. Reach for Singleton only when you genuinely want shared state, and Transient for cheap, stateless helpers.

Step 4: consume the service

You registered it — now use it. In a minimal API, you don't fetch the service; you declare it as a handler parameter and the framework supplies it:

app.MapGet("/products", (IProductRepository repo) => repo.All());

app.MapGet("/products/{id:int}", (int id, IProductRepository repo) =>
    repo.Find(id) is { } product
        ? Results.Ok(product)
        : Results.NotFound());

What just happened: The handler asks for an IProductRepository in its parameter list. The framework looks at the route values (id comes from the URL), recognizes that IProductRepository is a registered service, builds one according to its lifetime, and passes it in. You never wrote new. The endpoint depends only on the interface — swap the registration and every handler quietly gets the new implementation.

In classes — controllers, your own services, background workers — injection happens through the constructor instead:

public class ProductService
{
    private readonly IProductRepository _repo;

    public ProductService(IProductRepository repo)   // framework supplies this
    {
        _repo = repo;
    }

    public decimal TotalCatalogValue() => _repo.All().Sum(p => p.Price);
}

What just happened: ProductService declares its dependency as a constructor parameter and stashes it in a readonly field. When something asks the container for a ProductService, the framework sees the constructor needs an IProductRepository, builds that too, and passes it in — a chain of construction you never have to manage by hand. Constructor injection is the idiomatic way to wire dependencies into classes.

💡 Most minimal-API handlers can tell your services from your bound data by their types. But if the framework can't tell whether a parameter is a service or something to bind from the request, mark it with [FromServices] to be explicit: app.MapGet("/total", ([FromServices] ProductService svc) => svc.TotalCatalogValue());.

The trap: captive dependencies

Now the pitfall that bites everyone eventually. Lifetimes have a rule: a service can safely depend on others of the same lifetime or longer-lived ones — but not shorter-lived ones.

The classic violation is injecting a Scoped service into a Singleton:

// ⚠️ DON'T do this
public class ProductCache
{
    private readonly IProductRepository _repo;   // Scoped...
    public ProductCache(IProductRepository repo) // ...captured by a Singleton
    {
        _repo = repo;
    }
}

builder.Services.AddSingleton<ProductCache>();   // lives for the whole app

What just happened: ProductCache is a singleton — built once, kept forever. But it grabs a ProductRepository, which is scoped — meant to live for one request and then be disposed. Because the singleton holds onto it, that one repository never gets released. It's been captured: it outlives its intended scope, and every request unknowingly shares the same stale, request-one instance. This is a captive dependency, and EF Core's DbContext — which is scoped — is the textbook casualty. A singleton clutching a DbContext is a bug that surfaces as bizarre, hard-to-reproduce data corruption under load.

⚠️ The rule of thumb: never inject something shorter-lived into something longer-lived. Scoped-into-singleton and transient-into-singleton are the dangerous pairs. The fix is usually to make the outer service scoped too, or — when a singleton genuinely needs per-request work — inject an IServiceScopeFactory and create a scope on demand. The built-in container even has a "scope validation" check that throws on these mistakes in development, so it often catches you before production does.

Why this is the backbone of testable code

Step back and notice what DI bought you. Your endpoints and services depend on IProductRepository, never on ProductRepository. In a test, you register a fake — an in-memory or stubbed implementation of the same interface — and every consumer transparently uses it, no production code changed. The framework also owns the lifecycle: it builds your services, hands them around, and disposes them at the right moment so you don't leak connections or handles.

That's the real payoff: decoupling (program to the contract, swap the implementation) plus managed lifetimes (the framework handles construction and disposal). Together they're what make ASP.NET Core code straightforward to test — which is exactly the muscle you'll flex in Phase 8: Testing & Production.

Recap

  • The model: register services in one place; the framework constructs them and supplies them to whatever asks. You code against interfaces, not new.
  • Register on builder.Services against an interface, e.g. AddScoped<IProductRepository, ProductRepository>() — contract first, implementation second.
  • Three lifetimes: AddSingleton (one per app), AddScoped (one per HTTP request — the default for data access like a DbContext), AddTransient (a new one every resolution).
  • Consume via handler parameters in minimal APIs and via the constructor in classes; use [FromServices] to disambiguate when needed.
  • Captive dependency: never inject a shorter-lived service into a longer-lived one — a Scoped (or Transient) thing captured by a Singleton outlives its scope and breaks under load.
  • Payoff: decoupling plus framework-managed lifetimes, which is what makes your code testable.

Quick check

[
  {
    "q": "Which lifetime gives you one instance per HTTP request and is the sensible default for a repository or DbContext?",
    "choices": ["AddSingleton", "AddScoped", "AddTransient", "AddRequest"],
    "answer": 1,
    "explain": "AddScoped builds one instance per HTTP request, shared within that request and disposed when it ends — ideal for data access like a DbContext."
  },
  {
    "q": "In a minimal API, how does a handler receive a registered service?",
    "choices": ["By calling new on the concrete class", "By declaring it as a handler parameter, which the framework supplies", "By reading it from a global static field", "By passing it in the route template"],
    "answer": 1,
    "explain": "You declare the service as a handler parameter (e.g. (IProductRepository repo)); the framework recognizes the registered type and injects it."
  },
  {
    "q": "Why is injecting a Scoped service into a Singleton a bug?",
    "choices": ["Singletons can't take constructor parameters", "The Scoped service gets captured and outlives its scope, so every request shares one stale instance", "Scoped services are slower than Singletons", "The container refuses to register any Singleton"],
    "answer": 1,
    "explain": "The singleton holds the scoped instance forever — a captive dependency. It never gets disposed and is shared across all requests, causing hard-to-reproduce bugs under load (classic with EF Core's DbContext)."
  }
]

← Phase 3: Model Binding & Validation · Guide overview · Phase 5: The Middleware Pipeline →

Check your understanding

1. Which lifetime gives you one instance per HTTP request and is the sensible default for a repository or DbContext?

2. In a minimal API, how does a handler receive a registered service?

3. Why is injecting a Scoped service into a Singleton a bug?

Was this page helpful?