The dispose pattern, sealed simple Dispose, idempotency, ObjectDisposedException, ownership rules, DI container disposal and analyser support.
Implement IDisposable when your type owns something that must be released deterministically: an unmanaged resource (usually via SafeHandle) or another IDisposable. For a sealed class, a simple Dispose() that disposes owned fields is enough. For unsealed classes use the pattern Dispose() calling protected virtual Dispose(bool disposing) and GC.SuppressFinalize(this). Make Dispose idempotent and non-throwing, and throw ObjectDisposedException (ObjectDisposedException.ThrowIf) from members used after disposal. The owner disposes: whoever creates an object disposes it, unless ownership is explicitly transferred. The DI container disposes what it creates, at the end of the scope or the app.
"I implement
IDisposableonly when a type owns something that must be released deterministically: a native resource, ideally wrapped in aSafeHandle, or anotherIDisposableit created. For a sealed class a simple, idempotentDispose()that disposes owned fields is enough: no finaliser, noDispose(bool). For an unsealed class I use the standard pattern,Dispose()callingprotected virtual Dispose(bool disposing)plusGC.SuppressFinalize(this), so derived classes can add cleanup.Disposemust be safe to call twice and shouldn't throw; members used afterwards throwObjectDisposedException. On responsibility: the owner disposes, which by default means whoever created the object, unless ownership is transferred explicitly. In DI, the container owns and disposes what it creates, and the classic leak is resolving transient disposables from the root provider."
IDisposable| Your type holds | Implement IDisposable? |
Notes |
|---|---|---|
A SafeHandle / native resource it created |
Yes | Dispose the handle |
A disposable field it created (DbConnection, Stream, Timer, CancellationTokenSource) |
Yes | It owns it |
| A disposable passed in by the caller or DI | Usually no | Caller/container owns it |
| Event subscriptions to long-lived publishers | Often yes | Unsubscribe in Dispose to avoid leaks |
| Only managed memory | No | The GC handles it |
public sealed class ReportWriter : IDisposable
{
private readonly StreamWriter _writer;
private bool _disposed;
public ReportWriter(string path) => _writer = new StreamWriter(path);
public void Write(string line)
{
ObjectDisposedException.ThrowIf(_disposed, this);
_writer.WriteLine(line);
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_writer.Dispose();
}
}
| Member | Responsibility |
|---|---|
public void Dispose() |
Call Dispose(true) then GC.SuppressFinalize(this); not virtual |
protected virtual void Dispose(bool disposing) |
If disposing, dispose managed resources; always release unmanaged ones; set the flag |
| Derived override | Clean up its own resources, then call base.Dispose(disposing) (in finally) |
| Finaliser | Only if this class directly owns an unmanaged resource (rare; prefer SafeHandle) |
GC.SuppressFinalize is called even without a finaliser so a derived class that adds one is handled correctly (CA1816).
| Rule | Why |
|---|---|
| Idempotent | using plus a manual call, or two owners by mistake, must not crash |
Don't throw from Dispose |
It would mask the original exception from a using block |
ObjectDisposedException after disposal |
Fail fast instead of operating on released resources (ObjectDisposedException.ThrowIf, .NET 7+) |
| Thread safety when needed | Interlocked.Exchange on the flag if Dispose can race with other calls |
| Constructor failure cleanup | If the constructor created disposables and then throws, dispose them before rethrowing: no one else can |
| Situation | Owner |
|---|---|
You new it |
You |
A factory method returns a new disposable (File.OpenRead) |
The caller |
| You pass it to a constructor documented as "takes ownership" | The receiving object |
leaveOpen: true style parameter |
Caller keeps ownership |
| DI creates it | The container (scope or root) |
DI registered an existing instance (AddSingleton(instance)) |
You: the container won't dispose it |
| Lifetime | Disposed when |
|---|---|
| Scoped | The scope is disposed (end of HTTP request, or your CreateAsyncScope) |
| Singleton (container-created) | The root provider is disposed (app shutdown) |
| Transient resolved from a scope | That scope is disposed |
| Transient resolved from the root | App shutdown: tracked until then → leak |
| Analyser | Catches |
|---|---|
| CA2000 | Disposable created and not disposed on all paths |
| CA2213 | Disposable fields not disposed |
| CA1816 | Dispose should call GC.SuppressFinalize |
| CA1063 | Incorrect IDisposable implementation |
ValidateScopes / ValidateOnBuild (DI) |
Scoped services resolved from root, invalid graphs |
Our [reporting background service] resolved a transient ReportGenerator (which opened a SqlConnection) from the injected root IServiceProvider once a minute. Over several days memory and open connections climbed until pods were restarted. A gcdump showed thousands of ReportGenerator instances rooted from the root provider's list of disposables to dispose at shutdown. I changed the job to create a scope per run, await using var scope = provider.CreateAsyncScope(), and resolve the generator from it. Memory and connection counts went flat, and I added a code review checklist item plus ValidateScopes in development to catch similar mistakes.
The interviewer checks correct resource management, a very common source of production bugs.
Green flags: when to implement, sealed versus full pattern, idempotency, ObjectDisposedException.ThrowIf, SuppressFinalize, ownership rules, DI disposal behaviour and root-provider leak, analysers.
Red flags: adds finalisers everywhere; disposes injected dependencies; unaware of the root-provider transient leak.
using compiles tousing (var w = new ReportWriter(path)) { w.Write("x"); }
is lowered to:
var w = new ReportWriter(path);
try { w.Write("x"); }
finally { if (w != null) ((IDisposable)w).Dispose(); }
For a struct disposable the compiler calls Dispose without boxing (constrained call). Pattern-based disposal also works for ref struct types with a public Dispose() method.
Dispose(bool) existsTwo callers, two contracts:
| Caller | disposing |
Safe to touch other managed objects? |
|---|---|---|
Dispose() from user code |
true |
Yes |
| Finaliser | false |
No: they may already be finalised |
With SafeHandle, you almost never write a finaliser, so sealed classes don't need the bool at all.
public class Repository : IDisposable
{
private readonly DbConnection _connection;
private int _disposed;
public Repository(DbConnection connection) => _connection = connection; // owns by contract
public void Dispose()
{
Dispose(disposing: true);
GC.SuppressFinalize(this);
}
protected virtual void Dispose(bool disposing)
{
if (Interlocked.Exchange(ref _disposed, 1) == 1) return;
if (disposing) _connection.Dispose();
}
}
public sealed class CachingRepository : Repository
{
private readonly MemoryCache _cache = new(new MemoryCacheOptions());
private bool _disposed;
public CachingRepository(DbConnection c) : base(c) { }
protected override void Dispose(bool disposing)
{
if (_disposed) return;
_disposed = true;
try { if (disposing) _cache.Dispose(); }
finally { base.Dispose(disposing); } // base cleanup always runs
}
}
public sealed class Exporter : IDisposable
{
private readonly FileStream _file;
private readonly GZipStream _zip;
public Exporter(string path)
{
_file = File.Create(path);
try
{
_zip = new GZipStream(_file, CompressionLevel.Fastest, leaveOpen: true);
}
catch
{
_file.Dispose(); // nobody else can dispose it: the object was never returned
throw;
}
}
public void Dispose()
{
_zip.Dispose();
_file.Dispose();
}
}
Microsoft.Extensions.DependencyInjection keeps a list of disposables per scope (and one for the root). Every IDisposable/IAsyncDisposable instance it creates is added to the list of the scope it was resolved from, regardless of which interface you registered it under. Disposing the scope disposes them in reverse order of creation.
Consequences:
IAsyncDisposable is implemented, disposing the scope synchronously throws; use DisposeAsync/CreateAsyncScope (see the IAsyncDisposable question).| Type | Rule |
|---|---|
HttpClient |
Long-lived; use IHttpClientFactory or a shared instance with SocketsHttpHandler.PooledConnectionLifetime |
CancellationTokenSource |
Dispose, especially with CancelAfter (timer) or linked tokens (registrations) |
Timer, PeriodicTimer |
Dispose to stop callbacks and release the timer queue entry |
DbContext |
Scoped per unit of work; don't share across threads |
Task |
Implements IDisposable but you almost never need to dispose it |
MemoryStream |
No unmanaged resources, but disposing is still correct and harmless |
| Version | Change |
|---|---|
| C# 8 | using declarations, pattern-based Dispose for ref struct, await using |
| .NET Core 3.0 | IAsyncDisposable |
| .NET 6 | CreateAsyncScope() / AsyncServiceScope |
| .NET 7 | ObjectDisposedException.ThrowIf |
| .NET 8 | Keyed services; disposal tracking unchanged |
WriteLine after Dispose to see ObjectDisposedException.dotnet_diagnostic.CA2000.severity = error and CA2213 in .editorconfig; remove a using and see the build fail.Dispose(bool) and a derived class; verify the base cleanup runs even when the derived cleanup throws.AddSingleton(instance) and confirm the container does not dispose it at shutdown.using Microsoft.Extensions.DependencyInjection;
// Sealed: the simple form is enough
public sealed class ReportWriter : IDisposable
{
private readonly StreamWriter _writer;
private bool _disposed;
public ReportWriter(string path) => _writer = new StreamWriter(path);
public void WriteLine(string line)
{
ObjectDisposedException.ThrowIf(_disposed, this);
_writer.WriteLine(line);
}
public void Dispose()
{
if (_disposed) return; // idempotent
_disposed = true;
_writer.Dispose(); // dispose what we own
}
}
// Unsealed: full pattern so derived classes can add cleanup
public class Repository : IDisposable
{
private readonly System.Data.Common.DbConnection _connection;
private int _disposed;
public Repository(System.Data.Common.DbConnection connection) => _connection = connection; // we own it by contract
public void Dispose()
{
Dispose(disposing: true);
GC.SuppressFinalize(this);
}
protected virtual void Dispose(bool disposing)
{
if (Interlocked.Exchange(ref _disposed, 1) == 1) return; // thread-safe idempotency
if (disposing) _connection.Dispose();
}
}
public static class Jobs
{
// Correct: resolve per-run services from a scope that is disposed
public static async Task RunOnceAsync(IServiceProvider root)
{
await using AsyncServiceScope scope = root.CreateAsyncScope();
var writer = scope.ServiceProvider.GetRequiredService<ReportWriter>();
writer.WriteLine($"Run at {DateTimeOffset.UtcNow:O}");
} // writer is disposed with the scope
}
What happens: a hosted service calls _provider.GetRequiredService<T>() in a loop.
Why it is wrong: the root tracks every instance until shutdown → memory and resource leak.
Do instead: await using var scope = _provider.CreateAsyncScope(); per unit of work.
What happens: a class disposes an injected DbContext, HttpClient or a caller's stream.
Why it is wrong: other users of that object break with ObjectDisposedException.
Do instead: dispose only what you created or explicitly took ownership of.
DisposeWhat happens: Dispose flushes and throws on I/O failure.
Why it is wrong: inside using, it replaces the original exception, hiding the real failure.
Do instead: make Dispose safe; expose an explicit Flush/Complete method for operations that can fail.
DisposeWhat happens: a second call double-frees a native handle or throws.
Why it is wrong: double disposal is common (manual call + using, two owners).
Do instead: guard with a flag (Interlocked.Exchange if concurrent).
What happens: ~MyService() added next to Dispose.
Why it is wrong: slower allocation, extended lifetimes, no benefit.
Do instead: no finaliser unless you directly own unmanaged state; use SafeHandle.
base.Dispose(disposing)What happens: a derived class overrides Dispose(bool) and doesn't call the base.
Why it is wrong: base-class resources leak.
Do instead: call base.Dispose(disposing) in a finally block.
What happens: the constructor opens a file, then something else throws. Why it is wrong: the object is never returned, so nobody can dispose the file. Do instead: catch, dispose what was acquired, rethrow.
What happens: a struct enumerator or lock-like struct is copied and the copy is disposed.
Why it is wrong: the original's state isn't updated; resources may be released twice or never.
Do instead: avoid mutable disposable structs, or use them only via using on a local.
What happens: services.AddSingleton(new Foo()) where Foo holds a connection.
Why it is wrong: the container doesn't dispose instances it didn't create.
Do instead: register with a factory (AddSingleton(_ => new Foo())) or dispose it yourself on shutdown.
What happens: dispose bugs slip through review. Why it is wrong: they show up as slow production leaks. Do instead: enable CA2000, CA2213, CA1816, CA1063 at warning or error level.
Disposal bugs are ownership bugs, so make ownership explicit in APIs and in DI.
| Question | Rule of thumb |
|---|---|
| Who disposes? | The creator, unless ownership is transferred explicitly |
| Should my class be disposable? | Only if it owns disposables or native resources |
| Unsealed or sealed? | Seal by default; the simple pattern is then enough |
| DI lifetimes | Disposables per scope; never resolve transients from root |
Seniors enable CA2000 and CA2213 and review lifetimes in DI registrations as part of design.
using (var x = ...) { } and the C# 8 using declaration using var x = ...; both compile to try { ... } finally { x?.Dispose(); }. A declaration disposes at the end of the enclosing scope; several declarations are disposed in reverse order. For ref structs, using works with a pattern-based Dispose() method. Common bugs: returning or capturing an object that the using disposes, returning a lazy IEnumerable (LINQ or yield) built on a disposed resource, holding a resource longer than needed because a using declaration lives until the end of a long method, and an exception thrown by Dispose masking the original exception.
Implement IAsyncDisposable when cleanup involves asynchronous I/O, such as flushing a buffered stream, sending a final message or closing a network connection gracefully. DisposeAsync() returns ValueTask. await using var x = ...; compiles to try/finally with await x.DisposeAsync(), and you can write await using (x.ConfigureAwait(false)) in library code. A type that supports both should implement IDisposable and IAsyncDisposable, put async cleanup in protected virtual ValueTask DisposeAsyncCore(), and call GC.SuppressFinalize. In DI, use CreateAsyncScope(): disposing a provider or scope synchronously throws when it holds a service that implements only IAsyncDisposable.
A dependency is the weakest relationship: class A uses class B, so a change to B may affect A, but A does not keep B as part of its state. It shows up as a method parameter, a local variable, a return type, a static call or a new B() inside a method. Association is stronger: A stores a reference to B. Direction matters: high-level policy (business rules) should not depend on low-level details (SQL, HTTP). The Dependency Inversion Principle says both should depend on abstractions owned by the high-level side. Dependency injection supplies those abstractions from outside (usually through constructors), so classes do not create or find their own dependencies, which makes them testable and replaceable.
How to tell it (STAR)
Our [report job] resolved a transient from the root provider every minute, so the container kept every instance until shutdown. I found it in a heap snapshot and switched to a scope per run. Memory and connections became flat.
Last updated 31 Oct 2026.