Strategy pattern at the service of SOLID Open-Closed principle
The strategy pattern is a behavioral design pattern that allows you to define a family of algorithms that adhere to the
same contract, enabling the program to select the appropriate one at runtime. I won't delve into the details here, as
there are plenty of great contents available on the internet, such as https://refactoring.guru/design-patterns/strategy.
Instead, I'll demonstrate how to integrate it into your codebase to make it more extensible and compliant with the
Open-Closed principle of programming.
Consider an application feature where users can retrieve a list of animals in their country or any other country through
an input. To accomplish this, we've collaborated with a scientific lab that has gathered all the data for each animal
and provides us with an API with the following type of response.
[
{
id: "animalId1",
name: "Ameiva martinicensis",
locations: ["MQ"],
},
{
id: "animalId2",
name: "Oryctolagus cuniculus",
locations: ["EU"],
},
{
id: "animalId3",
name: "Passer domesticus",
locations: ["001"],
},
{
id: "animalId4",
name: "Canis lupus",
locations: ["FX", "MQ"],
},
]
However, there's one small issue: our application only supports
a portion of standard ISO 3166 Country Codes like US, FR, ES, UK,
etc..., while the API uses an extended version of these codes. For example, EU represents Europe, 001 signifies the
whole world, and MQ stands for Martinique, which is a French territory outside the mainland.
Our developers team quickly came up with a solution for this animal listing feature. (We assume some tests are covering
the feature behaviour of course)
class ListAnimal {
constructor(private readonly _animalAPIGateway: AnimalAPIGateway) {
}
execute({country}: { country: Country }): Array<Animal> {
const apiResponse = this._animalAPIGateway.listAll();
return apiResponse
.filter((a) => this.countryHasAnimal(country, a))
.map((a) => ({name: a.name}));
}
private readonly countryHasAnimal = (
country: Country,
animal: AnimalResponseItem,
) => {
if (country === "FR")
return (
animal.locations.includes("001") ||
animal.locations.includes("EU") ||
animal.locations.includes("FX")
); // etc...
// Same logic for other countries
return false; // We could also throw an exception but this is not the purpose of this article
};
}
type Country = "FR" | "UK" | "US" | "ES";
type Animal = { name: string };
interface AnimalAPIGateway {
listAll(): AnimalResponse;
}
type AnimalResponse = Array<AnimalResponseItem>;
type AnimalResponseItem = {
id: string;
name: string;
locations: Array<string>;
};
You've probably already noticed that the countryHasAnimal
method contains most of the logic for this feature. The main
issue with this method is that each time we introduce a new country into our application (which is growing rapidly), we
have to modify the codebase of the ListAnimal
class, thus violating the Open-Closed principle.
Now that we've identified this violation, let's analyze this piece of code and consider what solution we can devise to
rectify it. After some deliberation, here's what we've concluded:
- Each country's logic resides in an IF block.
- Each block only requires the
AnimalResponseItem
.
These observations lead us to consider using a Map data structure that would have a function filtering an
AnimalResponseItem
for each country our application supports.
First, let's introduce this typing:
type CountryFilters = Record<Country, CountryFilter>
type CountryFilter = (animal: AnimalResponseItem) => boolean;
Next, we can extract the logic into an external constant, for example, to ensure it doesn't disrupt our application's
test suite.
export const countryFilters: CountryFilters = {
FR: (animal) =>
animal.locations.includes("001") ||
animal.locations.includes("EU") ||
animal.locations.includes("MQ") ||
animal.locations.includes("FX"),
//US: (animal) => US LOGIC, etc...
};
Then, let's replace the code of countryHasAnimal
with a call to our countryFilters
constant.
class ListAnimal {
private readonly countryHasAnimal = (
country: Country,
animal: AnimalResponseItem,
) => {
return countryFilters[country](animal)
};
}
We can even refactor it further by introducing some syntactic sugar, resulting in a straightforward implementation of
our feature.
class ListAnimal {
constructor(private readonly _animalAPIGateway: AnimalAPIGateway) {
}
execute({country}: { country: Country }): Array<Animal> {
const apiResponse = this._animalAPIGateway.listAll();
return apiResponse
.filter(countryFilters[country])
.map((a) => ({name: a.name}));
}
}
And there you have it! Our code no longer violates the Open-Closed principle because introducing a new country into our
system only requires adding a new filter to the countryFilters
, which is considered an extension.
We won't need to touch our ListAnimal
feature at all! Beautiful, isn't it?
Stay tuned for more insights! Free to follow me on this platform
and LinkedIn. I share insights every week about software
design, OOP practices, and some personal project discoveries! ๐ป๐
Top comments (0)