Accueil / Articles

SOLID en tant que question sur le changement : refactoring d’un service Spring Boot

Apprenez à appliquer chaque principe SOLID dans un backend Spring Boot en vous demandant ce qui est susceptible de changer et comment éviter l’over-engineering que SOLID entraîne souvent.

Éditeur : REACTAPP.TOP12 min de lecture2660 mots

Sources: SOLID Principles for Java Developers: What 5 Years of Backend Development Taught Me

La plupart des développeurs savent réciter ce que représentent les cinq lettres de SOLID, mais très peu parviennent à expliquer comment ces principes doivent guider la conception d’un véritable service Spring Boot doté de repositories, de fournisseurs de paiement et de canaux de notification. Les définitions seules ne suffisent pas lorsque le responsable produit demande des notifications par WhatsApp un vendredi après-midi. Ce guide passe en revue chaque principe à l’aide de code backend typique, montre le refactoring qu’il suggère, et, tout aussi important, indique dans quels cas l’application mécanique de ce principe détériore la qualité du codebase.

Le fil conducteur commun à ces cinq principes n’est ni « plus d’interfaces » ni « de petites classes ». Il s’agit de rendre le code facile à modifier, afin que la modification d’un comportement n’affecte pas tout ce qui l’entoure. Les exemples ci-dessous sont en Java, mais la logique s’applique directement aux services TypeScript dans Node.js ; si vous préférez cet environnement, un article complémentaire est disponible à SOLID à travers des exemples pratiques en JavaScript.

Le problème que SOLID cherche à prévenir

Commençons par un service de commande qui effectue tout ce qu’exige une transaction en une seule méthode : il valide, persiste les données, envoie des e-mails, génère des factures, notifie et enregistre des données analytiques.

public class OrderService {
public void createOrder(Order order) {
        validateOrder(order);
        saveOrder(order);
        sendEmail(order);
        generateInvoice(order);
        sendNotification(order);
        updateAnalytics(order);
    }
}

Rien ici ne fonctionne mal. La logique s’écoute de haut en bas, elle passerait facilement une revue de code et pourrait fonctionner en production pendant des mois. Les problèmes apparaissent avec les exigences suivantes : un partie prenante souhaite que les notifications soient envoyées via WhatsApp plutôt qu’e-mail. Un autre veut que la génération des factures soit effectuée en arrière-plan. L’équipe des données annonce une migration de la base de données. Ensuite, les analyses doivent être publiées sur Kafka.

Toutes ces demandes finissent dans la même classe. Le code continue de fonctionner ; le problème, c’est que chaque petite modification oblige à ouvrir, modifier et rétester une unité qui est liée à cinq autres aspects. Ce couplage, où un changement entraîne de nombreux autres changements, est précisément ce que les principes SOLID visent à contrôler.

Responsabilité unique : comptez les raisons de changer

Le principe de la responsabilité unique stipule qu’une classe ne doit avoir qu’une seule responsabilité, ce qui, en pratique, signifie une seule raison de la modifier. On l’interprète souvent à tort comme « une méthode par classe ». Cette interprétation est fausse : une classe comptant vingt méthodes peut tout de même avoir une seule responsabilité, à condition que toutes servent le même objectif.

La question pertinente n’est pas la taille de la classe, mais ce qui pourrait vous forcer à la modifier. Si vous pouvez énumérer plusieurs réponses non liées, la classe porte probablement plusieurs responsabilités.

Détecter les responsabilités mélangées

Voici un service d’inscription d’utilisateur dans le style que l’on trouve dans de nombreux bases de code.

public class UserService {
public void registerUser(User user) {
        validate(user);
        saveToDatabase(user);
        sendEmail(user);
        generateReport(user);
    }
}

Il effectue quatre tâches distinctes : valider l’utilisateur, le stocker, envoyer un e-mail et générer un rapport. Cela lui donne quatre raisons indépendantes de changer. Un nouveau fournisseur d’e-mails nécessite la modification de UserService. Le passage à une base de données différente exige également la modification de UserService. Un changement dans le format de rapport oblige à nouveau à modifier UserService. Lorsque des équipes non liées modifient constamment le même fichier, on est face à un problème de conception.

Déléguer aux collaborateurs

Le refactoring conserve l’orchestration dans UserService et transfère les tâches spécialisées à des collaborateurs dédiés injectés via le constructeur.

public class UserService {
private final UserRepository userRepository;
    private final EmailService emailService;
    public void registerUser(User user) {
        validate(user);
        userRepository.save(user);
        emailService.sendWelcomeEmail(user);
    }
}

La structure de dépendances qui en résulte est facile à visualiser :

UserService
     |
     +---- UserRepository
     |
     +---- EmailService

UserService décide désormais de ce qui se passe lors de l’inscription, tandis que UserRepository gère la persistance et EmailService s’occupe de l’envoi. La validation reste dans le service car elle fait partie intégrante des règles d’inscription, et les rapports ne concernent pas du tout ce processus.

L’habitude à adopter est d’éviter de compter les méthodes et plutôt de se demander quelles forces externes pourraient vous amener à modifier une classe. Cette question permet d’identifier les vrais couplages plutôt que de se concentrer sur une taille superficielle.

Ouvert/Fermé : ajouter du comportement sans rouvrir du code stable

L’expression formelle selon laquelle les entités logicielles doivent être ouvertes à l’extension mais fermées à la modification semble abstraite. En termes plus concrets : vous devez pouvoir introduire de nouveaux comportements sans avoir à modifier à plusieurs reprises du code qui fonctionne déjà et a été testé.

La condition croissante

Le traitement des paiements en est un exemple classique. Une première version fonctionne souvent à l’aide d’une chaîne de caractères.

public class PaymentService {
public void pay(String type) {
        if (type.equals("CARD")) {
            // card payment
        }
        if (type.equals("UPI")) {
            // UPI payment
        }
        if (type.equals("NET_BANKING")) {
            // net banking
        }
    }
}

Cela convient pour trois méthodes. Ensuite, l’entreprise demande la prise en charge des portefeuilles électroniques, de PayPal, d’Apple Pay et des virements bancaires. Chaque ajout nécessite de rouvrir PaymentService, d’élargir la structure conditionnelle et de rétester chaque branche existante. La classe devient progressivement une longue chaîne d’instructions if/else.

Remplacer les branches par le polymorphisme

L’approche Ouvert/Fermé introduit une abstraction qui décrit ce que chaque méthode de paiement doit faire.

public interface PaymentProcessor {
void process(Payment payment);
}

Chaque méthode de paiement devient alors sa propre implémentation. Le processeur de cartes :

public class CardPaymentProcessor
        implements PaymentProcessor {
@Override
    public void process(Payment payment) {
        // card payment
    }
}

Et le processeur UPI :

public class UpiPaymentProcessor
        implements PaymentProcessor {
@Override
    public void process(Payment payment) {
        // UPI payment
    }
}

Ajouter un nouveau fournisseur signifie désormais écrire une nouvelle classe plutôt que d’éditer les existantes. Les traitements déjà en production restent inchangés, ce qui réduit l’impact de chaque modification. Dans une application Spring, on enregistre généralement chaque traiteur en tant que bean et on sélectionne le bon via un tableau indexé par type de paiement, de sorte que même la logique de sélection n’a pas besoin de développer une nouvelle branche.

Lorsqu’une interface ne vous sert à rien

Rien de tout cela n’implique que chaque classe ait besoin d’une interface. Un schéma très courant dans les projets Spring est le couplage un à un entre une interface et sa seule implémentation :

public interface UserService {
    void createUser();
}

avec une seule classe derrière elle :

public class UserServiceImpl implements UserService {
}

Si aucune perspective réaliste d’une deuxième implémentation n’existe, et que rien n’est gagné en la remplaçant par une autre, cette interface n’est qu’un fichier supplémentaire à naviguer. Les bibliothèques modernes de simulation permettent de simuler des classes concrètes, donc la seule faisabilité des tests n’est rarement un argument convaincant non plus. SOLID vise à concevoir pour les changements attendus, et non à envelopper systématiquement chaque classe dans une abstraction.

Substitution de Liskov : respecter le contrat, pas seulement la signature

Le principe de substitution de Liskov stipule qu’il faut pouvoir utiliser n’importe quel sous-type là où son type parent est attendu, sans compromettre la correction du programme. Parmi les cinq principes, il semble souvent le plus théorique ; il est donc utile de reprendre l’exemple des paiements.

L’abstraction reste identique à celle précédente :

public interface PaymentProcessor {
void process(Payment payment);
}

Une implémentation de carte remplit ce que l’interface promet :

public class CardPaymentProcessor
        implements PaymentProcessor {
public void process(Payment payment) {
        // process payment
    }
}

Jusqu’ici, tout va bien. Puis quelqu’un ajoute un processeur pour les paiements en espèces qui ne peut en réalité traiter rien en ligne :

public class CashPaymentProcessor
        implements PaymentProcessor {
public void process(Payment payment) {
        throw new UnsupportedOperationException();
    }
}

Le compilateur est satisfait, car la signature de la méthode correspond. Cependant, le comportement va à l’encontre des promesses faites. Tout appelant disposant d’un PaymentProcessor suppose raisonnablement que l’appel à process permettra de traiter un paiement. Avec cette implémentation, une boucle apparemment inoffensive sur tous les processeurs peut soudain provoquer une erreur en temps de exécution, et les appelants commencent à ajouter des vérifications instanceof ou des blocs try/catch pour se protéger, ce qui est un signe certain que l’abstraction a fuité.

Lors de la rédaction d’une implémentation, il convient de se demander si elle peut véritablement se comporter comme l’abstraction le promet. Si la réponse honnête est non, la solution consiste généralement à modifier l’abstraction, par exemple en modélisant les paiements hors ligne séparément ou en divisant cette fonctionnalité en une interface plus restreinte. Le principe de Liskov concerne la correction du comportement, et non le fait que le mot-clé implements se compile ou non.

Ségrégation des interfaces : empêcher les utilisateurs de dépendre de ce qu’ils ignorent

La ségrégation des interfaces stipule qu’aucun client ne doit être contraint de dépendre de méthodes qu’il n’utilise pas. Ce principe se manifeste généralement avec des interfaces grandes et englobantes.

Une interface qui sert tout le monde

Considérons une interface regroupant la gestion des employés, le calcul des salaires, l’approbation des congés et la génération de rapports :

public interface EmployeeOperations {
void createEmployee();
    void updateEmployee();
    void deleteEmployee();
    void generateSalary();
    void approveLeave();
    void generateReport();
}

generateReport();

Cependant, en dépendant de EmployeeOperations, il est lié à des logiques de création, de suppression, de salaire et de congés qu’il n’utilisera jamais.

Séparation selon les besoins des utilisateurs

public interface EmployeeManagement {
void createEmployee();
    void updateEmployee();
    void deleteEmployee();
}

Et un autre pour les rapports :

public interface EmployeeReporting {
void generateReport();
}

Pourquoi c’est important à mesure que le système grandit

Dans une petite application, un interface trop volumineuse représente un léger inconvénient. Dans une grande application, elle devient un réseau de couplages accidentels où un changement apporté à un utilisateur affecte de nombreux autres utilisateurs non liés. Des interfaces plus petites rendent les limites des dépendances explicites et plus faciles à comprendre.

L’avertissement est le même que pour le principe Ouvert/Fermé : ne divisez pas les interfaces en fragments simplement pour remplir une condition. Séparez-les uniquement lorsque des clients différents ont réellement besoin de sous-ensembles distincts de fonctionnalités.

Inversion des dépendances : faire en sorte que la logique métier dépende d’abstractions

L’inversion des dépendances exige que le code de niveau métier et le code d’infrastructure reposent tous deux sur des abstractions partagées, plutôt que la couche de stratégie de faire référence à des classes concrètes de bas niveau. Dans les applications Spring Boot, ce principe est sans doute le plus important des cinq.

Infrastructure intégrée de manière rigide

Ici, le service de commandes instancie son propre répertoire :

public class OrderService {
private final MySqlOrderRepository repository;
    public OrderService() {
        this.repository = new MySqlOrderRepository();
    }
}

La logique métier sait désormais précisément quelle technologie de base de données stocke ses données. Remplacer MySQL ou substituer un répertoire fictif dans un test unitaire nécessite de modifier le OrderService lui-même. La politique de haut niveau est liée à des détails de bas niveau.

Inversion de la dépendance

Définissez d’abord ce dont a réellement besoin la logique métier, indépendamment de la technologie de stockage :

public interface OrderRepository {
void save(Order order);
}

Ensuite, faites en sorte que la classe spécifique à MySQL soit une implémentation de ce contrat :

@Repository
public class MySqlOrderRepository
        implements OrderRepository {
    @Override
    public void save(Order order) {
        // save order
    }
}

Finalement, faites en sorte que le service reçoive l’abstraction via son constructeur :

@Service
public class OrderService {
private final OrderRepository repository;
    public OrderService(OrderRepository repository) {
        this.repository = repository;
    }
}

OrderService ne se soucie plus de l’endroit où les commandes finissent. L’implémentation peut être MySQL, PostgreSQL, MongoDB, une appel à un autre service ou un mock en mémoire utilisé dans les tests. La direction des connaissances a changé : l’infrastructure s’adapte à une interface définie par la couche métier, au lieu que la couche métier s’adapte à l’infrastructure.

Comment l’injection de dépendances de Spring soutient ces principes

Spring rend l’inversion des dépendances naturelle, car son conteneur s’occupe de la connexion entre les éléments. Lorsqu’un service déclare ses collaborateurs dans son constructeur, comme ci-dessous, Spring trouve un bean correspondant au type OrderRepository et le lui transmet.

@Service
public class OrderService {
private final OrderRepository repository;
    public OrderService(OrderRepository repository) {
        this.repository = repository;
    }
}

La classe ne crée jamais ses propres dépendances. Comparez cela avec l’instanciation directe à l’intérieur du code métier :

OrderRepository repository =
        new MySqlOrderRepository();

Ce seul ligne lie l’appelant à une classe concrète et rend toute substitution impossible sans la modifier. L’injection de constructeur maintient le couplage au niveau de l’abstraction et rend les dépendances visibles dans la signature de la classe, ce qui explique également pourquoi elle est généralement préférée à l’injection de champs. N’oubliez pas que l’injection de dépendances est un mécanisme tandis que l’inversion des dépendances est un principe de conception : le conteneur vous aide à relier les abstractions, mais vous devez encore choisir quelles abstractions définir.

SOLID ne signifie pas plus de classes

Les équipes qui viennent de découvrir SOLID vont parfois trop loin. Une seule fonctionnalité utilisateur se transforme en une douzaine de types :

UserValidator
UserCreator
UserUpdater
UserRepository
UserMapper
UserConverter
UserEmailService
UserNotificationService
UserFactory
UserBuilder
UserManager
UserProcessor

Si l’on applique cela à toute une base de code, on se retrouve avec quarante classes alors que huit seulement auraient permis d’exprimer le même concept de manière plus claire. C’est de l’over-engineering, et non une bonne architecture. Les principes existent pour réduire la complexité ; si leur application rend le code plus difficile à suivre, c’est probablement parce qu’on les applique mécaniquement plutôt que en réponse à une véritable source de changement.

Partir de ce qui est susceptible de changer

Au lieu de se demander où SOLID pourrait être appliqué, les ingénieurs backend expérimentés ont tendance à commencer par une autre question : qu’est-ce dans ce composant qui est susceptible de changer ? La réponse correspond directement aux principes :

  • La base de données peut changer : introduire une abstraction de repository.
  • Les fournisseurs de paiement peuvent changer : placer ces fournisseurs derrière une abstraction de paiement.
  • Les canaux de notification peuvent changer : séparez le comportement des notifications du flux métier.
  • Les règles métier continuent de s’accumuler : séparez les responsabilités avant qu’une seule classe ne les absorbe toutes.
  • Différents consommateurs ont besoin de différentes opérations : divisez les interfaces dont ils dépendent.
  • Les implémentations ne se comportent pas vraiment de la même manière : reconsidérez l’abstraction plutôt que de les forcer à y rentrer.
  • En formulant les choses de cette façon, on garde les principes ancrés dans la réalité. Ajoutez des abstractions là où la volatilité est réelle et laissez le code stable simple.

    Appliquer la même logique aux limites des services

    Cette démarche s’applique au-delà des classes individuelles. Imaginez un service déployable responsable de tous ces domaines :

    User
    Payment
    Notification
    Reporting
    Inventory
    Shipping
    

    Celui-ci peut fonctionner parfaitement, mais les utilisateurs, les paiements, les notifications, les rapports, l’inventaire et l’expédition évoluent pour des raisons différentes et souvent selon des calendriers propres à des équipes distinctes. La logique derrière le principe de responsabilité unique peut vous aider à identifier où se situent les frontières naturelles.

    Cela ne signifie pas que chaque concept de domaine mérite son propre microservice. SOLID n’est pas une exigence de créer davantage de services ; c’est plutôt un guide pour bien séparer les responsabilités et les dépendances, que ces frontières prennent la forme de modules, de paquets ou de services.

    Liste de contrôle pour l’évaluation des composants backend

    • Responsabilité unique : cette classe a-t-elle plusieurs raisons non liées entre elles de changer ?
  • Ouvert/Fermé : est-il possible d’ajouter une nouvelle variation sans modifier la logique existante et stable ?
  • Substitution de Liskov : chaque implémentation respecte-t-elle réellement le contrat de son abstraction ?
  • Ségrégation des interfaces : y a-t-il des clients qui dépendent de méthodes qu’ils n’appellent jamais ?
  • Inversion des dépendances : la logique métier est-elle liée à une implémentation concrète dont elle ne devrait pas avoir connaissance ?
  • Des réponses honnêtes à ces questions sont plus précieuses que la capacité de réciter les définitions.

    Points clés

    • L’objectif de ces cinq principes est le même : rendre les prochains changements peu coûteux et maîtrisés, afin que le remplacement d’une base de données, l’ajout d’un prestataire de paiement ou la modification d’un canal de notification semblent être des tâches ordinaires plutôt que risquées.
    • Lisez chaque principe comme une question. Un service complexe suscite la question « Qu’est-ce qui change ici ? », une interface à dix méthodes pose la question « Qui a besoin de tout cela ? », et un new SomeImplementation() dans la logique métier amène à se demander « Cette classe a-t-elle vraiment besoin de le savoir ? ».
    • Introduisez des abstractions là où un changement est plausible, et résistez-y ailleurs ; une interface avec une seule implémentation permanente, ou une douzaine de classes pour une seule fonctionnalité, augmentent les coûts sans apporter de protection.
    • Les violations du principe de Liskov indiquent généralement une abstraction erronée, il faut donc corriger le modèle plutôt que de simplement pallier les problèmes chez ceux qui l’utilisent.
    • Rédigez du code qui fonctionne aujourd’hui, et structurez-le de manière à ce que les modifications futures ne soient pas plus difficiles que celles d’aujourd’hui.