Les principes SOLID sont cinq règles de conception destinées à produire un code plus maintenable et évolutif. Formulés initialement par Robert C. Martin, ils visent à rendre le code plus clair, plus fiable et plus facile à faire évoluer. Acronyme anglais, SOLID correspond à :
Ces principes ne sont pas des règles strictes, mais des guides de conception. Leur application dépend fortement du contexte et du niveau de maturité du projet. Une mauvaise application des principes SOLID peut aussi conduire à une complexité excessive, notamment par une multiplication inutile d’interfaces et d’abstractions.
Nous allons dans cet article explorer chacun de ces principes, avec des explications et des exemples concrets en Java inspirés d’un domaine de gestion de commandes (système de e-commerce).
Le SRP indique qu’une classe ou un module ne doit avoir qu’une seule responsabilité. Cela signifie que chaque composant doit se concentrer sur une tâche précise. En évitant de multiplier les objectifs d’une classe, on facilite sa maintenance : une modification fonctionnelle n’impactera qu’une portion isolée du code.
Exemple de violation SRP : Imaginons une classe OrderManager dans un système de commandes qui gère tout à la fois : le traitement de la commande, son enregistrement en base de données et l’envoi d’un email de confirmation. Cette classe remplit plusieurs rôles à la fois, ce qui complique sa maintenance. En effet, si l’on doit changer la façon d’envoyer les emails, ou modifier le stockage en base, on risque d’affecter involontairement le traitement des commandes.
// Anti-pattern: OrderManager handling multiple responsibilities (violates SRP)
public class OrderManager {
public void processOrder(Order order) {
// Process order (e.g., validate items, calculate total, etc.)
// ...
saveOrderToDatabase(order); // Saves order to DB (different responsibility)
sendOrderConfirmationEmail(order); // Sends confirmation email (different responsibility)
}
private void saveOrderToDatabase(Order order) {
// Code to save order in a database
}
private void sendOrderConfirmationEmail(Order order) {
// Code to send a confirmation email to the customer
}
}
Ici, OrderManager enfreint SRP : on lui a confié plusieurs responsabilités. Elle traite la commande et gère des opérations de persistance et d’envoi d’email. Cette conception entraîne un couplage fort entre des fonctionnalités distinctes. Toute modification de l’une (par exemple la sauvegarde dans la base de données) nécessite de changer la classe, au risque d’introduire des effets de bord sur les autres fonctionnalités. Le code devient également difficile à tester (car tester OrderManager implique de tester la logique métier, la base de données et l’email à la fois) et à faire évoluer.
Version corrigée (SRP respecté) : Pour appliquer le principe de responsabilité unique, on va découper les fonctions de OrderManager en plusieurs classes plus spécialisées. Par exemple, on peut avoir un OrderProcessor dédié au traitement métier de la commande, un OrderRepository pour la sauvegarde en base, et un EmailService pour l’envoi des emails. Chacune de ces classes n’a qu’une tâche spécifique, ce qui les rend plus simples à modifier et à tester indépendamment.
// SRP-compliant: Each class has a single responsibility
public class OrderProcessor {
public void process(Order order) {
// Business logic to process the order (e.g., validate items, calculate total)
}
}
public class OrderRepository {
public void save(Order order) {
// Code to save the order to the database
}
}
public class EmailService {
public void sendConfirmationEmail(Order order) {
// Code to send a confirmation email to the customer
}
}
Dans cette version SOLID, chaque classe s’occupe d’un seul rôle :
Désormais, une évolution de la base de données n’affectera que OrderRepository, sans impacter le traitement métier ou l’envoi d’email. Le code est plus clair (chaque classe a un nom explicite et une responsabilité unique) et plus maintenable (on peut modifier ou réécrire une classe sans crainte d’effet de bord sur les autres). En cas de bug dans le processus d’envoi d’email, on sait exactement où intervenir.
Le principe OCP stipule que les entités logicielles doivent être ouvertes à l’extension, mais fermées aux modifications. En pratique, cela signifie que l’on doit pouvoir ajouter de nouvelles fonctionnalités sans modifier le code existant. Un code conçu selon OCP anticipe les évolutions : on peut étendre le comportement du système sans toucher aux classes éprouvées, réduisant ainsi le risque d'introduire des régressions. Être ouvert à l’extension ne signifie pas anticiper tous les cas possibles, mais concevoir des points d’extension clairs et stables. A noter que "forcer" l’application du principe peut parfois mener à des abstractions prématurées, surtout lorsque les besoins sont encore très variables.
Exemple de violation OCP : Considérons un calculateur de remise (DiscountCalculator) dans notre application de commandes. Imaginons que le montant de la remise dépende du type de client (par exemple "Client Régulier" ou "Client Premium"). Une implémentation "naïve" consisterait à utiliser une condition if/else ou un switch dans la méthode de calcul pour distinguer les types de clients et appliquer un pourcentage différent. Cette approche fonctionne pour les types connus, mais pose problème dès qu’on doit ajouter un nouveau type de client ("VIP" par exemple). On sera alors obligé de modifier la classe existante pour ajouter un nouveau cas. On enfreint ainsi OCP, car la classe n’est pas fermée à la modification.
// OCP violation: Adding new customer types requires modifying this method
public class DiscountCalculator {
public double calculateDiscount(Order order, String customerType) {
if ("Regular".equals(customerType)) {
return order.getTotal() * 0.05; // 5% discount for regular customers
} else if ("Premium".equals(customerType)) {
return order.getTotal() * 0.10; // 10% discount for premium customers
} else {
return 0.0; // No discount for other customer types
}
}
}
public class Application {
public static void main(String[] args) {
DiscountCalculator calculator = new DiscountCalculator();
Order order = ...
// Existing customer types
double regularDiscount = calculator.calculateDiscount(order, "Regular");
double premiumDiscount = calculator.calculateDiscount(order, "Premium");
}
}
Chaque fois qu’un nouveau type de client apparaît avec sa propre règle de remise, il est nécessaire modifier la méthode calculateDiscount, ce qui contrevient à OCP.
Modifier du code existant comporte un risque : une faute de logique peut impacter les calculs de remise pour les clients réguliers et premium qui fonctionnaient auparavant. Donc, cela rajoute des risques de régressions. Le code est également moins lisible et évolutif, car cette méthode centralise des règles qui devraient idéalement être séparées.
Version corrigée (OCP respecté) : Pour appliquer OCP, il est préférable d'utiliser le polymorphisme afin d’étendre le comportement sans modifier le calculateur de base. On peut définir une interface DiscountStrategy que chaque catégorie de clients implémente différemment. Ainsi, l’ajout d’un nouveau type de client se fera via une nouvelle classe implémentant DiscountStrategy, sans modifier le code existant. Le calculateur de remise utilisera cette abstraction. Voici une refonte du calculateur de remise :
// OCP-compliant: New customer types can be added via new strategy classes, without altering existing code
public enum CustomerType { REGULAR, PREMIUM, VIP }
public interface DiscountStrategy {
// Defines which customer type this strategy supports
CustomerType customerType();
// Calculates the discount amount for the given order
double applyDiscount(Order order);
}
public class RegularCustomerDiscount implements DiscountStrategy {
@Override
public CustomerType customerType() {
return CustomerType.REGULAR;
}
@Override
public double applyDiscount(Order order) {
// 5% discount for regular customers
return order.getTotal() * 0.05;
}
}
public class PremiumCustomerDiscount implements DiscountStrategy {
@Override
public CustomerType customerType() {
return CustomerType.PREMIUM;
}
@Override
public double applyDiscount(Order order) {
// 10% discount for premium customers
return order.getTotal() * 0.10;
}
}
public class DiscountCalculator {
private final Map<CustomerType, DiscountStrategy> strategies;
public DiscountCalculator(List<DiscountStrategy> strategies) {
// Build a lookup map from customer type to strategy
this.strategies = strategies.stream()
.collect(Collectors.toMap(DiscountStrategy::customerType, strategy -> strategy));
}
public double calculateDiscount(Order order, CustomerType customerType) {
// Retrieve the strategy and apply it if present
return Optional.ofNullable(strategies.get(customerType))
.map(strategy -> strategy.applyDiscount(order))
.orElse(0.0); // Default: no discount
}
}
public class Application {
public static void main(String[] args) {
// Register available discount strategies
List<DiscountStrategy> strategies = List.of(
new RegularCustomerDiscount(),
new PremiumCustomerDiscount(),
new VipCustomerDiscount()
);
DiscountCalculator calculator = new DiscountCalculator(strategies);
Order order = ....
double regularDiscount = calculator.calculateDiscount(order, CustomerType.REGULAR);
double premiumDiscount = calculator.calculateDiscount(order, CustomerType.PREMIUM);
double vipDiscount = calculator.calculateDiscount(order, CustomerType.VIP);
}
}
DiscountCalculator est fermé à la modification mais ouvert à l’extension : on peut ajouter de nouvelles stratégies de remise en créant simplement une nouvelle classe implémentant DiscountStrategy (par exemple, une classe VIPCustomerDiscount avec 15% de remise) sans toucher au code existant du calculateur. Le calculateur dépend d’une abstraction (DiscountStrategy) au lieu d’une liste de types en dur. Le code gagne en flexibilité et en lisibilité : chaque règle de remise est isolée dans une classe claire. De plus, cette conception facilite les tests unitaires : on peut tester chaque stratégie indépendamment, et tester DiscountCalculator avec une stratégie factice au besoin.
Le principe de substitution de Liskov repose sur la notion de contrat : une classe dérivée doit pouvoir remplacer sa classe parente sans modifier le comportement attendu. Autrement dit, tout objet d’une classe fille doit pouvoir se substituer à un objet de la classe mère sans "surprise". Ce principe repose sur la notion de contrat : une classe dérivée doit respecter le comportement attendu de sa classe parente, sans en modifier les règles.
Si une sous-classe ne respecte pas le "contrat" défini par sa super-classe (par exemple en levant des exceptions inattendues ou en modifiant des comportements attendus), on viole le LSP. Ce principe garantit la cohérence et la fiabilité du polymorphisme : le code qui utilise une abstraction (classe de base ou interface) ne devrait pas avoir à se soucier de la classe concrète qui l’implémente.
Exemple de violation LSP : Dans notre domaine de gestion de commandes, supposons que nous ayons une classe de base Order avec une méthode ship() qui expédie la commande (par exemple, en préparant l’envoi du colis).
On pourrait être tenté de créer une sous-classe BackOrder (commande en attente de réapprovisionnement) dérivant de Order. Cependant, une commande en "attente de stock" ne peut pas être expédiée immédiatement. Si dans la classe BackOrder on redéfinit ship() pour qu’elle lève une exception ou n’ait aucun effet, alors on crée une situation où BackOrder ne se comporte pas comme une Order ordinaire. En effet, toutes les portions de code qui s’attendent à pouvoir appeler ship() sur une Order va échouer si elle reçoit en réalité une BackOrder à cause de l'exception qui sera levée :
// LSP violation: BackOrder extends Order but cannot fulfill the contract of Order.ship()
public class Order {
public void ship() {
// Mark order as shipped (e.g., update status, notify customer)
}
}
public class BackOrder extends Order {
@Override
public void ship() {
// BackOrder cannot be shipped until items are back in stock
throw new UnsupportedOperationException("Cannot ship a backorder that is waiting for stock");
}
}
Version corrigée (LSP respecté) : Pour résoudre ce problème, on doit repenser la hiérarchie d’héritage de manière à ne pas forcer une classe fille à rendre un comportement invalide. Une solution est de séparer le concept de commande expédiable de celui de commande en attente, au lieu d’utiliser un héritage direct. Par exemple, on peut introduire une interface ShippableOrder pour les commandes expédiables, que les commandes normales implémenteront, mais que les backorders n’implémenteront pas. Ainsi, toute commande expédiable garantit la disponibilité de la méthode ship(), et les BackOrder ne possèdent pas cette méthode (ils ne sont donc pas traités comme des ShippableOrder).
// LSP-compliant: Only ShippableOrder have ship(), BackOrder does not try to override an unusable method
public interface Order { /* common order behavior */ }
public interface ShippableOrder extends Order {
void ship();
}
public class StandardOrder implements ShippableOrder {
@Override
public void ship() {
// Shipping logic for a standard order (update status, etc.)
}
}
public class BackOrder implements Order {
// BackOrder does not implement ShippableOrder, so it has no ship() method at all
// (It might have its own methods for handling stock availability)
}
Dans ce design refactorisé, on a retiré la méthode ship() de la classe de base générale. Seules les commandes qui peuvent être expédiées implémentent l’interface ShippableOrder avec la méthode ship().
Par conséquent, le contrat d'interface n'est plus "cassé" par la levée de l'exception non prévue initialement dans ship().
Le principe de ségrégation des interfaces recommande de ne pas imposer à une classe l’implémentation de méthodes dont elle n’a pas besoin. Autrement dit, il vaut mieux avoir plusieurs petites interfaces spécifiques plutôt qu’une seule large interface regroupant beaucoup de méthode. Chaque interface doit correspondre à un rôle précis, afin que les classes qui l’implémentent n’aient à définir que ce qui leur est utile. Ce principe améliore la flexibilité et la clarté du code : les changements dans une interface très ciblée n’impacteront que les classes concernées, et non une multitude d’implémentations sans rapport.
L’objectif de ce principe n’est pas de multiplier les interfaces, mais de s’assurer qu’elles restent cohérentes et adaptées aux usages réels.
Exemple de violation ISP : Supposons qu’on veuille générer des documents liés aux commandes (par exemple des factures et des étiquettes d’expédition). On pourrait définir une interface générique OrderDocumentGenerator avec deux méthodes : generateInvoice(Order) pour la facture et generateShippingLabel(Order) pour l’étiquette. Imaginons maintenant une classe InvoiceService qui ne s’occupe que des factures. Si InvoiceService implémente OrderDocumentGenerator, alors elle sera obligée de fournir une méthode generateShippingLabel qu’elle n’utilise pas. Cette conception enfreint ISP, car InvoiceService dépend d’une méthode dont elle n’a pas besoin.
// ISP violation: OrderDocumentGenerator forces classes to implement methods they don't need
public interface OrderDocumentGenerator {
void generateInvoice(Order order);
void generateShippingLabel(Order order);
}
public class InvoiceService implements OrderDocumentGenerator {
@Override
public void generateInvoice(Order order) {
// Generate invoice for the order
}
@Override
public void generateShippingLabel(Order order) {
// Not needed for invoices, but must be implemented
throw new UnsupportedOperationException("InvoiceService cannot generate shipping labels");
}
}
Version corrigée (ISP respecté) : La solution est de scinder l’interface en plusieurs interfaces plus spécifiques correspondant chacune à un besoin. Dans notre cas, on peut avoir une interface InvoiceGenerator pour la génération de factures, et une interface ShippingLabelGenerator pour les étiquettes de livraison. Ainsi, InvoiceService implémentera InvoiceGenerator uniquement, et une autre classe, par exemple ShippingLabelService, implémentera ShippingLabelGenerator. Chaque classe n’expose et ne définit que ce qui la concerne réellement.
// ISP-compliant: Separate interfaces for separate concerns
public interface InvoiceGenerator {
void generateInvoice(Order order);
}
public interface ShippingLabelGenerator {
void generateShippingLabel(Order order);
}
public class InvoiceService implements InvoiceGenerator {
@Override
public void generateInvoice(Order order) {
// Generate invoice for the order
}
}
public class ShippingLabelService implements ShippingLabelGenerator {
@Override
public void generateShippingLabel(Order order) {
// Generate shipping label for the order
}
}
Grâce à cette ségrégation d’interfaces, chaque classe ne dépend que de ce qu’elle utilise effectivement.
À noter que dans certains cas, une interface plus large peut rester pertinente si l’ensemble de ses méthodes est réellement utilisé.
Le principe DIP suggère de dépendre des abstractions plutôt que des implémentations concrètes. Plus concrètement, il énonce que les modules de haut niveau ne devraient pas dépendre des modules de bas niveau, mais devraient dépendre d’abstractions communes.
En pratiquant l’inversion des dépendances, on obtient un code plus flexible et extensible, car il est facile de substituer une implémentation par une autre sans toucher au module principal. Cela facilite également les tests unitaires (on peut injecter des objets factices (mock) à la place de composants réels).
Exemple de violation DIP : Reprenons notre système de commandes, et supposons une classe de haut niveau OrderService qui orchestre la finalisation des commandes (valider et sauvegarder la commande une fois le paiement effectué).
Si OrderService crée "en dur" un objet d’une classe concrète de bas niveau, imaginons MySQLOrderRepository pour enregistrer la commande dans une base de données MySQL, alors OrderService dépend directement d’une implémentation spécifique de stockage. Ce couplage fort pose problème : si demain on veut passer sur une autre base de données, ou sauvegarder différemment (fichier, mémoire, etc.), il faudra modifier le code de OrderService.
// DIP violation: OrderService depends directly on a concrete repository implementation
public class MySQLOrderRepository {
public void save(Order order) {
// Code to save order to MySQL database
}
}
public class OrderService {
private MySQLOrderRepository repository = new MySQLOrderRepository();
public void completeOrder(Order order) {
// ...
repository.save(order); // Direct call to a specific database implementation
}
}
Version corrigée (DIP respecté) : Pour inverser la dépendance, on introduit une abstraction pour le dépôt de commandes, par exemple une interface OrderRepository. OrderService dépendra de cette interface (et non plus d’une classe concrète). Ainsi, OrderService devient agnostique vis-à-vis de la manière dont les commandes sont stockées. On peut injecter n’importe quelle implémentation de OrderRepository : une BDD MySQL, une BDD PostgreSQL, ou même un faux dépôt en mémoire pour les tests, sans changer le code de OrderService. Concrètement, on passera l’implémentation spécifique via le constructeur de OrderService (injection de dépendance).
// DIP-compliant: OrderService depends on an abstraction (OrderRepository) rather than a concrete class
public interface OrderRepository {
void save(Order order);
Optional<Order> findById(String id);
}
public class MySQLOrderRepository implements OrderRepository {
@Override
public void save(Order order) {
// Save order to MySQL database
}
@Override
public Optional<Order> findById(String id) {
// ... retrieve order by id from MySQL
}
}
public class InMemoryOrderRepository implements OrderRepository {
private List<Order> storage = new ArrayList<>();
@Override
public void save(Order order) {
storage.add(order);
}
@Override
public Optional<Order> findById(String id) {
// ... retrieve order by id from storage list
}
}
public class OrderService {
private final OrderRepository repository;
public OrderService(OrderRepository repository) {
this.repository = repository; // Inject an implementation of OrderRepository
}
public void completeOrder(Order order) {
// ...
repository.save(order); // Save through the abstract interface
}
}
Dans cette implémentation SOLID, OrderService dépend d’une abstraction (OrderRepository) et non plus d’une classe concrète.
A noter que les frameworks d’injection de dépendances simplifient la mise en œuvre de ce principe (Spring), mais celui-ci peut être appliqué même sans framework, car il s’agit avant tout d’un choix de conception.
Les principes SOLID sont importants, mais comme tout outil, ils doivent être utilisés avec discernement. Les appliquer systématiquement peut alourdir inutilement le code, surtout dans les contextes suivants :
Les principes SOLID ne garantissent pas à eux seuls un bon design, mais ils offrent un cadre de réflexion précieux pour produire un code plus lisible, testable et évolutif. A noter qu'une application systématique et excessive de ces principes peut conduire à une complexité inutile, avec trop d’abstractions et de code au final difficile à maintenir. Dans certains cas, violer temporairement un principe SOLID est un choix conscient, documenté et assumé, afin de privilégier la rapidité, la lisibilité ou la stabilité.
En appliquant tous les principes, on obtient un code plus robuste, maintenable, testable et naturellement évolutif :
Chaque principe adresse une source classique de problèmes : classes trop chargées, modifications risquées, héritages mal maîtrisés, contrats inappropriés ou dépendances rigides. En les suivant, on réduit le couplage, on améliore la réutilisabilité et on facilite les tests.
L’application des principes SOLID améliore la lisibilité du code, facilite son évolution et son test, tout en limitant les risques de régression.
À noter que cet article a été en partie rédigé par une IA et reformulé, complété et vérifié par un humain.