DEV Community

Cover image for C# Alchemy: Simplifying the Strategy Pattern with Keyed Services and Dependency Injection
M. James Harmon
M. James Harmon

Posted on

C# Alchemy: Simplifying the Strategy Pattern with Keyed Services and Dependency Injection

In the previous entry of this series, C# Alchemy, we used KeyedCollection to simplify the implementation of a Pokédex for storing information about all the Pokémon we've caught. In this entry, we'll build a simple web API for interacting with our Pokédex and, along the way, demonstrate a handy new feature of .NET 8 that can be used with Dependency Injection: Keyed Services. First, let's take a look at how we could approach adding the ability to sort or offer ordered results from our Pokédex using the Strategy Pattern.

The Strategy Pattern

To quickly review or introduce it to those who are unfamiliar, the Strategy Pattern helps you eliminate cumbersome and error-prone code like this, replacing it with something more organized and extensible:

  ...

  if (sortMethod == "Name") {
   // sort by name
  } else if (sortMethod == "Hp") {
    // sort by hp
  } else if (sortMethod == "Type") {
    // sort by type
  }else {
    // default sort
  }
  ...
Enter fullscreen mode Exit fullscreen mode

or maybe it was done with a switch statement:

  ...
  switch(sortMethod) {
    case "Name": 
   // sort by name
   break;
    case  "Hp":
    // sort by hp
   break;
    case "Type"
    // sort by type
    break;
   default:
     // default sort
  }
  ...
Enter fullscreen mode Exit fullscreen mode

Why is this type of code potentially problematic? Although many of us start by writing code this way because it seems simple and straightforward, this approach can become troublesome over time. The main issues arise as the number of cases grows—particularly when conditional checks are duplicated—leading to an increased likelihood of mistakes. As a parent, I often think of kids as adorable little chaos machines, and this reminds me of Murphy's Law: if something can go wrong, it eventually will, especially in complex or repetitive code.

The Strategy Pattern helps mitigate these problems by allowing the compiler to detect structural issues rather than discovering them only at runtime. This pattern organizes code by encapsulating different algorithms or behaviors behind a common interface, making it easier to test and extend. Instead of modifying existing code to handle new cases, you can extend functionality in a modular way. This approach exemplifies the Open/Closed Principle, which is an important principle from software design to consider.

As an advocate for unit testing and test-driven approaches, I also sleep better at night knowing I've hidden concrete implementations behind abstractions that adhere to a contract. The Strategy Pattern is an easy sell for me because it simplifies testing both existing and new code and contributes to a more organized and manageable codebase as new cases or requirements are added.

Let's take a look at organizing our code behind the Strategy Pattern by creating a sorting interface to serve as the contract and then the concrete implementations available to our Pokédex API.

public interface ISortingStrategy
{
    IEnumerable<Pokemon> Sort(IEnumerable<Pokemon> pokemons, bool sortDescending = false);
}
Enter fullscreen mode Exit fullscreen mode

We will introduce the ability to sort by name, HP, type as well as offer a default ordering of sorting first by type and then by name.

public class SortByName : ISortingStrategy
{
    public IEnumerable<Pokemon> Sort(IEnumerable<Pokemon> pokemons, bool sortDescending = false)
    {
        return (sortDescending) ?
        pokemons.OrderByDescending(p=>p.Name) :
        pokemons.OrderBy(p=>p.Name);
    }
}

public class SortByType : ISortingStrategy
{
    public IEnumerable<Pokemon> Sort(IEnumerable<Pokemon> pokemons, bool sortDescending = false)
    {
        return (sortDescending) ?
        pokemons.OrderByDescending(p=>p.Type) :
        pokemons.OrderBy(p=>p.Type);
    }
}

public class SortyByHp : ISortingStrategy
{
    public IEnumerable<Pokemon> Sort(IEnumerable<Pokemon> pokemons, bool sortDescending = false)
    {
        return sortDescending ? 
        pokemons.OrderByDescending(p=>p.HP) :
        pokemons .OrderBy(p=>p.HP);
    }
}

public class DefaultSort : ISortingStrategy
{
    public IEnumerable<Pokemon> Sort(IEnumerable<Pokemon> pokemons,
     bool sortDescending = false)
    {
       return pokemons.OrderBy(p=>p.Type).
         ThenBy(p=>p.Name);
    }
}
Enter fullscreen mode Exit fullscreen mode

Unfortunately, implementing the Strategy Pattern still involves some decisions, each with its own trade-offs in terms of maintainability. Primarily, you need to decide how to access a sorting strategy. One option is to use a context class that receives the strategy and executes commands against it. This approach works well when using a known configuration. Alternatively, you can use a factory class in scenarios where the user might issue a command or perform an action that involves one of the strategies. .NET’s Keyed Services allow you to handle both approaches effectively. The following example will demonstrate how something like the second approach where a user is submitting a sorting preference is implemented using Keyed Services in Dependency Injection.

Pokédex API

To demonstrate using Keyed Services with Dependency Injection, I'll build a simple Pokedéx API. First, I'll create an extension method to integrate with the DI configuration:

 public static IServiceCollection AddPokedexServices(this IServiceCollection services) {

        services.AddSingleton<Pokedex>();
        services.AddKeyedTransient<ISortingStrategy,DefaultSort>("default");
        services.AddKeyedTransient<ISortingStrategy,SortByName>("name");
        services.AddKeyedTransient<ISortingStrategy,SortByType>("type");
        services.AddKeyedTransient<ISortingStrategy,SortyByHp>("hp");

        return services;
    }
Enter fullscreen mode Exit fullscreen mode

Note the named parameters (name, type, etc.); these are the keys used to retrieve each concrete sorting strategy by name. We are now ready to build the web API application and use a call to AddPokedexServices to enable the sorting strategy.

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

builder.Services.AddPokedexServices();

var app = builder.Build();
Enter fullscreen mode Exit fullscreen mode

Next, we’ll build the controllers for the API. First, we’ll create an Add method to allow us to add different Pokémon to the Pokédex and test the functionality.

app.MapPost("add", ([FromBody] Pokemon pokemonToAdd, Pokedex pokedex ) => {

  try { 
    pokedex.Add(pokemonToAdd);
   return Results.Ok();
  }catch {
   return Results.BadRequest("Pokemon submitted is not valid for addition");
  }
});
Enter fullscreen mode Exit fullscreen mode

Next, we’ll implement a List endpoint that retrieves the Pokémon in the Pokédex. This will utilize the DefaultSort strategy. This is introduced by using an attribute FromKeyedServices that allows you to target the strategy you wish the controller to receive:

app.MapGet("list", (Pokedex pokedex, [FromKeyedServices("default")] ISortingStrategy sortMethod) => {
  try {
  return Results.Ok(sortMethod.Sort(pokedex));
  }catch {
    return Results.StatusCode((int)HttpStatusCode.InternalServerError);
  }
});
Enter fullscreen mode Exit fullscreen mode

Now, we’ll add a most powerful endpoint that will return the most powerful Pokémon in the Pokédex. This will utilize the SortByHp strategy, with decreasing order implicit.

app.MapGet("mostpowerful", (Pokedex pokedex, [FromKeyedServices("hp")] ISortingStrategy sortMethod) => {
  try {
  return Results.Ok(
    sortMethod.Sort(pokedex,true).
    Take(1));
  }catch {
    return Results.StatusCode((int)HttpStatusCode.InternalServerError);
  }
});

Enter fullscreen mode Exit fullscreen mode

Finally, we’ll add alphabetical and sorting by type endpoints that allow us to control the order and make use of the SortByType and SortByName strategies. We’ll first add an extension method to keep the syntax clean:

 public static IEnumerable<Pokemon> Sort(this ISortingStrategy sortingStrategy,
     IEnumerable<Pokemon> pokemons, string direction) =>
        sortingStrategy.Sort(pokemons, direction.Equals("desc",StringComparison.OrdinalIgnoreCase));
Enter fullscreen mode Exit fullscreen mode
app.MapGet("list/bytype", (Pokedex pokedex,
 [FromKeyedServices("type")] ISortingStrategy sortMethod, string? order) => {
  try {
  return Results.Ok(
    sortMethod.Sort(pokedex,order ?? "asc"));
  }catch {
    return Results.StatusCode((int)HttpStatusCode.InternalServerError);
  }
});

app.MapGet("list/alphabetical", (Pokedex pokedex,
 [FromKeyedServices("name")] ISortingStrategy sortMethod, string? order) => {
  try {
  return Results.Ok(
    sortMethod.Sort(pokedex,order ?? "asc"));
  }catch {
    return Results.StatusCode((int)HttpStatusCode.InternalServerError);
  }
});
Enter fullscreen mode Exit fullscreen mode

Now we are ready to run the API and test it out with some Pokémon!

[
  { "name": "Lilligant", "hp": 70, "type": "Grass" },
  { "name": "Vaporeon", "hp": 130, "type": "Water" },
  { "name": "Pikachu", "hp": 35, "type": "Electric" },
  { "name": "Floragato", "hp": 61, "type": "Grass" },
  { "name": "Eevee", "hp": 55, "type": "Normal" }
]
Enter fullscreen mode Exit fullscreen mode

Now, if we navigate to the List endpoint, we should see the Pokémon sorted first by type and then by name, as implemented in the DefaultSort strategy:

pokemons.OrderBy(p=>p.Type).
         ThenBy(p=>p.Name);
Enter fullscreen mode Exit fullscreen mode

result:

[
  {
    "name": "Pikachu",
    "hp": 35,
    "type": "Electric"
  },
  {
    "name": "Lilligant",
    "hp": 70,
    "type": "Grass"
  },
  {
    "name": "Floragato",
    "hp": 61,
    "type": "Grass"
  },
  {
    "name": "Eevee",
    "hp": 55,
    "type": "Normal"
  },
  {
    "name": "Vaporeon",
    "hp": 130,
    "type": "Water"
  }
]
Enter fullscreen mode Exit fullscreen mode

Most powerful:

[
  {
    "name": "Vaporeon",
    "hp": 130,
    "type": "Water"
  }
]
Enter fullscreen mode Exit fullscreen mode

When testing out by type, we’ll sort in descending order and notice that, unlike with the default sort, the Pokémon within each type are not ordered by name:

[
  {
    "name": "Vaporeon",
    "hp": 130,
    "type": "Water"
  },
  {
    "name": "Eevee",
    "hp": 55,
    "type": "Normal"
  },
  {
    "name": "Lilligant",
    "hp": 70,
    "type": "Grass"
  },
  {
    "name": "Floragato",
    "hp": 61,
    "type": "Grass"
  },
  {
    "name": "Pikachu",
    "hp": 35,
    "type": "Electric"
  }
]
Enter fullscreen mode Exit fullscreen mode

And finally, alphabetical:

[
  {
    "name": "Eevee",
    "hp": 55,
    "type": "Normal"
  },
  {
    "name": "Floragato",
    "hp": 61,
    "type": "Grass"
  },
  {
    "name": "Lilligant",
    "hp": 70,
    "type": "Grass"
  },
  {
    "name": "Pikachu",
    "hp": 35,
    "type": "Electric"
  },
  {
    "name": "Vaporeon",
    "hp": 130,
    "type": "Water"
  }
]
Enter fullscreen mode Exit fullscreen mode

As demonstrated here, Keyed Services work really well at simplifying code that supports the Strategy Pattern, but there are plenty of other use cases where you might consider it. Configuring different implementations based on different environments, for example, or implementing a feature toggle. I have placed the code for the Pokédex API here.

Top comments (0)