1  Elements d’architecture

“If you think good architecture is expensive, try bad architecture.”

– Brian Foote & Joseph Yoder

Autant il est normal que des classes, modules ou composants évoluent et changent d’interface ou de signature, autant les changements autorisés pour l’architecture générale sont nettement moins permissifs, dans la mesure où ceux-ci sont gouvernés par les frontières et interactions possibles avec le monde extérieur : l’objectif principal est de maximiser la capacité d’adaptation, tout en minimisant la maintenance qui pourrait être nécessaire.

Une bonne architecture a plusieurs objectifs :

Derrière une bonne architecture, il y a aussi un investissement en ressources qui seront nécessaires à faire évoluer l’application : une architecture ouverte et pouvant être étendue n’a d’intérêt que si le développement est suivi et que ses personnes en charge s’engagent à y investir du temps et d’en augmenter la qualité dès qu’un changement lié à l’évolution du projet aura un impact sur du code existant - ceci fera lentement (mais sûrement !) dériver la base de code vers une augmentation de la dette technique.

D’après Wikipedia, la dette technique est un concept de développement logiciel qui désigne la difficulté à faire évoluer ou à corriger un code source mal conçu initialement.

Inventé en 1992, le terme vient d’une métaphore, qui applique au développement la notion de dettes que l’on peut trouver dans le milieu de la finance. On peut ainsi dire qu’une équipe de développement contracte une dette pour acquérir plus rapidement une fonction (Comment Créer de La Dette Technique Dès Le Début d’un Nouveau Projet ? 2023).

Faire évoluer correctement l’architecture d’un projet demande une bonne expérience, mais également un bon sens de l’observation, une attention portée aux détails et de la patience : il ne s’agit pas de se réveiller le matin et de se dire que “Aujourd’hui, je fais une bonne archi’!”, ni de définir, dès le début d’un projet, le périmètre précis d’une application. A la place, il convient d’attendre, d’être attentif aux détails et à l’évolution du système, de limiter ou retarder au maximum les décisions qui peuvent l’être. Il convient de se projeter au plus loin dans la définition ce que le système pourra devenir, et de faire attention aux premières frictions : à chaque demande et nécessité de réévaluation, il conviendra de peser les pours et contre entre réaliser un choix d’implémentation et continuer à l’ignorer, jusqu’à ce que cet équilibre atteigne son point d’inflexion (Martin 2018, (229)).

Dans la suite de ce chapitre, nous aborderons des concepts liés :

1.1 Modules et classes

Avant d’entrer dans le détail des principes SOLID, il convient de rappeler brièvement ce que sont les classes et les modules, deux notions fondamentales de la programmation orientée objet.

Une classe représente un modèle ou un plan de construction pour créer des objets. Elle regroupe à la fois des données (sous forme d’attributs) et des comportements (sous forme de méthodes) en une seule entité cohérente. L’objectif est de modéliser des éléments du monde réel ou conceptuel de manière structurée, lisible et réutilisable.

Un module, quant à lui, est une unité de regroupement plus large : il peut contenir plusieurs classes, fonctions ou constantes, et sert avant tout à organiser le code de façon logique. Les modules favorisent l’isolation des responsabilités, facilitent la maintenance, les tests et encouragent la réutilisation.

Dans une architecture bien pensée, les classes ne vivent pas seules : elles collaborent avec d’autres classes, communiquent à travers des interfaces et sont organisées en modules pour que l’ensemble reste compréhensible, évolutif et robuste.

C’est précisément pour guider cette organisation que les principes SOLID ont été formulés :

  • SRP - Single responsibility principle
  • OCP - Open-closed principle
  • LSP - Liskov Substitution
  • ISP - Interface ségrégation principle
  • DIP - Dependency Inversion Principle

1.1.1 Single Responsibility Principle

Le principe de responsabilité unique conseille de disposer de concepts ou domaines d’activité qui ne s’occupent chacun que d’une et une seule chose. Ceci rejoint (un peu) la philosophie UNIX, documentée par Doug McIlroy et qui demande de “faire une seule chose, mais de le faire bien(Raymond 2004).

Selon ce principe, une classe ou un élément de programmation ne doit donc pas avoir plus d’une seule raison de changer.

Plutôt que de centraliser le maximum de code à un seul endroit ou dans une seule classe par convenance ou commodité, le principe de responsabilité unique suggère que chaque classe soit responsable d’un et un seul concept.

Une manière de voir les choses consiste à différencier les acteurs ou les intervenants: imaginez disposer d’une classe représentant des données de membres du personnel; ces données pourraient être demandées par trois acteurs:

  • Le CFO (Chief Financial Officer)
  • Le CTO (Chief Technical Officer)
  • Le COO (Chief Operating Officer)

Chacun d’entre eux aura besoin de données et d’informations relatives à ces membres du personnel, et provenant donc d’une même source de données centralisée. Mais chacun d’entre eux également besoin d’une représentation différente ou de traitements distincts (Martin 2018).

Nous sommes d’accord qu’il s’agit à chaque fois de données liées aux employés; celles-ci vont cependant un cran plus loin et pourraient nécessiter des ajustements spécifiques en fonction de l’acteur concerné et de la manière dont il souhaite disposer des données. Dès que possible, identifiez les différents acteurs et demandeurs, en vue de prévoir les modifications qui pourraient être demandées par l’un d’entre eux.

Dans le cas d’un élément de code centralisé, une modification induite par un des acteurs pourrait ainsi avoir un impact sur les données utilisées par les autres.

Vous trouverez ci-dessous une classe Document, dont chaque instance est représentée par trois propriétés: son titre, son contenu et sa date de publication. Une méthode render permet également de proposer (très grossièrement) un type de sortie et un format de contenu: XML ou Markdown.

class Document:
    def __init__(self, title, content, published_at):
        self.title = title
        self.content = content
        self.published_at = published_at

    @property
    def iso_published_date(self):
        return self.published_at.iso_format()

    def render(self, format_type):
        if format_type == "XML":
            return f"""<?xml version = "1.0"?>
                <document>
                <title>{self.title}</title>
                <content>{self.content}</content>
                <publication_date>
                    {self.iso_published_date}
                </publication_date>
                </document>"""

        if format_type == "Markdown":
            import markdown
            return markdown.markdown(self.content)

        raise ValueError(
            "Format type '{}' is not known".format(format_type)
        )

Lorsque nous devrons ajouter un nouveau rendu (Atom, OpenXML, …) il sera nécessaire de modifier la classe Document. Ceci n’est :

  • Ni intuitif : ce n’est pas le document qui doit savoir dans quels formats il peut être converti
  • Ni conseillé : lorsque nous aurons quinze formats différents à gérer, il sera nécessaire d’avoir autant de conditions dans cette méthode.

En suivant le principe de responsabilité unique, une bonne pratique consiste à créer une nouvelle classe de rendu pour chaque type de format à gérer :

class Document:
    def __init__(self, title, content, published_at):
        self.title = title
        self.content = content
        self.published_at = published_at

class DocumentRenderer:
    def render(self, document):
        if format_type == "XML":
            return f"""<?xml version = "1.0"?>
            <document>
                <title>{self.title}</title>
                <content>{self.content}</content>
                <publication_date>
                    {self.published_at.isoformat()}
                </publication_date>
            </document>"""

        if format_type == "Markdown":
            import markdown
            return markdown.markdown(self.content)

        raise ValueError(
            "Format type '{}' is not known".format(
                format_type
            )
        )

A présent, lorsque nous devrons ajouter un nouveau format de prise en charge, il nous suffira de modifier la classe DocumentRenderer, sans que la classe Document ne soit impactée. En parallèle, le jour où nous ajouterons un champ champ author à une instance de type Document, rien ne dit que le rendu devra en tenir compte; nous modifierons donc notre classe pour y ajouter le nouveau champ sans que cela n’impacte nos différentes manières d’effectuer un rendu.

Un autre exemple consiterait à faire communiquer une méthode avec une base de données: ce ne sera pas à cette méthode à gérer l’inscription d’une exception à un emplacement spécifique (emplacement sur un disque, …) : cette action doit être prise en compte par une autre classe (ou un autre concept ou composant), qui s’occupera de définir elle-même l’emplacement où l’évènement sera enregistré, que ce soit dans une base de données, une instance Graylog ou un fichier. Cette manière de structurer le code permet de centraliser la configuration d’un type d’évènement à un seul endroit, ce qui augmente ainsi la testabilité globale du projet.

L’équivalent du principe de responsabilité unique au niveau des composants sera le Common Closure Principle. Au niveau architectural, cet équivalent correspondra aux frontières.

1.1.2 Open-Closed Principle

“For software systems to be easy to change, they must be designed to allow the behavior to change by adding new code instead of changing existing code.”

– Robert C. Martin

L’objectif est de rendre le système facile à étendre, en limitant l’impact qu’une modification puisse avoir. Reprendre notre exemple de modélisation de documents parle de lui-même :

  • Des données que nous avons converties dans un format spécifique pourraient à présent devoir être présentées dans une page web.
  • Demain, ce sera dans un document PDF,
  • Après demain, dans un tableur Excel.

La source de ces données reste la même (au travers d’une couche de présentation) : c’est leur mise en forme qui diffère à chaque fois. L’application n’a pas à connaître les détails d’implémentation : elle doit juste permettre une forme d’extension, sans avoir à appliquer quelconque modification en son cœur.

Un des principes essentiels en programmation orientée objets concerne l’héritage de classes et la surcharge de méthodes: plutôt que de partir sur une série de comparaisons comme nous l’avons initisée plus tôt pour définir le comportement d’une instance, il est parfois préférable de définir une nouvelle sous-classe, qui surcharge une méthode bien précise. Pour prendre un nouvel exemple, nous pourrions ainsi définir trois classes :

  • Une classe Customer, pour laquelle la méthode GetDiscount ne renvoit rien;
  • Une classe SilverCustomer, pour laquelle la méthode revoit une réduction de 10%;
  • Une classe GoldCustomer, pour laquelle la même méthode renvoit une réduction de 20%.

Si nous devions rencontrer un nouveau type de client, il nous suffira de créer une nouvelle sous-classe, implémentant la réduction que nous souhaitons lui offrir. Ceci évite d’avoir à gérer un ensemble conséquent de conditions dans la méthode initiale, en fonction d’une variable ou d’un paramètre - ici, le type de client.

Nous passerions ainsi de ceci :

class Customer():
    def __init__(self, customer_type: str):
        self.customer_type = customer_type

    def get_discount(customer: Customer) -> int:
        if customer.customer_type == "Silver":
            return 10
        elif customer.customer_type == "Gold":
            return 20
        return 0


>>> jack = Customer("Silver")
>>> jack.get_discount()
10

A ceci :

class Customer():
    def get_discount(self) -> int:
        return 0


class SilverCustomer(Customer):
    def get_discount(self) -> int:
        return 10


class GoldCustomer(Customer):
    def get_discount(self) -> int:
        return 20

>>> jack = SilverCustomer()
>>> jack.get_discount()
10

Notre classe Customer est à présent fermée à toute nouvelle modification, mais ouverte à des extensions lorsque de nouveaux types de clients devront être ajoutés au projet. En résumé, nous fermons la classe Customer à toute modification, mais nous ouvrons la possibilité de créer de nouvelles extensions en ajoutant de nouveaux types héritant de Customer.

De cette manière, nous simplifions également la maintenance de la méthode get_discount, dans la mesure où elle dépend directement du type dans lequel elle est implémentée.

Nous pouvons également appliquer ceci à notre exemple sur les rendus de document, où le code suivant :

class Document:
    def __init__(self, title, content, published_at):
        self.title = title
        self.content = content
        self.published_at = published_at

    def render(self, format_type):
        if format_type == "XML":
            return f"""<?xml version = "1.0"?>
                <document>
                <title>{self.title}</title>
                <content>{self.content}</content>
                <publication_date>{self.published_at.isoformat()}</publication_date>
                </document>"""

        if format_type == "Markdown":
            import markdown
            return markdown.markdown(self.content)

        raise ValueError(
            "Format type '{}' is not known".format(format_type)
        )

devient le suivant :

class Renderer:
    def render(self, document):
        raise NotImplementedError


class XmlRenderer(Renderer):
    def render(self, document)
        return """<?xml version = "1.0"?>
        <document>
            <title>{}</title>
            <content>{}</content>
            <publication_date>{}</publication_date>
        </document>""".format(
            document.title,
            document.content,
            document.published_at.isoformat()
        )


class MarkdownRenderer(Renderer):
    def render(self, document):
        import markdown
        return markdown.markdown(document.content)

Lorsque nous ajouterons notre nouveau type de rendu, nous ajouterons simplement une nouvelle classe de rendu qui héritera de Renderer.

1.1.3 Liskov Substitution Principle

Le principe de substitution fait qu’une classe héritant d’une autre classe doit se comporter de la même manière que cette dernière. Il n’est pas question que la sous-classe n’implémente pas ou n’ait pas besoin de certaines méthodes, alors que celles-ci sont disponibles sa classe parente. Mathématiquement, ce principe peut être défini de la manière suivante :

“[… if S is a subtype of T, then objects of type T in a computer program may be replaced with objects of type S (i.e., objects of type S may be substituted for objects of type T), without altering any of the desirable properties of that program (correctness, task performed, etc.).”

“Let q(x) be a property provable about objects x of type T. Then q(y) should be provable for objects y of type S, where S is a subtype of T.

Wikipédia

Ce n’est donc pas parce qu’une classe a besoin d’une méthode définie dans une autre classe qu’elle doit forcément en hériter. Cela bousillerait le principe de substitution, dans la mesure où une instance de cette classe pourra toujours être considérée comme étant du type de son parent.

Petit exemple pratique: si nous définissons une méthode make_some_noise et une méthode eat sur une classe Duck, et qu’une réflexion avancée (et sans doute un peu alcoolisée) nous dit que “Puisqu’un Lion fait aussi du bruit, faisons le hériter de notre classe ’Canard‘“, nous allons nous retrouver avec ceci:

class Duck:
    def make_some_noise(self):
        print("Kwak")

    def eat(self, thing):
        if thing in ("plant", "insect", "seed", "seaweed", "fish"):
            return "Yummy!"
        raise IndigestionError("Arrrh")


class Lion(Duck):
    def make_some_noise(self):
        print("Roaaar!")

Le principe de substitution de Liskov suggère qu’une classe doit toujours pouvoir être considérée comme une instance de sa classe parente, et doit pouvoir s’y substituer. Dans notre exemple, cela signifie que nous pourrons tout à fait accepter qu’un lion se comporte comme un canard et adore manger des plantes, insectes, graines, algues et du poisson. Miam ! Nous vous laissons tester la structure ci-dessus en glissant une antilope dans la boite à goûter du lion, ce qui nous donnera quelques trucs bizarres (et un lion atteint de botulisme).

Mais l’héritage n’est pas la seule manière de casser le LSP, notamment lorsqu’une sous-classe surcharge une méthode de la classe parent, juste pour … ne rien faire, voire implémenter un NotImplementedError - ce qui oblige l’appelant à vérifier le type de l’objet qu’il a en main.

Pour revenir à nos exemples de rendus de documents, nous aurions pu faire hériter notre MarkdownRenderer de la classe XmlRenderer:

class XmlRenderer:
    def render(self, document)
        return """<?xml version = "1.0"?>
        <document>
            <title>{}</title>
            <content>{}</content>
            <publication_date>{}</publication_date>
        </document>""".format(
            document.title,
            document.content,
            document.published_at.isoformat()
        )


class MarkdownRenderer(XmlRenderer):
    def render(self, document):
        import markdown
        return markdown.markdown(document.content)

Si nous décidons à un moment d’ajouter une méthode d’entête au niveau de notre classe de rendu XML, notre rendu en Markdown héritera irrémédiablement de cette même méthode :

class XmlRenderer:
    def header(self):
        return """<?xml version = "1.0"?>"""

    def render(self, document)
        return """{}
        <document>
            <title>{}</title>
            <content>{}</content>
            <publication_date>{}</publication_date>
        </document>""".format(
            self.header(),
            document.title,
            document.content,
            document.published_at.isoformat()
        )


class MarkdownRenderer(XmlRenderer):
    def render(self, document):
        import markdown
        return markdown.markdown(document.content)

Le code ci-dessus ne porte pas immédiatement à conséquence, mais dès que nous invoquerons la méthode header() sur une instance de type MarkdownRenderer, nous obtiendrons un bloc de déclaration XML (<?xml version = "1.0"?>) pour un fichier Markdown, ce qui n’aura aucun sens.

En revenant à notre proposition d’implémentation, suite au respect d’Open-Closed, une solution serait de n’implémenter la méthode header() qu’au niveau de la classe XmlRenderer :

class Renderer:
    def render(self, document):
        raise NotImplementedError


class XmlRenderer(Renderer):
    def header(self):
        return """<?xml version = "1.0"?>"""

    def render(self, document)
        return """{}
        <document>
            <title>{}</title>
            <content>{}</content>
            <publication_date>{}</publication_date>
        </document>""".format(
            self.header(),
            document.title,
            document.content,
            document.published_at.isoformat()
        )


class MarkdownRenderer(Renderer):
    def render(self, document):
        import markdown
        return markdown.markdown(document.content)

1.1.4 Interface Segregation

Le principe de ségrégation d’interface suggère d’exposer uniquement les opérations nécessaires à l’exécution d’un contexte. Ceci limite la nécessité de recompiler un module, et évite ainsi d’avoir à redéployer l’ensemble d’une application alors qu’il suffirait de déployer un nouveau fichier JAR ou une DLL au bon endroit.

“The lesson here is that depending on something that carries baggage that you don’t need can cause you troubles that you didn’t except. (Martin 2018, (86))

Plus simplement, plutôt que de dépendre d’une seule et même (grosse) interface présentant un ensemble conséquent de méthodes, il est proposé d’exploser cette interface en plusieurs (plus petites) interfaces. Ceci permet aux différents consommateurs de n’utiliser qu’un sous-ensemble précis d’interfaces, répondant chacune à un besoin précis, et permet donc à nos clients de ne pas dépendre de méthodes dont ils n’ont pas besoin.

Obsidian a expliqué il y a quelques temps leur politique d’épinglage, dans une optique de sécurité, afin de tacler les supply-chain attacks :

It may sound obvious but the primary way we reduce the risk of supply chain attacks is to avoid depending on third-party code. Obsidian has a low number of dependencies compared to other apps in our category. See a list of open source libraries on our Credits page.

Features like Bases and Canvas were implemented from scratch instead of importing off-the-shelf libraries. This gives us full control over what runs in Obsidian.

  • For small utility functions we almost always re-implement them in our code.
  • For medium modules we fork them and keep them inside our codebase if the licenses allows it.
  • For large libraries like pdf.js, Mermaid, and MathJax, we include known-good, version-locked files and only upgrade occasionally, or when security fixes land. We read release notes, look at upstream changes, and test thoroughly before switching.

This approach keeps our dependency graph shallow with few sub-dependencies. A smaller surface area lowers the chance of a malicious update slipping through.

Egalement : Nouvelle attaque contre NPM, cette fois à une cadence industrielle - Who could have figured out that automatically downloading half the internet and ten thousand always-changing dependencies every time you build could actually be a weakness ? (Daniel Stenberg)

GNU/Linux Magazine propose un exemple d’interface (Les Cinq Règles Pour Écrire Du Code Maintenable 2019, (37–42)) permettant d’implémenter une imprimante :

interface IPrinter
{
    public abstract void printPage();
    public abstract void scanPage();
    public abstract void faxPage();
}

public class Printer
{
    protected string name;

    public Printer(string name)
    {
        this.name = name;
    }
}

L’implémentation d’une imprimante multifonction aura tout son sens:

public class AllInOnePrinter extends Printer implements IPrinter
{
    public AllInOnePrinter(string name)
    {
        super(name);
    }

    public void printPage()
    {
        System.out.println(this.name + ": Impression");
    }

    public void scanPage()
    {
        System.out.println(this.name + ": Scan");
    }

    public void faxPage()
    {
        System.out.println(this.name + ": Fax");
    }
}

Tandis que l’implémentation d’une imprimante premier-prix ne servira pas à grand chose :

public class FirstPricePrinter extends Printer implements IPrinter
{
    public FirstPricePrinter(string name)
    {
        super(name);
    }

    public void printPage()
    {
        System.out.println(this.name + ": Impression");
    }

    public void scanPage()
    {
        System.out.println(this.name + ": Fonctionnalité absente");
    }

    public void faxPage()
    {
        System.out.println(this.name + ": Fonctionnalité absente");
    }
}

L’objectif est donc de découpler ces différentes fonctionnalités en plusieurs interfaces bien spécifiques, implémentant chacune une opération isolée :

interface IPrinterPrinter
{
    public abstract void printPage();
}

interface IPrinterScanner
{
    public abstract void scanPage();
}

interface IPrinterFax
{
    public abstract void faxPage();
}

Cette réflexion s’applique à n’importe quel composant : votre système d’exploitation, les librairies et dépendances tierces, les variables déclarées, … Quel que soit le composant que l’on utilise ou analyse, il est plus qu’intéressant de se limiter uniquement à ce dont nous avons besoin plutôt que d’embarquer le must absolu qui peut faire 1000x fonctions de plus que n’importe quel autre produit, alors que seules deux d’entre elles seront nécessaires.

En Python, ce comportement est inféré lors de l’exécution ; vous n’aurez donc pas besoin d’interfaces formelles pour suivre ce principe-ci. Utilisez du duck typing, les protocols (du package typing) ou les classes abtraites (abc) pour définir ce dont vous avez besoin, pas ce que vous obtiendrez. De manière générale, les langages dynamiques sont plus flexibles et moins couplés que les langages statiquement typés, pour lesquels l’application de ce principe-ci permettrait de juste mettre à jour une DLL ou un JAR sans que cela n’ait d’impact sur le reste de l’application.

Il est ainsi possible de trouver quelques horreurs, et ce dans tous les langages, comme dans la définition et l’utilisation d’un module isOdd, basé sur un autre module isNumber (alors qu’un typeof() == 'number' ferait très bien l’affaire) :

/*!
* is-odd <https://github.com/jonschlinkert/is-odd>
*
* Copyright (c) 2015-2017, Jon Schlinkert.
* Released under the MIT License.
*/
'use strict';

const isNumber = require('is-number');

module.exports = function isOdd(value) {

    const n = Math.abs(value);

    if (!isNumber(n)) {
        throw new TypeError('expected a number');
    }

    if (!Number.isInteger(n)) {
        throw new Error('expected an integer');
    }

    if (!Number.isSafeInteger(n)) {
        throw new Error('value exceeds maximum safe integer');
    }

    return (n % 2) === 1;
}

Voire, son opposé, qui dépend évidemment du premier, avec la réutilisation du module isOdd (utilisant lui-même le module is-number en JavaScript pour définir un nouveau module isEven) :

/*!
* is-even <https://github.com/jonschlinkert/is-even>
*
* Copyright (c) 2015, 2017, Jon Schlinkert.
* Released under the MIT License.
*/
'use strict';

var isOdd = require('is-odd');

module.exports = function isEven(i) {
    return !isOdd(i);
};

Il ne s’agit que d’un simple exemple, mais qui tend à une seule chose : gardez les choses simples (et, éventuellement, stupides). Dans l’exemple ci-dessus, l’utilisation du module is-odd requière déjà deux dépendances:

  • is-even
  • is-number

Imaginez la suite.

Réduire le nombre de dépendances accélère également l’intégration d’une personne souhaitant participer au projet : n’ajoutez pas de nouvelles dépendances à moins que leur plus-value ne surpasse le temps qu’il sera nécessaire à vos développeurs actuels et futurs pour en appréhender toutes les fonctionnalités (Krupp 2021). Soyez intelligents et n’ajoutez pas systématiquement une nouvelle dépendance ou fonctionnalité uniquement parce qu’elle est disponible, et maintenez la documentation nécessaire à une utilisation cohérente d’une dépendance par rapport à vos objectifs et au reste de l’application

Tip

Ce syndrôme (d’ajout systématique d’un nouveau composant ou d’une nouvelle technologie) est connu sous le nom de Magpie developer - ou Syndrôme de la pie (mais adapté aux développeurs) -, qui fait qu’un développeur est souvent attiré par tout ce qui est “nouveau et brillant”, plutôt que les technologies qui fonctionnent bien pour eux et pour leurs utilisateurs. (Eghbal 2016, (42))

Conserver des dépendances à jour peut facilement vous prendre 25% de votre temps (Krupp 2021) : dès que vous arrêterez de les suivre, vous risquez de rencontrer des bugs mineurs, des failles de sécurité, voire qu’elles ne soient plus du tout compatibles avec d’autres morceaux de vos applications.

Warning

TL;DR: Dependency cooldowns are a free, easy, and incredibly effective way to mitigate the large majority of open source supply chain attacks. More individual projects should apply cooldowns (via tools like Dependabot and Renovate) to their dependencies, and packaging ecosystems should invest in first-class support for cooldowns directly in their package managers.

Dans la mesure du possible, conservez un dependency cooldown, c’est-à-dire, de n’installer que des versions de dépendances ayant déjà fait leurs preuves. N’installez pas tout et n’importe quoi, mais surtout, n’installez pas n’importe quoi à n’importe quel moment.

https://therecord.media/malware-found-in-npm-package-with-millions-of-weekly-downloads : Malware found in npm package with millions of weekly downloads - Quand ton système a des tonnes de dépendances, la faille d’une seule dépendance devient la faille de ton système. Multiplie les dépendances, tu multiplie les risques. Simple, mathématique.

1.1.5 Dependency Inversion Principle

Dans une architecture conventionnelle, les composants de haut-niveau dépendent directement des composants de bas-niveau. L’inversion de dépendances stipule que c’est le composant de haut-niveau qui possède la définition de l’interface dont il a besoin, et le composant de bas-niveau qui l’implémente. L’objectif est que les interfaces soient les plus stables possibles, afin de réduire au maximum les modifications qui pourraient y être appliquées. De cette manière, toute modification fonctionnelle pourra être directement appliquée sur le composant de bas-niveau, sans que l’interface ne soit impactée.

The dependency inversion principle tells us that the most flexible systems are those in which source code dependencies refer only to abstractions, not to concretions (Martin 2018)

L’injection de dépendances est un patron de programmation qui suit le principe d’inversion de dépendances.

Django est bourré de ce principe, que ce soit pour les middlewares ou pour les connexions aux bases de données. Lorsque nous écrivons ceci dans notre fichier de configuration,

...

MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.middleware.common.CommonMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
    'django.middleware.clickjacking.XFrameOptionsMiddleware',
]

...

Django ira simplement récupérer chacun de ces middlewares, qui répondent chacun à une interface clairement définie, dans l’ordre.

Il n’y a donc pas de magie : l’interface exige une signature particulière, tandis que l’implémentation effective n’est réalisée qu’au niveau le plus bas. C’est ensuite le développeur qui va simplement brancher ou câbler des fonctionnalités au niveau du framework, en les déclarant au bon endroit. Pour créer un nouveau middleware, il nous suffirait d’implémenter de nouvelles fonctionnalités au niveau du code code suivant et de l’ajouter dans la configuration de l’application, au niveau de la liste de ceux qui sont déjà actifs :

def simple_middleware(get_response):
    # One-time configuration and initialization.

    def middleware(request):
        # Code to be executed for each request before
        # the view (and later middleware) are called.

        response = get_response(request)

        # Code to be executed for each request/response after
        # the view is called.

        return response

    return middleware

Dans d’autres projets écrits en Python, ce type de mécanisme peut être implémenté relativement facilement en utilisant les modules importlib et la fonction getattr.

Un autre exemple concerne les bases de données: pour garder un maximum de flexibilité, Django ajoute une couche d’abstraction en permettant de spécifier le moteur de base de données que vous souhaiteriez utiliser, qu’il s’agisse d’SQLite, MSSQL, Oracle, PostgreSQL ou MySQL/MariaDB (How Fucked Is My Database 2023).

D’un point de vue architectural, nous ne devons pas nous soucier de la manière dont les données sont stockées, s’il s’agit d’un disque magnétique, de mémoire vive, … En fait, on ne devrait même pas savoir s’il y a un disque du tout.

Important

Le code qui implémente des politiques de haut-niveau ne devrait pas dépendre de code qui implémente des détails de bas-niveau. A l’inverse, ce sont ces détails qui doivent dépendre de ces politiques. (Martin 2018, (59))

En termes architecturaux, ce principe autorise une définition des frontières, et en permettant une séparation claire en inversant le flux de dépendances et en faisant en sorte que les règles métiers n’aient aucune connaissance des interfaces graphiques qui les exploitent ou des moteurs de bases de données qui les stockent. Ceci autorise une forme d’immunité entre les composants.

1.2 Cohésion des composants

“Si les classes et modules permettent de définir des murs et des pièces, les composants permettent de définir des immeubles en (ré)utilisant ces mêmes briques et murs.”

– Robert C. Martin

Définir quelles classes appartiennent à quels composants constitue une décision importante d’une bonne architecture. Les trois principes suivants constituent la base (Martin 2018, (104)) :

  • REP - Reuse/Release Equivalence Principle,
  • CCP - Common Closure Principle,
  • CRP - Common Reuse Principle,

Nous verrons plus loin que ces principes ont un lien très fort avec les différents apps que nous pourrons trouver au niveau d’un projet Django : chacune de ces applications sera composée d’un ensemble de plusieurs classes, chacune d’entre elles ayant des liens (clés étrangères, requêtes, recherches croisées, …) vers d’autres classes - appartenant potentiellement à d’autres modules ou composants.

1.2.1 Reuse/Release Equivalence Principle

Nous vivons dans l’ère de la réutilisation logicielle - et ceci n’est possible qu’au travers d’un mécanisme de mise à disposition, suivi par des versions numérotées (ou, a minima identifiées). Au chapitre précédent, nous parlions de la nécessité d’épingler les versions pour définir nos composants - et les risques de ne pas le faire ; architecturalement, ceci signifie qu’un ensemble de modules doit être solidaire, et donc, que cet ensemble de classes et modules doit pouvoir être mis à disposition de manière unitaire.

1.2.2 Common Closure Principle

Là où le principe précédent restait relativement flou, l’objectif de ce principe-ci consiste à définir une politique de regroupement de plusieurs éléments de bas niveau dans un composant de plus haut niveau (Martin 2018, (105)):

  • Regroupez dans un même composants les classes qui changent pour une même raison et au même moment.
  • Séparez des des composants différents les classes qui changent à différents moments ou pour des raisons différentes (Tornhill 2015).

Note

Le principe de Common Closure est l’équivalent, au niveau des composants, du Single Responsibility Principle - dans la mesure où chaque composant consiste en un regroupement de plusieurs classes qui suivent un objectif commun.

1.2.3 Common Reuse Principle

Ce principe guide également dans le choix d’association d’un module à un composant : si deux classes sont tellement couplées qu’elles évoluent simultanément, c’est un signe qu’il faut les conserver dans le même composant.

Note

Le principe de Common Reuse est l’équivalent, pour les composants, du principe de Ségrégation d’interface pour une classe ou un module : le CRP conseille de ne pas dépendre de composants qui présentent des classes que nous n’utilisons pas (Martin 2018, (108)).

1.3 Couplage des composants

Dans la section précédente, nous avons vu trois principes liés à la cohésion des composants. Ceux-ci évoluant dans un environnement relativement complexe, il arrive inmanquablement qu’ils subissent une forme de couplage et qu’ils commencent à dépendre les uns des autres.

  • ADP - Acyclic Dependency Principle,
  • SDP - Stable Dependency Principle,
  • SAP - Stable Abstraction Principle.

1.3.1 Acyclic Dependency Principle

Le principe de gestion acyclique des dépendances conseille d’éviter qu’une dépendance ne dépende elle-même du module dont elle dépend.

Dans ce type de cas, il convient d’extraire de nos différents composants les dépendances communes dont ils ont besoin, afin de les placer dans un composant supplémentaire. En supposant que notre composant A dispose d’un module nécessaire au fonctionnement de B et que notre composant B dispose d’un module nécessaire au fonctionnement de A, il convient alors de sortir ces deux modules dans un composant C dont A et B pourront dépendre sans que nous ne créions de cycle dans le graph de dépendances :

Nous voyons clairement que les seuls “chemins” possibles sont A->B->C ou A->C, et qu’il est impossible de remonter de C vers A, de C vers B ou de B vers A :

1.3.2 Stable Dependency Principle

Ce principe définit une formule de stabilité pour les composants, en fonction de leur faculté à être modifié et leurs interdépendances : au plus un composant est nécessaire ou critique, au plus il devra être stable, dans la mesure chaque changement aura un impact sur l’ensemble des services et composants qui en consomment les fonctionnalités.

Cette facilité de modification nécessite de passer par des interfaces (donc, rarement modifiées, par définition) :

  • En C++, cela correspond aux mots clés #include. Pour faciliter cette stabilité, il convient de passer par des interfaces (donc, rarement modifiées, par définition).
  • En Python, ce ratio pourrait être calculé au travers des import, via les AST.

1.3.3 Stable Abstraction Principle

Le principe de stabilité d’abstraction définit et compare la stabilité des politiques et interfaces de haut niveau par rapport à celles des composants plus concrets.

Ce principe peut être vu comme une forme de modélisation du principe Open-Closed, mais appliquée aux composants :

  • Ceux qui ne changent pas (ou pratiquement pas) le plus haut possible dans la hiérarchie des éléments,
  • Ceux qui changent fréquemment, au plus bas, dans le sens de stabilité du flux.

1.4 Considérations

1.4.1 Sur les frameworks

Un framework n’est qu’un outil, et pas une obligation de structuration. L’idée sous-jacente est que le framework doit se conformer à la définition de l’application, et non l’inverse (Martin 2018, (196)) : en général, mais également dans le cadre de l’utilisation de Django, c’est un point critique à prendre en considération, car une fois que vous aurez fait ce choix, vous aurez extrêmement difficile à faire machine arrière :

  • Votre modèle métier sera largement couplé avec le type de base de données (relationnelle, ici),
  • Votre couche de présentation sera surtout disponible au travers d’un navigateur,
  • Les droits d’accès et permissions seront en grosse partie intégrés directement au framework,
  • La sécurité dépendra de votre habilité à suivre les versions,
  • Les fonctionnalités complémentaires (que vous n’aurez pas voulu/eu le temps de développer) dépendront de la bonne volonté de la communauté.

Le point à comprendre ici n’est pas que “__Django, c’est mal__”, mais qu’une fois que vous aurez défini la politique, les règles métiers, les données critiques et entités, et que vous aurez fait le choix de développer en âme et conscience votre nouvelle création en utilisant Django, vous serez bon gré mal gré, contraint de continuer avec. Cette décision ne sera pas irrévocable, mais difficile à contourner.

“At some point in their history most DevOps organizations were hobbled by tightly-coupled, monolithic architectures that while extremely successfull at helping them achieve product/market fit - put them at risk of organizational failure once they had to operate at scale (e.g. eBay’s monolithic C++ application in 2001, Amazon’s monolithic OBIDOS application in 2001, Twitter’s monolithic Rails front-end in 2009, and LinkedIn’s monolithic Leo application in 2011). In each of these cases, they were able to re-architect their systems and set the stage ot only to survice, but also to thrise and win in the marketplace.”

(Kim et al. 2016)

Ceci dit, Django compense ses contraintes en proposant énormément de flexibilité et de fonctionnalités out-of-the-box, c’est-à-dire que vous pourrez sans doute avancer vite et bien jusqu’à un point de rupture, puis revoir la conception et réinvestir à ce moment-là, mais en toute connaissance de cause. La difficulté à se passer du framework va surtout consister à basculer vers “autre chose” et a remplacer chacune des tentacules qui aura poussé partout dans l’application.

TipArchitecture Orientées Services

Il convient de noter que les services et les “Architectures Orientées Services” ne sont jamais qu’une définition d’implémentation d’un périmètre et de frontières, dans la mesure où un service n’est jamais qu’une fonction appelée au travers d’un protocole (REST, SOAP, …).

Ainsi, une application monolotihique sera tout aussi fonctionnelle qu’une application découpée en microservices. Juste qu’elles auront chacune leurs poins positifs et négatifs, et que le choix de l’une ou de l’autre ne sera jamais qu’une affaire de compromis (Neal Ford and Dehghani 2022)

1.4.2 Sur l’inversion de dépendances

Dans la partie SOLID, nous avons évoqué plusieurs principes de développement. Django est un framework qui évolue, et qui a pu présenter certains problèmes liés à l’un de ces principes.

Les Releases Notes de Django 2.0 date de décembre 2017 ; parmi ces notes, l’une d’elles cite l’abandon du support d’Oracle 11.2. En substance, cela signifie que le framework se chargeait lui-même de construire certaines parties de requêtes, qui deviennent non fonctionnelles dès lors que l’on met le framework ou le moteur de base de données à jour. Réécrit, cela signifie que:

  • Si vos données sont stockées dans un moteur géré par Oracle 11.2, vous serez limité à une version 1.11 de Django,
  • Tandis que si votre moteur est géré par une version ultérieure, le framework pourra être mis à jour.

Nous sommes dans un cas concret d’inversion de dépendances ratée : le framework (et encore moins vos politiques et règles métiers) ne devraient pas avoir connaissance du moteur de base de données. Pire, vos politiques et données métiers ne devraient pas avoir connaissance de la version du moteur de base de données.

En conclusion, le choix d’une version d’un moteur technique (la base de données) a une incidence directe sur les fonctionnalités mises à disposition par votre application, ce qui va à l’encontre des 12 facteurs (et des principes de développement).

Ce point sera rediscuté par la suite, notamment au niveau de l’épinglage des versions, de la reproduction des environnements et de l’interdépendance entre des choix techniques et fonctionnels.

1.5 Conclusions

Avec cette approche, les composants seront découplés au mieux de ce qu’il est réalistiquement envisageable.

Les composants peuvent être découpés au niveau :

  • Du code source, via des modules, paquets, dépendances, …
  • Du déploiement ou de l’exécution, au travers de dll, jar, linked libraries, …, voire au travers de threads ou de processus locaux.
  • Via la mise à disposition de nouveaux services, lorsqu’il est plus intuitif de contacter un nouveau point de terminaison que d’intégrer de force de nouveaux concepts dans une base de code existante.

Cette section se base sur deux ressources principales , qui répartissent un ensemble de conseils parmi quatre niveaux de composants :

  • Les méthodes et fonctions
  • Les classes
  • Les composants
  • Et des conseils plus généraux.

1.5.1 Au niveau des méthodes et fonctions

Certaines des valeurs ci-dessous sont totalement arbitraires (voire empiriques) ; une autre manière pour améliorer rapidement du code, consiste à suivre les règles proposées par Jeff Bay (Bay 2008) (Lahaye 2023) :

  • Utilisez un seul niveau d’indentation par méthode,

  • N’utilisez jamais le mot clé else,

  • Encapsuler toutes vos primitives,

  • First class collections

  • Limitez-vous à un seul niveau d’accès pour vos variables,

  • N’utilisez pas d’abréviations,

  • Faites en sorte que vos entités soient “à taille humaine”,

  • Pas plus de deux variables d’instance par classe,

  • Evitez les getters/setters ou propriétés.

  • Gardez vos méthodes/fonctions courtes. Pas plus de 15 lignes, en comptant les commentaires. Des exceptions sont possibles, mais dans une certaine mesure uniquement (pas plus de 6.9% de plus de 60 lignes; pas plus de 22.3% de plus de 30 lignes, au plus 43.7% de plus de 15 lignes et au moins 56.3% en dessous de 15 lignes). Oui, c’est dur à tenir, mais faisable.

  • Conserver une complexité de McCabe en dessous de 5, c’est-à-dire avec quatre branches au maximum. A nouveau, si une méthode présente une complexité cyclomatique de 15, la séparer en 3 fonctions ayant chacune une complexité de 5 conservera la complexité globale à 15, mais rendra le code de chacune de ces méthodes plus lisible, plus maintenable.

  • N’écrivez votre code qu’une seule fois: évitez les duplications, copie, etc.: imaginez qu’un bug soit découvert dans une fonction; il devra alors être corrigé dans toutes les fonctions qui auront été copiées/collées.

  • Conservez de petites interfaces et signatures de fonctions/méthodes. Quatre paramètres, pas plus. Au besoin, refactorisez certains paramètres dans une classe ou une structure, qui sera plus facile à tester.

1.5.2 Au niveau des classes

  • Privilégiez un couplage faible entre vos classes. Ceci n’est pas toujours possible, mais dans la mesure du possible, éclatez vos classes en fonction de leur domaine de compétences respectif. L’implémentation du service UserNotificationsService ne doit pas forcément se trouver embarqué dans une classe UserService. De même, pensez à passer par une interface (commune à plusieurs classes), afin d’ajouter une couche d’abstraction. La classe appellante n’aura alors que les méthodes offertes par l’interface comme points d’entrée.

Dans la même veine, faites en sorte que les dépendances aillent toutes “dans le même sens”, ce qui limitera l’effet spaghetti associé au code, tout en améliorant sa lisibilité et l’intuitivité de sa compréhension.

1.5.3 Au niveau des composants

  • Tout comme pour les classes, il faut conserver un couplage faible au niveau des composants également. Une manière d’arriver à ce résultat est de conserver un nombre de points d’entrée restreint, et d’éviter qu’il ne soit possible de contacter trop facilement des couches séparées de l’architecture. Pour une architecture n-tiers par exemple, la couche d’abstraction à la base de données ne peut être connue que des services; sans cela, au bout de quelques semaines, n’importe quelle couche de présentation risque de contacter directement la base de données, “__juste parce qu’elle en a la possibilité__”. Vous pourriez également passer par des interfaces, afin de réduire le nombre de points d’entrée connus par un composant externe (qui ne connaîtra par exemple que IFileTransfer avec ses méthodes put et get, et non pas les détails d’implémentation complet d’une classe FtpFileTransfer ou SshFileTransfer).
  • Conserver un bon balancement au niveau des composants : évitez qu’un composant A ne soit un énorme mastodonte, alors que le composant juste à côté ne soit capable que d’une action. De cette manière, les nouvelles fonctionnalités seront mieux réparties parmi les différents systèmes, et les responsabilités seront plus faciles à gérer. Un conseil est d’avoir un nombre de composants compris entre 6 et 12 (idéalement, 12), et que chacun de ces composants soit approximativement de même taille.

1.5.4 De manière générale

  • Conserver une densité de code faible : il n’est évidemment pas possible d’implémenter n’importe quelle nouvelle fonctionnalité en moins de 20 lignes de code; l’idée ici est que la réécriture du projet ne prenne pas plus de 20 hommes/mois. Pour cela, il faut (activement) passer du temps à réduire la taille du code existant: soit en faisant du refactoring (intensif), soit en utilisant des librairies existantes, soit en explosant un système existant en plusieurs sous-systèmes communiquant entre eux. Mais surtout, en évitant de copier/coller bêtement du code existant.
  • Automatiser les tests, en ajoutant un environnement d’intégration continue dès le début du projet et en faisant vérifier par des outils automatiques tous les points ci-dessus.

1.6 Anti-patterns

https://architecture-antipatterns.tech/

1.7 Architecture in Django: A Practical Lens

While these principles are universal, Django shapes how we apply them:

  • Apps as Components: Each Django app should follow CCP—group code that changes together.
  • Settings as Abstractions: Your settings.py is where DIP happens—database backends, email, caching.
  • Middleware as Plug-ins: They follow OCP—you extend without modifying core.
  • Avoiding Fat Views: SRP means views delegate to forms, services, or managers.

Django doesn’t enforce good architecture—but it enables it.