The joy of implementing Strategy Pattern in Symfony

One of the design patterns that I find myself most often implementing is the Strategy Design pattern. Oftentimes it helps decouple logic and allows better separation of concerns.

In previous versions of Symfony, it was of course possible to implement the pattern, but there was a bit more friction involved, like the need to fiddle with yaml files. This has dissapeared meanwhile thanks to the introduction of the attributes in PHP8. Now that we have the TaggedIterator and AutoconfigureTag attributes at our disposal, it has never been so effortless to implement the strategy pattern using Symfony framework.

In this post, we'll go through a concrete example of how to do this, in which we'll highlight the main components.

Case Study

Let's suppose we're building an imaginary Payment Gateway Software that supports some of the major credit/debit card providers (Visa, Mastercard, and American Express).

To keep things simple we will ignore lots of important elements like the credit card number, CVV, and so on and of course, there won't be any requests to external entities.

Acceptance criteria:

  • Create a POST endpoint called /card-payments that accepts the following JSON payload:
{
  "amount": "12.22",
  "currency": "EUR",
  "provider": "visa|mastercard|amex"
}
  • The endpoint should return a JSON response containing a transactionId property that has as a prefix the name of the payment provider passed in the input and is followed by some "random" string similar to the example below:
{
 "transactionId": "visa_asd22kjlkoiposd"
}
  • The assumption is that other logic should be dealt for each provider so generating the transactionId should be separated for each card payment provider

An example implementation

Note, that a lot of important aspects like validation, exception handling, and code structuring have not been considered in detail as the focus is on the strategy pattern itself.

First we'll start out by building the CardPaymentController, where we get the payload, deserialize it to a CardPaymentDto, and pass it further to a CardPaymentService which is responsible for finding the correct strategy and retrieving the transactionId from the strategy.

...
class CardPaymentController
{
   ...
    #[Route('/card-payments', methods: ['POST'])]
    public function index(Request $request): Response
    {
        $paymentDto = $this->serializer->deserialize($request->getContent(), CardPaymentDto::class, 'json');

        $paymentResponse = $this->cardPaymentService->handleCardPayment($paymentDto);

        return new JsonResponse($this->serializer->serialize($paymentResponse, 'json'), json: true);
    }
}

After the controller is created we will implement the strategy interface:

#[AutoconfigureTag(CardPaymentStrategyInterface::class)]
interface CardPaymentStrategyInterface
{
    public function isPaymentProviderSupported(string $paymentProvider);

    public function handlePayment(CardPaymentDto $payment): CardPaymentResponseDto;
}

Note the use of the AutoconfigureTag attribute here. Usually, it's more common to use a string here as the service tag, but I prefer using the name of the interface to minimize the chance of typos.

As the interface is defined we can start creating the strategy implementations:

class VisaCardPaymentStrategy implements CardPaymentStrategyInterface
{
    public function isPaymentProviderSupported(string $paymentProvider): bool
    {
        return $paymentProvider === PaymentProvider::VISA;
    }

    public function handlePayment(CardPaymentDto $payment): CardPaymentResponseDto
    {
        // Call Visa payment provider
        // More logic

        $transactionId = PaymentProvider::VISA . uniqid();
        return new CardPaymentResponseDto($transactionId);
    }
}

and similar for the other payment providers:

class MastercardCardPaymentStrategy implements CardPaymentStrategyInterface
{
    public function isPaymentProviderSupported(string $paymentProvider): bool
    {
        return $paymentProvider === PaymentProvider::MASTERCARD;
    }

    public function handlePayment(CardPaymentDto $payment): CardPaymentResponseDto
    {
        // Call Mastercard payment provider
        // More logic

        $transactionId = PaymentProvider::MASTERCARD . uniqid();
        return new CardPaymentResponseDto($transactionId);
    }
}

When the supported strategies are defined we can start implementing the CardPaymentService:

class CardPaymentService implements CardPaymentServiceInterface
{
    /**
     * @param iterable<CardPaymentStrategyInterface> $cardPaymentStrategies
     */
    public function __construct(
        #[TaggedIterator(CardPaymentStrategyInterface::class)]
        private readonly iterable $cardPaymentStrategies,
    ) {
    }

    public function handleCardPayment(CardPaymentDto $paymentDto): CardPaymentResponseDto
    {
        $paymentStrategy = $this->getStrategy($paymentDto->getProvider());

        return $paymentStrategy->handlePayment($paymentDto);
    }

    public function getStrategy(string $paymentProvider): CardPaymentStrategyInterface
    {
        $pickedPaymentStrategy = null;
        foreach ($this->cardPaymentStrategies as $paymentStrategy) {
            if ($paymentStrategy->isPaymentProviderSupported($paymentProvider)) {
                $pickedPaymentStrategy = $paymentStrategy;
            }
        }

        if (is_null($pickedPaymentStrategy)) {
            throw UnsupportedPaymentStrategyException::createForPaymentProvider($paymentProvider);
        }

        return $pickedPaymentStrategy;
    }
}

Note the TaggedIterator attribute here. It allows us to collect all the classes that were auto-configured using the CardPaymentStrategyInterface and have them available as an iterable. Here we simply find the correct strategy and call it using the PaymentDto and return the result of the picked strategy, which should contain the transactionId.

Make sure that service autoconfiguration is enabled in order for this setup work properly.

Conclusion

Applying the strategy pattern is really straightforward using the latest versions of Symfony and PHP (Symfony 6.4 and PHP 8.2 in the given example). Apart from keeping the code much more decoupled, it offers the benefit of keeping the code open to extension but closed to modifications. So for example, if we were to add other payment providers, we wouldn't need to touch any of the existing logic thus avoiding the risk of breaking anything there.

To see the entire implementation feel free to check out the git repository. Additionally from the code shown above, the repository contains a docker-compose setup and some basic tests that you can play around with.


References: