Aller au contenu
Nicolas Demay
#[AutowireIterator] : le Strategy pattern en une ligne avec Symfony

#[AutowireIterator] : le Strategy pattern en une ligne avec Symfony

Publié le 6 min de lecture

J’ai un service de signature électronique en prod. DocuSign, ça tourne, pas de souci. Sauf qu’on veut intégrer Yousign en parallèle, d’abord pour 10% du trafic, puis progressivement monter. Et si Yousign plante, on veut retomber automatiquement sur DocuSign.

Le genre de besoin qui sent le if/else à rallonge si on ne structure pas un minimum.

Considérons l’exemple suivant.

L’interface commune

Première étape : définir ce que “signer un document” veut dire, indépendamment du provider.

src/Signature/SignatureProviderInterface.php
namespace App\Signature;
interface SignatureProviderInterface
{
public function getName(): string;
public function sign(Document $document, Signer $signer): SignatureResult;
public function isAvailable(): bool;
}

C’est le Strategy pattern dans sa forme la plus simple : une interface, plusieurs implémentations interchangeables. Le code appelant ne sait pas (et n’a pas besoin de savoir) quel provider il utilise.

Deux implémentations

src/Signature/Provider/DocuSignProvider.php
namespace App\Signature\Provider;
class DocuSignProvider implements SignatureProviderInterface
{
public function getName(): string
{
return 'docusign';
}
public function sign(Document $document, Signer $signer): SignatureResult
{
// API calls to DocuSign
}
public function isAvailable(): bool
{
// Health check (ping API, check credentials, etc.)
return true;
}
}
src/Signature/Provider/YousignProvider.php
namespace App\Signature\Provider;
class YousignProvider implements SignatureProviderInterface
{
public function getName(): string
{
return 'yousign';
}
public function sign(Document $document, Signer $signer): SignatureResult
{
// API calls to Yousign
}
public function isAvailable(): bool
{
return true;
}
}

Rien de surprenant. Deux classes, même interface.

L’orchestrateur avec AutowireIterator

src/Signature/SignatureOrchestrator.php
namespace App\Signature;
use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;
class SignatureOrchestrator
{
public function __construct(
/** @var iterable<SignatureProviderInterface> */
#[AutowireIterator(SignatureProviderInterface::class, defaultPriorityMethod: 'getPriority')]
private iterable $providers,
) {
}
}

C’est cette ligne qui fait le travail. #[AutowireIterator] dit à Symfony : “injecte-moi tous les services qui implémentent SignatureProviderInterface”. Pas de configuration YAML, pas de CompilerPass, pas de câblage manuel. Symfony collecte automatiquement toutes les implémentations.

Le defaultPriorityMethod indique à Symfony d’appeler getPriority() sur chaque provider pour déterminer l’ordre d’itération. La priorité est portée par le contrat de l’interface, au même titre que le nom. Pas par la config du container.

Pour que le tag soit appliqué automatiquement à chaque classe qui implémente l’interface, il suffit de le déclarer une fois :

src/Signature/SignatureProviderInterface.php
namespace App\Signature;
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
#[AutoconfigureTag]
interface SignatureProviderInterface
{
public function getName(): string;
public function getPriority(): int;
public function sign(Document $document, Signer $signer): SignatureResult;
public function isAvailable(): bool;
}

Toute classe qui implémente SignatureProviderInterface reçoit le tag automatiquement, avec le FQCN de l’interface comme nom de tag (App\Signature\SignatureProviderInterface). Et on en profite pour ajouter getPriority() au contrat : chaque provider déclare son ordre de priorité, au même titre que son nom. Zéro configuration par provider.

Côté implémentation, chaque provider retourne simplement sa priorité. Plus la valeur est haute, plus il sera essayé en premier :

// DocuSignProvider
public function getPriority(): int
{
return 10; // Primary provider
}
// YousignProvider
public function getPriority(): int
{
return 5; // Secondary provider
}

Circuit breaker

La logique de fallback. Si le provider choisi est indisponible ou plante, on passe au suivant :

src/Signature/SignatureOrchestrator.php
public function sign(Document $document, Signer $signer): SignatureResult
{
$lastException = null;
foreach ($this->providers as $provider) {
if (!$provider->isAvailable()) {
continue;
}
try {
return $provider->sign($document, $signer);
} catch (SignatureProviderException $e) {
$lastException = $e;
// Log and try the next provider
continue;
}
}
throw new NoAvailableProviderException(
'All signature providers failed.',
previous: $lastException
);
}

On itère sur les providers. Le premier qui répond, on prend. S’il plante, on essaie le suivant. Si aucun ne fonctionne, on remonte une exception explicite.

Pas besoin de librairie de circuit breaker externe pour ce cas simple. L’itérateur fait le boulot.

Rollout progressif

Pour le rollout, on veut diriger un pourcentage du trafic vers un provider spécifique. Une config simple suffit :

config/services.yaml
parameters:
signature.rollout:
yousign: 10 # 10% of traffic goes to Yousign
docusign: 90 # 90% goes to DocuSign

Et on adapte l’orchestrateur :

src/Signature/SignatureOrchestrator.php
class SignatureOrchestrator
{
public function __construct(
/** @var iterable<SignatureProviderInterface> */
#[AutowireIterator(SignatureProviderInterface::class)]
private iterable $providers,
#[Autowire('%signature.rollout%')]
private array $rolloutConfig,
) {
}
public function sign(Document $document, Signer $signer): SignatureResult
{
$provider = $this->selectProvider();
try {
return $provider->sign($document, $signer);
} catch (SignatureProviderException) {
// Fallback: try the others
return $this->fallback($document, $signer, exclude: $provider->getName());
}
}
private function selectProvider(): SignatureProviderInterface
{
$rand = random_int(1, 100);
$cumulative = 0;
foreach ($this->providers as $provider) {
$cumulative += $this->rolloutConfig[$provider->getName()] ?? 0;
if ($rand <= $cumulative) {
return $provider;
}
}
throw new \LogicException('Rollout configuration does not cover all cases.');
}
private function fallback(
Document $document,
Signer $signer,
string $exclude
): SignatureResult {
foreach ($this->providers as $provider) {
if ($provider->getName() === $exclude) {
continue;
}
if (!$provider->isAvailable()) {
continue;
}
try {
return $provider->sign($document, $signer);
} catch (SignatureProviderException) {
continue;
}
}
throw new NoAvailableProviderException('All signature providers failed.');
}
}

On passe Yousign à 30% ? On change un chiffre dans la config. On veut tout basculer ? yousign: 100. Pas une ligne de code à toucher.

En production, on voudra probablement aller plus loin : un utilisateur orienté vers Yousign lors de sa première signature devrait y rester pour les suivantes, histoire de ne pas lui offrir une expérience incohérente. Ça se gère en persistant le choix du provider par utilisateur, mais c’est un sujet à part entière, pas le propos ici.

Ajouter un troisième provider

C’est là qu’on voit la puissance du pattern. On veut ajouter Universign ? Une seule classe :

src/Signature/Provider/UniversignProvider.php
namespace App\Signature\Provider;
class UniversignProvider implements SignatureProviderInterface
{
public function getName(): string
{
return 'universign';
}
public function getPriority(): int
{
return 1;
}
public function sign(Document $document, Signer $signer): SignatureResult
{
// API calls to Universign
}
public function isAvailable(): bool
{
return true;
}
}

Et on met à jour la config de rollout :

config/services.yaml
parameters:
signature.rollout:
yousign: 30
universign: 20
docusign: 50

Zéro modification dans l’orchestrateur. Symfony détecte automatiquement le nouveau service, le tag est appliqué via l’interface, et AutowireIterator l’injecte. Seule la config de rollout change, le code reste intact.

Ce qu’il faut retenir

AutowireIterator ne se limite pas au Strategy pattern, mais il s’y prête particulièrement bien. Pas de factory manuelle, pas de CompilerPass, pas de switch/case qui grossit à chaque nouveau provider.

Le mécanisme est simple :

  1. Une interface avec #[AutoconfigureTag]
  2. Des classes qui implémentent cette interface
  3. Un service qui reçoit #[AutowireIterator] et itère dessus

L’exemple présenté ici est volontairement simpliste, il ne couvre pas tous les edge cases d’un vrai système en production. Mais c’était le but : montrer avec un cas concret la puissance d’une des fonctionnalités que je préfère dans Symfony. Son injection de dépendances et la facilité avec laquelle on peut orchestrer tout un ensemble de services, c’est ce qui rend le framework aussi agréable à utiliser au quotidien.