C# Alchemy: Simplifying the Strategy Pattern with Keyed Services and Dependency Injection
MJ Harmon
Posted on August 6, 2024
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
}
...
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
}
...
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);
}
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);
}
}
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;
}
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();
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");
}
});
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);
}
});
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);
}
});
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));
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);
}
});
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" }
]
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);
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"
}
]
Most powerful:
[
{
"name": "Vaporeon",
"hp": 130,
"type": "Water"
}
]
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"
}
]
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"
}
]
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.
Posted on August 6, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 26, 2024