Dependency Inversion Principle : 3 exemples concrets
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
OrderServicesans 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 OrderServiceconnaî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.