Couplage, cohésion, connascence

Les pages précédentes donnent des règles : une classe ne doit avoir qu’une seule raison de changer, une frontière se place là où le langage change, le domaine ne doit dépendre de rien. Toutes présupposent une capacité qu’aucune n’enseigne : savoir juger si une dépendance est acceptable.

Le couplage et la cohésion sont la seule mesure réelle de cette question, et elles datent de 1974. Le problème est que « couplage » est un mot beaucoup trop grossier : dire « c’est couplé » ne dit ni à quel point, ni pourquoi, ni quoi faire. La connascence est l’instrument fin qui manque — elle gradue ce que le mot « couplage » aplatit en un seul jugement.

Le couplage n’est pas un défaut

Il faut évacuer cela d’abord, parce que c’est l’erreur de cadrage la plus commune. Un système à couplage nul n’est pas un bon système : c’est un ensemble de programmes sans rapport. Le couplage est ce qui fait qu’un programme est un programme.

L’objectif n’est donc pas de minimiser le couplage, mais de le placer : beaucoup à l’intérieur des modules, peu entre eux. Formulé en une phrase, tout l’art de la décomposition consiste à faire passer les frontières là où il y a le moins à couper.

Les deux notions se définissent de manière opérationnelle, par une question :

La question à poser
CouplageSi je change ceci, que suis-je obligé de changer ailleurs ?
CohésionCe qui est dans ce module change-t-il pour les mêmes raisons ?

Elles sont liées mais distinctes. Une forte cohésion interne réduit généralement le couplage externe — mais un module fourre-tout dont personne ne dépend a une cohésion déplorable et un couplage nul. La cohésion se juge de l’intérieur, le couplage de l’extérieur.

Les six formes de couplage

Le classement de Stevens, Myers et Constantine, du pire au meilleur. Il a cinquante ans et il est resté exact.

FormeCe que c’estExempleRemède
1De contenuA modifie l’état interne de BÉcrire dans un champ privé, monkey-patchingEncapsuler
2CommunA et B partagent une donnée globale mutableUn singleton mutable, une variable globalePasser la donnée, ou la rendre immuable
3ExterneA et B dépendent d’un format imposé de l’extérieurUn schéma de fichier, un protocole tiersUn adaptateur, un seul point de contact
4De contrôleA passe un drapeau qui dicte le comportement de Btraiter(commande, true)Deux fonctions, ou une stratégie
5D’estampilleA passe une structure entière alors que B n’a besoin que d’un champenvoyer(commandeComplete) pour lire l’e-mailPasser ce dont B a besoin
6De donnéesA passe exactement ce dont B a besoincalculerTva(montant, taux)Le meilleur atteignable

Deux remarques d’usage. Le couplage de contrôle — le paramètre booléen qui choisit une branche — est le plus fréquent des quatre premiers, et le plus facile à supprimer : un appel avec true en argument ne dit rien à la lecture, et signale presque toujours deux opérations différentes fusionnées. Le couplage d’estampille est le plus discret : il paraît inoffensif de passer l’objet entier, et il rend B dépendant de toute évolution de la structure de A.

Les sept formes de cohésion

Le classement dual, du meilleur au pire.

FormeCe qui regroupe les éléments
1FonctionnelleIls contribuent tous à une tâche bien définie
2SéquentielleLa sortie de l’un est l’entrée du suivant
3CommunicationnelleIls opèrent sur les mêmes données
4ProcéduraleIls s’exécutent dans un ordre imposé
5TemporelleIls arrivent au même moment — initialiser(), arreter()
6LogiqueIls sont « du même genre »
7CoïncidentelleAucune relation
Warning

La cohésion logique est le piège, parce qu’elle a l’apparence de l’ordre. Un dossier validateurs/, une classe ChaineUtils, un module helpers/ : tout y est rangé par nature technique, ce qui donne une arborescence propre et un couplage détestable. Le validateur d’IBAN et celui de code postal n’ont rien en commun, ne changent jamais ensemble, et n’ont aucune raison de vivre côte à côte — sinon qu’ils portent le même suffixe.

C’est la même erreur, à une autre échelle, que celle du découpage par couche plutôt que par fonctionnalité, signalée dans N-Tiers.

La connascence

Le mot vient de Meilir Page-Jones (1996) et signifie « naître ensemble ». La définition est presque identique à celle du couplage :

Deux éléments sont connascents si la modification de l’un impose la modification de l’autre pour que le programme reste correct.

L’apport n’est pas la définition, c’est de la rendre mesurable sur trois dimensions.

DimensionQuestionEffet
ForceEst-ce facile à repérer et à corriger ?Plus c’est fort, plus le changement est risqué
DegréCombien d’éléments sont concernés ?Une constante magique employée en 40 endroits a un degré de 40
LocalitéÀ quelle distance les éléments sont-ils ?C’est la dimension décisive

De là, les trois règles de Page-Jones :

  1. Minimiser la connascence globale, en décomposant le système.
  2. Minimiser la connascence qui traverse les frontières.
  3. Maximiser la connascence à l’intérieur des frontières.

La troisième est la contre-intuitive, et c’est la plus utile : à l’intérieur d’un module, une connascence serrée est souhaitable — c’est précisément ce qui fait que le module est une unité et non un sac. Un module dont les éléments ne sont pas connascents n’a aucune raison d’exister.

Les formes statiques

Repérables en lisant le code, de la plus faible à la plus forte.

FormeLes deux éléments doivent s’accorder sur…ExempleRemède
Nomun nomAppeler calculerTvaAucun — inévitable et bénigne, l’outillage la gère
Typeun typePasser un MontantAucun — bénigne
Sensla signification d’une valeurstatut = 2 veut dire « suspendu »Une énumération, un objet valeur
Positionun ordrecreer(nom, prenom), un tupleArguments nommés, un objet
Algorithmeun algorithmeDeux côtés qui calculent le même hachage ou parsent le même formatUn seul endroit qui le fait

Les formes dynamiques

Repérables seulement à l’exécution — donc plus fortes, puisque le compilateur et la relecture ne les attrapent pas.

FormeLes deux éléments doivent s’accorder sur…ExempleRemède
Exécutionl’ordre des appelsouvrir() avant lire()Rendre l’état impossible : le constructeur ouvre
Temporalitéle momentCompétition d’accès, délai d’attenteSynchronisation explicite, idempotence
Valeurplusieurs valeurs cohérentes ensembleLe total doit égaler la somme des lignesUn agrégat
Identitéla même instanceDeux objets devant partager le même cacheLa plus forte et la plus dangereuse ; à confiner

La localité change tout

C’est le point qui justifie l’existence de la connascence comme outil, et que le mot « couplage » est incapable d’exprimer.

La même connascence est bénigne ou catastrophique selon la distance. Prenons la connascence de position — l’ordre des arguments :

Distance entre les deux élémentsGravitéCe qu’on fait
Deux lignes voisinesNulleRien
Deux méthodes d’une même classeFaibleRien
Deux classes d’un même moduleÀ surveillerSelon le degré
Deux modulesÀ supprimerAffaiblir
Deux contextes délimitésGraveUn contrat explicite
Deux services déployés séparémentCritiqueInterdire

D’où l’heuristique la plus directement utilisable de toute la page :

La force acceptable décroît avec la distance. Toute forme est tolérable à quelques lignes de distance ; seules les connascences de nom et de type sont tolérables à travers une frontière de déploiement.

Et le corollaire, souvent oublié : il existe deux mouvements légitimes face à une connascence gênante. L’affaiblir — convertir une connascence de position en connascence de nom — ou la rapprocher, en déplaçant les deux éléments dans le même module sans rien changer d’autre. Le second est fréquemment le moins cher, et c’est presque toujours celui auquel on ne pense pas.

L’instrument en action

La même fonction, avant et après. L’exercice consiste à nommer chaque connascence, puis à la remplacer par une plus faible.

​
// ─────────── Avant : trois connascences fortes dans une signature ───────────
function tarifApplicable(
  debut: string,      // "AAAAMMJJ" → connascence d'ALGORITHME sur le format
  fin: string,        // même type que debut → connascence de POSITION
  mode: number,       // 0 plein, 1 réduit, 2 groupe → connascence de SENS
): number {
  const d = Number(debut.slice(0, 4)) * 372 + Number(debut.slice(4, 6)) * 31;
  // ... le format est re-parsé ici, et chez tous les appelants
}

tarifApplicable('20260301', '20260831', 1);   // Deux chaînes interchangeables,
                                              // et un 1 qui ne dit rien

// ─────────── Après ───────────
class Periode {
  private constructor(readonly debut: LocalDate, readonly fin: LocalDate) {}

  static de(debut: LocalDate, fin: LocalDate): Periode {
    if (fin.avant(debut)) throw new PeriodeInvalide();
    return new Periode(debut, fin);
  }

  contient(date: LocalDate): boolean { /* le format n'existe qu'ici */ }
}

enum Tarif { Plein, Reduit, Groupe }

function tarifApplicable(periode: Periode, tarif: Tarif): Montant { /* ... */ }

tarifApplicable(saisonEte, Tarif.Reduit);     // Deux types distincts,
                                              // intervertir ne compile pas
# ─────────── Avant : trois connascences fortes dans une signature ───────────
def tarif_applicable(
    debut: str,       # "AAAAMMJJ" → connascence d'ALGORITHME sur le format
    fin: str,         # même type que debut → connascence de POSITION
    mode: int,        # 0 plein, 1 réduit, 2 groupe → connascence de SENS
) -> float:
    annee = int(debut[:4])
    # ... le format est re-parsé ici, et chez tous les appelants


tarif_applicable("20260301", "20260831", 1)   # Deux chaînes interchangeables,
                                              # et un 1 qui ne dit rien


# ─────────── Après ───────────
from dataclasses import dataclass
from datetime import date
from enum import Enum


@dataclass(frozen=True)
class Periode:
    debut: date
    fin: date

    def __post_init__(self) -> None:
        if self.fin < self.debut:
            raise PeriodeInvalide

    def contient(self, jour: date) -> bool:
        return self.debut <= jour <= self.fin


class Tarif(Enum):
    PLEIN = "plein"
    REDUIT = "reduit"
    GROUPE = "groupe"


def tarif_applicable(periode: Periode, tarif: Tarif) -> Montant: ...


tarif_applicable(saison_ete, Tarif.REDUIT)    # Deux types distincts,
                                              # l'analyseur refuse l'inversion
// ─────────── Avant : trois connascences fortes dans une signature ───────────
fn tarif_applicable(
    debut: &str,      // "AAAAMMJJ" → connascence d'ALGORITHME sur le format
    fin: &str,        // même type que debut → connascence de POSITION
    mode: u8,         // 0 plein, 1 réduit, 2 groupe → connascence de SENS
) -> f64 {
    let annee: i32 = debut[..4].parse().unwrap();
    // ... le format est re-parsé ici, et chez tous les appelants
    todo!()
}

tarif_applicable("20260301", "20260831", 1);  // Deux &str interchangeables,
                                              // et un 1 qui ne dit rien

// ─────────── Après ───────────
pub struct Periode {
    debut: NaiveDate,
    fin: NaiveDate,
}

impl Periode {
    pub fn de(debut: NaiveDate, fin: NaiveDate) -> Result<Self, PeriodeInvalide> {
        if fin < debut {
            return Err(PeriodeInvalide);
        }
        Ok(Self { debut, fin })
    }

    pub fn contient(&self, jour: NaiveDate) -> bool {
        self.debut <= jour && jour <= self.fin
    }
}

pub enum Tarif { Plein, Reduit, Groupe }

fn tarif_applicable(periode: &Periode, tarif: Tarif) -> Montant { todo!() }

tarif_applicable(&saison_ete, Tarif::Reduit); // Deux types distincts,
                                              // intervertir ne compile pas

Le bilan du remaniement, forme par forme :

ConnascenceAvantAprès
Algorithme — le format de dateChez l’appelé et tous les appelantsSupprimée : le type de date porte le format
Sens — la valeur 1Entre chaque appelant et l’appeléDevenue connascence de nom : Tarif.Reduit
Position — l’ordre des deux datesDeux string interchangeables sans erreurDevenue connascence de type : l’inversion ne compile plus
Valeur — fin après debutNulle part vérifiéeConfinée dans Periode, vérifiée une fois

C’est exactement le mouvement décrit dans Entités et objets valeur sous le nom d’obsession des primitifs — et la connascence explique pourquoi ça marche, au lieu de le poser comme une bonne pratique.

La profondeur d’un module

Une dernière notion, empruntée à John Ousterhout, qui complète les deux premières en répondant à une question qu’elles ne posent pas : ce module valait-il son interface ?

Un module est profond quand il offre beaucoup de fonctionnalité derrière une interface étroite. Il est superficiel quand son interface est presque aussi grosse que ce qu’il fait.

Un module profond est un bon investissement : on apprend peu de choses — son interface — pour obtenir beaucoup. Un module superficiel est un coût net : il faut l’apprendre, et il ne cache presque rien.

L’archétype du module superficiel est la classe qui ne fait que transmettre, avec les mêmes noms et les mêmes types que ce qu’elle enveloppe. C’est précisément la couche traversante identifiée comme deuxième signal de saturation dans N-Tiers — et la profondeur donne le critère qui manquait pour la diagnostiquer sans hésiter.

Ce qui conduit à une conclusion inconfortable et juste : ajouter une couche d’abstraction n’améliore pas nécessairement la conception. Si l’abstraction ne cache rien, elle ajoute de la connascence de nom entre deux éléments de plus, et rien d’autre. C’est le même avertissement que celui posé au sujet de l’ouverture à l’extension dans SOLID, avec un instrument pour le mesurer.

Le complément historique est le critère de David Parnas (1972), qui reste le meilleur guide de découpage jamais écrit en une phrase : un module doit cacher une décision susceptible de changer. Pas une étape de traitement, pas une couche technique — une décision.

Ce que l’instrument explique

C’est ici que cette page justifie sa position dans la section : la connascence ré-explique tout ce qui précède et tout ce qui suit avec un seul vocabulaire.

NotionCe qu’elle fait, en termes de connascence
Responsabilité uniqueMaximiser la cohésion : un module, une raison de changer
Ségrégation des interfacesRéduire la connascence de nom et de type qui traverse une frontière
Inversion des dépendancesRemplacer une connascence avec une implémentation par une connascence avec une abstraction
StrategyConvertir une connascence de sens — un switch sur un code — en connascence de nom
ObserverSupprimer la connascence d’identité entre l’émetteur et ses dépendants
Objet valeurConfiner une connascence d’algorithme dans un seul type
AgrégatTracer une frontière autour d’une connascence de valeur — c’est la définition même d’un invariant
Contexte délimitéUne frontière où l’on n’accepte plus que les connascences de nom et de type, par contrat explicite
Port hexagonalConvertir une connascence avec une technologie en connascence avec une interface
Règle dupliquée (signal 3 du 3-tier)Une connascence d’algorithme entre deux services, sans propriétaire

Le tableau se lit dans les deux sens. Il donne le pourquoi de chaque patron, et il permet d’inventer le remède quand aucun patron connu ne s’applique : nommer la forme, puis chercher à l’affaiblir ou à la rapprocher.

Comment s’en servir

En revue de code. C’est l’usage le plus immédiatement rentable. « C’est couplé » n’est pas actionnable ; « il y a une connascence de sens entre le contrôleur et le dépôt, avec un degré de sept » désigne le problème, sa gravité et son remède. Nommer la forme transforme un jugement en constat.

Pour arbitrer un remaniement. L’objectif n’est jamais « supprimer le couplage », mais convertir une forme forte en forme faible, ou améliorer la localité. Formulé ainsi, le travail a un critère d’arrêt.

Pour placer une frontière. Entre deux découpages possibles, celui qui laisse passer les connascences les plus faibles est le bon. C’est la réponse opérationnelle à la question que les autres pages présupposaient résolue.

Les limites de l’outil

Il ne mesure pas la valeur. Un programme sans connascence forte serait un programme sans structure. Certaines connascences fortes sont le cœur du système — un agrégat est une connascence de valeur assumée.

Il ne dit rien du coût du remède. Affaiblir une connascence coûte presque toujours une abstraction, donc de l’indirection. L’instrument mesure le problème, pas la rentabilité de sa solution.

Il ne s’automatise pas. Aucun outil ne mesure la connascence. Les linters détectent des indices — listes de paramètres trop longues pour la position, constantes magiques pour le sens, duplication pour l’algorithme — mais la forme et la localité restent un jugement humain.

Il ne remplace pas le discernement. La connascence d’identité est classée « la pire » ; une instance de cache partagée est parfaitement légitime. Le classement est une échelle de vigilance, pas un interdit.

Références

  • Stevens, Myers & Constantine, Structured Design, IBM Systems Journal, 1974 — l’origine du couplage et de la cohésion
  • David Parnas, On the Criteria To Be Used in Decomposing Systems into Modules, 1972 — le critère de dissimulation d’information
  • Meilir Page-Jones, What Every Programmer Should Know About Object-Oriented Design, 1996 — l’introduction de la connascence
  • https://connascence.io/ — la référence en ligne, avec les formes et leurs remèdes
  • John Ousterhout, A Philosophy of Software Design, 2018 — les modules profonds et superficiels
  • https://www.youtube.com/watch?v=HQXVKHoUQxY — Connascence Examined, la conférence de Jim Weirich qui a fait redécouvrir la notion