Retour au blog
🌍 Vous pouvez aussi lire cet article en Anglais
Design Patterns

Maîtriser les Principes SOLID en PHP : Guide Pratique

24 min read

Il y a quelques années, j’ai travailler sur un code qui était vraiment difficile à maintenir. Il y avait une classe appelée OrderManager qui contenait plus de 3 000 lignes. Elle validait les commandes, calculait les prix, appliquait les remises, gérait l’inventaire, envoyait des emails, générait des factures, traitait les paiements, … Chaque modification était difficile et risquée, il faut parcourir ce long fichier tout en espérant que rien ne se casse.

Cette expérience m’a appris quelque chose de précieux : écrire du code qui fonctionne est facile, mais écrire du code maintenable, testable et extensible nécessite de suivre des principes claires. Aujourd’hui, nous allons parler de SOLID, et voir comment écrire du code propre en utilisant ces 5 principes.

Qu’est-ce que SOLID ?

SOLID est un acronyme pour cinq principes introduits par (Robert C. Martin) (Uncle Bob) au début des années 2000. Ces principes nous aident à écrire du code plus facile à comprendre, maintenir et étendre :

  • S pour Single Responsibility Principle (Principe de Responsabilité Unique)
  • O pour Open/Closed Principle (Principe Ouvert/Fermé)
  • L pour Liskov Substitution Principle (Principe de Substitution de Liskov)
  • I pour Interface Segregation Principle (Principe de Ségrégation des Interfaces)
  • D pour Dependency Inversion Principle (Principe d’Inversion des Dépendances)

Ce ne sont pas que des concepts académiques qu’il faut mémoriser. Ce sont des directives qui peuvent vraiment transformer notre façon d’écrire du code.

Fil Rouge : Traitement de Commandes E-Commerce

Tout au long de cet article, nous utiliserons un système de traitement de commandes e-commerce comme exemple. Ce domaine est suffisamment complexe pour démontrer efficacement les cinq principes.

Commençons par ce qu’il ne faut PAS faire. Voici le genre de classe qui fait tout (“god class”) que j’ai mentionné plus haut :

<?php

namespace App\Service;

class OrderManager
{
    public function __construct(
        private readonly EntityManagerInterface $em,
        private readonly MailerInterface $mailer,
    ) {}

    public function processOrder(array $orderData): Order
    {
        // Valider les données de la commande
        if (empty($orderData['items'])) {
            throw new \InvalidArgumentException('Order must have items');
        }

        foreach ($orderData['items'] as $item) {
            if ($item['quantity'] <= 0) {
                throw new \InvalidArgumentException('Quantity must be positive');
            }
            if ($item['price'] < 0) {
                throw new \InvalidArgumentException('Price cannot be negative');
            }
        }

        // Calculer le sous-total
        $subtotal = 0;
        foreach ($orderData['items'] as $item) {
            $subtotal += $item['price'] * $item['quantity'];
        }

        // Appliquer les remises
        $discount = 0;
        if (isset($orderData['coupon'])) {
            if ($orderData['coupon'] === 'SAVE10') {
                $discount = $subtotal * 0.10;
            } elseif ($orderData['coupon'] === 'SAVE20') {
                $discount = $subtotal * 0.20;
            } elseif ($orderData['coupon'] === 'FLAT50') {
                $discount = 50;
            }
        }

        // Calculer les taxes (TVA)
        $taxableAmount = $subtotal - $discount;
        $country = $orderData['shipping_country'] ?? 'SN';
        if ($country === 'SN') {
            $tax = $taxableAmount * 0.18;
        } elseif ($country === 'GN') {
            $tax = $taxableAmount * 0.18;
        } elseif ($country === 'FR') {
            $tax = $taxableAmount * 0.20;
        } elseif ($country === 'US') {
            $tax = $taxableAmount * 0.08;
        } else {
            $tax = $taxableAmount * 0.15;
        }

        // Calculer les frais de livraison
        $totalWeight = 0;
        foreach ($orderData['items'] as $item) {
            $totalWeight += ($item['weight'] ?? 0) * $item['quantity'];
        }

        $shippingMethod = $orderData['shipping_method'] ?? 'standard';
        if ($shippingMethod === 'standard') {
            $shipping = $totalWeight * 0.5;
        } elseif ($shippingMethod === 'express') {
            $shipping = $totalWeight * 1.5;
        } elseif ($shippingMethod === 'overnight') {
            $shipping = $totalWeight * 3.0;
        }

        // Vérifier l'inventaire
        foreach ($orderData['items'] as $item) {
            $product = $this->em->find(Product::class, $item['product_id']);
            if ($product->getStock() < $item['quantity']) {
                throw new \RuntimeException("Insufficient stock for {$product->getName()}");
            }
        }

        // Créer la commande
        $order = new Order();
        $order->setSubtotal($subtotal);
        $order->setDiscount($discount);
        $order->setTax($tax);
        $order->setShipping($shipping);
        $order->setTotal($subtotal - $discount + $tax + $shipping);
        $order->setStatus('pending');

        // Sauvegarder la commande et mettre à jour l'inventaire
        $this->em->persist($order);
        foreach ($orderData['items'] as $item) {
            $product = $this->em->find(Product::class, $item['product_id']);
            $product->setStock($product->getStock() - $item['quantity']);
        }
        $this->em->flush();

        // Envoyer l'email de confirmation
        $email = (new Email())
            ->to($orderData['customer_email'])
            ->subject('Order Confirmation #' . $order->getId())
            ->html("<p>Thank you for your order!</p>");
        $this->mailer->send($email);

        return $order;
    }

    // Plus de nombreuses autres méthodes pour les remboursements, annulations, rapports...
}

Cette classe fait beaucoup trop de choses, on dit qu’elle a beaucoup de responsabilités. Voyons comment les principes SOLID nous aident à résoudre ce problème.

S - Principe de Responsabilité Unique (SRP)

“Une classe ne devrait avoir qu’une seule raison de changer.” — Robert C. Martin

Le Principe de Responsabilité Unique stipule qu’une classe ne devrait avoir qu’une seule responsabilité. Si une classe a plusieurs responsabilités, les changements sur une responsabilité pourraient affecter ou casser les autres.

Le Problème

En regardant la classe OrderManager, il y a au moins cinq raisons de la modifier :

  1. Les règles de validation changent
  2. La logique de calcul des prix change
  3. Les règles des taxes changent
  4. Le calcul des frais de livraison change
  5. Le système de notification change

La Solution

Créons différentes classes qui ont exactement une responsabilité :

<?php

namespace App\Service\Order;

class OrderValidator
{
    public function validate(array $orderData): void
    {
        if (empty($orderData['items'])) {
            throw new InvalidOrderException('Order must have items');
        }

        foreach ($orderData['items'] as $item) {
            $this->validateItem($item);
        }
    }

    private function validateItem(array $item): void
    {
        if (!isset($item['product_id'])) {
            throw new InvalidOrderException('Item must have a product ID');
        }

        if (!isset($item['quantity']) || $item['quantity'] <= 0) {
            throw new InvalidOrderException('Quantity must be positive');
        }

        if (!isset($item['price']) || $item['price'] < 0) {
            throw new InvalidOrderException('Price cannot be negative');
        }
    }
}
<?php

namespace App\Service\Order;

class PriceCalculator
{
    public function calculateSubtotal(array $items): float
    {
        return array_reduce(
            $items,
            fn(float $total, array $item) => $total + ($item['price'] * $item['quantity']),
            0.0
        );
    }
}
<?php

namespace App\Service\Order;

class TaxCalculator
{
    private const TAX_RATES = [
        'SN' => 0.18,
        'GN' => 0.18,
        'FR' => 0.20,
        'US' => 0.08,
    ];

    private const DEFAULT_TAX_RATE = 0.18;

    public function calculate(float $amount, string $country): float
    {
        $rate = self::TAX_RATES[$country] ?? self::DEFAULT_TAX_RATE;

        return $amount * $rate;
    }
}
<?php

namespace App\Service\Order;

class DiscountCalculator
{
    public function calculate(float $subtotal, ?string $couponCode): float
    {
        if ($couponCode === null) {
            return 0.0;
        }

        return match ($couponCode) {
            'SAVE10' => $subtotal * 0.10,
            'SAVE20' => $subtotal * 0.20,
            'FLAT50' => min(50.0, $subtotal),
            default => 0.0,
        };
    }
}
<?php

namespace App\Service\Order;

class InventoryService
{
    public function __construct(
        private readonly EntityManagerInterface $em,
    ) {}

    public function checkAvailability(array $items): void
    {
        foreach ($items as $item) {
            $product = $this->em->find(Product::class, $item['product_id']);

            if ($product === null) {
                throw new ProductNotFoundException($item['product_id']);
            }

            if ($product->getStock() < $item['quantity']) {
                throw new InsufficientStockException($product, $item['quantity']);
            }
        }
    }

    public function decrementStock(array $items): void
    {
        foreach ($items as $item) {
            $product = $this->em->find(Product::class, $item['product_id']);
            $product->setStock($product->getStock() - $item['quantity']);
        }
    }
}
<?php

namespace App\Service\Order;

class OrderNotificationService
{
    public function __construct(
        private readonly MailerInterface $mailer,
    ) {}

    public function sendConfirmation(Order $order, string $customerEmail): void
    {
        $email = (new Email())
            ->to($customerEmail)
            ->subject("Order Confirmation #{$order->getId()}")
            ->html($this->buildConfirmationHtml($order));

        $this->mailer->send($email);
    }

    private function buildConfirmationHtml(Order $order): string
    {
        return "<p>Thank you for your order #{$order->getId()}!</p>";
    }
}

Nous avons là différentes classe qui on chacune une seule responsabilité : envoyer un mail, calculer les taxes, valider la commande, …

Maintenant la classe principale OrderService devient un simple coordinateur qui va déléguer aux autres classes :

<?php

namespace App\Service\Order;

class OrderService
{
    public function __construct(
        private readonly OrderValidator $validator,
        private readonly PriceCalculator $priceCalculator,
        private readonly DiscountCalculator $discountCalculator,
        private readonly TaxCalculator $taxCalculator,
        private readonly ShippingCalculator $shippingCalculator,
        private readonly InventoryService $inventoryService,
        private readonly OrderNotificationService $notificationService,
        private readonly EntityManagerInterface $em,
    ) {}

    public function process(array $orderData): Order
    {
        $this->validator->validate($orderData);
        $this->inventoryService->checkAvailability($orderData['items']);

        $subtotal = $this->priceCalculator->calculateSubtotal($orderData['items']);
        $discount = $this->discountCalculator->calculate($subtotal, $orderData['coupon'] ?? null);
        $tax = $this->taxCalculator->calculate($subtotal - $discount, $orderData['shipping_country'] ?? 'SN');
        $shipping = $this->shippingCalculator->calculate($orderData['items'], $orderData['shipping_method'] ?? 'standard');

        $order = new Order();
        $order->setSubtotal($subtotal);
        $order->setDiscount($discount);
        $order->setTax($tax);
        $order->setShipping($shipping);
        $order->setTotal($subtotal - $discount + $tax + $shipping);
        $order->setStatus('pending');

        $this->em->persist($order);
        $this->inventoryService->decrementStock($orderData['items']);
        $this->em->flush();

        $this->notificationService->sendConfirmation($order, $orderData['customer_email']);

        return $order;
    }
}

Ce n’est pas beau ça? Le code est beaucoup plus propre et maintenable, si je veux modifier les règles de calcul de la taxe, je pars modifier la classe TaxCalculator, simple et pratique.

O - Principe Ouvert/Fermé (OCP)

“Les entités logicielles doivent être ouvertes à l’extension, mais fermées à la modification.” — Bertrand Meyer

Le Principe Ouvert/Fermé signifie que nous devrions pouvoir ajouter de nouvelles fonctionnalités sans modifier le code existant. Cela est généralement réalisé grâce aux interfaces et au polymorphisme.

Le Problème

Regardons la classe DiscountCalculator :

public function calculate(float $subtotal, ?string $couponCode): float
{
    return match ($couponCode) {
        'SAVE10' => $subtotal * 0.10,
        'SAVE20' => $subtotal * 0.20,
        'FLAT50' => min(50.0, $subtotal),
        default => 0.0,
    };
}

Chaque fois que l’équipe marketing veut ajouter un nouveau type de remise (et crois-moi il y en aura toujours), nous devons modifier cette classe. On veut par exemple ajouter ces types de remise :

  • Un article acheté, un offert ?
  • Des remises selon la valeur du panier ?
  • Des remises pour les nouveaux clients ?
  • Des promotions saisonnières ?

La Solution

Au lieu d’une seule classe DiscountCalculator qui gère toutes les règles de remises, nous allons créer un système où chaque règle de remise est sa propre classe. Pour cela nous allons définir une interface DiscountRuleInterface dont chaque règle de remise implémentera :

<?php

namespace App\Discount;

interface DiscountRuleInterface
{
    public function supports(OrderContext $context): bool;

    public function calculate(OrderContext $context): float;

    public function getPriority(): int;
}
<?php

namespace App\Discount;

/**
 * DTO qui transporte les informations de commande vers les règles de remise.
 */
readonly class OrderContext
{
    public function __construct(
        public float $subtotal,
        public ?string $couponCode,
        public ?Customer $customer,
        public array $items,
    ) {}
}

Maintenant créons des règles de remise individuelles :

<?php

namespace App\Discount\Rule;

class PercentageCouponRule implements DiscountRuleInterface
{
    // Tu vas probablement définir ceci dans une base de données ou quelque part ailleurs
    private const COUPONS = [
        'SAVE10' => 0.10,
        'SAVE20' => 0.20,
        'SAVE30' => 0.30,
    ];

    public function supports(OrderContext $context): bool
    {
        return $context->couponCode !== null
            && isset(self::COUPONS[$context->couponCode]);
    }

    public function calculate(OrderContext $context): float
    {
        return $context->subtotal * self::COUPONS[$context->couponCode];
    }

    public function getPriority(): int
    {
        return 100;
    }
}
<?php

namespace App\Discount\Rule;

class FlatAmountCouponRule implements DiscountRuleInterface
{
    private const COUPONS = [
        'FLAT50' => 50.0,
        'FLAT100' => 100.0,
    ];

    public function supports(OrderContext $context): bool
    {
        return $context->couponCode !== null
            && isset(self::COUPONS[$context->couponCode]);
    }

    public function calculate(OrderContext $context): float
    {
        return min(self::COUPONS[$context->couponCode], $context->subtotal);
    }

    public function getPriority(): int
    {
        return 90;
    }
}
<?php

namespace App\Discount\Rule;

class FirstTimeCustomerRule implements DiscountRuleInterface
{
    public function supports(OrderContext $context): bool
    {
        return $context->customer !== null
            && $context->customer->getOrderCount() === 0;
    }

    public function calculate(OrderContext $context): float
    {
        return $context->subtotal * 0.15; // 15% de réduction sur la première commande
    }

    public function getPriority(): int
    {
        return 50;
    }
}
<?php

namespace App\Discount\Rule;

class BulkPurchaseRule implements DiscountRuleInterface
{
    public function supports(OrderContext $context): bool
    {
        return $context->subtotal >= 100000; // 100 000 XOF
    }

    public function calculate(OrderContext $context): float
    {
        // 5% de réduction pour les commandes de plus de 100 000 XOF
        return $context->subtotal * 0.05;
    }

    public function getPriority(): int
    {
        return 10;
    }
}

Maintenant la classe DiscountCalculator utilise juste les règles qui s’appliquent :

<?php

namespace App\Discount;

use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;

class DiscountCalculator
{
    /** @var DiscountRuleInterface[] */
    private array $rules;

    public function __construct(
        #[AutowireIterator('app.discount_rule')]
        iterable $rules,
    ) {
        $this->rules = $rules instanceof \Traversable
            ? iterator_to_array($rules)
            : $rules;

        // Trier par priorité (la plus haute en premier)
        usort($this->rules, fn($a, $b) => $b->getPriority() <=> $a->getPriority());
    }

    public function calculate(OrderContext $context): float
    {
        foreach ($this->rules as $rule) {
            if ($rule->supports($context)) {
                return $rule->calculate($context);
            }
        }

        return 0.0;
    }
}

Notes

Symfony : Utilise l’attribut #[AutowireIterator] combiné avec les services tagués :

# config/services.yaml
services:
    _instanceof:
        App\Discount\DiscountRuleInterface:
            tags: ['app.discount_rule']

L’attribut #[AutowireIterator] dans le constructeur injecte automatiquement tous les services tagués avec app.discount_rule.

Laravel : Utilise les bindings tagués avec giveTagged :

// AppServiceProvider.php
public function register(): void
{
    $this->app->tag([
        PercentageCouponRule::class,
        FlatAmountCouponRule::class,
        FirstTimeCustomerRule::class,
        BulkPurchaseRule::class,
    ], 'discount.rules');

    $this->app->when(DiscountCalculator::class)
        ->needs('$rules')
        ->giveTagged('discount.rules');
}

Maintenant quand l’équipe marketing veut ajouter un nouveau type de remise, nous ajoutons simplement une nouvelle classe implémentant DiscountRuleInterface. Nous ajoutons donc une nouvelle fonctionnalité sans modifier le code existant.

L - Principe de Substitution de Liskov (LSP)

“Si S est un sous-type de T, alors les objets de type T peuvent être remplacés par des objets de type S sans altérer la correction du programme.” — Barbara Liskov

Alors là, à lire la définition, on est perdus. On va donc traduire en Français simple :

Si votre code attend un objet de type ShippingCalculator, tout appel à une méthode de cet objet devrait fonctionner sans que le programme ne soit altéré. Pas de cas spécial, pas de “oh mais c’est différent”.

Expliquons avec un exemple plutôt.

Le Problème

Supposons que nous avons différents calculateurs de frais de livraison, nous allons livrer dans le pays, dans la region Ouest-Africaine et à l’internationale.

<?php

namespace App\Shipping;

interface ShippingCalculatorInterface
{
    public function calculate(array $items, string $destination): float;
}
<?php

namespace App\Shipping;

class StandardShippingCalculator implements ShippingCalculatorInterface
{
    public function calculate(array $items, string $destination): float
    {
        $weight = $this->calculateTotalWeight($items);

        return $weight * 500; // 500 XOF par kg
    }

    private function calculateTotalWeight(array $items): float
    {
        return array_reduce(
            $items,
            fn($total, $item) => $total + (($item['weight'] ?? 0) * $item['quantity']),
            0.0
        );
    }
}

Maintenant on ajoute un calculateur de frais de livraison express qui viole le contrat :

<?php

namespace App\Shipping;

// MAUVAIS : Viole LSP
class ExpressShippingCalculator implements ShippingCalculatorInterface
{
    public function calculate(array $items, string $destination): float
    {
        // Disponible uniquement pour les pays d'Afrique de l'Ouest !
        if (!$this->isWestAfrican($destination)) {
            return -1; // Valeur magique pour indiquer l'indisponibilité et c'est lui le problème
        }

        $weight = $this->calculateTotalWeight($items);

        return $weight * 1500; // 1500 XOF par kg
    }
}

Tu vois le problème ? L’interface dit que calculate() retourne un prix. Mais cette implémentation retourne -1 dès fois pour indiquer que la méthode de livraison n’est pas disponible. Maintenant il faudra toujours faire un if dans le code qui appelle cette méthode pour vérifier si la livraison est disponible. Nous avons failli au contrat.

La Solution

Il faut respecter le contrat (comme dans la vraie vie quoi). Si le contrat dit que la méthode retourne un prix, alors elle doit retourner un prix.

<?php

namespace App\Shipping;

interface ShippingCalculatorInterface
{
    /**
     * Nous ajoutons une méthode pour vérifier la disponibilité
     */
    public function isAvailable(array $items, string $destination): bool;

    /**
     * Calcule le coût de livraison.
     *
     * @throws ShippingUnavailableException si appelé quand non disponible
     */
    public function calculate(array $items, string $destination): float;

    public function getName(): string;
}

On peut aussi extraire la méthode partagée calculateTotalWeight dans une classe abstraite pour éviter la répétition :

<?php

namespace App\Shipping;

abstract class AbstractShippingCalculator implements ShippingCalculatorInterface
{
    protected function calculateTotalWeight(array $items): float
    {
        return array_reduce(
            $items,
            fn($total, $item) => $total + (($item['weight'] ?? 0) * $item['quantity']),
            0.0
        );
    }
}
<?php

namespace App\Shipping;

class StandardShippingCalculator extends AbstractShippingCalculator
{
    public function isAvailable(array $items, string $destination): bool
    {
        return true; // Toujours disponible
    }

    public function calculate(array $items, string $destination): float
    {
        return $this->calculateTotalWeight($items) * 500; // 500 XOF par kg
    }

    public function getName(): string
    {
        return 'Livraison Standard';
    }
}
<?php

namespace App\Shipping;

class ExpressShippingCalculator extends AbstractShippingCalculator
{
    private const WEST_AFRICAN_COUNTRIES = ['SN', 'GN', 'CI', 'ML'];

    public function isAvailable(array $items, string $destination): bool
    {
        return in_array($destination, self::WEST_AFRICAN_COUNTRIES, true);
    }

    public function calculate(array $items, string $destination): float
    {
        if (!$this->isAvailable($items, $destination)) {
            throw new ShippingUnavailableException(
                "La livraison express n'est pas disponible pour {$destination}"
            );
        }

        return $this->calculateTotalWeight($items) * 1500; // 1500 XOF par kg
    }

    public function getName(): string
    {
        return 'Livraison Express';
    }
}
<?php

namespace App\Shipping;

class InternationalShippingCalculator extends AbstractShippingCalculator
{
    private const WEST_AFRICAN_COUNTRIES = ['SN', 'GN', 'CI', 'ML'];

    public function isAvailable(array $items, string $destination): bool
    {
        // Uniquement pour les destinations hors Afrique de l'Ouest
        return !in_array($destination, self::WEST_AFRICAN_COUNTRIES, true);
    }

    public function calculate(array $items, string $destination): float
    {
        if (!$this->isAvailable($items, $destination)) {
            throw new ShippingUnavailableException(
                "La livraison internationale est uniquement pour les destinations hors Afrique de l'Ouest"
            );
        }

        $weight = $this->calculateTotalWeight($items);
        $baseRate = $weight * 5000; // 5000 XOF par kg tarif de base

        // Frais supplémentaires pour certaines régions
        return match (true) {
            in_array($destination, ['FR', 'BE', 'CH']) => $baseRate * 1.2,
            in_array($destination, ['US', 'CA']) => $baseRate * 1.5,
            in_array($destination, ['CN', 'JP']) => $baseRate * 1.8,
            default => $baseRate * 1.3,
        };
    }

    public function getName(): string
    {
        return 'Livraison Internationale';
    }
}

Maintenant le service de livraison peut utiliser n’importe quel calculateur de frais de livraison en toute sécurité :

<?php

namespace App\Shipping;

class ShippingService
{
    /** @var ShippingCalculatorInterface[] */
    private array $calculators;

    public function __construct(iterable $calculators)
    {
        $this->calculators = $calculators instanceof \Traversable
            ? iterator_to_array($calculators)
            : $calculators;
    }

    public function getAvailableMethods(array $items, string $destination): array
    {
        $methods = [];

        foreach ($this->calculators as $calculator) {
            if ($calculator->isAvailable($items, $destination)) {
                $methods[] = [
                    'name' => $calculator->getName(),
                    'cost' => $calculator->calculate($items, $destination),
                ];
            }
        }

        return $methods;
    }
}

Pas de vérification de type, pas de cas spéciaux. Chaque calculateur est substituable car ils respectent tous le même contrat.

I - Principe de Ségrégation des Interfaces (ISP)

“Les clients ne devraient pas être forcés de dépendre d’interfaces qu’ils n’utilisent pas.” — Robert C. Martin

T’es tu déjà retrouver à implémenter une interface et à devoir écrire des throw new \RuntimeException('Not implemented') pour certaines méthodes? Ceci est un problème et ce principe est la solution.

Le Problème

Imagine que nous avons créé une interface de traitement de commande “complète” :

<?php

namespace App\Order;

// MAUVAIS : Interface trop grosse
interface OrderProcessorInterface
{
    public function validateOrder(Order $order): bool;
    public function calculateTotal(Order $order): float;
    public function applyDiscount(Order $order, string $code): void;
    public function calculateTax(Order $order): float;
    public function calculateShipping(Order $order): float;
    public function checkInventory(Order $order): bool;
    public function reserveInventory(Order $order): void;
    public function processPayment(Order $order): bool;
    public function generateInvoice(Order $order): Invoice;
    public function sendConfirmationEmail(Order $order): void;
    public function sendShippingNotification(Order $order): void;
    public function generateShippingLabel(Order $order): string;
    public function trackShipment(Order $order): array;
}

Maintenant imagine que tu as besoin d’un simple service qui ne fait que valider les commandes. Tu devrais implémenter les 13 méthodes, en lançant des exceptions ou en retournant des nulls pour celles dont tu n’as pas besoin.

La Solution

Divise cette grosse interface en interfaces focalisées et cohérentes, un peu comme pour le SRP :

<?php

namespace App\Order;

interface OrderValidatorInterface
{
    public function validate(Order $order): ValidationResult;
}
<?php

namespace App\Order;

interface OrderPricingInterface
{
    public function calculateSubtotal(Order $order): float;
    public function calculateTax(Order $order): float;
    public function calculateShipping(Order $order): float;
    public function calculateTotal(Order $order): float;
}
<?php

namespace App\Order;

interface DiscountApplierInterface
{
    public function apply(Order $order, string $code): DiscountResult;
}
<?php

namespace App\Order;

interface InventoryManagerInterface
{
    public function checkAvailability(Order $order): AvailabilityResult;
    public function reserve(Order $order): void;
    public function release(Order $order): void;
}
<?php

namespace App\Order;

interface PaymentProcessorInterface
{
    public function process(Order $order, PaymentMethod $method): PaymentResult;
    public function refund(Order $order, float $amount): RefundResult;
}
<?php

namespace App\Order;

interface InvoiceGeneratorInterface
{
    public function generate(Order $order): Invoice;
}
<?php

namespace App\Order;

interface OrderNotifierInterface
{
    public function sendConfirmation(Order $order): void;
    public function sendShippingUpdate(Order $order, ShipmentStatus $status): void;
}
<?php

namespace App\Order;

interface ShipmentManagerInterface
{
    public function createLabel(Order $order): ShippingLabel;
    public function track(Order $order): TrackingInfo;
}

Maintenant nous allons implémenter uniquement ce dont nous avons besoin :

<?php

namespace App\Order;

class SimpleOrderValidator implements OrderValidatorInterface
{
    public function validate(Order $order): ValidationResult
    {
        $errors = [];

        if ($order->getItems()->isEmpty()) {
            $errors[] = 'Order must have at least one item';
        }

        if ($order->getCustomer() === null) {
            $errors[] = 'Order must have a customer';
        }

        return new ValidationResult(empty($errors), $errors);
    }
}

Et le service principal peut dépendre exactement de ce dont il a besoin :

<?php

namespace App\Order;

class OrderService
{
    public function __construct(
        private readonly OrderValidatorInterface $validator,
        private readonly InventoryManagerInterface $inventory,
        private readonly OrderPricingInterface $pricing,
        private readonly PaymentProcessorInterface $payment,
        private readonly OrderNotifierInterface $notifier,
    ) {}

    public function checkout(Order $order, PaymentMethod $paymentMethod): CheckoutResult
    {
        $validation = $this->validator->validate($order);
        if (!$validation->isValid()) {
            return CheckoutResult::failed($validation->getErrors());
        }

        $availability = $this->inventory->checkAvailability($order);
        if (!$availability->isAvailable()) {
            return CheckoutResult::failed(['Some items are out of stock']);
        }

        $order->setTotal($this->pricing->calculateTotal($order));

        $paymentResult = $this->payment->process($order, $paymentMethod);
        if (!$paymentResult->isSuccessful()) {
            return CheckoutResult::failed(['Payment failed']);
        }

        $this->inventory->reserve($order);
        $this->notifier->sendConfirmation($order);

        return CheckoutResult::success($order);
    }
}

Notes

Symfony : Le framework lui-même suit ISP. Les contrats comme CacheInterface vs CacheItemPoolInterface qui n’a que les méthodes dont tu as réellement besoin.

Laravel : Les contrats de Laravel suivent aussi ce principe. Illuminate\Contracts\Cache\Repository est séparé de Illuminate\Contracts\Cache\Store, chacun servant des besoins différents.

D - Principe d’Inversion des Dépendances (DIP)

“Les modules de haut niveau ne devraient pas dépendre des modules de bas niveau. Les deux devraient dépendre d’abstractions.” — Robert C. Martin

Le Principe d’Inversion des Dépendances consiste à découpler ton code des implémentations concrètes. Au lieu de dépendre de classes spécifiques, dépends d’interfaces.

Le Problème

Voici un service qui viole DIP :

<?php

namespace App\Order;

// MAUVAIS : Dépend d'implémentations concrètes
class OrderService
{
    public function __construct(
        private readonly StripePaymentGateway $paymentGateway, // Concret !
        private readonly MySQLOrderRepository $orderRepository, // Concret !
        private readonly SmtpEmailService $emailService, // Concret !
        private readonly RedisCache $cache, // Concret !
    ) {}

    public function process(Order $order): void
    {
        $this->paymentGateway->charge($order->getTotal());
        $this->orderRepository->save($order);
        $this->cache->set("order_{$order->getId()}", $order);
        $this->emailService->send($order->getCustomerEmail(), 'Order confirmed!');
    }
}

Problèmes avec cette approche :

  1. Impossible de changer d’implémentation : Tu veux utiliser PayPal au lieu de Stripe ? Change le code.
  2. Difficile à tester : Tu as besoin d’un vrai compte Stripe, d’une base de données MySQL, d’un serveur Redis et d’un serveur SMTP pour tester.
  3. Couplage fort : Le service en sait trop sur ses dépendances. Il ne devrait pas.

La Solution

Il faut dépendre d’abstractions :

<?php

namespace App\Payment;

interface PaymentGatewayInterface
{
    public function charge(float $amount, PaymentMethod $method): PaymentResult;
    public function refund(string $transactionId, float $amount): RefundResult;
}
<?php

namespace App\Repository;

interface OrderRepositoryInterface
{
    public function save(Order $order): void;
    public function find(int $id): ?Order;
    public function findByCustomer(Customer $customer): array;
}
<?php

namespace App\Notification;

interface NotificationServiceInterface
{
    public function send(Notification $notification): void;
}

Maintenant on crée des implémentations concrètes :

<?php

namespace App\Payment;

class StripePaymentGateway implements PaymentGatewayInterface
{
    public function __construct(
        private readonly string $apiKey,
    ) {}

    public function charge(float $amount, PaymentMethod $method): PaymentResult
    {
        // Implémentation spécifique à Stripe
    }

    public function refund(string $transactionId, float $amount): RefundResult
    {
        // Implémentation du remboursement Stripe
    }
}
<?php

namespace App\Payment;

class PayPalPaymentGateway implements PaymentGatewayInterface
{
    public function __construct(
        private readonly string $clientId,
        private readonly string $clientSecret,
    ) {}

    public function charge(float $amount, PaymentMethod $method): PaymentResult
    {
        // Implémentation spécifique à PayPal
    }

    public function refund(string $transactionId, float $amount): RefundResult
    {
        // Implémentation du remboursement PayPal
    }
}

Et une classe fake pour les tests :

<?php

namespace App\Tests\Mocks;

class FakePaymentGateway implements PaymentGatewayInterface
{
    private array $charges = [];
    private bool $shouldFail = false;

    public function charge(float $amount, PaymentMethod $method): PaymentResult
    {
        if ($this->shouldFail) {
            return PaymentResult::failed('Payment declined');
        }

        $transactionId = 'fake_' . uniqid();
        $this->charges[] = [
            'id' => $transactionId,
            'amount' => $amount,
        ];

        return PaymentResult::success($transactionId);
    }

    public function refund(string $transactionId, float $amount): RefundResult
    {
        return RefundResult::success();
    }

    // Helpers de test
    public function failNextCharge(): void
    {
        $this->shouldFail = true;
    }

    public function getCharges(): array
    {
        return $this->charges;
    }
}

Maintenant le service dépend des abstractions :

<?php

namespace App\Order;

class OrderService
{
    public function __construct(
        private readonly PaymentGatewayInterface $paymentGateway,
        private readonly OrderRepositoryInterface $orderRepository,
        private readonly NotificationServiceInterface $notificationService,
    ) {}

    public function process(Order $order, PaymentMethod $paymentMethod): OrderResult
    {
        $paymentResult = $this->paymentGateway->charge(
            $order->getTotal(),
            $paymentMethod
        );

        if (!$paymentResult->isSuccessful()) {
            return OrderResult::failed($paymentResult->getError());
        }

        $order->setPaymentTransactionId($paymentResult->getTransactionId());
        $order->setStatus(OrderStatus::PAID);

        $this->orderRepository->save($order);

        $this->notificationService->send(
            new OrderConfirmationNotification($order)
        );

        return OrderResult::success($order);
    }
}

Notes

Symfony : Configure quelle implémentation utiliser dans services.yaml :

# config/services.yaml
services:
    App\Payment\PaymentGatewayInterface:
        alias: App\Payment\StripePaymentGateway

    # Ou pour des bindings spécifiques à l'environnement :
    App\Payment\PaymentGatewayInterface:
        alias: App\Tests\Mocks\FakePaymentGateway
        # Uniquement en environnement de test
        when@test: true

Laravel : Lie les interfaces aux implémentations dans un service provider :

// AppServiceProvider.php
public function register(): void
{
    $this->app->bind(
        PaymentGatewayInterface::class,
        StripePaymentGateway::class
    );

    // Ou conditionnellement :
    if ($this->app->environment('testing')) {
        $this->app->bind(
            PaymentGatewayInterface::class,
            FakePaymentGateway::class
        );
    }
}

Comment les Principes SOLID Fonctionnent Ensemble

Ces principes ne sont pas destinés à être appliqués séparément. Ils se renforcent mutuellement :

  • SRP + OCP : Quand les classes ont des responsabilités uniques, elles sont naturellement plus faciles à étendre sans modification.
  • LSP + ISP : Les interfaces facilitent la création d’implémentations substituables.
  • DIP + Tous les autres : Dépendre des abstractions permet à tous les autres principes de fonctionner correctement.

Voici comment notre système de commandes refactorisé démontre cette synergie :

<?php

namespace App\Order;

class OrderService
{
    public function __construct(
        private readonly OrderValidatorInterface $validator,        // DIP : abstraction
        private readonly InventoryManagerInterface $inventory,      // DIP : abstraction
        private readonly DiscountCalculator $discountCalculator,    // OCP : extensible
        private readonly OrderPricingInterface $pricing,            // ISP : interface focalisée
        private readonly PaymentGatewayInterface $payment,          // DIP + LSP : substituable
        private readonly OrderRepositoryInterface $repository,      // DIP : abstraction
        private readonly OrderNotifierInterface $notifier,          // ISP : interface focalisée
    ) {}

    // SRP : Cette classe ne fait que coordonner le processus de commande
    public function checkout(Order $order, PaymentMethod $method): CheckoutResult
    {
        // Chaque étape est gérée par un service focalisé (SRP)
        // Nous pouvons facilement échanger les implémentations (DIP + LSP)
        // Nous pouvons ajouter de nouvelles règles de remise sans changer ce code (OCP)
        // Chaque interface est focalisée sur ce dont nous avons besoin (ISP)
    }
}

Quand NE PAS Appliquer SOLID

Les principes SOLID sont des directives, pas des lois. Les sur-appliquer peut mener à de la sur-ingénierie (over-engineering).

Signes d’Alerte de Sur-Ingénierie

  1. Trop de petites classes : Si tu as StringValidator, IntegerValidator, EmailValidator, PhoneValidator chacun dans des fichiers séparés pour un simple formulaire, tu es peut-être allé trop loin.

  2. Interfaces avec une seule implémentation : Si tu crées UserServiceInterface qui est seulement implémenté par UserService, demande-toi pourquoi.

  3. Abstraction prématurée : Ne crée pas PaymentGatewayInterface si tu ne supportes que Stripe et n’as aucun plan pour en ajouter d’autres.

  4. Couches d’indirection : Si comprendre une opération simple nécessite de naviguer à travers 10 classes, tu as trop abstrait.

Quand Garder les Choses Simples

// Ceci marche bien pour une application simple
class UserService
{
    public function __construct(
        private readonly EntityManagerInterface $em,
    ) {}

    public function createUser(string $email, string $password): User
    {
        $user = new User();
        $user->setEmail($email);
        $user->setPassword(password_hash($password, PASSWORD_DEFAULT));

        $this->em->persist($user);
        $this->em->flush();

        return $user;
    }
}

Ne crée pas UserCreatorInterface, UserFactoryInterface, PasswordHasherInterface, et UserPersisterInterface à moins que tu aies vraiment besoin de cette flexibilité.

Règles Générales

  1. Commence simple : Écris d’abord du code qui fonctionne, puis refactorise quand la complexité l’exige. Mais n’oublie de refactoriser, c’est comme ça que l’on fini avec la classe OrderManager mentionnée plus haut.
  2. Suis la Règle des Trois : Envisage d’abstraire quand tu as besoin du même comportement à trois endroits.
  3. Sois attentif : Si changer une chose en casse une autre, ou si les tests sont difficiles, SOLID peut aider.
  4. Considère ton équipe : Une architecture trop complexe que tes coéquipiers ne peuvent pas comprendre va à l’encontre du but recherché.

Conclusion

Les principes SOLID sont des outils puissants pour écrire du code maintenable :

  • Responsabilité Unique : Une classe, une raison de changer
  • Ouvert/Fermé : Étendre via du nouveau code, pas de modifications
  • Substitution de Liskov : Les sous-classes doivent honorer le contrat de leur parent
  • Ségrégation des Interfaces : Plusieurs petites interfaces valent mieux qu’une grande
  • Inversion des Dépendances : Dépends des abstractions, pas des concrétions

Ces principes nous ont aidé à transformer un cauchemar de 3 000 lignes dans OrderManager en un système propre, testable et extensible. Mais rappelle-toi : ce sont des outils, pas des lois. Applique-les judicieusement en fonction des besoins réels de ton application.

Si tu as trouvé cet article utile, consulte mon article sur Building a Robust Service Layer in Symfony, qui complète ces concepts avec des patterns pratiques pour organiser la logique métier.

Dans un prochain tutoriel, nous explorerons le Pattern Adapter et verrons comment il peut nous aider à créer des interfaces unifiées pour différents services externes, comme les passerelles de paiement dont nous avons discuté ici.

À la prochaine, bon code !