Disposal and Ownership
Know which objects the container disposes, when and in what order, and never dispose a service that was injected into you.
builder.Services.AddScoped<ReportWriter>(); // container disposes at end of request
builder.Services.AddSingleton(new ExchangeFeed()); // you own this one
await using var db = dbFactory.CreateDbContext(); // you created itThe rule is ownership: whoever creates an object disposes it. The container creates the services it resolves, so it disposes them; your code must not.
What the container disposes, and when
| Registration | Disposed by the container? | When |
|---|---|---|
| Scoped (type or factory) | Yes | When the scope ends (end of the HTTP request) |
| Transient resolved in a scope | Yes, if disposable | When that scope ends, not after use |
| Transient resolved from the root | Yes, if disposable | At app shutdown, so held in memory until then |
| Singleton (type or factory) | Yes | When the root provider is disposed at shutdown |
Singleton instance (AddSingleton(obj)) | No | Never: you created it, you own it |
Within a scope, services are disposed in reverse order of creation, so a service is disposed before the dependencies it was built with. If a service implements IAsyncDisposable, async disposal calls DisposeAsync; if it implements only IAsyncDisposable and the scope is disposed synchronously, the container throws. ASP.NET Core disposes request scopes asynchronously and the host disposes the root provider asynchronously, so services in requests and singletons are fine; your own CreateScope() is where this bites.
Never dispose an injected service
Writing using var db = injectedDb; or calling Dispose() on a constructor parameter disposes an object others in the same scope still use. The next repository call in the request throws ObjectDisposedException, and for singletons every later request fails until a restart. Injected means borrowed.
Factories and ownership
When code needs to control the lifetime of something disposable, inject a factory and own what it returns: IDbContextFactory<T>.CreateDbContext() or a connection factory that returns a new NpgsqlConnection. The caller wraps the result in await using. A factory registration (AddScoped(sp => new X())) is different: the container still owns X and disposes it. IHttpClientFactory.CreateClient is the documented exception: disposing the returned HttpClient is optional because the pooled handler is owned by the factory.
Production failure modes
- A disposable transient resolved from
app.Servicesin a loop (a timer, a singleton) is tracked by the root and kept until shutdown: steady memory growth that looks like a leak in a profiler as thousands of instances rooted by the service provider. - A singleton registered as an instance holding a socket or file handle is never disposed by the container. Prefer registering it with a factory so the container disposes it with the root provider; otherwise dispose it in
IHostApplicationLifetime.ApplicationStopped, which fires after requests are drained and hosted services have stopped (ApplicationStoppingis too early: in-flight work would hitObjectDisposedException). - Disposal that throws in
Disposehides the original exception from the request; makeDisposeidempotent and non-throwing.
Example
When to use it
- Stop an OrderCanceller from wrapping the injected ShopDb in using, which broke the stock update later in the same request
- Own a short-lived ShopDb in a singleton report job through IDbContextFactory and dispose it with await using
- Find a memory leak where a nightly export resolved a disposable transient from app.Services every minute
More examples
Disposing what you borrowed, and the fix
The broken version works in a unit test with a single call and fails in production when an endpoint calls two services that share the request's <code>ShopDb</code>. The rule is mechanical: a parameter you received is never disposed, while an object you created from <code>IDbContextFactory</code> is always disposed with <code>await using</code>.
using Microsoft.EntityFrameworkCore;
public sealed class Order
{
public Guid Id { get; set; }
public string Status { get; set; } = "Pending";
}
public sealed class ShopDb(DbContextOptions<ShopDb> options) : DbContext(options)
{
public DbSet<Order> Orders => Set<Order>();
}
// Wrong: disposes the request's shared DbContext. The next repository in the
// same request throws ObjectDisposedException.
public sealed class BrokenOrderCanceller(ShopDb db)
{
public async Task CancelAsync(Guid id, CancellationToken ct)
{
using (db)
{
var order = await db.Orders.SingleAsync(o => o.Id == id, ct);
order.Status = "Cancelled";
await db.SaveChangesAsync(ct);
}
}
}
// Right: use the injected context and let the scope dispose it.
public sealed class OrderCanceller(ShopDb db)
{
public async Task CancelAsync(Guid id, CancellationToken ct)
{
var order = await db.Orders.SingleAsync(o => o.Id == id, ct);
order.Status = "Cancelled";
await db.SaveChangesAsync(ct);
}
}
// Also right: own a context you create from a factory (for example in a singleton).
public sealed class OrderArchiver(IDbContextFactory<ShopDb> dbFactory)
{
public async Task<int> ArchiveCancelledAsync(CancellationToken ct)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
return await db.Orders.Where(o => o.Status == "Cancelled")
.ExecuteUpdateAsync(s => s.SetProperty(o => o.Status, "Archived"), ct);
}
}A connection factory: the caller owns the connection
<code>NpgsqlDataSource</code> is a singleton the container owns and disposes at shutdown, closing the pool. Each connection opened from it is owned by the caller and must be disposed promptly; forgetting that keeps the physical connection checked out until the garbage collector finalises it, and the pool runs dry under load with timeouts waiting for a connection.
using Npgsql;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton(_ => NpgsqlDataSource.Create(builder.Configuration.GetConnectionString("Shop")!));
builder.Services.AddScoped<InvoiceQueries>();
var app = builder.Build();
app.MapGet("/invoices/{number}/total", async (string number, InvoiceQueries queries, CancellationToken ct) =>
await queries.TotalAsync(number, ct) is decimal total ? Results.Ok(total) : Results.NotFound());
app.Run();
public sealed class InvoiceQueries(NpgsqlDataSource dataSource)
{
public async Task<decimal?> TotalAsync(string number, CancellationToken ct)
{
// OpenConnectionAsync creates a connection we own: dispose it to return it to the pool.
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var command = new NpgsqlCommand("select total from invoices where number = $1", connection);
command.Parameters.AddWithValue(number);
return await command.ExecuteScalarAsync(ct) as decimal?;
}
}Root-scope transient leak and the fix
The container must remember every disposable it creates so it can dispose it later, which means a disposable transient resolved from the root provider is referenced until the app stops. At one export per minute with a 1 MB buffer that is over 1.4 GB per day. Resolving through a short-lived scope ends the reference with the run.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddTransient<CsvExporter>();
builder.Services.AddHostedService<NightlyExport>();
builder.Build().Run();
public sealed class CsvExporter : IDisposable
{
private readonly MemoryStream _buffer = new(capacity: 1_048_576);
public void Export(string table) => _buffer.SetLength(0);
public void Dispose() => _buffer.Dispose();
}
public sealed class NightlyExport(IServiceProvider root, IServiceScopeFactory scopes) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(1));
while (await timer.WaitForNextTickAsync(stoppingToken))
{
// Leak: each exporter (and its 1 MB buffer) is tracked by the root until shutdown.
// root.GetRequiredService<CsvExporter>().Export("orders");
// Fix: a scope per run releases the exporter when the run ends.
await using var scope = scopes.CreateAsyncScope();
scope.ServiceProvider.GetRequiredService<CsvExporter>().Export("orders");
}
GC.KeepAlive(root);
}
}
Discussion