SOLID #5 : le principe d'inversion des dépendances (DIP)

Dernier épisode de la série SOLID. Le D désigne le Dependency Inversion Principle, le principe d'inversion des dépendances. C'est sans doute le plus structurant des cinq : c'est lui qui permet de garder le cœur d'une application indépendant de la base de données, du framework ou du service d'e-mails.

La définition

Robert C. Martin l'a publié en 1996, en deux parties :

  1. Les modules de haut niveau ne doivent pas dépendre des modules de bas niveau. Les deux doivent dépendre d'abstractions.
  2. Les abstractions ne doivent pas dépendre des détails. Ce sont les détails qui doivent dépendre des abstractions.

Le « haut niveau », ce sont les règles métier : ce que fait votre application. Le « bas niveau », ce sont les détails techniques : SQL, appels HTTP, envoi d'e-mails, système de fichiers. Ces détails changent souvent (nouvelle base, nouveau fournisseur), alors que les règles métier sont la raison d'être du logiciel.

Le point de départ

Prenons l'inscription à une newsletter, avec une règle simple : on n'inscrit pas deux fois la même adresse, et chaque nouvel abonné reçoit un e-mail de bienvenue.

<?php

use Symfony\Component\Mailer\Mailer;
use Symfony\Component\Mailer\Transport;
use Symfony\Component\Mime\Email;

final class InscriptionNewsletter
{
    public function inscrire(string $email): void
    {
        $email = strtolower(trim($email));
        $pdo = new PDO('mysql:host=db;dbname=blog', 'blog', getenv('DB_PASSWORD'));

        $requete = $pdo->prepare('SELECT 1 FROM abonnes WHERE email = ?');
        $requete->execute([$email]);
        if ($requete->fetchColumn() !== false) {
            return; // déjà inscrit
        }

        $pdo->prepare('INSERT INTO abonnes (email, inscrit_le) VALUES (?, NOW())')->execute([$email]);

        $mailer = new Mailer(Transport::fromDsn(getenv('MAILER_DSN')));
        $mailer->send((new Email())
            ->from('bonjour@legarsdelatech.fr')
            ->to($email)
            ->subject('Bienvenue dans la newsletter !')
            ->text('Merci pour votre inscription, à très vite.'));
    }
}

La règle métier tient en trois lignes, noyées au milieu des détails techniques. La classe crée elle-même ses dépendances (new PDO, Transport::fromDsn), si bien que :

  • impossible de la tester sans une vraie base MySQL et un vrai serveur d'e-mails ;
  • changer de fournisseur d'e-mails oblige à modifier le code métier ;
  • la date d'inscription dépend de l'horloge du serveur SQL (NOW()), impossible à contrôler dans un test.

Étape 1 : injecter les dépendances

Premier réflexe : ne plus créer les dépendances dans la classe, mais les recevoir par le constructeur. C'est l'injection de dépendances.

<?php

use Symfony\Component\Mailer\MailerInterface;

final class InscriptionNewsletter
{
    public function __construct(
        private PDO $pdo,
        private MailerInterface $mailer,
    ) {}

    public function inscrire(string $email): void
    {
        // … même code, avec $this->pdo et $this->mailer
    }
}

C'est mieux : on peut maintenant fournir d'autres objets. Mais la règle métier dépend toujours de PDO et de MailerInterface, c'est-à-dire des outils techniques. Les dépendances pointent encore dans le mauvais sens.

Étape 2 : inverser la dépendance

L'inversion consiste à laisser le métier définir les abstractions dont il a besoin, dans son propre vocabulaire. La newsletter n'a pas besoin de « PDO » ni d'un « mailer » : elle a besoin d'une liste d'abonnés et de quelqu'un pour souhaiter la bienvenue.

<?php

namespace App\Newsletter;

use Psr\Clock\ClockInterface;

interface Abonnes
{
    public function existe(string $email): bool;

    public function ajouter(string $email, \DateTimeImmutable $inscritLe): void;
}

interface Notificateur
{
    public function envoyerBienvenue(string $email): void;
}

final class InscriptionNewsletter
{
    public function __construct(
        private Abonnes $abonnes,
        private Notificateur $notificateur,
        private ClockInterface $horloge,
    ) {}

    public function inscrire(string $email): void
    {
        $email = strtolower(trim($email));
        if ($this->abonnes->existe($email)) {
            return;
        }

        $this->abonnes->ajouter($email, $this->horloge->now());
        $this->notificateur->envoyerBienvenue($email);
    }
}

La classe ne contient plus que la règle métier, lisible d'un coup d'œil. Les détails techniques implémentent les interfaces du métier, dans une autre partie du code :

<?php

namespace App\Infrastructure;

use App\Newsletter\Abonnes;
use App\Newsletter\Notificateur;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;

final class AbonnesPdo implements Abonnes
{
    public function __construct(private \PDO $pdo) {}

    public function existe(string $email): bool
    {
        $requete = $this->pdo->prepare('SELECT 1 FROM abonnes WHERE email = ?');
        $requete->execute([$email]);

        return $requete->fetchColumn() !== false;
    }

    public function ajouter(string $email, \DateTimeImmutable $inscritLe): void
    {
        $this->pdo
            ->prepare('INSERT INTO abonnes (email, inscrit_le) VALUES (?, ?)')
            ->execute([$email, $inscritLe->format('Y-m-d H:i:s')]);
    }
}

final class NotificateurMailer implements Notificateur
{
    public function __construct(private MailerInterface $mailer) {}

    public function envoyerBienvenue(string $email): void
    {
        $this->mailer->send((new Email())
            ->from('bonjour@legarsdelatech.fr')
            ->to($email)
            ->subject('Bienvenue dans la newsletter !')
            ->text('Merci pour votre inscription, à très vite.'));
    }
}

C'est là que se trouve l'inversion. Regardez comment les fichiers s'organisent :

src/
├── Newsletter/                  ← haut niveau : les règles métier
│   ├── InscriptionNewsletter.php
│   ├── Abonnes.php              ← interface définie par le métier
│   └── Notificateur.php         ← interface définie par le métier
└── Infrastructure/              ← bas niveau : les détails techniques
    ├── AbonnesPdo.php           → implémente Newsletter\Abonnes
    └── NotificateurMailer.php   → implémente Newsletter\Notificateur

C'est l'infrastructure qui dépend du métier (elle implémente ses interfaces), et non plus l'inverse. Le métier ignore même l'existence de MySQL et de Symfony Mailer.

Le bénéfice : des tests rapides, sans infrastructure

Pour tester la règle métier, il suffit de deux implémentations en mémoire, de quelques lignes chacune :

<?php

use App\Newsletter\Abonnes;
use App\Newsletter\Notificateur;

final class AbonnesEnMemoire implements Abonnes
{
    /** @var array<string, \DateTimeImmutable> */
    public array $inscrits = [];

    public function existe(string $email): bool
    {
        return isset($this->inscrits[$email]);
    }

    public function ajouter(string $email, \DateTimeImmutable $inscritLe): void
    {
        $this->inscrits[$email] = $inscritLe;
    }
}

final class NotificateurEspion implements Notificateur
{
    /** @var list<string> */
    public array $destinataires = [];

    public function envoyerBienvenue(string $email): void
    {
        $this->destinataires[] = $email;
    }
}
<?php

use App\Newsletter\InscriptionNewsletter;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Clock\MockClock;

final class InscriptionNewsletterTest extends TestCase
{
    private AbonnesEnMemoire $abonnes;
    private NotificateurEspion $notificateur;
    private InscriptionNewsletter $inscription;

    protected function setUp(): void
    {
        $this->abonnes = new AbonnesEnMemoire();
        $this->notificateur = new NotificateurEspion();
        $this->inscription = new InscriptionNewsletter(
            $this->abonnes,
            $this->notificateur,
            new MockClock('2026-10-24 10:00:00'),
        );
    }

    public function testInscritUneNouvelleAdresseEtSouhaiteLaBienvenue(): void
    {
        $this->inscription->inscrire('  Ada@Example.org ');

        $this->assertEquals(
            new \DateTimeImmutable('2026-10-24 10:00:00'),
            $this->abonnes->inscrits['ada@example.org'],
        );
        $this->assertSame(['ada@example.org'], $this->notificateur->destinataires);
    }

    public function testNInscritPasDeuxFoisLaMemeAdresse(): void
    {
        $this->inscription->inscrire('ada@example.org');
        $this->inscription->inscrire('ADA@example.org');

        $this->assertCount(1, $this->abonnes->inscrits);
        $this->assertSame(['ada@example.org'], $this->notificateur->destinataires);
    }
}

Ces tests s'exécutent en quelques millisecondes, sans Docker, sans base de données et sans e-mail envoyé par erreur. L'horloge aussi est une dépendance : grâce à l'interface standard ClockInterface (PSR-20), le test fixe la date au lieu de subir celle du serveur.

Brancher les vraies implémentations

Reste à indiquer quelle implémentation utiliser en production. Avec Symfony, l'autowiring s'en charge : une ligne par interface dans la configuration, ou un attribut #[AsAlias] sur la classe concernée.

# config/services.yaml
services:
    _defaults:
        autowire: true
        autoconfigure: true

    App\:
        resource: '../src/'

    # « Quand une classe demande des Abonnes, fournis-lui AbonnesPdo. »
    App\Newsletter\Abonnes: '@App\Infrastructure\AbonnesPdo'
    App\Newsletter\Notificateur: '@App\Infrastructure\NotificateurMailer'

Drupal fonctionne de la même façon : ses services dépendent d'interfaces comme ConfigFactoryInterface ou EntityTypeManagerInterface, et le conteneur fournit les implémentations. Le thème de ce blog en est un exemple : la classe qui prépare la date et le temps de lecture des articles reçoit ses outils par son constructeur.

<?php

namespace Drupal\legarsdelatech\Hook;

use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Datetime\DateFormatterInterface;

class LegarsdelatechHooks {

  public function __construct(
    protected ConfigFactoryInterface $configFactory,
    protected DateFormatterInterface $dateFormatter,
  ) {}

  // …

}

Injection, inversion, conteneur : ne pas confondre

NotionCe que c'estExemple
Injection de dépendancesUne technique : recevoir ses dépendances de l'extérieur au lieu de les créer.__construct(private Abonnes $abonnes)
Inversion des dépendancesUn principe de conception : le métier définit les abstractions, les détails les implémentent.AbonnesPdo implements Abonnes
Conteneur d'injectionUn outil qui construit les objets et les relie automatiquement.Le conteneur de services de Symfony ou de Drupal

On peut injecter des dépendances sans inverser quoi que ce soit (l'étape 1 ci-dessus), et respecter l'inversion sans aucun conteneur, en construisant les objets à la main.

Ne pas en abuser

Inutile de créer une interface pour chaque classe. Le principe vise les dépendances instables : base de données, API externes, système de fichiers, e-mails, horloge, générateur aléatoire… Un objet valeur comme une adresse e-mail, une collection ou une fonction de la bibliothèque standard ne changera pas d'implémentation : en dépendre directement ne pose aucun problème.

SOLID en résumé

LettrePrincipeEn une phrase
SResponsabilité uniqueUne classe n'a qu'une raison de changer.
OOuvert/ferméOn ajoute un comportement sans modifier le code existant.
LSubstitution de LiskovUne implémentation peut en remplacer une autre sans mauvaise surprise.
ISégrégation des interfacesChaque client ne dépend que des méthodes qu'il utilise.
DInversion des dépendancesLe métier définit ses abstractions, les détails techniques les implémentent.

Ces cinq principes se renforcent les uns les autres : des classes à responsabilité unique (S), qui dépendent de petites interfaces (I) dont les implémentations sont interchangeables (L), que l'on peut étendre sans modifier le code existant (O), et dont le métier ne dépend jamais des détails (D).

Ce ne sont pas des lois, mais des guides. Appliqués à l'aveugle, ils produisent des usines à gaz. Utilisés pour résoudre un vrai problème (code difficile à tester, modifications qui cassent tout, fonctionnalités impossibles à ajouter), ils changent la vie. Le meilleur indicateur reste simple : votre code est-il facile à modifier ?

La série SOLID

  1. S : responsabilité unique
  2. O : ouvert/fermé
  3. L : substitution de Liskov
  4. I : ségrégation des interfaces
  5. D : inversion des dépendances (vous êtes ici)