Give resource cleanup a clear place to live

Implementing IDisposable starts with one method. Designing its lifecycle correctly requires more decisions: which resources the object owns, what happens on a second call, how derived classes participate, and whether finalization is needed.
Those decisions are easy to scatter across a codebase. A copied disposal pattern can compile even when cleanup sits in the wrong branch or a derived class forgets part of the sequence.
Cuemon offers an opt-in base class for this work. Cuemon.Disposable, distributed in Cuemon.Kernel, centralizes the lifecycle and gives resource-specific cleanup two explicit names: OnDisposeManagedResources and OnDisposeUnmanagedResources.
For classes where inheritance fits, that is a practical reason to consider it over implementing the conventional pattern separately each time. Fewer repeated lifecycle decisions can reduce opportunities for mistakes. This is an engineering judgment based on the API's structure, rather than a measured claim about defect rates.
Microsoft's guidance remains the baseline
Microsoft's disposal guidance covers ownership, repeated calls, inheritance, and finalization. Owning a disposable member normally brings cleanup responsibility; merely borrowing one does not.
For an extensible class, the conventional pattern uses public Dispose() and protected virtual Dispose(bool disposing). Managed cleanup runs when disposing is true; direct unmanaged cleanup also runs on the false path. Successful explicit disposal suppresses finalization. Repeated disposal should do nothing.
Microsoft also shows a simpler implementation for sealed classes. A small owner may need only a direct IDisposable implementation. Where native handles are involved, Microsoft recommends SafeHandle to avoid writing a finalizer yourself.
Cuemon keeps IDisposable as the consumer contract. The choice is whether a reusable implementation is useful for the class being written.
Named hooks reduce the code each class must get right
Disposable makes managed cleanup an abstract obligation and unmanaged cleanup an optional virtual hook. Its protected Dispose(bool) is non-virtual: derived classes customize the hooks rather than replace the lifecycle coordinator.
The following table summarizes the extension points and their intended responsibilities.
| Member | Responsibility |
|---|---|
OnDisposeManagedResources() |
Release owned managed disposable objects during explicit disposal. |
OnDisposeUnmanagedResources() |
Release directly owned unmanaged resources on either disposal path. |
Disposed |
Expose whether the base class has entered disposal. |
The names help a reviewer ask a precise question: does this cleanup belong in this hook? A FileStream is a managed disposable object even though it ultimately controls an operating-system handle. An owned SafeHandle also belongs in managed cleanup. The underlying native resource does not make the wrapper an unmanaged field.
Consumers continue to use using and IDisposable. They do not need a Cuemon-specific release operation.
Here is an illustrative owner of a file stream. The example targets modern .NET and creates the resource itself, making ownership explicit.
using System;
using System.IO;
using Cuemon;
namespace DisposalExamples;
internal sealed class OwnedFile : Disposable
{
private readonly FileStream _stream;
public OwnedFile(string path)
{
_stream = File.OpenRead(path);
}
public int ReadByte()
{
ObjectDisposedException.ThrowIf(Disposed, this);
return _stream.ReadByte();
}
protected override void OnDisposeManagedResources()
{
_stream.Dispose();
}
}
There is no local disposed flag, boolean disposal branch, or public disposal implementation to repeat. There is also no finalizer to add for this managed-only owner. The operation checks the inherited state explicitly; the base class cannot insert such checks into arbitrary derived methods.
In a deeper hierarchy, a hook override must preserve any cleanup supplied by its base implementation. Named hooks make that obligation visible, but do not enforce it automatically.
Understand what the synchronous coordinator guarantees
The current implementation checks Disposed, enters a lock, checks the state again, and sets it before invoking cleanup. Explicit disposal then calls the managed hook followed by the unmanaged hook. Subsequent calls skip those hooks.
Setting the state before callbacks also prevents a callback that re-enters Dispose() from starting cleanup again. The lock protects the disposal transition; it does not make the derived class's normal operations safe to run concurrently with disposal. Because the state changes before cleanup completes, Disposed is not a completion signal for another thread.
There is an exception consequence worth reviewing. If managed cleanup throws, the unmanaged hook is not reached, and the object is already marked disposed. A second call will not retry cleanup. Exceptions propagate, and GC.SuppressFinalize is reached only when Dispose(true) returns successfully. Resource-specific failure handling remains part of the derived implementation.
Disposable itself has no finalizer. Overriding OnDisposeUnmanagedResources does not arrange eventual cleanup if the caller forgets disposal. Direct native ownership still requires a deliberate finalization design; Cuemon separately provides FinalizeDisposable, while SafeHandle remains the usual choice for handles. Keep finalizer-path cleanup independent of managed objects.
These boundaries matter when adopting a shared base class. The benefit is a consistent coordinator and explicit extension points, with ownership and cleanup correctness still reviewed locally.
AsyncDisposable gives asynchronous cleanup its own hook
Microsoft's asynchronous disposal pattern uses IAsyncDisposable, a ValueTask-returning DisposeAsync(), and await using. For extensible implementations, its conventional customization method is DisposeAsyncCore().
Cuemon's AsyncDisposable derives from Disposable, implements IAsyncDisposable, and introduces OnDisposeManagedResourcesAsync().
Its sequence is short: await the async hook with ConfigureAwait(false), call inherited Dispose(false) for the unmanaged path, then suppress finalization. Async managed cleanup therefore precedes synchronous unmanaged cleanup on a successful call.
An illustrative owner can forward async cleanup directly to an owned dependency:
using System;
using System.Threading.Tasks;
using Cuemon.Extensions;
namespace DisposalExamples;
internal sealed class OwnedAsyncResource : AsyncDisposable
{
private readonly IAsyncDisposable _resource;
// Ownership transfers to this wrapper when construction succeeds.
public OwnedAsyncResource(IAsyncDisposable resource)
{
ArgumentNullException.ThrowIfNull(resource);
_resource = resource;
}
protected override ValueTask OnDisposeManagedResourcesAsync()
{
return _resource.DisposeAsync();
}
}
Use an owner like this through await using. The hook clearly communicates why cleanup needs an asynchronous boundary.
The current async implementation has two material limits. First, it invokes the async hook before entering inherited disposal, without checking Disposed or serializing that hook. Repeated or concurrent DisposeAsync() calls can invoke async cleanup repeatedly. Derived cleanup and the owning lifecycle must account for that behavior; the synchronous guard does not provide async idempotency.
Second, its override of OnDisposeManagedResources() is empty. Calling inherited Dispose() does not call the async hook. Ordinary using therefore does not release an async-only managed member through this implementation. If callers require both disposal modes, define and verify both paths deliberately, or use an implementation whose contract already covers that requirement. Avoid assuming that implementing both interfaces makes their cleanup interchangeable.
If the async hook throws, inherited disposal and finalization suppression are not reached. As with the synchronous base, the hook must implement the intended resource-specific behavior.
Why the async counterpart lives outside Kernel
Disposable lives in the Cuemon namespace and Kernel assembly. AsyncDisposable lives in the Cuemon.Extensions namespace and Cuemon.Extensions.Core assembly. That separation preserves an intentional dependency boundary.
For .NET Standard 2.0 compatibility, the Extensions.Core project conditionally references Microsoft.Bcl.AsyncInterfaces and System.Threading.Tasks.Extensions. Those packages supply the compatibility surface needed for IAsyncDisposable and ValueTask on that target.
Kernel's policy excludes those additional runtime package dependencies. Its project file has no corresponding references. Keeping AsyncDisposable in Extensions.Core lets the synchronous foundation stay small while consumers opt into the async compatibility dependencies where needed.
This is a Cuemon assembly policy, rather than a .NET prohibition. The two compatibility references are conditional; modern .NET targets do not acquire them through that project item group. Shared build tooling is a separate concern from these runtime dependencies.
Opt in where the abstraction earns its place
Consider Disposable when a class owns disposable resources, has an available base-class slot, and benefits from a shared lifecycle with named cleanup hooks. It is especially useful when several types would otherwise duplicate disposal coordination.
A direct IDisposable implementation remains appropriate for a small sealed owner, an existing hierarchy with its own disposal contract, or a library that should not expose Cuemon inheritance. Borrowed dependencies do not become owned simply because a convenient hook exists.
Consider AsyncDisposable when async cleanup is the intended lifecycle and its current repeated-call and synchronous-path behavior fits the design. Verify the disposal mode used by the actual caller or host.
The strongest case for these types is clarity: put owned managed cleanup in OnDisposeManagedResources, direct unmanaged cleanup in OnDisposeUnmanagedResources, and asynchronous managed cleanup in OnDisposeManagedResourcesAsync. Review those responsibilities against the lifetime the class promises. That is how a small convenience abstraction can reduce the amount of disposal code each implementation must get right.
Timeline
Stable releases show how the cleanup hooks evolved and why the two implementations now live in different assemblies.
v6.0.02021-04-18The named synchronous cleanup hooks shipped in Cuemon.Core.
The release documents
DisposableandFinalizeDisposable. The tagged implementation already separates managed and unmanaged cleanup. Earlier Git history exists, so this release is not presented as the feature's first-ever implementation.v9.0.02024-11-13AsyncDisposable added an asynchronous managed cleanup hook.
The counterpart was introduced in Cuemon.Extensions.Core, deriving from the synchronous base and implementing
IAsyncDisposable.v9.0.22025-03-31The synchronous disposed flag moved ahead of cleanup callbacks.
Setting the flag after the synchronization check and before cleanup prevents recursive disposal from entering the callbacks again.
v10.5.02026-03-13Disposable moved into the new Kernel assembly.
The type retained its
Cuemonnamespace. Cuemon.Core added type forwarding for moved types, while AsyncDisposable remained in Extensions.Core.v10.7.12026-09-09(current)The stable source retains the split between Kernel and Extensions.Core.
The latest stable tag available in the inspected history contains both implementations in their current locations. The current main-branch files preserve the lifecycle behavior described here.
