Fluent API to await multiple calls and get their respective results

4/15/2024

Sometimes, you have multiple async calls to make, and you want to do that asynchronously and get the results afterward. Let's build a fluent API to do that.

The problem

Imagine we have three API calls that can be done independently, and we want the results of each of the calls. We could do something like this:

var result1 = CallAPI1();
var result2 = CallAPI2();
var result3 = CallAPI3();

var result1Value = await result1;
var result2Value = await result2;
var result3Value = await result3;

That works, but it's not very elegant. Let's build a fluent API to do that. We want something like this:

var results = await TaskHelper.StartWith(() => CallAPI1())
    .And(() => CallAPI2())
    .And(() => CallAPI3())
    .WaitAllAsync();

Where results is a ValueTuple of the returned types, so we can also do this instead:

var (resultOne, resultTwo, resultThree) = await TaskHelper.StartWith(() => CallAPI1())
    .And(() => CallAPI2())
    .And(() => CallAPI3())
    .WaitAllAsync();

The solution

Let's build a structure that can fulfill the requirement. The idea is that each call needs its own object so that we are statically and strongly typed.

public static class TaskHelper
{
    public static TaskHelper<T> StartWith<T>(Func<Task<T>> task)
    {
        return new TaskHelper<T>(task);
    }
}

public class TaskHelper<T>
{
    private readonly List<Func<Task<object>>> _tasks;

    public TaskHelper(Func<Task<T>> initialTask)
    {
        _tasks = [() => initialTask().ContinueWith(t => (object)t.Result)];
    }

    public TaskHelper<T, TNext> And<TNext>(Func<Task<TNext>> nextTask)
    {
        return new TaskHelper<T, TNext>(_tasks, nextTask);
    }

    public async Task<T> WaitAllAsync()
    {
        var results = await Task.WhenAll(_tasks.Select(t => t()));
        return (T)results[0];
    }
}

public class TaskHelper<T1, T2>
{
    private readonly List<Func<Task<object>>> _tasks;

    public TaskHelper(List<Func<Task<object>>> existingTasks, Func<Task<T2>> nextTask)
    {
        _tasks = [..existingTasks, () => nextTask().ContinueWith(t => (object)t.Result)];
    }

    public TaskHelper<T1, T2, T3> And<T3>(Func<Task<T3>> nextTask)
    {
        return new TaskHelper<T1, T2, T3>(_tasks, nextTask);
    }

    public async Task<(T1, T2)> WaitAllAsync()
    {
        var results = await Task.WhenAll(_tasks.Select(t => t()));
        return ((T1)results[0], (T2)results[1]);
    }
}

public class TaskHelper<T1, T2, T3>
{
    private readonly List<Func<Task<object>>> _tasks;

    public TaskHelper(List<Func<Task<object>>> existingTasks, Func<Task<T3>> nextTask)
    {
        _tasks = [..existingTasks, () => nextTask().ContinueWith(t => (object)t.Result)];
    }

    public async Task<(T1, T2, T3)> WaitAllAsync()
    {
        var results = await Task.WhenAll(_tasks.Select(t => t()));
        return ((T1)results[0], (T2)results[1], (T3)results[2]);
    }
}

This way, we can chain as many calls as we want, and we will get the results in a strongly typed way. To support more And calls we would need another TaskHelper class with more generic parameters.

But the result looks like this: Compiler

We can see that the compiler knows exactly which type will be returned! And that all thanks to generics. We basically chain calls with one higher generic parameter each time, and the compiler can infer the types.

Thanks to @toupswork for the initial challenge and thought!

Resources

  • The code for this blog post on GitHub
  • All my examples for this blog found here

How many API's does .NET have?

.NET is big, very big! So how many API's does it have? Let's find out!

Marking API's as obsolete or as experimental

Often times your API in your program or library evolves. So you will need a mechanism of telling that a specific API (an interface or just a simple method call) is obsolete and might be not there anymore in the next major version.

Also it can happen that you have a preview version of a API, which might not be rock-stable and the API-surface might change. How do we indicate that to the user?

The state machine in C# with async/await

You often here that the async/await keywords leads to a state machine. But what does that mean? Let's discuss this with a simple example.

An error has occurred. This application may no longer respond until reloaded. Reload x