Changer d’algorithme dans Symfony sans modifier le code : le pattern Strategy

Illustration de Changer d’algorithme dans Symfony sans modifier le code : le pattern Strategy

Utiliser le pattern Strategy dans Symfony pour changer de comportement sans multiplier les conditions.

Vous est-il déjà arrivé de devoir choisir entre plusieurs comportements dans une application et de finir avec une succession de blocs if/else ou switch ? En plus de nuire à la lisibilité, ces conditions deviennent difficiles à maintenir à mesure que l’application évolue.

Prenons un système comme Google Maps. Pour l’utilisateur, il n’existe qu’une seule interface : trouver un itinéraire. En interne, la logique varie pourtant selon le mode de déplacement : voiture, vélo ou marche. C’est une application du pattern Strategy. Dans cet article, je montre comment l’utiliser dans Symfony pour conserver un code flexible, extensible et lisible.

Le pattern Strategy en quelques mots

Le pattern Strategy est un patron de comportement classique. Il permet de :

  • définir une famille d’algorithmes ;

  • encapsuler chaque algorithme séparément ;

  • les rendre interchangeables au moment de l’exécution.

Le code client n’a pas besoin d’être modifié lorsque l’algorithme change : il utilise simplement l’interface. Cette séparation des responsabilités fait l’intérêt du pattern Strategy.

L’injection de dépendances comme point d’appui

Dans Symfony, ce pattern repose sur le conteneur d’injection de dépendances (DI). L’injection de dépendances est souvent perçue comme un simple moyen de gérer des services. Sa véritable force tient pourtant à sa capacité à injecter dynamiquement l’implémentation adaptée au contexte.

Prenons un exemple concret : la publication d’un article. Je veux publier des articles dans différents formats de sortie : flux JSON, flux RSS ou simple page HTML. Au lieu de coder plusieurs conditions en dur, je vais utiliser des stratégies.

Étape 1 : définir le contrat

Je commence par une interface commune. Ce contrat garantit un comportement cohérent pour tous les services de publication.

PHP
interface PublisherInterface
{
    public function publish(Post $post): void;
}

Étape 2 : créer les implémentations concrètes

Créons maintenant les classes qui représentent mes stratégies : JsonPublisher et RssPublisher. Elles implémentent la même interface, mais chacune prend en charge la logique propre à son format.

PHP
final readonly class JSONPublisher implements PublisherInterface
{
    public function __construct(private LoggerInterface $logger) {}

    #[\Override]
    public function publish(Post $post): void
    {
        $fqcn = $this::class;
        $this->logger->critical("Strategy {$fqcn}");
    }

    #[\Override]
    public function supports(string $channel): bool
    {
        return $channel === "json";
    }
}
PHP
final readonly class RSSPublisher implements PublisherInterface
{
    public function __construct(private LoggerInterface $logger) {}

    #[\Override]
    public function publish(Post $post): void
    {
        $fqcn = $this::class;
        $this->logger->critical("Strategy {$fqcn}");
    }

    #[\Override]
    public function supports(string $channel): bool
    {
        return $channel === "rss";
    }
}

Chaque classe est indépendante, autonome et remplaçable.

Méthode 1 : l’itérateur injecté automatiquement

Voici le premier problème : lorsque Symfony tente d’injecter PublisherInterface, il ne sait pas quelle implémentation utiliser, JsonPublisher ou RssPublisher.

Pour le résoudre, je demande à Symfony de rassembler tous les services qui implémentent mon interface et de les injecter sous la forme d’un itérable. Je peux ainsi les parcourir et choisir le bon service au moment de l’exécution.

J’attribue d’abord un tag à l’interface afin que Symfony sache qu’il doit rassembler ses implémentations :

PHP
#[AutoconfigureTag]
interface PublisherInterface
{
    public function publish(Post $post): void;
    public function supports(string $channel): bool;
}

Je crée ensuite un service central Publisher qui les reçoit toutes :

PHP
final readonly class Publisher
{
    /** @param iterable<PublisherInterface> */
    public function __construct(
        #[AutowireIterator(PublisherInterface::class)]
        private iterable $publishers,
    ) {}

    public function publish(Post $post, string $channel): void
    {
        foreach ($this->publishers as $publisher) {
            if ($publisher->supports($channel)) {
                $publisher->publish($post);
                return;
            }
        }
    }
}

Le contrôleur doit désormais injecter uniquement le service Publisher. Il n’a pas à connaître chaque service de publication : il délègue simplement le traitement. C’est le principe même du pattern Strategy : changer de comportement sans modifier le code client.

PHP
final class PostController extends AbstractController
{
    public function __construct(
        private readonly ClockInterface $clock,
        private readonly Publisher $publisher, // Ideally, should use interface instead
    ) {}

    #[Route('/{id}/{channel}', name: 'app_post_show', requirements: [
        "channel" => "html|json|rss"
    ], methods: ['GET'])]
    public function show(Post $post, string $channel = "html"): Response
    {
        $this->publisher->publish($post, $channel);
        return $this->render('post/show.html.twig', ['post' => $post]);
    }
}

Je peux encore améliorer cette approche. Puisque Publisher se comporte lui-même comme une stratégie, faisons-lui implémenter directement PublisherInterface. L’ensemble reste ainsi cohérent et prévisible.

PHP
final readonly class Publisher implements PublisherInterface
{
    public function __construct(
        #[AutowireIterator(PublisherInterface::class, excludeSelf: true)]
        private iterable $publishers,
    ) {}

    #[\Override]
    public function publish(Post $post, string $channel): void
    {
        foreach ($this->publishers as $publisher) {
            if ($publisher->supports($channel)) {
                $publisher->publish($post);
                return;
            }
        }
    }

    #[\Override]
    public function supports(string $channel): bool
    {
        return true;
    }
}

Symfony sait ne pas injecter le service Publisher lui-même, même s’il porte le tag, grâce à l’option excludeSelf, dont la valeur par défaut est true.

Méthode 2 : le localisateur de services optimisé

La méthode fondée sur l’itérateur fonctionne bien, mais elle présente un défaut : je dois instancier tous les services de publication uniquement pour vérifier leur compatibilité. Cette opération ajoute une surcharge inutile.

Je peux inverser la conception. Au lieu de demander à chaque stratégie si elle prend en charge un canal, chacune peut simplement déclarer la clé de son canal à l’avance.

Cette approche respecte le principe de ségrégation des interfaces : je divise mon contrat en deux :

PHP
#[AutoconfigureTag]
interface StrategyPublisherInterface extends PublisherInterface
{
    public static function supports(): string;
}

Mes services de publication implémentent désormais StrategyPublisherInterface, et leur méthode supports() renvoie leur canal :

PHP
interface PublisherInterface
{
    public function publish(Post $post, string $channel = "html"): void;
}

Je configure ensuite le service central Publisher avec le ServiceLocator de Symfony. J’obtiens ainsi des recherches en O(1) :

PHP
#[AsAlias(PublisherInterface::class)]
final readonly class Publisher implements PublisherInterface
{
    public function __construct(
        #[AutowireLocator(StrategyPublisherInterface::class, defaultIndexMethod: "supports")]
        private ServiceLocator $publishers
    ) {}

    public function publish(Post $post, string $channel = "html"): void
    {
        if ($this->publishers->has($channel)) {
            $this->publishers->get($channel)->publish($post, $channel);
        }
    }
}

Symfony gère alors la correspondance automatiquement. Je n’instancie pas toutes les stratégies, mais uniquement celle dont j’ai besoin. Le résultat est plus lisible, plus rapide et plus facile à maintenir.

L’utilisation dans le contrôleur devient encore plus simple :

PHP
final class PostController extends AbstractController
{
    public function __construct(
        private readonly ClockInterface $clock,
        private readonly PublisherInterface $publisher,
    ) {}

    #[Route('/{id}/{channel}', name: 'app_post_show', requirements: [
        "channel" => "html|json|rss"
    ], methods: ['GET'])]
    public function show(Post $post, string $channel = "html"): Response
    {
        $this->publisher->publish($post, $channel);
        return $this->render('post/show.html.twig', ['post' => $post]);
    }
}

Conclusion

Les deux approches, par itérateur ou par localisateur, implémentent correctement le pattern Strategy dans Symfony.

  • La méthode par itérateur est directe et intuitive. Elle convient aux ensembles réduits de stratégies.

  • La méthode par localisateur est plus efficace et s’adapte mieux à un grand nombre de stratégies, car elle évite les instanciations inutiles.

Dans les deux cas, vous éliminez les conditions volumineuses, gardez une base de code lisible et facilitez l’ajout de nouveaux comportements. C’est le principal avantage du pattern Strategy : la flexibilité sans compromettre la maintenabilité.

Bon code !

Articles liés