Why is Collector Pattern so Awesome

How to achieve open for extension and closed for modification one of sOlid principals?

Why Collector pattern beats config tagging? How to use the in Symfony application? How it turns locked architecture into scaling one?

I already wrote about Collector pattern as one we can learn from Symfony or Laravel. But they're so useful and underused I have need to write a more about them.

Yesterday I worked on Rector and needed an entry point to add one or more Rectors by user.


To give you a context, now you can register particular Rectors to config as in Symfony:

use Rector\Privatization\Rector\MethodCall\PrivatizeLocalGetterToPropertyRector;
use Rector\Config\RectorConfig;

return function (RectorConfig $rectorConfig): void {
    $rectorConfig->rule(PrivatizeLocalGetterToPropertyRector::class);
};

Towards Scalable Architecture

Well, we could accept PR and hard-code it into application, that would work too. But the point is to allow end-user to add as many customs services of specific type as he or she wants without need to modify our application.

Open to Extension, Closed to Modification

This is how open/closed principle looks like. If you still don't have the idea, see very nice and descriptive examples in jupeter/clean-code-php.

Let's start with ideas:

1. Add a Provider and Collect it in CompilerPass?

My first idea was a provider that would return such Rector:

<?php declare(strict_types=1);

namespace App\Rector;

use Rector\Contract\Rector\RectorInterface;

final class SymfonyRectorProvider implements RectorInterface
{
    public function provide()
    {
        $rector = new CustomSymfonyRector;
        // some custom modifications

        return $rector;
    }
}

Such service is registered by user to the config:

use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;

return function (ContainerConfigurator $containerConfigurator): void {
    $services = $containerConfigurator->services();
    $services->defaults()
        ->autowire();

    $services->set(\App\Rector\SymfonyRectorProvide::class);
};

And collected by our application via CompilerPass:

<?php declare(strict_types=1);

namespace Rector\RectorBuilder\DependencyInjection\CompilerPass;

use Rector\Rector\RectorCollector;
use Rector\RectorBuilder\Contract\RectorProviderInterface;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\ExpressionLanguage\Expression;
use Symplify\PackageBuilder\DependencyInjection\DefinitionFinder;

final class RectorProvidersCompilerPass implements CompilerPassInterface
{
    public function process(ContainerBuilder $containerBuilder): void
    {
        $rectorCollectorDefinition = $containerBuilder->getDefinition(RectorCollector::class);

        $rectorProviderDefinitions = DefinitionFinder::findAllByType(
            $containerBuilder,
            RectorProviderInterface::class
        );

        foreach ($rectorProviderDefinitions as $rectorProviderDefinition) {
            $providedRector = new Expression(
                sprintf('service("%s").provide()', $rectorProviderDefinition->getClass())
            );
            $rectorCollectorDefinition->addMethodCall('addRector', [$providedRector]);
        }
    }
}

Are you curious what DefinitionFinder? It's just a helper class around ContainerBuilder.

2. Use Expression Language?

Wait, what is this?

$providedRector = new Expression(
    sprintf('service("%s").provide()', $rectorProviderDefinition->getClass())
);

That is part of Symfony Expression Language that allows calling methods on services before container compilation.

Could you guess, how the final code in compiled container would look like?


Something like this:

$rectorCollector = new Rector\Rector\RectorCollector;
$rectorCollector->addRector((new App\Rector\SymfonyRectorProvider)->provide());

To be honest, it's magic and unclear code to me. It also needs symfony\expression package to be installed manually. I don't want to refer people to this paragraph just to understand 3 lines in CompilerPass. That code smells bad.

But what now?

From One-to-One to One-to-Many

To simulate real life we should have at least 2 problems at once :)

The most common case is product in e-commerce. Product JBL Charge 3 has 1 category - speaker. Ok, you write a code with Doctrine Entity that each product has one category. But as it happens in life, change is the only constant, website grows and search expands with new request from your boss: "A product needs to have multiple categories". What now?

The same happened for Rector - I need to add multiple Rectors in RectorProvider. What now?

Damn! Mmm, tell people to use one provider per Rector?

use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;

return function (ContainerConfigurator $containerConfigurator): void {
    $services = $containerConfigurator->services();
    $services->defaults()
        ->autowire();

    $services->set(\App\Rector\SymfonyRectorProvider::class);

    $services->set(\App\Rector\AnotherSymfonyRectorProvider::class);
};

Quick solution, yet smelly:

And flow of WTFs is coming at you.

3. Does Collector Scale?

Let's try a different approach that Colletor pattern screams at us. We now have one-to-one RectorColletor implementation:

<?php declare(strict_types=1);

namespace Rector\Rector;

use Rector\Core\Contract\Rector\RectorInterface;

final class RectorCollector
{
    // ...

    public function addRector(RectorInterface $rector): void
    {
        $this->rectors[] = $rector;
    }
}

What do we want?

Drop that Expression Language Magic

Thanks to Collector pattern we now have 1 place to solve these problems at:

 <?php declare(strict_types=1);

 namespace Rector\Rector;

 use Rector\Contract\Rector\RectorInterface;
 use Rector\RectorBuilder\Contract\RectorProviderInterface;

 final class RectorCollector
 {
     public function addRector(RectorInterface $rector): void
     {
         $this->rectors[] = $rector;
     }
+
+    public function addRectorProvider(RectorProviderInterface $rectorProvider): void
+    {
+         $this->addRector($rectorProvider->provide());
+    }
 }

And thanks to that, we can cleanup CompilerPass:

<?php declare(strict_types=1);

namespace Rector\RectorBuilder\DependencyInjection\CompilerPass;

use Rector\Rector\RectorCollector;
use Rector\RectorBuilder\Contract\RectorProviderInterface;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\ExpressionLanguage\Expression;
use Symplify\PackageBuilder\DependencyInjection\DefinitionFinder;

final class RectorProvidersCompilerPass implements CompilerPassInterface
{
    public function process(ContainerBuilder $containerBuilder): void
    {
        $rectorCollectorDefinition = $containerBuilder->getDefinition(RectorCollector::class);

        $rectorProviderDefinitions = DefinitionFinder::findAllByType(
            $containerBuilder,
            RectorProviderInterface::class
        );

        foreach ($rectorProviderDefinitions as $rectorProviderDefinition) {
-           $providedRector = new Expression(
-               sprintf('service("%s").provide()', $rectorProviderDefinition->getClass())
-           );
-           $rectorCollectorDefinition->addMethodCall('addRector', [$providedRector]);
+           $rectorCollectorDefinition->addMethodCall('addRectorProvider', [
+               '@' . $rectorProviderDefinition->getClass()
+           ]);
        }
    }
}

How do we Provide Multiple Items?

I didn't forget, our dear manager. Do you have idea how would you add it?

<?php declare(strict_types=1);

namespace Rector\RectorBuilder\Contract;

use Rector\Core\Contract\Rector\RectorInterface;


interface RectorProviderInterface
{
   /**
    * @return RectorInterface[]
    */
   public function provide(): array
}

And update RectorCollector class:

 <?php declare(strict_types=1);

 namespace Rector\Rector;

 use Rector\Contract\Rector\RectorInterface;
 use Rector\RectorBuilder\Contract\RectorProviderInterface;

 final class RectorCollector
 {
     public function addRector(RectorInterface $rector): void
     {
         $this->rectors[] = $rector;
     }

     public function addRectorProvider(RectorProviderInterface $rectorProvider): void
     {
-         $this->addRector($rectorProvider->provide());
+         foreach ($rectorProvider->provide() as $rector) {
+             $this->addRector($rector);
+         }
     }
 }

Now we have:

✅ single entry point for Collector + Provider

✅ typehinted RectorInterface control in code

✅ clean config for use and compiler for our code

✅ removed symfony/expression-language dependency

4. Add Tagging?

We forget tagging, right? The most spread useless code in Symfony configs.


"Perfection is achieved, not when there is nothing more to add, but when there is nothing left to take away."
Antoine de Saint-Exupery

Why would you add it and where? I don't take arguments like "well, it's historical reasons" and !tagged, since it add more coupling.

That's it for today!



Happy collecting!




Do you learn from my contents or use open-source packages like Rector every day?
Consider supporting it on GitHub Sponsors. I'd really appreciate it!