Inside ASP.NET Core’s DI Container

Sagar HSSagar HS
5 min read

By removing a client’s knowledge of how its dependencies are implemented, programs become more reusable, testable and maintainable.


TL;DR

  • Transient: Brand-new every resolve (ideal for stateless or disposable helpers)

  • Scoped: One instance per logical operation (in web apps, per HTTP request)

  • Singleton: One instance for the app’s entire lifetime (shared caches, HTTP clients, configuration readers)

Imagine deploying a new feature only to watch your app’s memory creep upward until it finally crashes. Hours of debugging later, you discover a singleton service has captured a scoped dependency and that scope never ends. DI lifetimes in .NET exist to prevent exactly these kinds of issues, but they can also introduce leaks, performance bottlenecks or subtle race conditions if misused.

🆕 Transient: “Fresh Every Time”

When to use

  • Very lightweight, stateless helpers

  • Per-operation builders or formatters

  • Short-lived, disposable objects you want automatically cleaned up

Email Templating

You have an IEmailTemplateRenderer that builds a personalised HTML email. Each send should get its own fresh instance (and dispose of it when done).

// Contract
public interface IEmailTemplateRenderer : IDisposable
{
    string Render<TModel>(string templateName, TModel model);
}

// Implementation
public class RazorEmailTemplateRenderer : IEmailTemplateRenderer
{
    private readonly IRazorLightEngine _engine;
    public RazorEmailTemplateRenderer(IRazorLightEngine engine) 
        => _engine = engine;

    public string Render<TModel>(string templateName, TModel model)
        => _engine.CompileRenderAsync(templateName, model).Result;

    public void Dispose()
    {
        // Clean up any temp files, etc.
    }
}

// Registration in Program.cs
builder.Services
    .AddTransient<IRazorLightEngine>(sp =>
        new RazorLightEngineBuilder()
            .UseFileSystemProject("EmailTemplates")
            .UseMemoryCachingProvider()
            .Build())
    .AddTransient<IEmailTemplateRenderer, RazorEmailTemplateRenderer>();
  • Every time you inject IEmailTemplateRenderer, the container runs its factory delegate, spinning up a brand-new RazorLightEngine and RazorEmailTemplateRenderer.

  • Because it implements IDisposable, .NET will track it in the request-scope and call Dispose() at the end (even though it’s transient).

  • Pitfall: if you fire off multiple email sends within the same request, you’ll get a separate engine instance each time (higher memory/CPU). Consider caching the engine itself as a singleton and rendering transient models.


🔄 Scoped: “One Per Operation/Request”

When to use

  • Unit-of-Work patterns (e.g. EF Core DbContext)

  • Per-request state (caches, security context)

  • Services that aggregate multiple repositories

EF Core + Repository

You want exactly one DbContext per HTTP request, so all queries/commands participate in the same transaction.

// Your EF Core context
public class AppDbContext : DbContext
{
    public DbSet<User> Users { get; set; }
    // ... ctor/config omitted
}

// A simple repository
public interface IUserRepository
{
    Task<User?> FindByEmailAsync(string email);
    Task AddAsync(User user);
}

public class UserRepository : IUserRepository
{
    private readonly AppDbContext _db;
    public UserRepository(AppDbContext db) => _db = db;

    public Task<User?> FindByEmailAsync(string email)
        => _db.Users.SingleOrDefaultAsync(u => u.Email == email);

    public async Task AddAsync(User user)
    {
        _db.Users.Add(user);
        await _db.SaveChangesAsync();
    }
}

// Registration in Program.cs
builder.Services
    .AddDbContext<AppDbContext>(options =>
        options.UseSqlServer(configuration.GetConnectionString("Default")))
    .AddScoped<IUserRepository, UserRepository>();

And in a controller:

[ApiController]
[Route("api/users")]
public class UsersController : ControllerBase
{
    private readonly IUserRepository _users;
    public UsersController(IUserRepository users) => _users = users;

    [HttpPost]
    public async Task<IActionResult> Register(RegisterDto dto)
    {
        var exists = await _users.FindByEmailAsync(dto.Email);
        if (exists != null) return Conflict("Email in use");
        await _users.AddAsync(new User { /*…*/ });
        return CreatedAtAction(nameof(Register), /*…*/);
    }
}
  • .NET creates a new IServiceScope at the start of each HTTP request.

  • Any Scoped or Transient service resolved within the request is tied to that scope.

  • At request end, disposing the scope calls Dispose() on all resolved IDisposable services.

  • Pitfall: injecting a scoped service into a singleton captures the root scope, never disposing it (memory leak).


🔒 Singleton: “One For Whole App”

When to use

  • Cross-request caches, configuration readers

  • Thread-safe, immutable clients or heavy-weight factories

  • Anything you want built once and available forever

HTTP API Client with Polly Policies

You call an external weather API. You want one instance of your client (with its HttpClient + resilience policies) shared across all requests.

public interface IWeatherClient
{
    Task<WeatherForecast> GetForecastAsync(string city);
}

public class WeatherClient : IWeatherClient
{
    private readonly HttpClient _http;
    public WeatherClient(HttpClient http) => _http = http;

    public async Task<WeatherForecast> GetForecastAsync(string city)
    {
        var res = await _http.GetAsync($"/forecast?city={city}");
        res.EnsureSuccessStatusCode();
        return await res.Content.ReadFromJsonAsync<WeatherForecast>()!;
    }
}

// Registration in Program.cs
builder.Services.AddHttpClient<IWeatherClient, WeatherClient>(client =>
{
    client.BaseAddress = new Uri("https://api.weather.com/");
})
// Configure Polly retry & circuit-breaker once at startup
.AddPolicyHandler(Policy<HttpResponseMessage>
    .Handle<HttpRequestException>()
    .RetryAsync(3))
.AddPolicyHandler(Policy<HttpResponseMessage>
    .Handle<HttpRequestException>()
    .CircuitBreakerAsync(2, TimeSpan.FromMinutes(1)));

// The IHttpClientFactory is itself a singleton; each typed client uses its
// underlying handler pool across the app’s lifetime.

Deep dive

  • AddHttpClient<…>() under the hood registers a singleton factory that hands out HttpMessageHandler pools.

  • Your typed WeatherClient is created once per injection by default (because typed clients are effectively transient), but share the same long-lived handlers.

  • If you explicitly do .AddSingleton<IWeatherClient, WeatherClient>(), then one WeatherClient is built at container start and never re-constructed.

  • Pitfall: singletons live until app shutdown. Any captured scoped service or HttpContext will never be released.


🔍 Putting It All Together

  • Transient → new instance every resolve (good for stateless builders or per-call disposables)

  • Scoped → one instance per logical operation/request (good for EF Core, UoW patterns)

  • Singleton → one instance for entire app lifetime (good for shared caches, config, thread-safe clients)

Common Gotchas

  1. Captive Dependency

     // DON’T do this:
     builder.Services.AddSingleton<MySingleton>(sp => 
       new MySingleton(sp.GetRequiredService<IRepository>()));
    

    The IRepository is scoped but you resolved it at root, so its scope never ends.

  2. IDisposable Tracking

    • Transients and Scoped services implementing IDisposable are auto-disposed by the scope/container.

    • Singletons are only disposed when the root provider is disposed (app shutdown).

  3. Performance

    • Transient: pay the cost on every resolve.

    • Scoped: minimal cost after first resolve in a scope.

    • Singleton: zero cost after startup.

By matching each real-world component to the proper lifetime and being mindful of how .NET container builds, scopes, and disposes instances you’ll avoid leaks, race conditions, and unexpected behaviour in your applications.

0
Subscribe to my newsletter

Read articles from Sagar HS directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Sagar HS
Sagar HS

Software engineer with 4 + years delivering high-performance .NET APIs, polished React front-ends, and hands-off CI/CD pipelines. Hackathon quests include AgroCropProtocol, a crop-insurance DApp recognised with a World coin pool prize and ZK Memory Organ, a zk-SNARK privacy prototype highlighted by Torus at ETH-Oxford. Recent experiments like Fracture Log keep me exploring AI observability.