Le gros œuvre : ce qui porte, et ce qui ne porte rien
Les plans sont validés, le sol est reconnu, les matériaux sont commandés. Sur Le Chantier, le gros œuvre commence : murs porteurs, planchers, trémies. C'est le moment où le bâtiment cesse d'être un dessin et devient une structure qui tient debout toute seule.
Une fois les enduits passés, un mur porteur et une cloison se ressemblent beaucoup. La différence apparaît le jour où quelqu'un veut ouvrir un passage. L'un se démonte en une matinée, l'autre demande un bureau d'études, une poutre et l'accord de tout le monde.
Dans un composant logiciel aussi, certaines lignes de séparation portent la structure et d'autres sont décoratives. Le travail de l'architecte est de savoir lesquelles.
Le billet précédent a choisi le magasin de données. On descend maintenant d'un cran, à l'intérieur du composant lui-même.
Il y a deux niveaux d'architecture. Le niveau système décrit comment les composants se parlent : découpage, couplage, messagerie, cache, supervision. Le niveau composant décrit ce qui se passe dans la boîte. Ce billet reste dans la boîte ; le niveau système occupe les billets du palier 3.
À l'intérieur, six sujets suffisent à faire la différence entre un composant qu'on reprend et un composant qu'on réécrit : le découpage en couches, les interfaces et l'injection de dépendances, les cinq principes SOLID, les conventions de nommage, la gestion des exceptions et la journalisation. Les deux derniers sont ceux qu'on expédie en fin de sprint, et ce sont ceux qui coûtent le plus cher une nuit d'incident.
Les trois couches, et ce qu'elles interdisent
Sur Le Chantier, chaque niveau repose sur celui du dessous. On ne fait pas descendre une charge du troisième étage directement dans les fondations en sautant les planchers intermédiaires, même si ça raccourcirait le chemin.
Un composant se découpe en trois couches. L'interface de service reçoit les demandes de l'extérieur. La logique métier décide. L'accès aux données exécute les lectures et les écritures.
web, mobile, autre service"] svc["Interface de service
routes, validation, formats"] biz["Logique métier
règles, calculs, orchestration"] dal["Accès aux données
requêtes, mapping, transactions"] db[("Magasin de données")] ui --> svc svc --> biz biz --> dal dal --> db svc -. raccourci interdit .-> dal classDef ext fill:#ede9e1,stroke:#5f5e5a,color:#1a1a1a classDef couche fill:#f5f2ec,stroke:#b5651d,color:#1a1a1a classDef metier fill:#d89253,stroke:#8a4d16,color:#1a1a1a classDef store fill:#1f2428,stroke:#8a4d16,color:#f5f2ec class ui ext class svc,dal couche class biz metier class db store
La flèche en pointillé est la plus importante du schéma. Un découpage en couches vaut moins par ce qu'il autorise que par ce qu'il interdit.
| Couche | Ce qu'elle contient | Ce qu'elle ne contient jamais | Le signe qu'elle déborde |
|---|---|---|---|
| Interface de service | Routes, sérialisation, validation de forme, codes de retour | Une règle de gestion, une requête SQL | Un if sur le statut commercial du client dans un contrôleur |
| Logique métier | Règles, calculs, orchestration, décisions | Un objet HTTP, un nom de table ou de colonne | Une méthode métier qui prend une requête HTTP en paramètre |
| Accès aux données | Requêtes, mapping, transactions, pagination | Un calcul de remise, une règle d'éligibilité | Une clause WHERE qui encode la politique tarifaire |
Une couche qui déborde ne fait jamais de bruit. Elle se paie six mois plus tard, quand la règle qu'on croyait au centre se révèle recopiée à trois endroits.
Deux mots se confondent souvent : couche et tier. Une couche est un découpage logique du code. Un tier est un découpage physique du déploiement. Les trois couches ci-dessus peuvent parfaitement tourner dans un seul processus, sur une seule machine.
| Question | Couche | Tier |
|---|---|---|
| Ce que ça découpe | Le code | Le déploiement |
| Ce qui la décide | La conception interne du composant | L'exploitation, la sécurité, la charge |
| Le coût d'un ajout | Un dossier et une interface | Une machine, un réseau, de la latence |
| Le moment où ça se change | À la refonte du composant | À la refonte de l'infrastructure |
Confondre les deux mène à deux erreurs symétriques : déployer trois machines pour résoudre un problème de rangement de code, ou tout empiler dans un même fichier au prétexte que ça tourne de toute façon au même endroit.
Le flux de code et la traversée des frontières
Suis une demande du début à la fin. Elle entre par l'interface de service, qui vérifie la forme, refuse ce qui est malformé et traduit la charge utile en objets du domaine. Elle passe à la logique métier, qui décide. La logique métier demande à la couche d'accès aux données de lire ou d'écrire. Le résultat remonte le même escalier, en sens inverse.
Chaque frontière est un changement de vocabulaire. Un identifiant de client venu d'une URL devient un objet métier, puis une ligne de table, puis un objet métier de nouveau. Laisser fuir l'entité de la base directement dans le contrat de l'API est le raccourci le plus tentant et le plus coûteux : le jour où tu renommes une colonne, tu casses tes clients.
Le couplage entre couches se limite avec des interfaces. La logique métier ne connaît pas DepotClientSql, elle connaît DepotClient. C'est un contrat, pas une implémentation, et il tient en quelques signatures de méthodes.
Chaque couche connaît le contrat de la couche du dessous, jamais son intérieur. C'est la seule chose qui rend une couche remplaçable.
Les exceptions traversent ces frontières en sens inverse. Une expiration de connexion SQL, levée trois niveaux plus bas, ne doit pas ressortir telle quelle jusqu'à l'appelant. Le composant a besoin d'un point de rattrapage unique, en bordure, qui journalise l'exception technique complète d'un côté et rend de l'autre une erreur générique : un code de retour, un message compréhensible, un identifiant de corrélation.
L'appelant n'apprend rien de ta base de données. Toi, tu retrouves l'incident complet dans tes journaux avec l'identifiant que le client t'a donné au téléphone.
Interfaces et injection de dépendances
Sur un chantier, on ne scelle pas la chaudière dans le mur porteur. On pose des réservations, des raccords standard, des platines. Le modèle exact de la chaudière se décide plus tard, et se remplace dans quinze ans sans toucher à la structure.
Le mot-clé new dans la logique métier est une soudure. La classe qui l'écrit connaît le nom de l'implémentation, son constructeur, sa chaîne de connexion, et parfois son fichier de configuration.
// Avant : la logique métier fabrique elle-même sa dépendance
public class ServiceFacturation {
private final DepotClientSql depot =
new DepotClientSql("jdbc:postgresql://prod-db:5432/app");
public Facture emettre(long clientId) {
var client = depot.chargerParId(clientId);
return Facture.pour(client);
}
}
Cette classe ne se teste pas seule. Pour vérifier une règle de facturation, il faut une base PostgreSQL qui répond. Elle se réutilise mal dans un autre contexte, et changer de dépôt oblige à rouvrir son code.
L'injection par constructeur retourne la responsabilité. La classe déclare ce dont elle a besoin, quelqu'un d'autre le lui fournit.
// Après : la dépendance est déclarée, pas fabriquée
public class ServiceFacturation {
private final DepotClient depot;
public ServiceFacturation(DepotClient depot) {
this.depot = depot;
}
public Facture emettre(long clientId) {
var client = depot.chargerParId(clientId);
return Facture.pour(client);
}
}
Le test devient trivial, et surtout rapide.
@Test
void emet_une_facture_au_nom_du_client() {
var depot = new DepotClientEnMemoire(Map.of(42L, new Client(42L, "Dupont")));
var facture = new ServiceFacturation(depot).emettre(42L);
assertEquals("Dupont", facture.nomClient());
}
Le câblage se concentre alors en un seul endroit, à la racine du composant : un conteneur d'injection (Spring, .NET, Symfony et NestJS en fournissent un) ou, dans un petit programme, la méthode de démarrage elle-même.
logique métier"] itf(["DepotClient
interface"]) sql["DepotClientSql
PostgreSQL"] mem["DepotClientEnMemoire
tests"] biz --> itf sql -.-> itf mem -.-> itf classDef metier fill:#d89253,stroke:#8a4d16,color:#1a1a1a classDef contrat fill:#f5f2ec,stroke:#b5651d,color:#1a1a1a classDef impl fill:#ede9e1,stroke:#5f5e5a,color:#1a1a1a class biz metier class itf contrat class sql,mem impl
Le sens des flèches est le vrai sujet. Les implémentations pointent vers le contrat, jamais l'inverse, et le contrat appartient à celui qui l'utilise.
Une précision qui évite l'excès inverse : tout n'a pas besoin d'une interface. Un objet de valeur, une structure de données, un utilitaire sans état se construisent très bien avec new. L'interface se justifie quand il y a une frontière, une entrée-sortie, ou un choix susceptible de changer.
SOLID, sans réciter l'acronyme
Les cinq principes ont été rassemblés par Robert C. Martin autour de l'an 2000 ; l'acronyme est venu plus tard, proposé par Michael Feathers. Récités en entretien, ils ne servent à rien. Utilisés devant un fichier de 900 lignes, ils donnent cinq questions précises.
Responsabilité unique. Une classe ne devrait avoir qu'une seule raison de changer. Une classe Facture qui calcule un total, produit un PDF et envoie un courriel en a trois : un changement de barème, un changement de charte graphique, un changement de fournisseur d'envoi. Trois équipes différentes viendront modifier le même fichier.
Ouvert à l'extension, fermé à la modification. Ajouter un format d'export ne devrait pas rouvrir le code existant. Le switch sur le format qui grossit à chaque nouveau besoin se remplace par un jeu d'implémentations d'une même interface, choisies à l'exécution.
// Ajouter un format consiste à écrire une classe, pas à modifier celle-ci
public interface Exportateur {
boolean gere(Format format);
byte[] exporter(Rapport rapport);
}
La sélection de la bonne implémentation est exactement le rôle d'une fabrique, traitée dans le billet sur les design patterns.
Substitution. Un sous-type doit pouvoir remplacer son parent sans que l'appelant s'en aperçoive. Un CompteEpargne qui hérite de Compte mais lève une exception sur retirer() casse tout code qui manipule des Compte sans savoir lequel il tient. Le problème n'est pas l'exception, c'est la promesse rompue par la hiérarchie.
Ségrégation des interfaces. Une interface Repository à vingt méthodes oblige chaque implémentation à en écrire vingt, dont quinze qui renvoient une erreur. Mieux vaut plusieurs contrats étroits que chacun peut honorer entièrement.
Inversion des dépendances. Le module de haut niveau ne dépend pas de l'implémentation de bas niveau ; les deux dépendent d'une abstraction. Le schéma de la section précédente en est l'application directe.
| Principe | La question à se poser | Le signe qu'il est violé |
|---|---|---|
| Responsabilité unique | Combien de raisons distinctes de modifier ce fichier ? | Deux équipes différentes le touchent chaque mois |
| Ouvert/fermé | Ajouter un cas oblige-t-il à rouvrir l'existant ? | Un switch qui gagne une branche par trimestre |
| Substitution | Puis-je remplacer le parent par l'enfant sans rien casser ? | Un sous-type qui refuse une opération de son parent |
| Ségrégation | Toutes les implémentations peuvent-elles honorer tout le contrat ? | Des méthodes qui lèvent NotSupported |
| Inversion | Qui possède l'interface, le consommateur ou le fournisseur ? | Le métier importe le pilote de base de données |
Les cinq principes visent la même chose : localiser l'impact d'un changement. Une modification qui touche un seul fichier est une modification qu'on ose faire.
Ces principes servent directement les qualités de service décrites dans le billet sur les *-ilities : modularité, extensibilité, testabilité s'obtiennent ici, ligne par ligne, pas dans un schéma.
Conventions de nommage : choisir tôt, s'y tenir
Sur un chantier, les pièces sont repérées sur les plans avec une nomenclature unique. Deux plans qui numérotent différemment les mêmes locaux, et l'électricien perce la mauvaise cloison.
Le nommage a deux axes, qu'on mélange souvent. La structure concerne la casse et la ponctuation : camelCase, PascalCase, snake_case, majuscules pour les constantes. Le contenu concerne le sens : ce que le nom dit de la chose qu'il désigne.
Sur la structure, le seul bon choix est l'idiome du langage. Un développeur Java lit du camelCase, un développeur Python du snake_case, et aller contre ajoute une friction à chaque lecture. Le reste se règle avec un formateur automatique dans la chaîne de build, pas en revue de code.
| Élément | Structure usuelle | Contenu | Exemple |
|---|---|---|---|
| Classe | PascalCase | Un nom, pas un verbe | CalculateurRemise |
| Méthode | camelCase (Java, JS), snake_case (Python) | Un verbe à l'infinitif | calculerRemise |
| Booléen | camelCase | Une question à réponse oui ou non | estEligible, aExpire |
| Collection | camelCase | Un pluriel | lignesFacture |
| Constante | MAJUSCULES_AVEC_UNDERSCORES | Ce que la valeur signifie, pas sa valeur | DUREE_MAX_SESSION |
Sur ce sujet, avoir raison n'a aucun intérêt. Ce qui compte est qu'une règle existe, qu'elle soit écrite quelque part, et qu'elle soit la même dans tout le composant.
Deux pièges reviennent. Les abréviations maison, illisibles pour quelqu'un qui arrive : garde uniquement celles que le métier emploie vraiment. Et les noms qui décrivent la technique au lieu du domaine, du genre DataManager2, qui n'apprennent rien à personne.
Exceptions : n'attraper que ce qu'on sait traiter
Une fissure repérée dans une cloison se traite de deux façons. On la rebouche à l'enduit, et elle revient. Ou on regarde ce qui bouge derrière.
La règle tient en une phrase : n'attrape une exception que si tu sais quoi en faire. Écrire une ligne dans le journal n'est pas savoir quoi en faire. Le programme continue alors dans un état que personne n'a prévu, et l'erreur réelle sortira plus loin, sous une forme méconnaissable.
// À éviter : bloc énorme, capture générique, aucune décision
try {
var client = depot.chargerParId(id);
var facture = calculerFacture(client);
envoyer(facture);
} catch (Exception e) {
log.error("erreur", e);
}
Ce bloc attrape aussi bien une panne réseau qu'une erreur de programmation, et traite les deux de la même manière : en n'en faisant rien.
// Mieux : bloc court, type précis, décision réelle
try {
envoyer(facture);
} catch (ServiceEnvoiIndisponibleException e) {
fileAttente.reporter(facture); // on sait quoi faire : on réessaiera
}
Trois habitudes en découlent. Attraper le type le plus spécifique possible, parce qu'un catch générique en milieu de pile masque des bugs pendant des mois. Réduire le bloc à l'appel qui peut réellement échouer, pas aux quarante lignes qui l'entourent. Et laisser remonter tout le reste jusqu'au point de rattrapage unique du composant, celui qui journalise et rend une erreur générique.
Une exception avalée est pire qu'un arrêt franc. L'arrêt te dit où regarder ; l'exception avalée te laisse chercher la cause à trois écrans de distance.
Journalisation : deux usages, pas un
Avant de couler une dalle, on photographie les gaines et les réservations. Une fois le béton pris, plus personne ne saura ce qu'il y a dedans, et il sera trop tard pour photographier.
La journalisation sert à deux choses distinctes, et la seconde est presque toujours oubliée.
Le premier usage est la trace des erreurs. Ce qui s'est passé, quand, dans quel contexte, avec quelles valeurs, et sous quel identifiant de corrélation. Les niveaux servent à trier : ERROR pour ce qui demande une action, WARN pour ce qui devrait être regardé, INFO pour les événements métier notables, DEBUG pour ce qui reste éteint en production.
Le second usage est la collecte de données d'usage. Quelles fonctions sont réellement appelées, à quelle fréquence, par quel profil d'utilisateur, avec quels volumes. Ce sont les chiffres qui alimenteront la prochaine décision de dimensionnement et la prochaine discussion produit. Personne ne peut les reconstituer après coup.
Un format structuré (JSON, avec des champs stables) vaut bien mieux qu'une phrase concaténée : il se filtre, il s'agrège, il se cherche. À l'inverse, ne journalise jamais un mot de passe, un jeton, un numéro de carte ou une charge utile complète contenant des données personnelles. Un journal se conserve, se copie et se lit par beaucoup de monde.
Ce que tu ne journalises pas au moment où ça se produit n'existera plus jamais. Ce que tu journalises de trop devient une fuite de données en attente.
La centralisation de ces journaux et l'identifiant de corrélation d'un service à l'autre relèvent du niveau système, traités plus loin dans la série.
Scénario B : Ariane 501, une exception qui fait ce qui était prévu
Le 4 juin 1996, le vol inaugural d'Ariane 5 s'achève environ 39 secondes après le début de la séquence de vol. Le lanceur dévie brutalement, se disloque sous les charges aérodynamiques et son système d'autodestruction s'enclenche. La commission d'enquête indépendante présidée par Jacques-Louis Lions a publié son rapport le 19 juillet 1996. Il est public, court, et se lit comme un cours d'architecture des composants.
Le point de départ est une conversion. Dans le système de référence inertielle, une valeur flottante sur 64 bits liée à la vitesse horizontale du lanceur est convertie en entier signé sur 16 bits. La valeur dépasse ce qu'un entier 16 bits peut représenter et la conversion lève une erreur d'opérande. Sur les sept variables susceptibles de provoquer ce type d'erreur, quatre étaient protégées. Les trois autres ne l'étaient pas : le raisonnement retenu les jugeait soit physiquement bornées, soit dotées d'une marge de sécurité suffisante, et un objectif de charge processeur de 80 % au maximum pesait sur la décision. Le rapport n'a trouvé aucune trace de données de trajectoire utilisées pour vérifier ce raisonnement.
Ariane 5 vole nettement plus vite à l'horizontale qu'Ariane 4. Le module, lui, avait été repris tel quel.
Deuxième détail : ce code appartient à la fonction d'alignement de la plateforme inertielle, qui ne produit un résultat utile qu'avant le décollage. Sur Ariane 5, elle ne servait plus à rien une fois le lanceur parti. Elle continuait pourtant de tourner une quarantaine de secondes après le décollage, parce qu'une exigence opérationnelle héritée d'Ariane 4 le prévoyait, pour permettre un réalignement rapide en cas d'arrêt tardif du compte à rebours.
Troisième détail, le plus dur. L'exception n'était pas rattrapée, et le comportement prévu par défaut était l'arrêt du processeur. Le calculateur de secours s'arrête le premier ; environ cinq centièmes de seconde plus tard, le calculateur actif s'arrête à son tour, pour la même raison, parce qu'il exécute le même logiciel. Le rapport explique ce choix par une culture d'ingénierie orientée vers les pannes matérielles aléatoires : face à une carte défaillante, arrêter le calculateur et basculer sur le secours est une réponse parfaitement rationnelle.
Quatrième détail : le système arrêté a émis sur le bus un motif de diagnostic. Le calculateur de bord l'a interprété comme une donnée de vol et a commandé des braquages de tuyères en butée.
Le module d'Ariane 4 n'était pas défectueux. Il fonctionnait exactement dans le domaine pour lequel il avait été validé. Ce qui manquait, c'était la revalidation de ses hypothèses dans un contexte nouveau.
Quatre leçons pour un composant, et aucune ne demande de faire voler quoi que ce soit :
- Un composant réutilisé apporte avec lui des hypothèses implicites. Tant qu'elles ne sont pas écrites dans son contrat, personne ne peut vérifier qu'elles tiennent encore.
- Une exception non rattrapée exécute un comportement par défaut. Ce comportement est une décision de conception, même quand personne ne l'a prise consciemment.
- Redonder du matériel ne protège pas d'un défaut logiciel. Deux exemplaires du même code prennent la même décision au même instant.
- Un consommateur qui ne distingue pas une donnée valide d'un motif de diagnostic n'a pas de contrat d'interface, il a une convention orale.
Points clés
- Les trois couches (interface de service, logique métier, accès aux données) valent surtout par ce qu'elles interdisent : aucun raccourci, aucun saut, aucun vocabulaire qui traverse une frontière sans être traduit.
- Couche et tier désignent deux découpages distincts : l'un porte sur le code, l'autre sur le déploiement, et ils se décident pour des raisons différentes.
- L'injection par constructeur remplace la soudure du
newpar un contrat. Le gain immédiat est la testabilité ; le gain durable est la possibilité de remplacer une implémentation. - SOLID ne se récite pas : ce sont cinq questions à poser devant un fichier trop gros, toutes destinées à limiter le rayon d'un changement.
- Exceptions et journalisation appartiennent à la conception, au même titre que les couches. Le rapport sur Ariane 501 montre ce que coûtent une hypothèse non revalidée et une exception dont personne n'a choisi le comportement par défaut.
Dans la série
Palier 2 : Dresser les plans. Domaine : Applications.
- Précédent : SQL ou NoSQL : choisir son magasin de données
- Suivant : Design patterns 101 : Factory, Repository, Façade, Command
- Vue d'ensemble : Architecture logicielle : par où commencer
Pour aller plus loin
- Rapport de la commission d'enquête sur le vol 501 d'Ariane 5, le document public de juillet 1996
- SOLID, la présentation des cinq principes et de leur origine
- Injection de dépendances, le panorama des variantes et de leurs compromis
- Design patterns 101 : Factory, Repository, Façade, Command, la suite logique de ce billet
- Les *-ilities : traduire les exigences en capacités techniques, pour relier ces choix de code aux qualités de service attendues