Async all the way when using pipeline

844 Views Asked by At

If I have an application that uses a pipeline with many stages to be executed using foreach on all the stages and call:

CanExecute
Execute

Interface is this:

public interface IService
{
    bool CanExecute(IContext subject);

    IContext Execute(IContext subject);
}

It basically takes in a context and returns a context where it has become richer.

Within one of the stages Execute method I need to call a service, and want to do async. So now the Execute method needs to change to e.g.

Task<IContext> ExecuteAsync(IContext subject);

with await for the call to the service.

All the other stages have no async code but need to change now as best practice is "async all the way".

Is this normal to have to make these changes when you bring in async code?

2

There are 2 best solutions below

1
On BEST ANSWER

C# 8 offers multiple ways to avoid modifying the synchronous services. C# 7 can also handle this with pattern matching statements.

Default Implementation Members

Interface versioning is one of the main use cases for default interface members. They can be used to avoid changing existing classes when an interface changes. You can add a default implementation for ExecuteAsync that returns the result of Execute as a ValueTask.

Let's say you have these interfaces :

public interface IContext{}

public interface IService
{
    public bool CanExecute(IContext subject);

    public IContext Execute(IContext subject);       
}

public class ServiceA:IService
{
    public bool CanExecute(IContext subject)=>true;
    public IContext Execute(IContext subject){return subject;}
}

To create an asynchronous service without modifying the synchronous ones, you can add a default implementation to IService and override it in new services :

public interface IService
{
    public bool CanExecute(IContext subject);

    public IContext Execute(IContext subject);

    public ValueTask<IContext> ExecuteAsync(IContext subject)=>new ValueTask<IContext>(Execute(subject));

}

public class ServiceB:IService
{
    public bool CanExecute(IContext subject)=>true;
    public IContext Execute(IContext subject)=>ExecuteAsync(subject).Result;

    public async ValueTask<IContext> ExecuteAsync(IContext subject)
    {
        await Task.Yield();
        return subject;
    }
}    

ServiceB.Execute still needs a body and one thing that makes sense is to call ExecuteAsync() and block, as ugly as that looks. Another possibility would be to throw if Execute is called :

public IContext Execute(IContext subject)=>throw new InvalidOperationException("This is an async service");

Pattern Matching

Another option would be to create a second interface just for the asynchronous services :

public interface IService
{
    public bool CanExecute(IContext subject);

    public IContext Execute(IContext subject);        

}

public interface IServiceAsync:IService
{        
    public ValueTask<IContext> ExecuteAsync(IContext subject);    
}

Both service implementations would remain the same. The pipeline code would change to make different calls based on the service's type :

async Task Main()
{
    IService[] pipeline=new[]{(IService)new ServiceA(),new ServiceB()};
    IContext ctx=new Context();
    foreach(var svc in pipeline)
    {
        if (svc.CanExecute(ctx))
        {
            var result=svc switch { IServiceAsync a=>await a.ExecuteAsync(ctx),
                                    IService b => b.Execute(ctx)};        
            ctx=result;
        }
    }
}

The pattern matching expression calls a different branch based on the current service's type. Natching on a type produces a strongly typed instance (a or b) which can be used to call the appropriate method.

Switch expressions are exhaustive - the compiler will generate a warning if it can't verify that all options are matched by the patterns.

C# 7

C# 7 doesn't have switch expressions, so the more verbose pattern matching switch statement is needed :

if (svc.CanExecute(ctx))
{
    switch (svc)
    {
        case IServiceAsync a:
            ctx=await a.ExecuteAsync(ctx);                    
            break;                    
        case IService b :
            ctx=b.Execute(ctx);        
            break;
        default:
            throw new InvalidOperationException("Unknown service type!");
    }
}

Switch statements aren't exhaustive so we need to add the default section to catch errors at runtime.

0
On

Is this normal to have to make these changes when you bring in async code?

It is normal to have to make changes when you change the signature of any method. If you want to rename it and change the return type, then yes, everywhere that method is called will have to change.

The best way to change them is to make them asynchronous too, all the way up the chain.