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 services: section as you know from Symfony:

# rector.yml
services:
    Rector\Rector\Contrib\Symfony\HttpKernel\GetterToPropertyRector: ~


But how would you add custom Rector class with a PHP provider?

final class YourOwnRectorProvider
{
    public function provider()
    {
        $rector = new CustomSymfonyRector;
        // some custom modifications

        return $rector;
    }
}

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:

# rector.yml
services:
    _defaults:
        autowire: true

    App\Rector\SymfonyRectorProvider: ~


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?

# rector.yml
services:
    _defaults:
        autowire: true

    App\Rector\SymfonyRectorProvider: ~
    App\Rector\AnotherSymfonyRectorProvider: ~

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\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\Contract\Rector\RectorInterface;

interface Rector\RectorBuilder\Contract\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.

Try to convince me though if you're sure about its advantages.

How was our Path from the End?

Add provider

Use expression language?

Does collector scale?

Add tagging

"Git Story" over git history

I want to share with you one last idea. I could show you the final commit - or even worse - just the final versions of RectorProviderInterface, RectorCollector and RectorProvidersCompilerPass. But what could you take from such a code? Nothing, because only when we fail, we learn something new.

Same can be applied to git history. When I see 2 final files with 2 commits, I learn nothing new. And pose questions and comments on blind paths, that author already took (and explains me again in words to my comments).

Next time you squash 20 commits to 1, remember:

Git should tell the story, as the human history does.



Happy collecting!


  Continue Learning


Typo? Fix it, please  and join 47 people who build this website

GitHub RSS @votrubaT Runs on Statie Hosted on GitHub Build by 48 people

Like what I write about? Hire me & we can work together