When you’re developing a project, you often see a collection of ifs one after the other, or a huge switch / case that executes code according to a state. This code is often unreadable and difficult to maintain.
But did you know that there’s a design pattern that avoids this? Symfony makes it easy to integrate.
Why do we use a Design Pattern Strategy?
During the development of an application in the real estate sector, we had to implement a function enabling users toobtain and display their home’s consumption figures.
Initially, it was only possible to manually enter meter readings (electricity, gas or other). Consumption was then calculated on the basis of these meter readings.
We then wanted to exploit the interoperability capabilities of energy suppliers, by automatically retrieving consumption data from Linky and Gazpar meters. But how could we do this while minimizing the impact on already functional code?
The solution: Design Pattern Strategy.
Definition of Design Pattern Strategy
The Design Pattern Strategy is a design pattern that enables algorithms to be selected and executed according to certain conditions, all in compliance with one of the SOLID principles: Single Responsibility.
First, we’ll set up a service to run these different algorithms. We’ll call this service ExternalConsumptionProvider. Each algorithm will be represented by a service we’ll call provider. The purpose of a provider is to provide us with consumption data for a given category.
In our example, we will have 2 providers:
EnedisDataConnectProvider
: Who will send us the consumption data from our Linky meter.GrdfGazparProvider
: Who will send us the consumption data from our Gazpar meter.
Here’s a diagram to help you understand:
This diagram shows our ExternalConsommationProvider
service (the yellow block at the very top). This will contain a list of providers (the property visible via the little red square). Each provider will be an instance of ConsommationProviderInterface
(the yellow block in the middle). This will require each provider to develop 2 methods (support()
and findByRange()
).
Finally, we find our 2 providers EnedisDataConnectProvider
and GrdfGazparProvider
(each implementing the interface in question, symbolized by the dotted arrow).
The aim of our ExternalConsommationProvider
service is, via its findByRange
method, to return consumption data to us. Without even having developed our 2 providers, we can already develop this method:
public function findByRange(Logement $logement, DateTime $start, DateTime $end, User $user): array
{
$consommations = [];
foreach ($this->getProviders() as $provider) {
if ($provider->support($logement, $user)) {
$consommations = array_merge(
$consommations,
$provider->findByRange($logement, $start, $end, $user)
);
}
}
return $consommations;
}
Let’s take a look at this code: we loop over the providers of our service. If each provider is supported (in short, if the user wishes to obtain consumption data from that provider), we add the consumption data retrieved by the provider to our $consommations
table.
As far as our providers are concerned, all we need to do is develop the methods of our :
class EnedisDataConnectProvider implements ConsommationProviderInterface
{
public function support(Logement $logement, User $user): bool
{
// On vérifie si notre logement est bien connecté au compteur Linky
}
public function findByRange(Logement $logement, DateTime $start, DateTime $end, User $user): array
{
// On retourne les consommations de notre compteur Linky grâce à l'API Data Connect d'Enedis
}
}
Now that we’ve implemented our ExternalConsommationProvider
service and our 2 providers, how do we integrate these providers into our service?
This is where Symfony’s Compiler Pass comes in.
Compiler Pass definition
To add a provider to our service, we use the addProvider()
method, which expects an instance of ConsommationProviderInterface
as a parameter.
With Symfony, we can tag our services to identify them more easily in our application.
So we’re going to add a tag to all our services implementing the ConsommationProviderInterface
interface. To do this, simply add the following lines to our config/services.yaml
file:
_instanceof:
App\Service\ConsommationProviderInterface:
tags: [ app.rt2012.external_consommation_provider ]
So, our 2 providers will have the tags app.rt2012.external_consommation_provider
.
Now that we’ve identified our providers, we can create a CompilerPass. To do this, let’s create the ExternalConsommationProviderPass
class like this:
class ExternalConsommationProviderPass implements CompilerPassInterface
{
public function process(ContainerBuilder $container)
{
if (!$container->has(ExternalConsommationProvider::class)) {
return;
}
$definition = $container->findDefinition(ExternalConsommationProvider::class);
$taggedServices = $container->findTaggedServiceIds('app.rt2012.external_consommation_provider');
foreach ($taggedServices as $id => $tags) {
$definition->addMethodCall('addProvider', [new Reference($id)]);
}
}
}
In our process()
method, we check whether the ExternalConsommationProvider
service exists. Next, we retrieve all services tagged with the name app.rt2012.external_consommation_provider
, i.e. our 2 providers. Finally, we loop over these services to add them to our ExternalConsommationProvider
service using the addProvider()
method.
Finally, our application needs to take this CompilerPass into account. To do this, simply add it to the container, via the build
method of the App\Kernel
class:
class Kernel extends BaseKernel
{
public function build(ContainerBuilder $container)
{
$container->addCompilerPass(new ExternalConsommationProviderPass());
}
}
The benefits of a Design Pattern Strategy
This is not the first and only time we’ve used this technique. We were able to exploit its advantages in the context of simple file imports. We’ve developed a service called FileImporter
that can import two different file types, such as a .xml
file and a .yaml
file. This gives us two services: XmlFileImporter
and YamlFileImporter
. Each contains a support
method for checking whether the file is compliant and a execute
method for importing the file. Our main service will know which one to choose, depending on the file to be imported.
The main advantage of the Design Pattern Strategy is that it allows us to execute different algorithms according to a given state.
Let’s imagine that, tomorrow, we also need to display the consumption of a solar self-consumption device. All we have to do is create a provider implementing the ConsommationProviderInterface
interface.
Similarly, if we need to handle the import of another type of file (in .json
format for example), we’ll simply need to create another service ( JsonFileImporter
for example).
So we’ll be ready for future improvements 😉