Comment développer des applications robustes et flexibles ?

Cet article fait partie d'une série sur SOLID : les 5 principes fondamentaux de la programmation. Vous pouvez retrouver tous les autres ici.


Commençons par une petite histoire. Un peintre est chargé de repeindre le sol d’une cuisine.

Tout semble bien se passer, mais à la fin, il se rend compte qu’il s’est mis dans une situation un peu embarrassante : il s’est accidentellement coincé dans le coin de la pièce, et il ne peut plus revenir à l’entrée sans marcher sur la peinture fraîche.

Le pauvre peintre est alors confronté à un dilemme :

  • Revenir à la porte d’entrée, et donc refaire le travail qu’il vient de terminer.
  • Détruire le mur pour sortir de la pièce.

Absurde, n’est-ce pas ? Et pourtant, cette situation se produit absolument TOUT LE TEMPS en dans les projets informatiques.

Reprenons depuis le début, un développeur est chargé de créer une application pour un client.

Tout semble bien se passer, mais à la fin, il se rend compte qu’il s’est mis dans une situation un peu embarrassante : il a accidentellement par développé un système tellement rigide et peu extensible qu’il ne peut plus rien modifier sans tout casser.

Le pauvre développeur est alors confronté à un dilemme :

Après quelques mois, certaines applications ont tendance à ressembler à ça...

Dans le cas du peintre, on est d’accord que casser le mur est une solution complètement irréaliste. Ça ne viendrait à l’idée de personne de faire ça.

Et pourtant, dans le cas du développeur, c’est généralement la solution privilégiée, car les conséquences sont plus subtiles. Cependant, elles sont tout aussi désastreuses.

Les anglais ont une expression parfaite pour illustrer ça : to code yourself into a corner.

Ce n’est pas du tout comparable. Le client final, il ne les voit pas les trous dans le design d’une application, contrairement au propriétaire de la cuisine.

Certes, il ne les voit pas directement, mais quand les délais de livraison atteindront systématiquement un mois de retard, que l’équipe de développement aura sombré dans l’alcoolisme et que la quantité de bugs sera comparable à celle d’un produit Microsoft, ça risque de l’emmerder.

C’est un effet papillon : une petite cassure dans le design d’une application aujourd’hui peut entraîner des jours de maintenance plus tard. C’est ce que l’on appelle la dette technique.

Mais pourquoi la plupart des projets finissent comme ça ? Et comment éviter la catastrophe sans devoir tout réécrire ? On va voir ça plus en détail.

Besoin d'un résumé des principes SOLID et leur application concrète ? Allez lire le bonus de cet article.

Problème

Aujourd’hui, à l’époque des méthodes agiles, tous les projets informatiques se déroule de la manière suivante : des besoins se présentent, et on apporte des solutions au fur et à mesure.

Typiquement, on a une exigence (fonctionnalité, récit utilisateur, correction de bug…), on la développe, on la teste et on la déploie. C’est un fonctionnement pratique, car rapide et répétable à l’infini.

Une itération typique sur un projet agile.

De la même manière, lorsqu’une exigence change, on la modifie dans le code, on la teste et on la redéploie. A priori, aucun souci.

Et pourtant, c’est là que les choses se gâtent.

Car très souvent, les développeurs sont pris par surprise avec une exigence qui en contredit une autre déployée 3 mois auparavant.

Ils doivent donc revenir en arrière pour modifier le module (classe, fonction...) de l’ancienne exigence, ce qui peut avoir des conséquences dramatiques.


Tout d’abord, cela risque d’amener à une violation du principe de responsabilité unique (SRP).

En effet, si un module a été pensé pour ne faire qu’une seule chose, il est peu probable qu’une nouvelle exigence vienne simplement changer son comportement (sauf s’il s’agit d’un bug). Typiquement, elle demandera d’ajouter un truc en plus.

Par exemple, au jour J : le peintre doit préparer son matériel avant d’aller sur place.

public void PrepareForWork(PaintingMission mission)
{
    GatherPaint(mission.paintNeeded);
    GatherTools(mission.toolsNeeded);
    PutIniform();
}

J+3 mois : ah oui, au fait, on aimerait que si le peintre n’a pas le matériel nécessaire, il appelle le client pour décaler les travaux.

public void PrepareForWork(PaintingMission mission)
{
    // 1er responsabilité : vérification
    if(PaintAvailable(mission.paintNeeded) && ToolsAvailable(mission.toolsNeeded))
    {
        // 2nd responsabilité : préparation
        GatherPaint(mission.paintNeeded);
        GatherTools(mission.toolsNeeded);
        PutIniform();
    }
    else
    {
        // 3eme responsabilité : annulation
        CallClient();
    }
}

Erreur fatale, car le module fait maintenant 3 choses : préparer le matériel, vérifier que tout y est et appeler le client. C’est une violation du SRP.


Mais pire, c’est que chaque modification sur un module met en danger toutes ses dépendances, car celles-ci pourraient ne plus être compatibles avec la dernière version.

Laissez-moi vous illustrer ça avec une anecdote personnelle.

J’étais en train de construire une bibliothèque de fonctions pour mon entreprise commune à tous les projets (cf. mon article sur la boite à outils).

J’avais fait une fonction IsEmpty(object) qui permettait simplement de vérifier si une donnée provenant de la base était considérée comme “vide” (NULL ou "").

public static bool IsEmpty(object obj)
{
    if (obj == null) return true;
    if (obj is string && object == "") return true;
    return false;
}
Notez que l'idée n'était pas bonne de base, j'aurais dû séparer ça en deux sous-fonctions.

Mais en l’utilisant dans une autre application, je me suis rendu compte que ma fonction ne marchait pas.

En effet, le framework du projet avait une valeur spéciale pour désigner un NULL venant d’une base de données, qui n’était ni NULL, ni "" (BDNull.Value pour les connaisseurs de .NET).

Qu'à cela ne tienne ! J’ai modifié ma fonction pour qu’elle prenne en compte cette valeur dans la condition.

public static bool IsEmpty(object obj)
{
    if (obj == null) return true;
    if (obj is string && object == "") return true;
    if (obj == DBNull.Value) return true;
    return false;
}

Cool, le code fonctionne dans la nouvelle application, problème résolut ! Sauf que quand mon collègue a voulu compiler l’ancienne, plus rien ne fonctionnait.

J’avais accidentellement bombardé toutes les applications qui dépendaient de cette bibliothèque.

Autant vous dire que je ne l’ai fait qu’une fois, pas deux.


Solution

Donc, au final, on a besoin de modifier un module parce qu’une exigence le demande. Mais en faisant ça, on risque de casser le design de l’application et de mettre en danger tout le programme. Comment faire ?

Et bien justement, on ne modifie rien ! C’est le principe ouvert/fermé : un module doit être ouvert à l’extension, mais fermé à la modification.

Autrement dit, une fois qu’un module est prêt pour la production, on y touche plus jamais. Toutes les modifications se font à l’extérieur pour que le cœur reste toujours EXACTEMENT le même.

Ce principe est absolument fondamental, car il permet de rendre une codebase beaucoup plus :

  • Flexible : tous vos modules sont extensibles, donc vous n’êtes jamais bloqué dans le coin.
  • Robuste : en ne faisant qu’étendre un module, vous ne mettez jamais en danger ses dépendances.

Et plus vos modules sont abstraits (frameworks, bibliothèques…), plus ce principe devient obligatoire, car plus il y a de chances qu’une modification ruine absolument tout.

Catastrophe !

Explication

Ok dans la théorie, mais dans les faits, ça a l’air plus casse-tête qu’autre chose...

Je ne vous cache pas que ce principe est plus subtil que le SRP, cependant, tout devient plus clair quand on comprend les implications des deux concepts.


Fermé à la modification

Une fois qu’un module est prêt à la production, on y touche plus, même quand les exigences changent.

Cela signifie qu’avant de mettre un module en production, il doit être impeccable. Une fois qu’il commence à avoir des dépendances, c’est terminé.

Cela implique des tests :

  • Unitaires : module indépendant du reste.
  • D’intégration : module assemblé avec le reste.
  • De non-régression : est-ce qu’il ne casse pas autre chose dans le programme ? C’est un pléonasme car si le principe ouvert/fermé est correctement respecté, il ne devrait y avoir AUCUNE régression, jamais.
  • Fonctionnels et métiers : est-ce qu’il correspond parfaitement aux exigences telles qu’elles ont été demandées ?

Vous remarquerez que, comme pour le SRP, la méthode du développement piloté par des tests (TDD) est encore une fois très pertinente.

Illustration de Wikipedia

Et bien sûr, le code du module doit aussi être propre et réusiné aux petits oignons. Il faut prendre son temps et ne pas faire de déploiements impulsifs, même si c’est tentant ! Bref, ne pas faire comme moi 😖

Mais ce n’est pas un peu long de faire tout ça ?

C’est ce que je suis dit quand j’ai découvert ce principe pour la première fois. C’est quoi ce truc de barbu, j’ai pas que ça à foutre, j’ai une famille à nourrir !

Mais mon expérience, ainsi que celle de développeurs beaucoup plus balèzes que moi, m’ont appris que le temps investi dans la création des modules est ridicule par rapport au temps perdu à maintenir une codebase complètement ruinée.

Pour le peintre, il est plus facile de repeindre complètement le sol de la pièce plutôt que de reconstruire la maison.

Et comment faire si on a merdé ?

Malheureusement, il peut arriver qu’une situation imprévue (typiquement, un cas non testé) cause des problèmes après un passage en production. Dans ce cas, pas le choix, il faut modifier le module de l’intérieur.

Mais c’est la seule exception ! Et c’est justement pour ça que vous devez avoir un panel de tests de non-régression solide et prêts à être exécutés une fois la modification faite.


Ouvert à l’extension

Si un module a besoin d’une modification, elle doit être faite de l’extérieur.

Imageons ça avec une petite allégorie. Votre application est arbre, avec un tronc, des branches, et des brindilles.

Si vous avez fait pousser un bel arbre, avec des belles branches, vous devriez avoir des belles brindilles. Ces brindilles sont les seules choses qui intéressent le client. Ce sont les règles métier.

Le reste de l’arbre aura pour seul et unique but d’accueillir et de faire de la place pour ces brindilles. Et pour avoir un bel arbre, il faut abstraire un maximum de code.

Que le peintre ai à faire le mur, le sol ou le plafond, dans tous les cas, il devra peindre.

Qu’il ai à faire le sol de votre cuisine, d’un bar ou du palais de l’Élysée, dans tous les cas, il doit peindre le sol.

Si vous faites un module dédié à tout le processus de la peinture du sol de votre cuisine, vous vous tirez une balle dans le pied, car le jour où le peintre devra faire autre chose, vous serez bloqué dans le coin :

  • Soit vous modifiez le module pour correspondre aux nouvelles exigences, et vous violez le principe ouvert/fermé, ainsi que le SRP (la totale).
public PaintFloor(Location location)
{
    PrepareForWork();
    GoToLocation(location);
    PrepareRoom(location);

    if(location is Bar)
    {
        // ...
    }
    else if(location is Kitchen) 
    {
        // ...
    }
    // ...
    // Et ça peut continuer encore longtemps
}
Généralement, quand vous avez une série de if… else if… ou un énorme switch, c’est que vous avez merdé quelque part.
  • Soit vous faites un nouveau module, ce qui amène à beaucoup de duplication de code (DRY).
public PaintFloorKitchen(Kitchen location)
{
    PrepareForWork();
    GoToLocation(location);
    PrepareRoom(location);
    // ...
}

public PaintFloorBar(Bar location)
{
    PrepareForWork();
    GoToLocation(location);
    PrepareRoom(location);
    // ...
}

public PaintFloorElysée(Elysee location)
{
    PrepareForWork();
    GoToLocation(location);
    PrepareRoom(location);
    // ...
}
Pareil, si la majorité de votre code est copié-collé, c’est qu’il a un gros souci.

En abstrayant votre code, vous pourrez faire peindre n’importe quoi au peintre sans modifier ni dupliquer vos modules.

Cela demande de toujours avoir une longueur d’avance sur les exigences, afin de prévoir de l’espace pour le futur.

Je dois vous avouer que ce n’est pas facile. Cependant, certains détails dans les exigences doivent vous mettre la puce à l’oreille :

  • Il y a X TYPES de… : 80% du temps, il y en aura plus que le nombre annoncé.
class Paint { /*...*/ }
class AcrylicPaint : Paint { /*...*/ }
class GlyceroPaint : Paint { /*...*/ }
// On reserve de la place au cas où il y aurait d'autres types.
Il y a deux types de peinture : la peinture à l’eau et la peinture à l’huile.
  • Il faut VERBE un NOM : un module pour le verbe, un module pour le nom.
// Nom
class Paint { /*...*/ }

// Nom
class Painter 
{
    // Verbe
    public MixPaint(Paint paint1, Paint paint2) { /*...*/ }
}
Il faut que le peintre puisse mélanger deux peintures.
  • Il faut faire tel ACTION sous tel CONDITION : celui-ci est fourbe, car on a tendance à mélanger les deux. Mais bien souvent, la condition peut s’appliquer à d’autres actions, et l’action peut avoir plusieurs conditions.
// La peinture
class Paint { /*...*/ }

// Les travaux
class PaintingMission 
{
    public Paint PaintNeeded { get; private set; }
    public Painter Worker { get; private set; }

    // Action
    public void PostponeMission() { /*...*/ }

    // Assemblage Condition/Action
    public static void PostponeMissionIfPaintUnavailable()
    {
        if(!Worker.IsPaintAvailable(PaintNeeded))
            this.Postpone();
    }
}

// Le peintre
class Painter
{
    private List<Paint> PaintAvailable { get; set; }

    // Condition
    public bool IsPaintAvailable(Paint paint) 
    {
        return PaintAvailable.Where(x => x.Type == paint.Type && x.Color == paint.Color).Count > 0
    }
}
Il faut que le peintre décale les travaux S’IL n’a plus de peinture.

Tout ça relève plus de l’analyse du cahier des charges, un sujet qui mérite son propre article à lui tout seul.

La seule chose à retenir, c’est qu’il ne faut jamais se contenter de ce qui est écrit, et toujours réfléchir hors de la boite.

Mais au final est-ce que ce n’est pas une violation du principe YAGNI ?

Techniquement, si. Vous pouvez créer un module très ouvert et ne pas vous en servir.

Si le peintre ne peint QUE des sols de cuisine, alors les couches PaintFloor et PaintFloorKitchen peuvent sembler redondantes…

Mais ce n’est pas ABSOLUMENT PAS une excuse pour ne pas les faire, déjà parce que ça ne prend pas plus de temps, mais surtout parce que vous ne pouvez jamais être certain de l’avenir.

Et s’il décide subitement de se reconvertir en peintre de plafonds de bars ? Vous ne savez pas. Donc, dans le doute, programmez toujours ouvert.


Application

Récapitulons, pour appliquer le principe ouvert/fermé, il faut faire deux choses :

  • Chouchouter ses modules avec des tests et du réusinage tout en respectant les bonnes pratiques, de manière à ne plus jamais y retoucher après la mise en production.
  • Créer des couches d’abstraction de manière à rendre le programme flexible, peu importe les nouvelles exigences.

Pour le premier point, je vous renvoie vers mon article sur le principe de responsabilité unique (SRP), car tout y est expliqué. Ici, je vais uniquement traiter le second point.

Il y a tellement de manières d’abstraire des modules que je pourrais écrire un livre rien que sur ça. En voici quelques-unes :


Types abstraits

C’est la méthode que l’inventeur des principes SOLID, Robert C. Martin (l’oncle Bob) a utilisé pour décrire le principe ouvert/fermé.

Elle reprend l’idée de l’arbre que j’ai utilisée au-dessus :

  • Des types abstraits, qui contiennent des règles génériques. Ils sont destinés à être utilisés par d’autres types (abstraits ou non).
  • Des règles métiers, qui héritent des règles génériques tout en contenant leurs propres règles. Elles sont destinées à être utilisées directement par l’utilisateur final.

Le résultat donne lieu à un enchaînement de couches d’abstraction évolutif à l’infini. Vous pouvez ajouter des modules en dessous, au-dessus, et même entre deux.

class Paint { /*...*/ }

class PaintFloor : Paint { /*...*/ }
class PaintCeiling : Paint { /*...*/ }
class PaintWalls : Paint { /*...*/ }

class PaintFloorKitchen : PaintFloor { /*...*/ }
class PaintFloorBar : PaintFloor { /*...*/ }
class PaintFloorElysee : PaintFloor { /*...*/ }

// Extensible à l'infinie

Injection de dépendances

C’est un principe qui dit que les dépendances d’un module doivent toujours être construites et injectées de l’extérieur.

En gros, si vous avez une classe qui est composée de l’instance d’une autre classe, au lieu de l’initialiser dans le constructeur, vous faites ça à l’extérieur et vous la passez toute cuite.

Painter joseThePainter = new Painter();
Paint acrylicBluePaint = new Paint();
PaintingMission paintTheKitchenFloor = new PlanningMission(joseThePainter, acrylicBluePaint);

Cela permet de créer des modules plus flexibles, car vous avez un contrôle total sur les données qui vont servir à initialiser la classe, tout en conservant le principe d’encapsulation. C’est tout bénef !


Décorateurs

C’est une stratégie qui consiste à venir ajouter dynamiquement des responsabilités à un module sans briser le SRP, car tout se fait de l’extérieur.

Mais quelle différence avec le polymorphisme ?

La différence, c’est que le polymorphisme permet de créer une sous-classe, alors qu’un décorateur permet d’ajouter une option à une classe existante.

Là où le polymorphisme vous permet de définir un objet de tel ou tel sous-type, les décorateurs permet de définir un objet qui va contenir telles et telles caractéristiques.

Illustration de Wikipedia

Pour le coup, comme le code est assez fat, je vous renvoie vers Wikipedia qui montre plein d’exemples dans plein de langages différents. Je ferais sûrement un article intégralement dédié au sujet tant je le trouve fascinent !


Architecture fonctionnelle

Bien que le principe ouvert/fermé est généralement associé à la POO, celui-ci s’applique tout aussi bien, voir encore mieux, à la programmation fonctionnelle (PF).

Tout d’abord, la composition de fonctions permet de chaîner des modules pour créer des comportements différents. Ainsi, vous pouvez modifier les données à l’entrée et à la sortie d’une fonction sans avoir à la modifier.

public static Paint GetColoredPaint(string hexColor) { }

public static ColoredPaint MixPaint(Paint paint1, Paint paint2) { }

public static DryPaint DryPaint(Paint paint) { }

public static void Main(string[] args)
{
    // On laisse sécher
    DryPaint dryPurplePaint = DryPaint(
        // Mélange rouge + bleu = violet
        MixPaint(
            // Peinture rouge
            GetColoredPaint("0000FF"),
            // Peinture bleue
            GetColoredPaint("FF0000")
        )
    )
}

Encore plus fort, les fonctions d’ordre supérieur permettent de passer des fonctions en paramètre d’autres fonctions, et donc de modifier leur comportement interne, mais de l’extérieur !

public record Paint
{
    public int Quantity { get; }
    public string Color { get; }

    public Paint(int quantity) => (Quantity) = (quantity);
}

public static int Add(List<object> list, Func<object, int> action) 
{
    int res = 0;
    foreach(int item in list)
    {
        res += action(item);
    }
    return res;
}

public static void Main(string[] args)
{
    var list = new List<Paint>() { new Paint(5), new Paint(6), new Paint(10) };
    var paintQuantity = Add(list, x => x.Quantity); // 21
    var paintQuantity = Add(list, x => x.Quantity > 8 ? 8 : x.Quantity); // 19
}

Et bien que cela semble hors sujet, il est tout à fait possible de mixer PF et POO dans un seul programme, afin de tirer le meilleur des deux mondes.

J’explique tout dans mon article sur la programmation fonctionnelle, ainsi que dans celui sur Scala. Qui sait, peut-être qu’ils vont révolutionner votre manière de coder 😉


Voilà pour le principe ouvert/fermé, un concept un peu intimidant, mais absolument primordial pour designer des codebases bien huilée.

Souvenez-vous qu’en programmation, les efforts d’aujourd’hui vous récompenseront demain. Donc ne faites pas comme ce peintre, et prévoyez vos coups d’avance (et ne touchez pas à ce p***** de mur).

Aller, encore une fois pour que ça rentre 😋

  • Travaillez vos modules à 100% avant de les mettre en production (robuste).
  • Laissez toujours une porte ouverte pour étendre vos modules (flexible).

Le principe de substitution de Liskov (Liskov Substitution Principle) correspond au L de SOLID : un ensemble de 5 concepts fondamentaux pour construire des applications maintenables, pérennes et évolutives.

Vous pouvez retrouver tous mes autres articles sur SOLID ici. Ils ont tous pour but de vous faire découvrir ces principes et leur importance primordiale.

Mais pour résumé et une application concrète des principes SOLID dans un vrai programme, allez lire le bonus de cet article.

Et pour les gens qui raffolent d’architecture logicielle et de bonnes pratiques, voici d’autres articles qui vont vous intéresser :

Quant à moi, je vous laisse, il faut que j’aille pêcher un silure dans mon étang, ça fait 3 mois que j’essaye de l’avoir et je commence à perdre patience. Je vous donne rendez-vous la semaine prochaine pour un nouvel article sur le blog des développeurs ultra-efficaces !


Accéder au bonus : Résumé et application des principes SOLID.