📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

How do you implement IDisposable correctly, and who is responsible for disposing an object?

The dispose pattern, sealed simple Dispose, idempotency, ObjectDisposedException, ownership rules, DI container disposal and analyser support.

Mid-Level Coding Intermediate dependency-injection dispose-pattern idisposable resource-management

🎯 Key points

  • Implement IDisposable when a type owns native resources or other disposables
  • Sealed classes need only a simple idempotent Dispose
  • Unsealed classes use Dispose() plus protected virtual Dispose(bool) and GC.SuppressFinalize
  • Throw ObjectDisposedException from members after disposal
  • The owner disposes; ownership transfer must be explicit
  • The DI container disposes what it creates; transients from the root provider leak until shutdown
  • Use CA2000, CA2213 and CA1816 analysers

⏱ 30-second answer

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.

🗣 Full answer (senior)

How I would answer this in an interview

"I implement IDisposable only when a type owns something that must be released deterministically: a native resource, ideally wrapped in a SafeHandle, or another IDisposable it created. For a sealed class a simple, idempotent Dispose() that disposes owned fields is enough: no finaliser, no Dispose(bool). For an unsealed class I use the standard pattern, Dispose() calling protected virtual Dispose(bool disposing) plus GC.SuppressFinalize(this), so derived classes can add cleanup. Dispose must be safe to call twice and shouldn't throw; members used afterwards throw ObjectDisposedException. 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."


1. When to implement 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

2. Sealed class: the simple form

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();
    }
}

3. Unsealed class: the full pattern

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).

4. The rules

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

5. Who disposes: ownership

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

6. DI disposal behaviour

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

7. Tooling

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

Production example

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.

🔍 What the interviewer is checking

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.

📚 Learn in depth

1. What using compiles to

using (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.

2. Why Dispose(bool) exists

Two 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.

3. Inheritance done right

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
    }
}

4. Constructor that acquires several resources

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();
    }
}

5. How the DI container tracks disposables

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:

  • A transient disposable resolved from the root stays in the root list until shutdown.
  • Scoped services resolved from the root (with validation off) effectively become singletons.
  • If only IAsyncDisposable is implemented, disposing the scope synchronously throws; use DisposeAsync/CreateAsyncScope (see the IAsyncDisposable question).

6. Disposable types with special rules

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

7. Version notes

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

8. Try it yourself

  1. Run the code example and call WriteLine after Dispose to see ObjectDisposedException.
  2. Register a transient disposable, resolve it 10,000 times from the root provider, take a gcdump and find what roots the instances.
  3. Repeat with a scope per resolution and confirm instances are collected.
  4. Enable dotnet_diagnostic.CA2000.severity = error and CA2213 in .editorconfig; remove a using and see the build fail.
  5. Write a base class with Dispose(bool) and a derived class; verify the base cleanup runs even when the derived cleanup throws.
  6. Register an instance with AddSingleton(instance) and confirm the container does not dispose it at shutdown.

💻 Code

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
}

⚠ Common mistakes

1. Resolving transient disposables from the root provider

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.

2. Disposing objects you don't own

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.

3. Throwing from Dispose

What 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.

4. Non-idempotent Dispose

What 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).

5. Adding finalisers to managed-only disposables

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.

6. Forgetting 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.

7. Leaking resources when the constructor throws

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.

8. Using a disposable struct by copy

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.

9. Registering an instance and expecting the container to dispose it

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.

10. Not enabling the analysers

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.

🏛 Senior insight: trade-offs

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.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate says callers should use using Why: Checks using pitfalls
    How to answer

    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.

  2. When: Candidate mentions async cleanup Why: Checks IAsyncDisposable
    How to answer

    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.

  3. When: Candidate mentions DI disposal Why: Probes DI lifetimes
    How to answer

    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.

🛠 Real-world problems: how you handle them

Background job leaked connections by resolving transients from the root provider
Situation
A hosted service generated reports every minute using a transient `ReportGenerator` holding a database connection.
Signals
Memory and open database connections grew for days until restarts.
Investigation
A heap snapshot showed thousands of `ReportGenerator` objects rooted by the root `ServiceProvider`'s disposable list.
Resolution
Created an async scope per run with `CreateAsyncScope()` and `await using`.
Prevention
Enabled `ValidateScopes` in development and added a code review checklist item for root resolution.

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.

↩ Usually asked after

More in Garbage Collection

Last updated 31 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me