Aller au contenu
>_ developpeur-python
Tous les articles
ArchitectureDesign PatternPython

L'arbre de décision comme architecture : des règles métier qui changent souvent

Sébastien Mizrahi ,
L'arbre de décision comme architecture : des règles métier qui changent souvent

L'arbre de décision, en architecture logicielle, consiste à découper une logique métier en noeuds autonomes : chaque noeud pose une question, y répond, puis oriente vers le noeud suivant. L'arbre lui-même est décrit en configuration, pas dans le code. C'est une réponse efficace quand les règles sont nombreuses et changent souvent. Précisons tout de suite un point qui prête à confusion : il ne s'agit pas des arbres de décision du machine learning, appris à partir de données. Ici, les règles sont écrites par des humains, explicites et auditables.

Le problème : la cascade de "if" qui grossit

Le point de départ est presque toujours le même. Vous avez un flux d'éléments à classer selon quelques règles métier. Le réflexe naturel est une suite de conditions imbriquées, et ça fonctionne très bien... pendant trois mois.

Puis les règles se multiplient, des exceptions apparaissent, et la fonction centrale devient un bloc que plus personne n'ose modifier. Chaque ajout touche du code partagé, donc chaque ajout est risqué. Et surtout, la moindre modification d'une règle exige un déploiement.

J'ai rencontré ce cas chez un client, sur un service qui analyse des rapports DMARC (le standard d'authentification des emails) pour classer des adresses IP en légitimes ou suspectes. Les critères : adresses explicitement exclues, listes de réputation, domaines de confiance, reverse DNS, résultats SPF et DKIM, politique déclarée par le domaine. Une dizaine de règles, ajustées en permanence au fil des faux positifs.

Le principe : un noeud, une règle, une orientation

L'idée est de renverser la structure. Au lieu d'une grosse fonction qui connaît toutes les règles, chaque règle devient un noeud indépendant. Un noeud reçoit l'élément à traiter et renvoie trois choses :

  • un statut : l'élément est valide ou invalide à ce stade ;
  • une direction : continuer vers la branche A, la branche B, ou s'arrêter ;
  • éventuellement des décisions (un code et une cible) qui serviront à la suite du traitement.

Le moteur, lui, ne connaît aucune règle. Il sait juste exécuter un noeud, lire sa réponse et passer au suivant.

Parcours d'un enregistrement dans l'arbre de décision : noeuds de règles, branches et arrêt anticipé Chaque noeud répond puis oriente. Le parcours s'arrête dès qu'un noeud dit stop ou invalide l'élément.

L'implémentation

Tout part d'une classe de base minimale. Elle porte le nom du noeud, sa description, sa configuration, et impose une méthode à implémenter.

class DecisionNode:
    """Base class for every rule of the tree."""

    def __init__(self, logger, config, name, description):
        self.name = name
        self.description = description
        self.logger = logger
        self.config = config

    def run(self, record, status):
        """Return (status, direction, decisions). Must be implemented by children."""
        raise NotImplementedError

Un noeud concret reste très court, parce qu'il ne fait qu'une chose. Celui-ci écarte du traitement les adresses explicitement exclues.

class NodeExcludeIp(DecisionNode):
    """Stop the traversal when the source IP is explicitly excluded."""

    def run(self, record, status):
        for excluded_ip in self.config:
            if record.source_ip == excluded_ip:
                return self.result(DecisionStatus.VALID, DecisionNodeResult.STOP)
        return self.result(DecisionStatus.VALID, DecisionNodeResult.NODE_A)

Vient ensuite la partie qui change tout : l'arbre est déclaré en configuration. On y lit le nom du noeud, ses paramètres, et vers quoi il branche.

{
  "NodeExcludeIp": {
    "config": ["127.0.0.1"],
    "nodeA": {
      "NodeReputationList": {
        "config": { "providers": ["rbl.example.net"], "isWhitelist": true },
        "nodeA": {
          "NodeTrustedDomain": {
            "config": { "reverseDns": true, "whitelist": ["example.com"] }
          }
        }
      }
    }
  }
}

Le moteur se contente de parcourir cette structure, récursivement. Il exécute le noeud courant, s'arrête si la réponse le demande, sinon il descend dans la branche indiquée.

def walk(self, name, branch, record, status):
    status, direction, decisions = self.exec_node(name, branch.get("config", {}), record, status)

    if direction == DecisionNodeResult.STOP or status == DecisionStatus.INVALID:
        return status, decisions

    next_branch = branch.get(direction.value)
    if next_branch is None:
        return status, decisions

    next_name = next(iter(next_branch))
    return self.walk(next_name, next_branch[next_name], record, status)

Dernier détail, et il compte : le noeud est chargé dynamiquement à partir de son nom. Le moteur n'importe aucun noeud en dur.

module = importlib.import_module(".nodes." + name, package=__package__)
node = getattr(module, name)(self.logger, config)

Conséquence directe : ajouter une règle revient à écrire une classe dans le dossier des noeuds et à la référencer dans la configuration. Le moteur n'est jamais modifié.

Les avantages

L'isolation, pour commencer. Une règle tient dans une classe de vingt lignes qui se lit et se teste seule. Le test unitaire devient trivial : un élément en entrée, on vérifie le statut et la direction en sortie.

La souplesse vient juste après. Réordonner l'arbre, ajouter une exception, désactiver une règle le temps d'une campagne : tout se joue dans la configuration. Sur ce projet, ajuster le comportement ne demandait pas de toucher au code.

Et comme le moteur charge les noeuds dynamiquement, il est fermé à la modification mais ouvert à l'extension. Concrètement, on ajoute des règles pendant des mois sans jamais rouvrir le coeur du système.

La traçabilité, elle, est presque toujours sous-estimée. Chaque noeud étant identifié, on enregistre le chemin parcouru et le dernier noeud ayant tranché. Je mesurais aussi le temps passé dans chacun, ce qui fait ressortir les lenteurs sans avoir à les chercher.

Côté performance, la structure aide deux fois : le parcours s'arrête dès qu'une décision tombe, et comme les éléments sont indépendants, on parallélise sans effort. Le moteur traitait les enregistrements en threads, plafonnés par un sémaphore pour ne pas saturer la machine.

Les inconvénients

La lisibilité du flux en prend un coup, d'abord. Le code ne raconte plus toute l'histoire : pour comprendre le traitement, il faut lire la configuration en parallèle. Même compromis que pour le bus d'événements, on gagne en souplesse ce qu'on perd en lecture directe.

Le chargement dynamique a lui aussi un prix. Un nom de noeud mal orthographié dans le JSON ne se voit pas à la compilation, seulement à l'exécution. D'où une règle simple : valider la configuration au démarrage, plutôt que de découvrir le problème en production.

Attention aussi à la profondeur. Sans discipline, la configuration atteint vite plusieurs centaines de lignes, impossibles à embrasser d'un coup. Et faute de mécanisme de réutilisation, les mêmes branches finissent dupliquées à trois endroits.

Il y a un piège plus subtil : l'ordre des noeuds porte deux responsabilités à la fois, la sémantique et la performance. Une règle coûteuse placée trop haut, une résolution DNS par exemple, se paie sur tout le flux. À l'inverse, un filtre bon marché en tête écarte l'essentiel du volume pour presque rien.

Le débogage réclame de l'outillage, et c'est un investissement à faire au départ, pas après coup. Sans journalisation du chemin parcouru, expliquer pourquoi un élément a été classé ainsi devient vite pénible.

Reste à savoir quand ce pattern ne convient pas. Si les règles s'influencent mutuellement, ou si la décision demande une vue d'ensemble plutôt qu'une succession de tests, un score pondéré ou un modèle statistique feront mieux qu'un arbre écrit à la main.

Quand l'utiliser

Ce pattern est pertinent dès qu'on classe ou filtre un flux selon des critères successifs : réputation et antispam, détection de fraude, modération, tri de tickets. Il l'est tout autant pour les logiques d'éligibilité (assurance, crédit, aides, remises commerciales), pour du routage (quel traitement appliquer selon les caractéristiques d'un dossier), ou pour des contrôles de conformité qui doivent être justifiés un par un.

Le signal le plus fiable reste celui-ci : si les règles changent plus vite que vos cycles de livraison, ou si des personnes non développeuses doivent pouvoir les ajuster, l'arbre en configuration prend tout son sens. Dans le cas contraire, avec trois conditions stables, une simple suite de tests dans le code reste la bonne réponse.

Ce qu'il faut retenir

L'arbre de décision transforme une logique métier touffue en un assemblage de petites règles indépendantes, dont l'enchaînement vit en configuration. On y gagne en isolation, en testabilité et en capacité d'évolution, et on accepte en échange un flux moins lisible dans le code et un besoin réel d'outillage pour le déboguer.

C'est le même arbitrage que pour beaucoup de choix de conception, comme celui entre composition et héritage : la bonne architecture n'est pas la plus élégante, c'est celle dont on accepte le prix en connaissance de cause.

Vous avez une logique métier devenue difficile à faire évoluer ? C'est typiquement le genre de sujet sur lequel j'interviens. Voyez mes expertises en architecture ou parlons-en.

Un projet Python ou IA à concrétiser ?

Discutons de votre besoin.

Me contacter