Let’s talk about these two patterns:

public void GetSomethingDone()
{
    var metaData = "Some Sort of Metadata for timing";
    var result = DoTimedWork(metaData, () =>
    {
        MyResult resul;
        //Some other complicated work/method-call happens here
        return result;
    });
    
    //either uses or returns result here
}

public T DoTimedWork(string metaData, Func<T> action)
{
    //Setup code probably here
    using(var timer = new MetricsTimer(metaData))
    {
        return action();
    }
}

And another:

public void GetSomethingDone()
{
    var metaData = "Some Sort of Metadata for timing";
    MyResult result;
    Using(DoTimedWork(metaData))
    {
        //Some other complicated work/method-call happens here
        result = complicatedWorkResultSomehow;
    };
    
    //either uses or returns result here
}

public IDisposable DoTimedWork(string metaData)
{
    //Setup code probably here
    return new MetricsTimer(metaData);
}

Let’s discuss shall we?

Idiomatic

my first instinct is that the second one is more idiomatic. Let’s focus on these two lines of code in each:

    var result = DoTimedWork(metaData, () =>
    {
    Using(DoTimedWork(metaData))
    {

The using keyword has been in the language since at least .Net 1.1 and has some key observations to anyone reading it. Specifically, that regardless of any exceptions inside the block of code, Dispose() is always called on the argument at the end of the code block. Since this is a timing function, we can infer that the timer stops when this block ends.

This second example uses callbacks. Specifically Func<TResult>, but it could have been Func<T,TResult> or even Action or Action<T>, all of which have been around since .Net 3.5. I would consider .Net 3.5 as a reasonable baseline of language features to expect a C# developer to know. However, there is some missing knowledge required here. Namely, you need to go and read the method (at least the signature if not the implementation) to understand what to expect from this method. More work is required to read over and understand what is happening, beyond the two lines of code shown. This only gets more complicated if we were to add more lambdas.

SRP

This function we have written is a helper for the timing action.

public IDisposible DoTimedWork(string metaData)
{
    //Setup code probably here
    return new MetricsTimer(metaData);
}

However, this next function is one that times some action that is performed on a very specific (or very generic) function passed in.

public T DoTimedWork(string metaData, Func<T> action)
{
    //Setup code probably here
    using(var timer = new MetricsTimer(metaData))
    {
        return action();
    }
}

This function has two reasons to change. We could be updating our timing code, or we could be updating the result/response types we use with this helper. If we change the way we use this helper, we have to change its implementation.

The first function has one simple reason to change, that the timing code needs to be changed. It does not care about anything else. One reason to change. SRP.

You can imagine what the conversation around Open/Closed and other SOLID principles might go.

Contract Changes

There is very little I can imagine you could change about the contract here:

public IDisposable DoTimedWork(string metaData);

Sure, we could add overloads with new arguments, and by adding defaults we could be sure that the usages still compile. But the return value is always IDisposable.

But what about this signature?

public T DoTimedWork(string metaData, Func<T> action)

I’ve already mentioned a few: Func<TResult>, Func<T, TResult>, Action, Action<T>, Action<T1, T2>. We might have to evolve and cater for various new input scenarios. Let’s look at a concrete one:

public TResult DoTimedWork(string metaData, T input, Func<T, TResult> action)

There are so many problems with this. We are really just changing to support our addiction to delegates. Having gone down this rabbit-hole before, there are too many variations on this method to enumerate through - this is the best way to increase your LOC statistics.

With so many overloads, you are bound to start confusing the compiler, and resort to adding casts on your lambdas to get this to build. This is a dangerous path to go down.

There are two groups of arguments here, one for passing through the lambda and one for building the timer. Don’t mix the two.

Final words

If you really are intent to go down the path of actions at all, I leave you with this approach, if only to make the hole in your foot a little smaller:

public void GetSomethingDone()
{
    var metaData = "Some Sort of Metadata for timing";
    MyResult result = Using(DoTimedWork(metaData), () =>
    {
        //Some other complicated work/method-call happens here
        result = complicatedWorkResultSomehow;
    });
    
    //either uses or returns result here
}

public IDisposable DoTimedWork(string metaData)
{
    //Setup code probably here
    return new MetricsTimer(metaData);
}

public Using(IDisposable disposable, Func<T> action)
{
    //Setup code probably here
    using(disposable)
    {
        return action();
    }
}

As always, I love to be challenged and hear new opinions, or else I cannot grow as a developer. But until then I will be sticking with using over lambda.