Learnixo
Back to blog
AI Systemsintermediate

Dependency Injection in Minimal APIs — Services, Scopes, and Lifetime

How DI works in Minimal API endpoints: service injection, parameter binding order, keyed services, service lifetimes, and patterns for organizing DI in large Minimal API projects.

Asma Hafeez KhanMay 16, 20264 min read
Minimal APIsDependency InjectionASP.NET Core.NETDI
Share:𝕏

DI in Minimal API Endpoints

Minimal API handlers are delegates. ASP.NET Core resolves services from DI and injects them alongside route/query parameters automatically. The framework distinguishes services from HTTP-bound parameters by type.

C#
// ASP.NET Core resolves these in order:
// 1. Route parameters (from URL template)
// 2. Query string parameters (from ?key=value)
// 3. Body (from [FromBody] or JSON content type)
// 4. Services (from DI container — everything else)

app.MapGet("/patients/{id}", async (
    Guid               id,          // route parameter
    CancellationToken  ct,          // special — from HttpContext
    AppDbContext       db,          // from DI
    ILogger<Program>   logger,      // from DI
    ICurrentUser       currentUser) // from DI
=> {
    logger.LogInformation("Loading patient {Id}", id);
    var patient = await db.Patients.FindAsync(id, ct);
    return patient is null ? Results.NotFound() : Results.Ok(patient);
});

Service Lifetimes

Singleton:    one instance for the entire application lifetime
  Register:   services.AddSingleton()
  Use for:    stateless services, caches, configuration, connection pools

Scoped:       one instance per HTTP request (one scope per request)
  Register:   services.AddScoped()
  Use for:    DbContext, unit of work, per-request state

Transient:    new instance every time it is requested
  Register:   services.AddTransient()
  Use for:    lightweight stateless services, validators

Captive Dependency Anti-Pattern

C#
// WRONG: Singleton holding a reference to a Scoped service
builder.Services.AddSingleton<MyEmailService>();  // singleton
// MyEmailService injects IDbContext (scoped) in its constructor
// → DbContext lifetime is captured in the singleton's lifetime
// → DbContext is reused across requests → data leaks between users

// Fix: use IServiceScopeFactory to create a fresh scope when needed
public sealed class MyEmailService
{
    private readonly IServiceScopeFactory _factory;

    public MyEmailService(IServiceScopeFactory factory) => _factory = factory;

    public async Task SendAsync(string email, CancellationToken ct)
    {
        await using var scope = _factory.CreateAsyncScope();
        var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
        // db is scoped to this operation — not the singleton's lifetime
    }
}

Production issue I've seen: A singleton email notification service injected AppDbContext directly in its constructor. The context was created once when the singleton was first built and reused for every notification — across all users and requests. After 100 requests, the change tracker had accumulated every entity ever touched by any user. Notifications started including wrong patient data from previous requests. The fix: IServiceScopeFactory to create a fresh scope per notification.


Keyed Services (.NET 8+)

C#
// Register multiple implementations of the same interface with a key
builder.Services.AddKeyedScoped<INotificationService, EmailNotification>("email");
builder.Services.AddKeyedScoped<INotificationService, SmsNotification>("sms");
builder.Services.AddKeyedScoped<INotificationService, PushNotification>("push");

// Inject by key in a Minimal API endpoint
app.MapPost("/notifications/send", async (
    [FromKeyedServices("email")] INotificationService emailService,
    [FromKeyedServices("sms")]   INotificationService smsService,
    SendNotificationRequest req,
    CancellationToken ct) =>
{
    if (req.Channels.Contains("email"))
        await emailService.SendAsync(req, ct);
    if (req.Channels.Contains("sms"))
        await smsService.SendAsync(req, ct);

    return Results.Accepted();
});

Organizing Service Registration

C#
// Extension method per layer — keeps Program.cs clean
// Application/ServiceRegistration.cs
public static class ApplicationServiceRegistration
{
    public static IServiceCollection AddApplication(this IServiceCollection services)
    {
        services.AddScoped<CreatePatientHandler>();
        services.AddScoped<GetPatientHandler>();
        services.AddScoped<AddPrescriptionHandler>();
        // ... all handlers
        return services;
    }
}

// Infrastructure/ServiceRegistration.cs
public static class InfrastructureServiceRegistration
{
    public static IServiceCollection AddInfrastructure(
        this IServiceCollection services, IConfiguration config)
    {
        services.AddDbContext<AppDbContext>(options =>
            options.UseSqlServer(config.GetConnectionString("Default")));

        services.AddScoped<IPatientRepository, PatientRepository>();
        services.AddScoped<IUnitOfWork, UnitOfWork>();

        services.AddStackExchangeRedisCache(options =>
            options.Configuration = config.GetConnectionString("Redis"));

        return services;
    }
}

// Program.cs — clean and readable
builder.Services.AddApplication();
builder.Services.AddInfrastructure(builder.Configuration);

HttpContext and Special Parameters

C#
// These are automatically resolved without DI registration:
app.MapGet("/info", (
    HttpContext ctx,        // full HttpContext
    HttpRequest req,        // request specifically
    HttpResponse res,       // response specifically
    ClaimsPrincipal user,   // authenticated user claims
    CancellationToken ct)   // request cancellation token
=> {
    var userId = user.FindFirstValue(JwtRegisteredClaimNames.Sub);
    return Results.Ok(new { userId, path = req.Path.Value });
});

Dependency Validation

C#
// Validate DI at startup — catches missing registrations before first request
builder.Services.BuildServiceProvider(
    validateScopes: true,           // catches captive dependencies
    validateOnBuild: true);         // validates all registrations at startup
// OR
builder.Host.UseDefaultServiceProvider(options =>
{
    options.ValidateScopes  = true;
    options.ValidateOnBuild = true;
});

Injecting Configuration

C#
// Strongly-typed configuration
builder.Services.AddOptions<JwtOptions>()
    .BindConfiguration("Jwt")
    .ValidateDataAnnotations()
    .ValidateOnStart();  // validate at startup, not on first use

// Inject in endpoint
app.MapGet("/config-check", (IOptions<JwtOptions> opts) =>
    Results.Ok(new { opts.Value.Issuer, opts.Value.Audience }));

Red Flag / Green Answer

Red Flag: "Our Minimal API endpoints directly call HttpContext.RequestServices.GetService<T>() to resolve services."

Service locator anti-pattern. Hides dependencies, makes testing impossible (you cannot inject substitutes), and bypasses DI lifetime management. ASP.NET Core resolves services through constructor injection or parameter injection automatically.

Green Answer:

Declare services as parameters in the handler delegate. ASP.NET Core injects them automatically. Test by passing substitutes directly.


Key Takeaway

Minimal API endpoints receive services as delegate parameters — no [FromServices] needed. The framework distinguishes services from route/query/body parameters by type. Use IServiceScopeFactory in singletons that need scoped services. Register handlers, repositories, and services in extension methods per layer to keep Program.cs clean. Enable ValidateOnBuild to catch missing registrations at startup, not at runtime.

Enjoyed this article?

Explore the AI Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.