Symfony compiler pass - Strategy pattern: how to write code that is SOLID compatible

We constantly attend job interviews where questions about SOLID principles are mandatory. After that, there are usually a couple of questions about design patterns. The candidate may believe that they have finally found a company where these principles are followed, and they will get to see in practice what they have been reading and hearing about throughout their commercial experience. At the same time, the hiring side may think they have found a candidate who understands good coding practices and will either improve the project's status or, at the very least, not degrade it when writing new functionality.

P.S.: Of course, this story doesn't apply to 100% or maybe even 90% of developers and companies. However, based on my experience conducting hundreds of interviews and working on around 10 projects, the situation is very similar if not 1:1.

Today, I propose to explore an example of how to write code that adheres to the following best practices (or at least I would like it to):

  1. Open-Closed Principle

  2. Dependency Inversion + Dependency Injection

  3. A method must not have more than 5-7 dependencies in the constructor(link1, link2).

  4. Single Responsibility

If you know of any other practices, please add them.

Let's consider this question based on a typical task that developers often face, using the example of payment methods.

The problem statement

You're assigned to maintain a project with payment functionality, such as generating reports based on a parameter that comes in the GET request, like https://pay.com?provider={paypal}/. Let's call this parameter "provider," and the implemented payment methods are provider1, provider2, provider3, and provider4.

The current code is implemented, at best, like this:

<?php
class PaymentReportGenerator{
    function __construct($provider1, $provider2, $provider3, $provider4){
    }

    public function generateReport(string $targetProviderName){    
        switch($targetProviderName):
        case $provider1::NAME;
            return $provider1->getReport();
        case $provider2::NAME;
            return $provider2->getReport();
        case $provider3::NAME;
            return $provider3->getReport();
        case $provider4::NAME;
            return $provider4->getReport();
        default:
            throw new \PaymentReportServiceException("Wrong provider name given");
    }
}

The code is quite good, and often programmers consider it to comply with all SOLID principles.

Now, you receive a new task: the company is expanding to new markets, and you need to add 6 more payment methods, with the possibility of adding more soon.

The developer now faces a challenging choice of how to extend the existing code. I'll try to list typical ways to solve this problem:

1) Straightforward approach

Increase the number of parameters in the constructor and add more cases to the switch statement.
This approach might seem like a quick solution, but it tends to violate the Open-Closed Principle, as every time a new provider is added, you need to modify the generateReport function. Additionally, it increases the cyclomatic complexity and makes the code harder to maintain.

2) Abandon Dependency Injection

Dynamically create an instance of the required service based on the incoming value. This typically looks like this:

public function generateReport(string $targetProviderName){
$className = '/some/namespace/' . targetProviderName . 'PaymentReportService';
$methodName = 'getReport';
$paymentReportService = new $className();

if (class_exists($className)) {
    $instance = new $className();

    if (method_exists($instance, $methodName)) {
        return $instance->$methodName("John");
    } else {
        echo "Method $methodName not found in class $className.";
    }
} else {
    echo "Class $className not found.";
}

The drawbacks of this approach are likely apparent to everyone, but I'll mention a few of the most glaring issues:

  1. All dependencies, if they exist, must be manually passed, and where to get them? Right, again shove them all into the constructor.

  2. If (or when) the signature of the final classes changes, you'll have to come back here and make changes each time.

  3. Not the worst but the most unpleasant - code written this way won't be easily discoverable by an IDE, leading to increased time spent on maintaining this code in the future. According to statistics, programmers spend about 90% of their time reading code and only about 10% writing it. Thus, such code will incur significant costs for the business due to developer salaries not being insignificant.

  4. Lastly, there's no talk of any inversion of dependencies here at all.

Essentially, we've broken all the benefits that the excellent Symfony framework provides.

3) Manual retrieval from the container

Digging into the dependency container object and doing something similar to the previous approach. I won't detail how exactly to do this, but I'll just say that this approach is very close to what we're aiming for. However, we are still deviating from the original idea of how the framework should be used. Also, for this trick, we have to mark our classes as public in services.yaml or directly in the class files, or else they cannot be easily requested from the container. Framework developers consider this action risky, as it can lead to vulnerabilities in your code (for example, if you use third-party libraries with vulnerabilities, an attacker can gain access to the internals of your objects).

Perhaps you know or have encountered other ways to solve this problem; please write about them. I'm very interested.

4) Proposed solution

I should clarify that the example below is just a specific case of using the functionality of a compiler pass. This example shows that you can do very cool things without writing a multitude of configs and delving into the depths of the framework documentation.

  1. We need to create an interface that will unify all our services, for example: IPaymentReportProvider.

  2. Specify that each of our classes implements this interface. It's worth noting that you don't necessarily have to use an interface to mark the necessary set of classes; you can also use tags.

  3. Next, go to services.yaml and add the following lines:

     services:
    
         # ...other config....
    
         ### Describe that our strategy must got list of all objects that implements our target interface
         App\Service\Payments\PaymentReportGenerator:
           arguments: [!tagged_iterator { tag: 'app.payment_report.provider', index_by: 'key' , default_index_method: 'getProviderName'}]
       #############
         _instanceof:
           App\Service\Payments\ReportProviders\IPaymentReportProvider:
             lazy: true
             tags: ['app.payment_report.provider']
    

The second block automatically creates, registers, and tags all classes implementing the IPaymentReportProvider interface with the tag - app.payment_report.provider. The lazy: true argument indicates that the classes will be initialized only when explicitly called, and a lightweight proxy object will be used when provided as a list.

The first part declares a service and specifies that one of the parameters to be provided to the object upon creation is a collection of objects tagged with -app.payment_report.provider. Two additional parameters indicate that we want to use a custom key as an index and specify which static method to invoke to get the key value for each element.

In essence, the strategy itself is already implemented using Symfony's features. Now, we need to implement/modify our context class (naming got from here).

class PaymentReportGenerator
{
    /**
     * @var IPaymentReportProvider[]
     */
    protected array $providers = [];

    public function __construct(iterable $providers)
    {
        $providers = $providers instanceof Traversable ? iterator_to_array($providers) : $providers;

        $this->providers = $providers;
    }

    public function generate(string $paymentProviderName, array $reportOptions):array
    {
        return $this->findPaymentProvider($paymentProviderName)->generateReport($reportOptions);
    }

    private function findPaymentProvider(string $paymentProviderName): IPaymentReportProvider
    {
        if (!isset($this->providers[$paymentProviderName])) {
            throw new InvalidArgumentException(sprintf(' Payment provider for "%s" not found', $paymentProviderName));
        }
        return $this->providers[$paymentProviderName];
    }
}

At the moment of object creation, Symfony gathers all classes with the specified tag and injects the collection into the constructor as the variable $providers.

Then, when the client method generate is called, we easily locate the desired provider and initiate the report generation (the object will only be initialized at the moment of calling the generateReport method).

Working example
Repository with code - link.

Conclusion:

In this exploration of expanding payment methods in a Symfony project, we delved into various approaches to maintain code flexibility, adhere to SOLID principles, and facilitate future scalability. From direct parameter injection to dynamic service creation, we dissected the pros and cons of each strategy.

The introduction of Symfony features like compiler passes, tags, and lazy loading allowed us to implement a robust solution with minimal code modification, creating a system that dynamically adapts to the inclusion of new payment providers. The chosen strategy not only maintains the integrity of the code but also aligns with Symfony's design philosophy.

As developers, it's crucial to strike a balance between simplicity and extensibility. The discussed techniques not only address the immediate challenge of incorporating additional payment methods but also contribute to building a maintainable and scalable codebase.

Remember, the key to successful software design lies not only in solving current problems but in anticipating and accommodating future changes. By embracing Symfony's features intelligently, developers can create systems that evolve seamlessly, ensuring their projects stay resilient and adaptable in the face of ever-changing requirements.