Refactoring if-else code blocks to Strategy pattern with Symfony service locator

Photo by Walls.io on Unsplash Introduction Often in your code you will implement logic that has a few independent flows where only one of them will be executed during runtime depending on specific conditions or parameters. For example, when processing payments in an online store — there will be one piece of code for handling credit card payments, and a different one for processing PayPal transactions. For a single order, only one of them will be used — but you need to make all of them available to be called in your order processing logic. How to organize such code in a way that you don’t end up with a huge block containing if statements? The answer is the Strategy pattern! As I’ve been going through a similar scenario recently, let me guide you on how to organize such a logic well: We will use the Strategy pattern to keep different implementations separated from each other. And then, we will leverage the service container from the Symfony framework so that in the client class we can call the selected strategy class methods in an elegant way. Let’s start by putting everything in one class First iteration Recently I’ve been implementing a class to fetch prices of different types of assets (stocks, funds, bonds, etc.). This required me to use a bunch of public APIs — so that I can use API provider X for stocks from US markets, provider Y for cryptocurrencies, and so on. When writing the code in the first iteration, I started simply — just by putting calls to a single API in one class:

Apr 3, 2025 - 11:10
 0
Refactoring if-else code blocks to Strategy pattern with Symfony service locator

Photo by Walls.io on Unsplash

Introduction

Often in your code you will implement logic that has a few independent flows where only one of them will be executed during runtime depending on specific conditions or parameters.

For example, when processing payments in an online store — there will be one piece of code for handling credit card payments, and a different one for processing PayPal transactions. For a single order, only one of them will be used — but you need to make all of them available to be called in your order processing logic.

How to organize such code in a way that you don’t end up with a huge block containing if statements? The answer is the Strategy pattern!

As I’ve been going through a similar scenario recently, let me guide you on how to organize such a logic well:

  • We will use the Strategy pattern to keep different implementations separated from each other.
  • And then, we will leverage the service container from the Symfony framework so that in the client class we can call the selected strategy class methods in an elegant way.

Let’s start by putting everything in one class

First iteration

Recently I’ve been implementing a class to fetch prices of different types of assets (stocks, funds, bonds, etc.). This required me to use a bunch of public APIs — so that I can use API provider X for stocks from US markets, provider Y for cryptocurrencies, and so on.

When writing the code in the first iteration, I started simply — just by putting calls to a single API in one class:

      

    class AssetPriceService  
    {  
      //...  

      public function getAssetPrice(Asset $asset): float {  
        $response = $this->httpClient->request('GET', $this->getPriceApiUrl($asset));  
        $content = $response->toArray();  

        return (float)$content['results']['price'];  
      }  
    }

Adding another API client implementation

The very next step is handling the scenario when we want to fetch prices of multiple types of assets — from different API providers. The most trivial way would be just to add an if-else block to the existing method:

      

    class AssetPriceService  
    {  
      //...  

      public function getAssetPrice(Asset $asset): float {  
        if ($asset->getType() === 'stock') {  
          $response = $this->httpClient->request('GET', $this->getPriceApiUrl($asset));  
          $content = $response->toArray();  

          return (float)$content['results']['price'];  
        }  
        else if ($asset->getType() === 'cryptocurrency') {  
          $response = $this->httpClient->request('GET', $this->getCryptoPriceApiUrl($asset));  
          $content = $response->toArray();  

          return (float)$content['0']['last_price'];  
        }  
        else {  
          throw new RuntimeException('Not supported asset type');  
        }  
      }  
    }

What is wrong with this code?

We can easily see that if we go further this way, we will quickly get lost in a spaghetti code built in a huge if-else statement.

If you notice this pattern in your code, it means that this is the moment to introduce the Strategy pattern!

What are the characteristics of code suitable for such a refactoring?

  • It contains separate, independent flows (algorithms).
  • And only one of these flows will be executed for a given set of parameters during runtime.

— hence the if-else statement.

In our case: the code contains separate API client implementations  — but only one of them will be used during runtime to fetch prices for a given asset.

Refactoring to the Strategy pattern

Creating strategy classes

In the Strategy pattern, different implementations are extracted into separate classes, called strategies.

Then, during runtime, the client class chooses the appropriate strategy to use, depending on the input parameters (in our example — depending on the asset type).

This way, any changes in the implementation of a specific API client (like changing the authentication method, the URL, or switching between test and production API), will happen in isolation from the main service class and from other API client classes.

Then, the main service — AssetPriceService— will be a good candidate to contain code that is not related to API communication but rather to the business logic, like e.g. extra transformations on top of the raw prices, custom calculations, persisting the data, etc.

So, let’s extract the API client code to two separate classes, one for each type of asset:

      

    class StockPriceApiClient  
    {  
      // ...  

      public function fetchPrice(Asset $asset): float {  
        $response = $this->httpClient->request('GET', $this->getPriceApiUrl($asset));  
        $content = $response->toArray();  

        return (float)$content['results']['price'];  
      }  
    }  

    class CryptocurrencyPriceApiClient  
    {  
      // ...  

      public function fetchPrice(Asset $asset): float {  
        $response = $this->httpClient->request('GET', $this->getCryptoPriceApiUrl($asset));  
        $content = $response->toArray();  

        return (float)$content['0']['last_price'];  
      }  
    }

You can see that both classes have the same structure so probably a natural next step would be to define an interface that these classes will implement. We will do it in one of the next steps — so keep on reading!

For now, let’s make the changes in the service that calls the API clients’ methods.

Adjusting the main class

The main class — in our case the AssetPriceService — needs to be adjusted to choose the proper strategy and run its methods. So, let’s just refactor it to use the classes that we defined above:

      

    class AssetPriceService  
    {  
        //...  

        public function __construct( private readonly StockPriceApiClient $stockPriceApiClient,  
          private readonly CryptocurrencyPriceApiClient $cryptocurrencyPriceApiClient ) {  
        }  

        public function getAssetPrice(Asset $asset): float {  
          if ($asset->getType() === 'stock') {  
            return $this->stockPriceApiClient->fetchPrice($asset);  
          }  
          else if ($asset->getType() === 'cryptocurrency') {  
            return $this->cryptocurrencyPriceApiClient->fetchPrice($asset);  
          }  
          else {  
            throw new RuntimeException('Not supported asset type');  
          }  
        }  
    }

As you can see, what changed is not only the getAssetPrice method but also the constructor. Now in our service we need to inject all the API client classes as dependencies, and then call the proper one based on the asset type. If we’re using the Symfony framework, the dependencies specified in the constructor will be injected automatically.

Okay, it looks nicer but this code is still not optimal:

  • Adding a new API client in the future will require us to add the dependency manually in the service constructor.
  • Additionally, we instantiate all the strategy classes and pass them as dependencies, while during runtime only one of them will be used.

Can we do something about it? Let’s leverage the Symfony service locator to handle the dependencies in a more elegant way.

Using the service locator with the Strategy pattern

Why service locator is the solution

Symfony service locator is a solution for the use case that emerges when implementing the Strategy pattern:

  • we implemented separate classes for different implementations of the logic,
  • but we don’t want to instantiate and pass all of them to the client class — as during runtime most likely only one will be used.

A service locator is a container that holds a defined set of classes and instantiates them lazily, i.e. only when they are actually used.

Injecting the service locator in the constructor

So, in theAssetPriceService let’s replace injecting specific API clients in the constructor by injecting a service locator. Then, when choosing the proper client to use, we will retrieve the instance of the client from the service locator:

      

    use Psr\Container\ContainerInterface;  

    class AssetPriceService  
    {  
      //...  

      public function __construct( #[AutowireLocator([  
          StockPriceApiClient::class,  
          CryptocurrencyPriceApiClient::class,  
        ])]  
        private readonly ContainerInterface $apiClients ) {  
      }  

      public function getAssetPrice(Asset $asset): float {  
        if ($asset->getType() === 'stock') {  
          $apiClient = $this->apiClients->get(StockPriceApiClient::class);   
        }  
        else if ($asset->getType() === 'cryptocurrency') {  
          $apiClient = $this->apiClients->get(CryptocurrencyPriceApiClient::class);  
        }  
        else {  
          throw new RuntimeException('Not supported asset type');  
        }  

        return $apiClient->fetchPrice($asset);  
      }

As you can see, we added the AutowireLocator attribute that defines which dependencies we want to have available in the container at runtime.

But… still, we need to list all the API client classes in the autowire locator attribute! This is not what we wanted!

Leveraging a common interface

What we already saw earlier, is that all API client classes can implement a common interface. Okay, so now is the moment to do the relevant refactoring and introduce it:

    interface PriceApiClientInterface  
    {  
      public function fetchPrice(Asset $asset): float;  
    }  

    class StockPriceApiClient implements PriceApiClientInterface  
    {  
      //...  
    }  

    class CryptocurrencyPriceApiClient implements PriceApiClientInterface  
    {  
      //...  
    }

What we would like to achieve now with the service locator, is that all classes that implement this interface are available as dependencies for the AssetPriceService , without a need to list them explicitly.

The first thing to try would be to put the interface in the AutowireLocator attribute — but this will result in an error as this is not supported by the framework.

But no worries! The service locator supports tags. So, we just need to add the following three lines to our services.yaml file in the services section:

    _instanceof:  
        App\PriceApiClientInterface:  
            tags: [ 'price_api_client' ]

This way, all classes that implement the PriceApiClientInterface will be automatically tagged with price_api_client tag.

And now we can use the tag in the service locator attribute:

      

    use Psr\Container\ContainerInterface;  

    class AssetPriceService  
    {  
      public function __construct( #[AutowireLocator('price_api_client')]  
        private readonly ContainerInterface $apiClients ) {  
      }  

      //...  
    }

That’s it! All strategy implementations — all API client classes — will be available as dependencies to our client class — the AssetPriceService .

We don’t need to list them explicitly, we don’t need to instantiate them. A proper API client class will be instantiated during runtime when called from the service.

Final touches

To organize the service class in an elegant way, let’s do a quick refactoring:

  • Let’s keep the asset-to-API-client mapping separated.
  • Let’s also use the PHP8 match expression so that the code is concise and easy to read.
  • In the final code, our method for getting asset prices can be just a one-liner!

The full service code will look as follows:

      

    use Psr\Container\ContainerInterface;  

    class AssetPriceService  
    {  
      public function __construct( #[AutowireLocator('price_api_client')]  
        private readonly ContainerInterface $apiClients ) {  
      }  

      public function getAssetPrice(Asset $asset): float {  
        return $this  
                ->apiClients  
                ->get($this->getApiClientForAsset($asset))  
                ->fetchPrice($asset);  
      }  

      private function getApiClientForAsset(Asset $asset): string {  
        return match ($asset->getType()) {  
          'stock'          => StockPriceApiClient::class,  
          'cryptocurrency' => CryptocurrencyPriceApiClient::class,  
          default          => throw new RuntimeException('Not supported asset type');  
        };  
      }  
    }

Questions and answers

Why don’t we name the classes as “Strategies”?

A side note about the naming — as you can notice, the API client classes are not actually called Strategies. Of course, we could name them to reflect the name of the design pattern, like StockStrategy and CryptocurrencyStrategy but… it doesn’t sound understandable in the bigger context in this case.

So it’s totally okay to use a given design pattern like Strategy and at the same time keep the naming in the code that is related to the specific business logic that we’re implementing. There’s no point in forcing yourself to put the name of the given pattern in the class name if it makes the overall code structure less readable.

Why don’t we just inject the Symfony container instead of playing with the service locator?

Theoretically, we could just inject the whole Symfony container into our AssetPriceService as a dependency in the constructor… but it’s far from perfect.

This way we would actually loose information on what dependencies this service has — because we would just throw the entire container with all services as one huge dependency. That’s more of an anti-pattern when making use of the service container rather than the desired approach.