Changer d’algorithme dans Symfony avec le patron Strategy

Utiliser le patron Strategy et le conteneur Symfony pour choisir un comportement sans multiplier les conditions.
Une suite de blocs if/else ou switch fonctionne tant que les variantes restent peu nombreuses. Elle devient vite pénible lorsque chaque nouveau comportement oblige à modifier la même classe.
Un calcul d’itinéraire illustre bien le besoin. L’interface reste la même, mais l’algorithme change selon le mode de déplacement. Le patron Strategy isole chaque variante derrière un contrat commun. Symfony peut ensuite injecter la bonne implémentation.
Le patron Strategy en quelques mots
Le patron Strategy est un patron de comportement. 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 utilise seulement l’interface et ne change pas avec l’algorithme. Cette séparation fait l’intérêt du patron 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 la publication d’un article. Le même contenu doit sortir en JSON, en RSS ou dans une page HTML. Une stratégie remplace les conditions qui sélectionnaient auparavant chaque format.
Définir le contrat
Je commence par une interface commune. Ce contrat garantit un comportement cohérent pour tous les services de publication.
Créer les implémentations
Les deux stratégies sont JsonPublisher et RssPublisher. Elles implémentent la même interface, chacune avec la logique de son format.
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";
}
}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.
Injecter un itérateur de stratégies
Symfony ne sait pas quelle implémentation de PublisherInterface injecter entre JsonPublisher et 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 :
#[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 :
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 injecte seulement Publisher et lui délègue le traitement. Il ne connaît aucune stratégie concrète. Ajouter un format ne l’oblige donc pas à changer.
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.
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.
Utiliser un localisateur de services
L’itérateur instancie tous les services de publication avant de vérifier lequel accepte le format demandé. Un localisateur évite ce travail.
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.
J’applique alors le principe de ségrégation des interfaces et sépare le contrat en deux interfaces.
#[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 :
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) :
#[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 :
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]);
}
}Choisir entre l’itérateur et le localisateur
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, le code client ne contient plus la sélection de l’algorithme. Une nouvelle stratégie s’ajoute sans modifier le contrôleur.
Le patron Strategy est pertinent lorsque plusieurs algorithmes répondent au même contrat et évoluent séparément. Pour deux branches stables, une condition reste souvent plus lisible. Le conteneur Symfony devient intéressant quand les stratégies se multiplient ou doivent être sélectionnées par configuration.