DEV Community

Cover image for ๐Ÿค Strategy pattern at the service of SOLID Open-Closed principle
Thomas Heniart
Thomas Heniart

Posted on

๐Ÿค Strategy pattern at the service of SOLID Open-Closed principle

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"],
    },
]
Enter fullscreen mode Exit fullscreen mode

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>;
};
Enter fullscreen mode Exit fullscreen mode

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:

  1. Each country's logic resides in an IF block.
  2. 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;
Enter fullscreen mode Exit fullscreen mode

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...
};
Enter fullscreen mode Exit fullscreen mode

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)
    };
}
Enter fullscreen mode Exit fullscreen mode

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}));
    }
}
Enter fullscreen mode Exit fullscreen mode

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)