Disposal and Resource Lifetime
The ownership rule
Whoever creates a disposable disposes it; whoever receives one does not. Every review question about disposal reduces to "who owns this instance?"
- Created in a method:
using/await using, no exceptions. - Created in a constructor and stored in a field: the class owns it, so the class implements
IDisposableand disposes the field. - Injected via DI: the container owns it. Disposing an injected
DbContextor typedHttpClientbreaks the next consumer of the same scoped instance. A class whose only disposables are injected does not implementIDisposableat all.
// non-compiling: illustrative
// WRONG: disposing what DI owns; second service in the scope gets a disposed context
public sealed class OrderService : IDisposable
{
private readonly AppDbContext _db;
public OrderService(AppDbContext db) => _db = db;
public void Dispose() => _db.Dispose();
}
// RIGHT: no IDisposable; the scope disposes the context
public sealed class OrderService
{
private readonly AppDbContext _db;
public OrderService(AppDbContext db) => _db = db;
}
What must never be wrapped in using
HttpClientfromIHttpClientFactory: disposal is a no-op on the handler you care about, butusingdocuments a false ownership. Sockets are managed by the factory's handler pool;new HttpClient()per call plususingis the classic socket-exhaustion bug (TIME_WAIT pileup under load).CancellationTokenSourcestill referenced by registered callbacks or linked sources elsewhere - dispose it, but only after nothing can use it; a fired timer callback touching a disposed CTS throwsObjectDisposedExceptionintermittently.- Streams passed into a serializer/reader with
leaveOpensemantics: check the flag.new StreamReader(stream)disposes the underlying stream by default; when the caller still needs it, passleaveOpen: true.
IAsyncDisposable
Anything holding resources whose cleanup does I/O (DbContext, transactions, System.Threading.Timer with in-flight callbacks, streams over network) prefers IAsyncDisposable:
await using var transaction = await _db.Database.BeginTransactionAsync(ct);
Rules:
- A type implementing
IAsyncDisposableshould also implementIDisposable(sync fallback) unless synchronous cleanup is impossible - non-DI callers and containers checking only one interface both exist. Dispose()calling.GetAwaiter().GetResult()onDisposeAsync()is sync-over-async in disguise; implement real sync cleanup or document that sync dispose is unsupported.await usingon a type that only hasIDisposabledoes not compile - do not "fix" it by fake-async wrappers.
Implementing IDisposable
For a sealed class holding only managed disposables - the 95% case - the full pattern is overkill:
public sealed class MeterRegistry : IDisposable
{
private readonly Meter _meter = new("app");
private bool _disposed;
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_meter.Dispose();
}
}
Disposemust be idempotent and never throw.- The full
Dispose(bool disposing)+ finalizer pattern is only for classes directly owning unmanaged handles - and those should beSafeHandleinstead, which eliminates the finalizer entirely. A finalizer on a class holding only managed fields is a rejection: it costs a GC generation for every instance and its "cleanup" touches fields that may already be finalized. - Fields set to null in Dispose "to help the GC": noise, remove.
Review flags
- Disposable created and returned from a method: the method name must convey transfer (
Create,Open), and the caller mustusingit. A factory whose callers forget disposal shows up as connection-pool exhaustion at load, not in tests. - Disposable stored in a static or singleton but recreated per operation - the previous instance leaks.
Timer,FileSystemWatcher, event subscriptions: recreate implies dispose-old-first. using varscoping:using var stream = ...at the top of a long method holds the file handle for the whole method. Tighten with a block when the resource is only needed briefly.- Iterator methods (
yield return) and async methods:usinginside them disposes only when enumeration/awaiting completes - an abandoned, half-enumerated iterator defers disposal to GC time. For handles that must close deterministically, materialize instead of yielding.