.NET DI Architecture

Dependency Injection in ASP.NET Core - the practical guide

Scoped vs Transient vs Singleton explained with a real bug each one causes, plus the composition-root pattern I use in every .NET project.

By admin Apr 16, 2026 1

Why DI matters

Dependency Injection is not about interfaces or "unit testing" - it is about keeping the wiring of your application in exactly one place. When a new controller needs a database, an email sender, and a clock, it asks for them in its constructor. It does not know where they come from. That is the whole trick.

The three lifetimes

ASP.NET Core ships with three lifetimes. Pick the wrong one and the bugs are subtle.
csharp
// one instance per HTTP request - the default for DbContext, services
services.AddScoped<IBlogService, BlogService>();

// a new instance every time someone asks - cheap, stateless helpers
services.AddTransient<IEmailSender, SmtpEmailSender>();

// one instance for the whole process - config, caches, expensive clients
services.AddSingleton<IClock, SystemClock>();

The bug each one causes

* Scoped injected into a Singleton → "Cannot consume scoped service from singleton". ASP.NET catches this at startup if you enable scope validation.\n* Singleton that holds a DbContext → cross-request state leaks, threading errors, "The instance of entity type cannot be tracked" explosions.\n* Transient HttpClient → socket exhaustion. Use IHttpClientFactory instead.
Rule of thumb: start with Scoped. Move to Singleton only when you have evidence the object is expensive to build AND provably thread-safe. Transient is for pure helpers.

The composition root pattern

Do not scatter AddScoped calls across Program.cs. Put them in one extension method per layer - the "composition root". Your Web project calls a single AddInfrastructure() and AddApplication() and is done.
csharp
// Portfolio.Infrastructure/InfrastructureServiceCollectionExtensions.cs
public static IServiceCollection AddInfrastructure(
    this IServiceCollection services, IConfiguration config)
{
    services.AddDbContext<PortfolioDbContext>(o =>
        o.UseSqlServer(config.GetConnectionString("DefaultConnection")));

    services.AddScoped<IBlogRepo, BlogRepo>();
    services.AddScoped<IProjectRepo, ProjectRepo>();
    // ... every other repo lives here
    return services;
}
csharp
// Program.cs - the whole startup stays short
builder.Services
    .AddInfrastructure(builder.Configuration)
    .AddApplication();

builder.Services.AddControllersWithViews();

var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllerRoute("default", "{controller=Home}/{action=Index}/{id?}");
app.Run();

Constructor injection wins

Prefer constructors over service locators. Your class's public API makes every dependency visible at the top of the file - no surprise calls to serviceProvider.GetRequiredService() buried three layers deep.
csharp
public class BlogController(IBlogService blog, IWebHostEnvironment env) : BaseController
{
    private readonly IBlogService _blog = blog;
    private readonly IWebHostEnvironment _env = env;

    public async Task<IActionResult> Index() =>
        View(await _blog.GetAllAsync(onlyPublished: true));
}

Takeaway

Use Scoped by default, keep wiring in a composition root, and stick to constructor injection. That is 90% of the DI story in ASP.NET Core.