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);
};
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.
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:
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
.
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?
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:
services:
directly like the others?And flow of WTFs is coming at you.
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;
}
}
CompilerPass
or configThanks 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()
+ ]);
}
}
}
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
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."
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-souce packages like Rector every day?
Consider supporting it on GitHub Sponsors.
I'd really appreciate it!