mardi 13 septembre 2022

How to choose the right strategy at runtime when implementing the strategy pattern?

Problem description

Consider the following implementation of the strategy pattern:

// this is the strategy definition
public interface ICalculator
{
  int ComputeResult(int a, int b);
}

// this is an implementation of the strategy
public sealed class SumCalculator: ICalculator 
{
  public int ComputeResult(int a, int b) => a + b;
}

// this is another implementation of the strategy
public sealed class SubtractionCalculator: ICalculator 
{
  public int ComputeResult(int a, int b) => a - b;
}

Let's suppose we need to write some client code for the ICalculator service. The client code is given the following input data:

  • an integer number, via the variable a
  • another integer number, via the variable b
  • contextual information used to decide which strategy needs to be used. Let's suppose to have an enum named TaskType whose possible values are Sum and Subtract.

From a functional perspective, the client code should do something like this:

int a = GetFirstOperand();
int b = GetSecondOperand();
TaskType taskType = GetTaskType();

ICalculator calculator = null;

switch(taskType)
{
  case TaskType.Sum:
    calculator = new SumCalculator();
    break;

  case TaskType.Subtract:
    calculator = new SubtractionCalculator();
    break;    

  default:
    throw new NotSupportedException($"Task type {taskType} is not supported");
}

int result = calculator.ComputeResult(a,b);
Console.Writeline($"The result is: {result}");

Consider now a codebase using dependency injection and delegating object creation and lifetime management to a DI container. In this case, the client code of the ICalculator service can't directly take the responsibility of creating objects.

What I'm trying to find is, basically, an elegant and effective way to solve this problem.

What I usually do in this scenario

This is what I usually do to solve this problem. I call this design pattern the composite design pattern, but I'm quite sure this is not exactly the pattern named composite design pattern in the gang of four book.

First of all, a reshape of the ICalculator interface is needed (more on this later):

public interface ICalculator
{
  int ComputeResult(int a, int b, TaskType taskType);
  bool CanHandleTask(TaskType taskType);
}

The existing interface implementations need to be changed:

public sealed class SumCalculator: ICalculator 
{
  public int ComputeResult(int a, int b, TaskType taskType)
  {
    if (!this.CanHandleTask(taskType))
    {
      throw new InvalidOperationException($"{nameof(SumCalculator)} cannot handle task {taskType}");
    }
    
    return a + b;
  }
  
  public bool CanHandleTask(TaskType taskType) => taskType == TaskType.Sum;
}

public sealed class SubtractionCalculator: ICalculator 
{
  public int ComputeResult(int a, int b, TaskType taskType)
  {
    if (!this.CanHandleTask(taskType))
    {
      throw new InvalidOperationException($"{nameof(SubtractionCalculator)} cannot handle task {taskType}");
    }
    
    return a - b;
  }
  
  public bool CanHandleTask(TaskType taskType) => taskType == TaskType.Subtract;
}

A third implementation of the ICalculator interface needs to be written. I call this object the composite object:

public sealed class CompositeCalculator: ICalculator 
{
  private readonly IEnumerable<ICalculator> _calculators;
  
  public CompositeCalculator(IEnumerable<ICalculator> calculators)
  {
    _calculators = calculators ?? throw new ArgumentNullException(nameof(calculators));
  }

  public int ComputeResult(int a, int b, TaskType taskType)
  {
    if (!this.CanHandleTask(taskType))
    {
      throw new InvalidOperationException($"{nameof(CompositeCalculator)} cannot handle task {taskType}");
    }
    
    var handler = _calculators.First(x => x.CanHandleTask(taskType));
    return handler.ComputeResult(a, b, taskType);
  }
  
  public bool CanHandleTask(TaskType taskType) => _calculators.Any(x => x.CanHandleTask(taskType));
}

This is the client code of ICalculator:

// this class encapsulates the client code of ICalculator
public sealed class AnotherService 
{
  private readonly ICalculator _calculator;
  
  public AnotherService(ICalculator calculator)
  {
    _calculator = calculator ?? throw new ArgumentNullException(nameof(calculator));
  }
  
  public void DoSomething()
  {
    // code omitted for brevity
    int a = ...;
    int b = ...;
    TaskType taskType = ...;
    
    var result = _calculator.ComputeResult(a, b, taskType);
    Console.Writeline($"The result is {result}");
  }
}

Finally, here is the registration of the ICalculator interface in the DI container:

services.AddSingleton<SumCalculator>();
services.AddSingleton<SubtractionCalculator>();

services.AddSingleton<ICalculator>(sp => 
{
  var calculators = new List<ICalculator>
  {
    sp.GetRequiredService<SumCalculator>(),
    sp.GetRequiredService<SubtractionCalculator>()
  };
  
  return new CompositeCalculator(calculators);
});

This pattern works, but I don't like the fact that the ICalculator interface needs to be modified in order to introduce the CanHandleTask method and the extraneous parameter taskType to the ComputeResult method.

The original definition of the ICalculator interface (see the Problem description above) seems to be a more natural definition for a service able to compute a result using two integer numbers as input to the computation.

An alternative solution

An alternative solution to this problem is introducing a factory object for the ICalculator interface. This is somewhat similar to the IHttpClientFactory interface introduced in .NET core.

This way we can keep the original definition for the ICalculator interface:

public interface ICalculator
{
  int ComputeResult(int a, int b);
}

We need to introduce a factory object for ICalculator instances:

public interface ICalculatorFactory
{
  ICalculator CreateCalculator(TaskType taskType);
}

These are the implementations of the ICalculator interface (no more need for the composite object):

public sealed class SumCalculator: ICalculator 
{
  public int ComputeResult(int a, int b) => a + b;
}
    
public sealed class SubtractionCalculator: ICalculator 
{
  public int ComputeResult(int a, int b) => a - b;
}

This is the new version of the client code:

// this class encapsulates the client code of ICalculator
public sealed class AnotherService 
{
  private readonly ICalculatorFactory _calculatorFactory;

  public AnotherService(ICalculatorFactory calculatorFactory)
  {
    _calculatorFactory = calculatorFactory ?? throw new ArgumentNullException(nameof(calculatorFactory));
  }

  public void DoSomething()
  {
    // code omitted for brevity
    int a = ...;
    int b = ...;
    TaskType taskType = ...;
    
    var calculator = _calculatorFactory.CreateCalculator(taskType);
    
    var result = calculator.ComputeResult(a, b);
    Console.Writeline($"The result is {result}");
  }
}

The concrete implementation for the ICalculatorFactory interface delegates the object creation to the DI container and is defined inside the composition root (because it depends on the DI container directly):

public sealed class ServiceProviderCalculatorFactory: ICalculatorFactory
{
  private readonly IServiceProvider _serviceProvider;
  
  public ServiceProviderCalculatorFactory(IServiceProvider serviceProvider)
  {
    _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
  }
  
  public ICalculator CreateCalculator(TaskType taskType) 
  {
    switch(taskType)
    {
      case TaskType.Sum:
        return _serviceProvider.GetRequiredService<SumCalculator>();
    
      case TaskType.Subtract:
        return _serviceProvider.GetRequiredService<SubtractionCalculator>();
    
      default:
        throw new NotSupportedException($"Task type {taskType} is not supported");
    }
  }
}

Finally, here is the service registration on the DI container:

services.AddSingleton<SumCalculator>();
services.AddSingleton<SubtractionCalculator>();
services.AddSingleton<ICalculatorFactory, ServiceProviderCalculatorFactory>();

The main advantange of this solution is avoiding all of the CanHandle ceremony of the composite pattern described above.

Question

Is there a better or canonical way to resolve this problem ?

Aucun commentaire:

Enregistrer un commentaire