Inside ASP.NET Core’s DI Container

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-newRazorLightEngine
andRazorEmailTemplateRenderer
.Because it implements
IDisposable
, .NET will track it in the request-scope and callDispose()
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
orTransient
service resolved within the request is tied to that scope.At request end, disposing the scope calls
Dispose()
on all resolvedIDisposable
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 outHttpMessageHandler
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 oneWeatherClient
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
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.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).
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.
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.