mykeels.com

Using the DisposableScope pattern in .NET

Want to take an action at the beginning and end of a scope? The DisposableScope pattern is your friend

Using the DisposableScope pattern in .NET

I recently needed to take an action at both the beginning and end of a scope. Something like this:

void SomeMethod()
{
    TakeActionAtTheStartOfTheScope();
    // ...
    TakeActionAtTheEndOfTheScope();
}

So in a desire for some elegance, I began thinking, why do I have to have two method calls? What if in the future, a dev writes some code below TakeActionAtTheEndOfTheScope() that messes up the flow of the actions?

I began eyeing the using keyword because it seems perfectly suited for this purpose.

void SomeMethod()
{
    using var _ = new SomethingThatImplementsIDisposable();
    // ...
}

This will take the actions at the beginning and end of the scope, but how do we implement SomethingThatImplementsIDisposable? I decided to create a new class called DisposableScope that implements IDisposable and takes two actions as its constructor parameters:

public class DisposableScope : IDisposable
{
    private readonly Action onEnd;
    private bool disposed;

    public DisposableScope(Action onStart, Action onEnd)
    {
        this.onEnd = onEnd;
        onStart();
    }

    public virtual void Dispose()
    {
        if (disposed)
            return;

        disposed = true;
        onEnd();
    }
}

So now we can use it like this:

void SomeMethod()
{
    using var _ = new DisposableScope(
        () => TakeActionAtTheStartOfTheScope(),
        () => TakeActionAtTheEndOfTheScope()
    );
    // ...
}

This is more elegant than calling two separate methods. We can now be confident the actions will be taken at the beginning and end of the scope, regardless of how many return paths the method has.

Real-world use cases

Here are some practical examples where this pattern shines:

Timing operations:

void ProcessOrder(Order order)
{
    using var _ = new DisposableScope(
        () => _stopwatch.Start(),
        () => {
            _logger.LogInformation("Order processed in {Elapsed}ms", _stopwatch.ElapsedMilliseconds);
            _stopwatch.Reset();
        }
    );
    
    // ... process order ...
}

State management:

async Task ImportDataAsync()
{
    using var _ = new DisposableScope(
        () => IsImporting = true,
        () => IsImporting = false
    );
    
    // ... import logic ...
}

Temporary configuration changes:

void RunInStrictMode()
{
    var originalMode = _config.ValidationMode;
    using var _ = new DisposableScope(
        () => _config.ValidationMode = ValidationMode.Strict,
        () => _config.ValidationMode = originalMode
    );
    
    // ... operations that need strict validation ...
}

What if the method throws an exception?

Because IDisposable works like a finally block, its Dispose() method will be called even if the method throws an exception, and in our DisposableScope class, the onEnd action will be taken at the end of the scope. Depending on our use-case, this may or may not be a problem.

In my specific use-case, I wanted to only take the onEnd action if the method does NOT throw an exception. So I created a new class called the CompletionAwareDisposableScope that inherits from DisposableScope and overrides the Dispose() method to only take the onEnd action if the method does NOT throw an exception.

public sealed class CompletionAwareDisposableScope : DisposableScope
{
    public bool Completed { get; private set; }

    public CompletionAwareDisposableScope(Action onCreate, Action onDispose)
        : base(onCreate, onDispose) { }

    public override void Dispose()
    {
        if (Completed)
        {
            base.Dispose();
        }
    }

    public void Complete()
    {
        Completed = true;
    }
}

Here's how to use it:

void TransferFunds(Account from, Account to, decimal amount)
{
    using var scope = new CompletionAwareDisposableScope(
        () => _ledger.BeginTransaction(),
        () => _ledger.CommitTransaction()
    );
    
    from.Withdraw(amount);
    to.Deposit(amount);  // If this throws, CommitTransaction won't be called
    
    scope.Complete();
}

The key difference: you must explicitly call Complete() before the scope ends. If an exception is thrown before that call, the onEnd action is skipped.

When to use each variant

  • Use DisposableScope when cleanup should always happen (e.g., resetting state, stopping timers, releasing UI locks)
  • Use CompletionAwareDisposableScope when cleanup should only happen on success (e.g., committing transactions, sending confirmation emails)

Quiz

Test your understanding of the DisposableScope pattern:

1. What happens if an exception is thrown inside a using block with DisposableScope?

Show answer

The Dispose() method is still called, and the onEnd action will execute. This is because using works like a try-finally block—Dispose() is always called when the scope exits, regardless of how it exits.

2. Given this code, will "Finished" be logged?

void ProcessData()
{
    using var scope = new CompletionAwareDisposableScope(
        () => Console.WriteLine("Started"),
        () => Console.WriteLine("Finished")
    );
    
    throw new InvalidOperationException("Oops!");
    
    scope.Complete();
}
Show answer

No. "Started" will be logged, but "Finished" will not. Since the exception is thrown before scope.Complete() is called, the Completed property remains false, and Dispose() skips the onEnd action.

3. Why does DisposableScope have a disposed field?

Show answer

To prevent the onEnd action from being called multiple times. If Dispose() is called more than once (which is allowed by the IDisposable contract), the disposed flag ensures the cleanup logic only runs once.

4. Which variant would you use for a "loading spinner" that should hide when an operation completes, even if it fails?

Show answer

DisposableScope. You want the spinner to hide regardless of success or failure, so the cleanup should always happen.

using var _ = new DisposableScope(
    () => ShowLoadingSpinner(),
    () => HideLoadingSpinner()
);

Related Articles

Tags