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.
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.
// 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
// 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
AppDbContextdirectly 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:IServiceScopeFactoryto create a fresh scope per notification.
Keyed Services (.NET 8+)
// 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
// 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
// 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
// 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
// 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. UseIServiceScopeFactoryin singletons that need scoped services. Register handlers, repositories, and services in extension methods per layer to keepProgram.csclean. EnableValidateOnBuildto catch missing registrations at startup, not at runtime.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.