Dependency Inversion Principle : 3 exemples concrets

Par KamangaMar 13, 20268 mins de lecture

Chez un client dans le secteur de l'assurance que j'accompagnais (20 développeurs, 8 services backend), la suite de tests prenait 18 minutes à s'exécuter. Pas parce que les tests étaient lents. Parce que chaque test unitaire démarrait une vraie base de données PostgreSQL, un vrai serveur Redis, et appelait le vrai Sendgrid.

Ce n'était pas un problème de tests. C'était un problème d'architecture : les modules métier dépendaient directement des implémentations concrètes d'infrastructure.

Après avoir introduit le Dependency Inversion Principle sur les 8 services les plus critiques, la suite de tests est passée à 3 minutes. La couverture de tests a augmenté de 35% à 72% en 3 mois. Pas parce que les développeurs avaient soudain envie d'écrire des tests, mais parce que les tests étaient devenus faciles à écrire.


Le problème : la dépendance directe

Robert C. Martin (Uncle Bob) a formulé le DIP en 1996 dans ses travaux sur les principes SOLID : "Les modules de haut niveau ne doivent pas dépendre des modules de bas niveau. Les deux doivent dépendre d'abstractions." C'est la règle de dépendance au cœur de la Clean Architecture : les flèches de dépendance doivent toujours pointer vers l'intérieur, vers le domaine métier.

En pratique, la quasi-totalité des codebases sans discipline architecturale viole ce principe :

// Violation du DIP : le module de haut niveau dépend des implémentations concrètes
public class OrderService {

    private final PostgresDatabase db = new PostgresDatabase("localhost", 5432); // dépendance directe
    private final SendgridEmailClient email = new SendgridEmailClient("api-key"); // dépendance directe

    public String createOrder(OrderData orderData) {
        String orderId = db.save(orderData);
        email.sendConfirmation(orderData.getEmail(), orderId);
        return orderId;
    }
}

Ce que ça coûte concrètement :

  • Impossible de tester OrderService sans une vraie base de données PostgreSQL et un compte Sendgrid
  • Changer de base de données oblige à modifier OrderService
  • Changer d'email provider oblige à modifier OrderService
  • OrderService connaît des détails d'implémentation (host, port, api_key) qui n'ont rien à voir avec la logique métier de commande

Le principe : inverser la direction des dépendances

La solution DIP : OrderService ne dépend pas de PostgreSQLDatabase ni de SendgridEmailClient. Il dépend d'abstractions (OrderRepository, EmailNotifier) que les implémentations concrètes respectent.

Sans DIP :
OrderService → PostgreSQLDatabase
OrderService → SendgridEmailClient

Avec DIP :
OrderService → OrderRepository (abstraction) ← PostgreSQLOrderRepository (implémentation)
OrderService → EmailNotifier (abstraction)   ← SendgridEmailNotifier (implémentation)

La flèche s'inverse : l'implémentation concrète dépend de l'abstraction, pas l'inverse.


Exemple 1 : Java, injection par constructeur

// Les abstractions
public interface OrderRepository {
    String save(OrderData orderData);
}

public interface EmailNotifier {
    void sendConfirmation(String email, String orderId);
}

// Le module de haut niveau : dépend uniquement des abstractions
public class OrderService {

    private final OrderRepository repository;
    private final EmailNotifier notifier;

    public OrderService(OrderRepository repository, EmailNotifier notifier) {
        this.repository = repository;
        this.notifier = notifier;
    }

    public String createOrder(OrderData orderData) {
        String orderId = repository.save(orderData);
        notifier.sendConfirmation(orderData.getEmail(), orderId);
        return orderId;
    }
}

// Les implémentations concrètes
public class PostgresOrderRepository implements OrderRepository {
    public String save(OrderData orderData) {
        return jdbcTemplate.queryForObject("INSERT INTO orders ... RETURNING id", String.class, orderData);
    }
}

public class SendgridEmailNotifier implements EmailNotifier {
    public void sendConfirmation(String email, String orderId) {
        client.send(email, "order_confirmation", Map.of("orderId", orderId));
    }
}

// Composition à la racine de l'application
public OrderService createOrderService() {
    OrderRepository repository = new PostgresOrderRepository(dataSource);
    EmailNotifier notifier = new SendgridEmailNotifier(System.getenv("SENDGRID_KEY"));
    return new OrderService(repository, notifier);
}

Le gain pour les tests :

// Test sans base de données ni Sendgrid : s'exécute en millisecondes
class InMemoryOrderRepository implements OrderRepository {
    private final Map<String, OrderData> orders = new HashMap<>();

    public String save(OrderData orderData) {
        String orderId = UUID.randomUUID().toString();
        orders.put(orderId, orderData);
        return orderId;
    }

    public boolean contains(String orderId) {
        return orders.containsKey(orderId);
    }
}

@Test
void createOrder_persiste_et_notifie() {
    InMemoryOrderRepository repository = new InMemoryOrderRepository();
    FakeEmailNotifier notifier = new FakeEmailNotifier();
    OrderService service = new OrderService(repository, notifier);

    String orderId = service.createOrder(new OrderData("user@example.com", items));

    assertThat(repository.contains(orderId)).isTrue();
    assertThat(notifier.getSentConfirmations()).hasSize(1);
}

Vous voulez savoir où placer l'abstraction et où vous arrêter ?

Inverser une dépendance au bon endroit (et résister à l'envie d'abstraire le reste) ne s'apprend pas en lisant un exemple, ça se travaille sur votre propre code. En mentoring 1:1, je relis vos services avec vous, on extrait les ports ensemble et vous repartez avec le réflexe de sentir quand une dépendance mérite une interface. C'est du 1:1 pour monter en niveau, pas un audit d'équipe.


Exemple 2 : Java, ports hexagonaux et fakes en mémoire

// Les ports (abstractions hexagonales)
public interface OrderRepository {
    String save(OrderData orderData);
    Optional<Order> findById(String orderId);
}

public interface PaymentGateway {
    PaymentResult charge(BigDecimal amount, String currency, String paymentMethodId);
}

// Le module de haut niveau - dépend uniquement des ports
@Service
public class OrderService {

    private final OrderRepository repository;
    private final PaymentGateway payment;

    public OrderService(OrderRepository repository, PaymentGateway payment) {
        this.repository = repository;
        this.payment = payment;
    }

    public Order createPaidOrder(OrderData orderData) {
        PaymentResult result = payment.charge(
            orderData.getTotal(),
            orderData.getCurrency(),
            orderData.getPaymentMethodId()
        );

        if (!result.isSuccess()) {
            throw new PaymentFailedException(result.getErrorCode());
        }

        String orderId = repository.save(
            orderData.withPaymentId(result.getTransactionId())
                     .withStatus(OrderStatus.PAID)
        );

        return repository.findById(orderId)
            .orElseThrow(() -> new IllegalStateException("Order not found after save"));
    }
}

// Fake en mémoire pour les tests - sans Stripe, sans base de données
class InMemoryOrderRepository implements OrderRepository {

    private final Map<String, OrderData> store = new HashMap<>();

    @Override
    public String save(OrderData orderData) {
        String id = UUID.randomUUID().toString();
        store.put(id, orderData);
        return id;
    }

    @Override
    public Optional<Order> findById(String orderId) {
        return Optional.ofNullable(store.get(orderId))
            .map(data -> new Order(orderId, data.getStatus()));
    }
}

class StubPaymentGateway implements PaymentGateway {

    private final boolean shouldSucceed;

    StubPaymentGateway(boolean shouldSucceed) {
        this.shouldSucceed = shouldSucceed;
    }

    @Override
    public PaymentResult charge(BigDecimal amount, String currency, String paymentMethodId) {
        return shouldSucceed
            ? PaymentResult.success("txn-456")
            : PaymentResult.failure("CARD_DECLINED");
    }
}

// Test JUnit 5 - s'exécute en millisecondes, sans infra
class OrderServiceTest {

    @Test
    void shouldCreateOrderWithSuccessfulPayment() {
        OrderRepository repository = new InMemoryOrderRepository();
        PaymentGateway payment = new StubPaymentGateway(true);
        OrderService service = new OrderService(repository, payment);

        Order order = service.createPaidOrder(
            new OrderData(new BigDecimal("1000"), "EUR", "pm-123")
        );

        assertEquals(OrderStatus.PAID, order.getStatus());
    }

    @Test
    void shouldThrowWhenPaymentFails() {
        OrderService service = new OrderService(
            new InMemoryOrderRepository(),
            new StubPaymentGateway(false)
        );

        assertThrows(PaymentFailedException.class, () ->
            service.createPaidOrder(new OrderData(new BigDecimal("1000"), "EUR", "pm-123"))
        );
    }
}

L'avantage Java/Spring : les interfaces sont des contrats vérifiés à la compilation. Si un adapter ne respecte pas le port, javac rejette le code avant l'exécution. Spring injecte automatiquement l'adapter correct (@Repository, @Component) en production : le service métier ne sait pas quelle implémentation tourne.


Exemple 3 : Java, le DIP avec Spring

// L'abstraction
public interface NotificationService {
    void sendOrderConfirmation(String userId, String orderId);
    void sendShippingUpdate(String userId, String orderId, String trackingCode);
}

// Le module de haut niveau (service métier)
@Service
public class OrderFulfillmentService {

    private final OrderRepository orderRepository;
    private final NotificationService notificationService;

    public OrderFulfillmentService(
            OrderRepository orderRepository,
            NotificationService notificationService) {
        this.orderRepository = orderRepository;
        this.notificationService = notificationService;
    }

    public void fulfillOrder(String orderId) {
        Order order = orderRepository.findById(orderId)
            .orElseThrow(() -> new OrderNotFoundException(orderId));

        order.setStatus(OrderStatus.FULFILLING);
        orderRepository.save(order);

        notificationService.sendOrderConfirmation(order.getUserId(), orderId);
    }
}

// Implémentation de test
@Service
@Profile("test")
public class MockNotificationService implements NotificationService {

    private final List<String> sentNotifications = new ArrayList<>();

    @Override
    public void sendOrderConfirmation(String userId, String orderId) {
        sentNotifications.add("confirmation:" + userId + ":" + orderId);
    }
}

L'avantage Spring : @Profile("test") permet d'utiliser automatiquement le mock en environnement de test sans modifier le code métier. Le framework gère le wiring, le code métier reste pur.


Quand appliquer le DIP, et quand ne pas le faire

Appliquer le DIP :

  • Toute dépendance vers un système externe (base de données, API tierce, service d'email, service de paiement, file de message)
  • Toute dépendance vers une infrastructure susceptible de changer
  • Tout code que vous voulez unit-tester sans infrastructure

Ne pas appliquer le DIP mécaniquement :

  • Les entités et value objects du domaine (Order, User, Money) : pas de logique d'infrastructure
  • Les utilitaires purs (calculs mathématiques, formatage de dates) : pas d'effet de bord
  • Les dépendances stables et peu susceptibles de changer dans des petits projets

Le signal : si écrire un test unitaire pour votre classe nécessite de démarrer une base de données, une queue de messages, ou de configurer un service externe, appliquez le DIP. Cette question est aussi centrale dans les tests d'intégration sur du code legacy : l'absence de DIP est souvent ce qui rend ces tests si difficiles et si fragiles à mettre en place.


Le DIP n'est qu'une des 100 pratiques qui rendent un code testable

Cet article détaille une pratique craft : inverser ses dépendances pour découpler le métier de l'infrastructure. Le Craft Bundle réunit les 100 pratiques que j'applique pour coder propre, du choix d'une abstraction au design d'un test rapide. Celles que l'IA ne vous apprendra jamais parce qu'elle ne les a jamais vues tenir en production.


FAQ sur le Dependency Inversion Principle

1. Quelle est la différence entre DIP et Dependency Injection ?

Le DIP est un principe architectural : les modules doivent dépendre d'abstractions. La Dependency Injection (DI) est une technique d'implémentation du DIP : les dépendances sont fournies de l'extérieur plutôt que créées à l'intérieur. On peut respecter le DIP sans DI (ex : factory pattern). On peut utiliser la DI sans respecter le DIP (ex : injecter une classe concrète sans interface). En pratique, les deux vont généralement ensemble.

2. Les containers DI (Spring, .NET DI, Angular) font-ils le travail à notre place ?

Ils automatisent le wiring (qui injecte quoi), mais ne créent pas les abstractions à notre place. Spring injecte les classes concrètes si vous ne créez pas d'interfaces. La discipline architecturale de créer des interfaces pour les dépendances d'infrastructure reste une décision de l'équipe. Le framework ne peut pas la prendre à votre place.

3. Le DIP ne crée-t-il pas trop d'abstractions ?

Oui, si mal appliqué. Créer une interface pour chaque classe, même les plus stables, est du sur-engineering. La règle que Martin Fowler formule clairement : créer une abstraction quand il y a au moins 2 implémentations possibles (production + test, provider A + provider B) ou quand la dépendance est vers un système externe. "Pas d'abstraction prématurée" s'applique au DIP comme à tout principe.

4. Comment introduire le DIP progressivement dans un codebase existant ?

Le strangler fig pattern appliqué au DIP : ne pas refactorer tout le code existant d'un coup. À chaque nouvelle fonctionnalité ou bug fix qui touche une classe avec dépendances directes, extraire l'interface et introduire l'injection. Après 6 mois de cette discipline, les zones les plus actives du codebase respectent le DIP. Les zones stables peuvent rester dans l'état existant si elles ne causent pas de problèmes. Dans un contexte de code legacy fortement couplé, cette approche progressive est souvent la seule viable sans bloquer les livraisons.

5. Le DIP s'applique-t-il aux frontends (React, Vue) ?

Oui. En React : les composants ne doivent pas appeler directement fetch() ou axios : ils doivent dépendre d'une abstraction (un service, un hook custom, un context) qui peut être mockée dans les tests. useOrderService() hook qui cache l'implémentation HTTP, facilement mockable dans les tests. Le DIP s'applique partout où il y a des effets de bord, frontend inclus.


Ressource gratuite : Engineering Maturity Self-Assessment

L'Engineering Maturity Self-Assessment couvre le domaine Craft & Conception : évaluez votre niveau sur les principes SOLID, le couplage, et la testabilité. Obtenez un score de maturité et des recommandations concrètes en 10 minutes.


Ecris par Kamanga

Expert IT avec 25 ans d'expérience en développement logiciel, diplômé EPITECH et MBA. Spécialisé en software craftsmanship, gestion du changement, stratégie, direction des systèmes d'information, coaching et certifié en agilité.