Dependency Inversion Principle : 3 exemples concrets

Par KamangaMar 13, 20267 mins de lecture

Dependency Inversion Principle : 3 exemples concrets

Chez un client dans le secteur du retail en ligne 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 du module de bas niveau

class OrderService:
    def __init__(self):
        self.db = PostgreSQLDatabase(host="localhost", port=5432)  # dépendance directe
        self.email = SendgridEmailClient(api_key="...")              # dépendance directe

    def create_order(self, order_data):
        order_id = self.db.save(order_data)
        self.email.send_confirmation(order_data["email"], order_id)
        return order_id

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 : Python, injection par constructeur

from abc import ABC, abstractmethod

class OrderRepository(ABC):
    @abstractmethod
    def save(self, order_data: dict) -> str:
        pass

class EmailNotifier(ABC):
    @abstractmethod
    def send_confirmation(self, email: str, order_id: str) -> None:
        pass

# Le module de haut niveau — dépend uniquement des abstractions
class OrderService:
    def __init__(self, repository: OrderRepository, notifier: EmailNotifier):
        self.repository = repository
        self.notifier = notifier

    def create_order(self, order_data: dict) -> str:
        order_id = self.repository.save(order_data)
        self.notifier.send_confirmation(order_data["email"], order_id)
        return order_id

# Les implémentations concrètes
class PostgreSQLOrderRepository(OrderRepository):
    def save(self, order_data: dict) -> str:
        return self.db.execute("INSERT INTO orders ...", order_data)

class SendgridEmailNotifier(EmailNotifier):
    def send_confirmation(self, email: str, order_id: str) -> None:
        self.client.send(to=email, template="order_confirmation", data={"order_id": order_id})

# Composition à la racine de l'application
def create_order_service():
    repository = PostgreSQLOrderRepository(db)
    notifier = SendgridEmailNotifier(api_key=os.getenv("SENDGRID_KEY"))
    return OrderService(repository, notifier)

Le gain pour les tests :

# Test sans base de données ni Sendgrid — s'exécute en millisecondes
class InMemoryOrderRepository(OrderRepository):
    def __init__(self):
        self.orders = {}

    def save(self, order_data: dict) -> str:
        order_id = str(uuid.uuid4())
        self.orders[order_id] = order_data
        return order_id

def test_create_order():
    repository = InMemoryOrderRepository()
    notifier = MockEmailNotifier()
    service = OrderService(repository, notifier)

    order_id = service.create_order({"email": "user@example.com", "items": [...]})

    assert order_id in repository.orders
    assert len(notifier.sent_confirmations) == 1

Votre codebase est difficile à tester parce que les dépendances sont fortement couplées ?

Introduire le DIP dans un codebase existant nécessite une stratégie progressive, pas un refactoring massif qui bloque toute l'équipe. En 30 minutes, on peut identifier les zones de couplage les plus problématiques et définir un plan de refactoring réaliste avec des gains mesurables.


Exemple 2 : TypeScript, interfaces et injection par framework

// Les abstractions (interfaces TypeScript)
interface OrderRepository {
    save(orderData: OrderData): Promise<string>;
    findById(orderId: string): Promise<Order | null>;
}

interface PaymentGateway {
    charge(amount: number, currency: string, paymentMethod: string): Promise<PaymentResult>;
}

// Le module de haut niveau
class OrderService {
    constructor(
        private readonly repository: OrderRepository,
        private readonly payment: PaymentGateway
    ) {}

    async createPaidOrder(orderData: OrderData): Promise<Order> {
        const paymentResult = await this.payment.charge(
            orderData.total,
            orderData.currency,
            orderData.paymentMethodId
        );

        if (!paymentResult.success) {
            throw new PaymentFailedError(paymentResult.errorCode);
        }

        const orderId = await this.repository.save({
            ...orderData,
            paymentId: paymentResult.transactionId,
            status: 'paid'
        });

        return this.repository.findById(orderId);
    }
}

// Test — sans Stripe, sans base de données
describe('OrderService', () => {
    it('should create order with successful payment', async () => {
        const mockRepository: OrderRepository = {
            save: jest.fn().mockResolvedValue('order-123'),
            findById: jest.fn().mockResolvedValue({ id: 'order-123', status: 'paid' })
        };

        const mockPayment: PaymentGateway = {
            charge: jest.fn().mockResolvedValue({ success: true, transactionId: 'txn-456' })
        };

        const service = new OrderService(mockRepository, mockPayment);
        const order = await service.createPaidOrder({ total: 1000, currency: 'EUR' });

        expect(order.status).toBe('paid');
    });
});

L'avantage TypeScript : les interfaces sont vérifiées à la compilation. Si une implémentation ne respecte pas l'interface, le compilateur rejette le code avant même l'exécution.


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.


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é.