Home PHP Quand ton code a besoin d’une stratégie et pas d’un énième if…

Quand ton code a besoin d’une stratégie et pas d’un énième if…

  Yoann Galland 10 min 23 décembre 2025

Prenons un exemple simple, concret et d’actualité…

🎄 Il était une fois… un code promo de Noël

Nous sommes début décembre.
Le sapin est en place, Mariah Carey commence à se réveiller, et le marketing débarque.

« On veut un code promo NOEL.
-50% sur tout le panier. Simple. »

Simple.
Ce mot qui, en développement, annonce rarement quelque chose de simple.

🔹 Le premier code promo (tout va bien)

On commence tranquillement.

🔹 Règle métier

  • Code promo : NOEL

  • Règle : -50% sur le total du panier
    (une règle simple, tout le monde est content)

Notre petit développeur commence alors à créer son premier service PromoCodeService.

<?php

namespace App\Service;

use App\Entity\Cart;
use App\Entity\Price;
use App\Entity\PromoCode;

class PromoCodeService
{
    public function apply(Cart $cart, PromoCode $code): void
    {
        $cart->setPromoCode($code)->setTotalPrice($this->calculateTotal($cart, $code));
    }

    private function calculateTotal(Cart $cart, PromoCode $code): Price
    {
        $price = new Price();
        $percentage = 1 - $code->getValue() / 100;
        foreach ($cart->getItems() as $item) {
            $item->setTotalPrice((new Price())->setAmount($item->getUnitPrice()->getAmount() * $item->getQuantity() * $percentage)->setAmountWithTaxes($item->getUnitPrice()->getAmountWithTaxes() * $item->getQuantity() * $percentage));
            $price->increaseAmount($item->getTotalPrice()->getAmount())->increaseAmountWithTaxes($item->getTotalPrice()->getAmountWithTaxes());
        }

        return $price->increaseAmount($cart->getShippingPrice()->getAmount())->increaseAmountWithTaxes($cart->getShippingPrice()->getAmountWithTaxes());
    }
}
PromoCodeService

Tout fonctionne.
Le code est propre.
Il n’y a qu’une règle.
La vie est belle.

🔹 NOEL10 arrive (ça commence à sentir le sapin)

Deux jours plus tard.

« On aimerait aussi un code NOEL10 :
-10€ dès 100€ d’achat. »

Toujours simple, hein.

🔹Règle métier

  • Code promo : NOEL10

  • Règle : -10€ dès 100€ d’achat

Notre petit développeur reprend donc son service PromoCodeService.

<?php namespace App\Service;

use App\Entity\Cart;
use App\Entity\Price;
use App\Entity\PromoCode;
use App\Entity\PromoCodeTypeEnum;

class PromoCodeService
{
    private const int MINIMUM_AMOUNT_ORDER = 100;
    private const float TAXES_PERCENTAGE = 1.2;

    public function apply(Cart $cart, PromoCode $code): void
    {
        $cart->setPromoCode($code)->setTotalPrice($this->calculateTotal($cart, $code));
    }

    private function calculateTotal(Cart $cart, PromoCode $code): Price
    {
        if ($code->getType() === PromoCodeTypeEnum::PERCENTAGE) {
            $price = new Price();
            $percentage = 1 - $code->getValue() / 100;
            foreach ($cart->getItems() as $item) {
                $item->setTotalPrice((new Price())->setAmount($item->getUnitPrice()->getAmount() * $item->getQuantity() * $percentage)->setAmountWithTaxes($item->getUnitPrice()->getAmountWithTaxes() * $item->getQuantity() * $percentage));
                $price->increaseAmount($item->getTotalPrice()->getAmount())->increaseAmountWithTaxes($item->getTotalPrice()->getAmountWithTaxes());
            }

            return $price->increaseAmount($cart->getShippingPrice()->getAmount())->increaseAmountWithTaxes($cart->getShippingPrice()->getAmountWithTaxes());
        } else {
            $price = new Price();
            foreach ($cart->getItems() as $item) {
                $item->setTotalPrice((new Price())->setAmount($item->getUnitPrice()->getAmount() * $item->getQuantity())->setAmountWithTaxes($item->getUnitPrice()->getAmountWithTaxes() * $item->getQuantity()));
                $price->increaseAmount($item->getTotalPrice()->getAmount())->increaseAmountWithTaxes($item->getTotalPrice()->getAmountWithTaxes());
            }
            if ($price->getAmountWithTaxes() > self::MINIMUM_AMOUNT_ORDER) {
                $price->setAmount($price->getAmount() - $code->getValue())->setAmountWithTaxes($price->getAmountWithTaxes() - $code->getValue() * self::TAXES_PERCENTAGE);
            }

            return $price->increaseAmount($cart->getShippingPrice()->getAmount())->increaseAmountWithTaxes($cart->getShippingPrice()->getAmountWithTaxes());
        }
    }
}
PromoCodeService

🔹 NOEL_LIVRAISON : la livraison offerte (et là, ça commence à déraper)

Nouvelle réunion.

« On veut aussi la livraison offerte dès 50€,
sauf s’il y a un produit très lourd.
Ah, et ça s’appelle NOEL_LIVRAISON. »

🔹Règle métier

  • Code promo : NOEL_LIVRAISON

  • Règle : livraison offerte à partir de 50€ si le panier ne contient pas de produit trop lourd

Notre petit développeur ronchonne un peu… mais il reprend son service PromoCodeService.

<?php namespace App\Service;

use App\Entity\Cart;
use App\Entity\CategoryEnum;
use App\Entity\Price;
use App\Entity\PromoCode;
use App\Entity\PromoCodeTypeEnum;

class PromoCodeService
{
    private const int MINIMUM_AMOUNT_ORDER = 100;
    private const int MINIMUM_AMOUNT_ORDER_FOR_SHIPPING = 50;
    private const float TAXES_PERCENTAGE = 1.2;

    public function apply(Cart $cart, PromoCode $code): void
    {
        $cart->setPromoCode($code)->setTotalPrice($this->calculateTotal($cart, $code));
    }

    private function calculateTotal(Cart $cart, PromoCode $code): Price
    {
        if ($code->getType() === PromoCodeTypeEnum::PERCENTAGE) {
            $price = new Price();
            $percentage = 1 - $code->getValue() / 100;
            foreach ($cart->getItems() as $item) {
                $item->setTotalPrice((new Price())->setAmount($item->getUnitPrice()->getAmount() * $item->getQuantity() * $percentage)->setAmountWithTaxes($item->getUnitPrice()->getAmountWithTaxes() * $item->getQuantity() * $percentage));
                $price->increaseAmount($item->getTotalPrice()->getAmount())->increaseAmountWithTaxes($item->getTotalPrice()->getAmountWithTaxes());
            }

            return $price->increaseAmount($cart->getShippingPrice()->getAmount())->increaseAmountWithTaxes($cart->getShippingPrice()->getAmountWithTaxes());
        } elseif ($code->getType() === PromoCodeTypeEnum::VALUE) {
            $price = new Price();
            foreach ($cart->getItems() as $item) {
                $item->setTotalPrice((new Price())->setAmount($item->getUnitPrice()->getAmount() * $item->getQuantity())->setAmountWithTaxes($item->getUnitPrice()->getAmountWithTaxes() * $item->getQuantity()));
                $price->increaseAmount($item->getTotalPrice()->getAmount())->increaseAmountWithTaxes($item->getTotalPrice()->getAmountWithTaxes());
            }
            if ($price->getAmountWithTaxes() > self::MINIMUM_AMOUNT_ORDER) {
                $price->setAmount($price->getAmount() - $code->getValue())->setAmountWithTaxes($price->getAmountWithTaxes() - $code->getValue() * self::TAXES_PERCENTAGE);
            }

            return $price->increaseAmount($cart->getShippingPrice()->getAmount())->increaseAmountWithTaxes($cart->getShippingPrice()->getAmountWithTaxes());
        } else {
            $hasBulkyProduct = false;
            $price = new Price();
            foreach ($cart->getItems() as $item) {
                $item->setTotalPrice((new Price())->setAmount($item->getUnitPrice()->getAmount() * $item->getQuantity())->setAmountWithTaxes($item->getUnitPrice()->getAmountWithTaxes() * $item->getQuantity()));
                $price->increaseAmount($item->getTotalPrice()->getAmount())->increaseAmountWithTaxes($item->getTotalPrice()->getAmountWithTaxes());
                if (!$hasBulkyProduct) {
                    $hasBulkyProduct = $item->getProduct()->getCategory() === CategoryEnum::BULKY;
                }
            }
            if ($hasBulkyProduct || $price->getAmountWithTaxes() <= self::MINIMUM_AMOUNT_ORDER_FOR_SHIPPING) {
                $price->increaseAmount($cart->getShippingPrice()->getAmount())->increaseAmountWithTaxes($cart->getShippingPrice()->getAmountWithTaxes());
            }

            return $price;
        }
    }
}
PromoCodeService

Wahou…
Ça marche.
Mais le service devient énorme, difficile à lire et encore plus difficile à tester.

🔹NOELX2 : le coup de grâce — “un acheté, un offert”

Dernier email.
Vendredi, 17h30.

« Ah oui, dernière chose :
un produit acheté, le deuxième offert.
Code : NOELX2.
Urgent, mise en prod aujourd’hui. »

Et là… c’en est trop pour notre petit développeur.

Il se rend compte que :

  • son service est devenu monstrueux

  • les règles métier sont entremêlées

  • les tests deviennent un cauchemar

  • chaque nouveau code promo est un risque

Il se dit alors :

« Ce n’est plus possible.
Il faut que je trouve une autre solution. »

Heureusement, il existe un design pattern pour ça : le Strategy Pattern

Notre petit développeur découvre alors qu’il existe un design pattern parfaitement adapté à sa problématique :
le design pattern Stratégie.
Il commence à lire un peu de documentation et se dit :

« Tiens… mais ça pourrait être exactement ce qu’il me faut. »

Mais au fond, qu’est-ce que le Strategy Pattern ?

Pour faire simple, le design pattern Stratégie consiste à isoler chaque logique métier dans une classe dédiée.
Une stratégie = un comportement = une responsabilité claire.
Et là, notre développeur est content : il se rappelle de ses cours à la fac et réalise qu’il respecte enfin la première lettre de SOLID — le Single Responsibility Principle.

Si vous voulez une définition plus formelle (et quelques jolis schémas UML), je vous invite à consulter
la bible en la matière : Refactoring Guru.

⚠️ Attention toutefois
Il ne faut pas tomber dans la sur-architecture et l’utiliser partout.
Parfois, un simple if fait largement l’affaire… et c’est très bien comme ça.

Ok, super… mais comment on fait ça avec Symfony ?

Bonne nouvelle : le Strategy Pattern se marie très bien avec une Factory.

L’idée est simple :

  • chaque stratégie sait si elle peut gérer un code promo

  • la Factory se charge de trouver la bonne stratégie

  • le reste du code n’a plus à se poser de questions

Reprenons donc notre exemple de tout à l’heure.

Notre petit développeur revient à son problème de codes promo et décide cette fois de suivre ce design.

Une classe par logique métier

C’est parti.
Il crée une classe par type de promotion :

  • PercentagePromoCodeService (gestion de la promotion en pourcentage)

  • ValuePromoCodeService (gestion de la promotion avec une remise fixe en euros)

  • ShippingPromoCodeService (gestion de la promotion sur la livraison)

  • BuyOneGetOnePromoCodeService (gestion du coup de grâce : un acheté, un offert)

Chaque classe va implémenter la même interface, ce qui va permettre :

  • de choisir dynamiquement la bonne stratégie

  • d’appliquer la logique métier sans if ni switch

<?php namespace App\Service\PromoCode;

use App\Entity\Cart;
use App\Entity\PromoCode;
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;

#[AutoconfigureTag]
interface PromoCodeServiceInterface
{
    public function supports(PromoCode $code): bool;

    public function apply(Cart $cart, PromoCode $code): void;
}
PromoCodeServiceInterface
  • supports() permet de savoir si la stratégie peut gérer le code promo

  • apply() applique la logique métier associée

Exemple : PercentagePromoCodeService

<?php

namespace App\Service\PromoCode;

use App\Entity\Cart;
use App\Entity\Price;
use App\Entity\PromoCode;
use App\Entity\PromoCodeTypeEnum;

class PercentagePromoCodeService implements PromoCodeServiceInterface
{
    public function supports(PromoCode $code): bool
    {
        return $code->getType() === PromoCodeTypeEnum::PERCENTAGE;
    }

    public function apply(Cart $cart, PromoCode $code): void
    {
        $cart
            ->setPromoCode($code)
            ->setTotalPrice($this->calculateTotal($cart, $code))
        ;
    }

    private function calculateTotal(Cart $cart, PromoCode $code): Price
    {
        $price = new Price();
        $percentage = 1 - $code->getValue() / 100;
        foreach ($cart->getItems() as $item) {
            $item->setTotalPrice(
                (new Price())
                    ->setAmount($item->getUnitPrice()->getAmount() * $item->getQuantity() * $percentage)
                    ->setAmountWithTaxes($item->getUnitPrice()->getAmountWithTaxes() * $item->getQuantity() * $percentage),
            );
            $price->increaseAmount($item->getTotalPrice()->getAmount())->increaseAmountWithTaxes($item->getTotalPrice()->getAmountWithTaxes());
        }

        return $price->increaseAmount($cart->getShippingPrice()->getAmount())->increaseAmountWithTaxes($cart->getShippingPrice()->getAmountWithTaxes());
    }
}
PercentagePromoCodeService

Chaque classe fait une seule chose, et la fait bien.

La Factory : choisir la bonne stratégie automatiquement

Il ne reste plus qu’à créer la Factory, chargée de trouver la bonne stratégie et de l’exécuter.

<?php

namespace App\Service\PromoCode;

use App\Entity\Cart;
use App\Entity\PromoCode;
use Symfony\Component\DependencyInjection\Attribute\TaggedIterator;

class PromoCodeServiceFactory
{
    /** @var PromoCodeServiceInterface[] $services */
    public function __construct(
        #[TaggedIterator(PromoCodeServiceInterface::class)] private readonly iterable $services,
    )
    {
    }

    public function apply(Cart $cart, PromoCode $code): void
    {
        foreach ($this->services as $service) {
            if ($service->supports($code)) {
                $service->apply($cart, $code);
            }
        }

        Throw new \LogicException('No promo code service found for '.$code->getType()->value);
    }
}
PromoCodeServiceFactory

Grâce à TaggedIterator, Symfony injecte automatiquement toutes les classes qui implémentent PromoCodeServiceInterface.

Pas besoin de configuration manuelle.
Pas besoin de switch.
Pas besoin de toucher à la Factory quand une nouvelle promo arrive.

Tout est prêt… et notre développeur est heureux

Toutes les classes sont en place.
Les tests sont simples, ciblés, lisibles.
Le code est clair, maintenable et surtout évolutif.

Notre petit développeur est ravi.

🔹 Nouvelle règle (encore) venant du marketing

Un mail arrive.

« Ah oui, encore une chose :
le code NOEL fonctionne trop bien,
il faudrait le restreindre aux nouveaux clients. »

🔹 Règle métier

  • Code promo : NOEL

  • Règle : -50% sur le total du panier uniquement pour les nouveaux clients

Et là… miracle.

Notre petit développeur sourit.

Il se dit :

« Parfait.
Je modifie PercentagePromoCodeService,
j’adapte le test associé,
et je ne touche à rien d’autre. »

30 minutes plus tard :

  • le code est propre

  • les tests passent

  • la mise en production se fait sans stress

Tout va bien dans le meilleur des mondes 🎄✨

Conclusion

Au départ, tout semblait simple.
Un code promo, puis deux… puis trois… et très vite, un service devenu trop gros, trop complexe et trop fragile.

Le vrai problème n’était pas Noël, ni le marketing, ni même Symfony.
C’était une accumulation de règles métier hétérogènes gérées au même endroit.

Le Strategy Pattern, combiné à une Factory, permet de répondre élégamment à ce genre de situation :

  • chaque règle métier est isolée

  • chaque promotion a sa propre responsabilité

  • le code devient lisible, testable et extensible

  • l’ajout d’une nouvelle règle n’impacte plus l’existant

Ce pattern ne rend pas le code ✨magique✨, et il ne doit pas être utilisé partout.
Mais dès que les règles métier commencent à s’accumuler, à diverger et à évoluer rapidement, il devient un allié précieux pour garder une architecture saine.

Ton futur toi (et tes collègues) te diront merci 🎄

Lire les articles similaires

Laisser un commentaire

Social Share Buttons and Icons powered by Ultimatelysocial