Sept habitudes qui facilitent la modification du code par le développeur suivant
Clauses de protection, noms révélant l’intention, limites API, types précis, fonctions de décision pures, erreurs structurées et demandes de fusion ciblées, avec des exemples en JavaScript.
Sources: 7 Coding Habits That Separate Senior Developers From the Rest
Apprendre un autre framework finit par ne plus vous rendre significativement meilleur ingénieur. Ce qui reste réellement utile, c’est la manière dont vous écrivez et modifiez du code : les développeurs expérimentés produisent souvent moins de code, avec une intention plus claire, ce qui le rend plus facile à lire, à tester et à modifier lorsque les exigences changent. Cet article présente sept habitudes concrètes, chacune accompagnée d’un exemple en JavaScript ou TypeScript avant et après, et explique le coût spécifique que chacune réduit.
Aucune de ces habitudes ne nécessite un nouvel outil. Ce sont des décisions que vous prenez en tapant une fonction, en nommant une variable, en modélisant des données ou en soumettant une demande de fusion.
1. Gardez le parcours idéal visible grâce à des clauses de protection
Supposons qu’une fonction doive rechercher un utilisateur, vérifier que le compte est actif, s’assurer que l’utilisateur a les permissions nécessaires, et ne pas exécuter d’action avant cela. Une première version naturelle consiste à imbriquer une condition dans une autre :
function performAction(userId) {
const user = findUser(userId);
if (user) {
if (user.isActive) {
if (user.hasPermission) {
return executeAction(user);
}
}
}
return null;
}
Cela fonctionne, mais l’opération qui compte réellement se trouve à trois niveaux de profondeur, et la commande de secours return null en bas est visuellement dissociée des conditions qui la déclenchent.
L’alternative consiste à rejeter immédiatement les cas invalides au moyen de clauses de protection, afin que chaque prédicat échoué apparaisse sur sa propre ligne. (L’indentation dans cet extrait est irrégulière ; les quatre instructions suivant la recherche devraient se trouver au même niveau.)
function performAction(userId) {
const user = findUser(userId);
if (!user) return null;
if (!user.isActive) return null;
if (!user.hasPermission) return null;
return executeAction(user);
}
Désormais, le flux principal s’écoule de manière linéaire : vérifier, vérifier, vérifier, agir. Cela ne signifie pas pour autant que la logique imbriquée est toujours erronée. Lorsque les branches représentent des résultats véritablement différents plutôt que des prédicats, une structure if/else peut être la forme la plus claire. L’objectif est d’éviter que la logique métier essentielle ne soit enfouie sous des conditions qui n’ont en réalité qu’une fonction de filtrage.
L’habitude : traiter d’abord les cas exceptionnels et laisser le flux normal tel quel. Quelqu’un qui exécute la fonction doit pouvoir voir ce qui se passe normalement sans devoir suivre cinq niveaux de crochets. Un avantage supplémentaire est que chaque vérification peut ensuite retourner un résultat ou une erreur plus spécifique, ce qui est difficile à faire proprement dans la version imbriquée.
2. Donner aux éléments des noms qui reflètent leur fonction
Poques modifications améliorent autant la lisibilité que des noms plus appropriés. Regardez ce couple de lignes :
const data = getOrder();
process(data);
Qu’est-ce que data, et que fait process avec ? Il faut lire d’autres parties du code pour le découvrir. Comparez :
const pendingOrder = getPendingOrder();
processOrder(pendingOrder);
Ces mêmes deux lignes vous indiquent maintenant quel type d’ordre est impliqué et ce qui lui arrive. De bons noms réduisent l’effort mental nécessaire pour comprendre un ensemble de codes, car le lecteur n’a plus besoin de reconstituer le sens à partir du contexte.
Les noms de fonctions méritent la même attention. Un verbe générique comme celui-ci cache le comportement :
handle(order);
alors que cette version l’indique clairement :
calculateOrderTotal(order);
De même, un send sans précision laisse indéterminé ce qui est envoyé et à qui :
send(order);
un nom descriptif répond quant à lui à ces deux questions :
sendOrderConfirmation(order);
Les noms longs ne sont pas l’objectif ; ce qui compte, c’est des noms qui expriment l’intention. Un effet secondaire utile est que les noms vagues exposent souvent des fonctions vagues. Si handle est difficile à renommer, c’est peut-être parce qu’il effectue plusieurs tâches non liées et mérite d’être divisé.
L’habitude : avant de choisir un nom, demandez-vous si quelqu’un qui ne lit que cette ligne, sans regarder ailleurs, comprendrait ce que représente la valeur ou la fonction. Si ce n’est pas le cas, continuez à chercher un meilleur nom.
3. Mettre les systèmes externes derrière une barrière
Les applications communiquent avec de nombreux systèmes externes : des fournisseurs de paiement, des services de messagerie, du stockage cloud, des fournisseurs d’identité, des API de tiers ainsi que des microservices internes. Une erreur fréquente consiste à laisser les formats de réponse de ces systèmes être intégrés directement dans le code métier :
const customer = await stripeCustomer.get();
console.log(customer.email_address);
console.log(customer.customer_name);
Chaque ligne qui lit email_address ou customer_name dépend désormais des noms exacts des champs indiqués par le fournisseur. Si ce dernier renomme un champ ou modifie sa structure de réponse, la modification se répercute dans l’ensemble du code.
La solution consiste à utiliser une fonction de liaison qui appelle le service externe et traduit sa réponse en un modèle propre à votre application :
async function getCustomer(customerId) {
const response = await externalService.getCustomer(customerId);
return {
email: response.email_address,
name: response.customer_name
};
}
Le reste du code utilise cette structure interne et n’a jamais accès aux noms utilisés par le fournisseur :
const customer = await getCustomer(customerId);
console.log(customer.email);
console.log(customer.name);
Lorsque l’API externe change, seul le mappage à l’intérieur de getCustomer doit être mis à jour. Cette même frontière constitue également l’endroit idéal pour valider la réponse, normaliser les valeurs manquantes et remplacer celles-ci par des valeurs fictives lors des tests. C’est l’idée qui sous-tend les adaptateurs et les couches de protection dans des architectures plus complexes, appliquée à l’échelle d’une seule fonction.
L’habitude : conserver la complexité externe en périphérie. La logique métier ne doit pas avoir besoin de connaître les particularités de chaque service tiers, et cette séparation devient d’autant plus précieuse que le nombre d’intégrations augmente.
4. Rendre les états invalides difficiles à représenter
Une façon tentante de définir un modèle consiste à rendre chaque champ optionnel, car c’est rapide et cela ne provoque jamais d’erreur de compilation :
type Order = {
id?: string;
paymentId?: string;
status?: string;
};
Le coût apparaît partout où le modèle est utilisé. Chaque consommateur doit effectuer des vérifications de sécurité, car rien ne garantit la présence d’un champ donné :
if (order.id) {
// ...
}
if (order.paymentId) {
// ...
}
if (order.status === "paid") {
// ...
}
Le problème plus profond n’est pas le manque de concision. Le type ne précise en rien quelles combinaisons de champs sont valides. Une commande avec un ID de paiement mais sans identifiant, ou un statut "paid" sans paiement effectif, sont tous deux considérés comme légitimes du point de vue du compilateur.
Un modèle plus précis décrit un état spécifique :
type PaidOrder = {
id: string;
paymentId: string;
status: "paid";
};
Une valeur de type PaidOrder garantit trois choses :
- elle possède un identifiant
- elle possède un ID de paiement
- son statut est exactement
"paid"
Ce type sert désormais à la fois de documentation et de mécanisme de sécurité en temps de compilation. En pratique, on définit un type par état, comme une commande en attente sans identifiant de paiement et une commande payée avec un tel identifiant, puis on les combine en une union discriminée basée sur status. TypeScript peut alors restreindre automatiquement le type après une vérification telle que order.status === "paid", et vous avertir lorsque un nouvel état n’est pas pris en charge. Pour voir un exemple concret de ce pattern, consultez la substitution des indicateurs de chargement par une union discriminée.
L’habitude : ne vous contentez pas des champs optionnels simplement parce qu’ils facilitent la rédaction de la première version. Demandez quels états sont réellement possibles, puis adaptez vos types en fonction d’eux. Cela ne supprimera pas tous les bugs, mais il deviendra beaucoup plus difficile de créer des données incorrectes de ce type.
5. Séparer les décisions des actions
Les règles métier deviennent difficiles à tester lorsqu’elles sont mêlées à des effets secondaires. Prenons par exemple une fonctionnalité réservée uniquement aux utilisateurs adultes vérifiés :
async function processUser(userId) {
const user = await database.getUser(userId);
if (user.isVerified && user.age >= 18) {
await database.enableFeature(userId);
await emailService.send(user.email);
}
}
La règle elle-même, qui exige une vérification et un âge d’au moins 18 ans, est combinée à une lecture de base de données, une écriture dans la base de données et l’envoi d’un e-mail. Tester cette règle signifie simuler les trois opérations.
Extraitez la décision dans une fonction pure qui prend des données en entrée et retourne un booléen :
function canUseFeature(user) {
return user.isVerified && user.age >= 18;
}
La fonction d’orchestration récupère ensuite l’utilisateur, interroge la règle et exécute les effets secondaires uniquement lorsqu’elle est validée :
async function processUser(userId) {
const user = await database.getUser(userId);
if (!canUseFeature(user)) {
return;
}
await database.enableFeature(userId);
await emailService.send(user.email);
}
La règle peut désormais être testée directement, à l’aide d’un objet simple :
expect(
canUseFeature({
isVerified: true,
age: 25
})
).toBe(true);
Aucune base de données, aucun service d’e-mail, aucune simulation : seulement la décision et ses entrées. Cela rend très simple la prise en compte de cas limites tels qu’un utilisateur non vérifié âgé de 30 ans ou un utilisateur vérifié ayant exactement 18 ans.
L’habitude : séparer « devrions-nous le faire ? » de « comment le faisons-nous ? ». Parmi les exemples appropriés pour cette séparation, on trouve :
- les vérifications d’accès et de permissions
- les calculs de prix
- la validation des entrées
- la décision de notifier quelqu’un
- les politiques de tentative répétée
- les règles relatives à l’éligibilité et aux fonctionnalités activables
Plus une règle est importante pour l’entreprise, plus elle bénéficie du fait d’être intégrée dans une fonction petite, simple et bien testée.
6. Rendre les erreurs utiles pour les utilisateurs et le logiciel
Une erreur comme celle-ci ne fournit presque rien pour travailler :
throw new Error("Something went wrong");
Elle ne précise pas ce qui a échoué, où, pourquoi, ni si l’appelant peut faire quelque chose à ce sujet.
Une meilleure erreur comprend un code stable ainsi qu’un message lisible par les humains. L’exemple suppose une classe AppError qui étend Error et stocke ces deux valeurs ; il ne s’agit pas d’une classe intégrée.
throw new AppError(
"PAYMENT_FAILED",
"The payment could not be completed."
);
Quand elle est serialisée pour une réponse API, elle offre aux clients quelque chose de prévisible :
{
"code": "PAYMENT_FAILED",
"message": "The payment could not be completed."
}
Distinction à retenir : les messages sont écrits pour les humains, tandis que les codes le sont pour les systèmes. Les messages peuvent être reformulés ou traduits, mais les valeurs de code doivent rester stables afin que les clients puissent s’y référer en toute sécurité. Un frontend, par exemple, peut réagir à une erreur spécifique :
if (error.code === "PAYMENT_FAILED") {
showPaymentError();
}
Les journaux de logs méritent le même traitement. Un message simple est difficile à interpréter à 3 heures du matin :
console.log("Payment failed");
Un contexte structuré permet de rechercher et de diagnostiquer plus facilement les entrées :
logger.error("Payment failed", {
orderId,
provider,
attempt
});
Soyez méticuleux quant aux éléments inclus dans ce contexte. Ne journalisez jamais :
- les mots de passe
- les tokens d’accès
- les clés API
- d’autres secrets
- des données personnelles sensibles
Si vous avez besoin d’une structure standard pour les corps d’erreur HTTP, il est utile de consulter les détails du problème RFC 9457.
L’habitude : donner à chaque échec une forme que l’humain puisse lire et un programme puisse exploiter. Le fait de lancer est la partie facile ; le véritable travail consiste à rendre les échecs prévisibles et faciles à diagnostiquer.
7. Garder chaque modification ciblée
La dernière habitude concerne la modification du code plutôt que son écriture. Imaginez une seule demande de fusion qui contiendrait :
- une nouvelle fonctionnalité de vérification
- une migration de base de données
- un refacteurage du service de paiement
- des mises à jour frontend
- des modifications à la logique de tentative
- plusieurs corrections de bugs non liés
Même si tout fonctionne, il est très difficile d’en effectuer une bonne revue. Les reviewers lisent rapidement, les risques se cachent dans le bruit, et si quelque chose tombe en panne en production, revenir en arrière sur une partie signifie revenir en arrière sur tout.
En divisant les modifications en éléments ciblés, le même travail apparaît ainsi :
PR #1 → Add checkout validation
PR #2 → Refactor payment service
PR #3 → Update checkout UI
PR #4 → Improve payment retry logic
Chacun d’eux est plus facile à :
- revoir
Les modifications ciblées facilitent également la collaboration : lorsqu’une régression apparaît, la zone à examiner est restreinte, et des outils tels que git bisect permettent d’identifier une modification qui a un sens en soi.
L’habitude : avant d’intégrer une autre modification à la tâche en cours, demandez-vous si elle concerne le même problème. Si ce n’est pas le cas, créez pour elle un commit ou une demande de fusion distincte. Une modification ciblée est plus fiable qu’une vaste modification qui se trouve par hasard à passer les tests.
Le fil conducteur : faciliter la prochaine modification
Ces habitudes semblent initialement sans rapport, mais elles partagent un seul objectif. Une bonne ingénierie ne consiste pas seulement à faire fonctionner le code d’aujourd’hui ; elle vise aussi à rendre les modifications de demain moins complexes. Chaque habitude y contribue en son propre domaine :
- Un parcours heureux visible facilite la compréhension de la logique
- Des noms significatifs rendent l’intention évidente
- Des limites autour des systèmes externes contiennent les effets de leurs modifications
- Des types précis éliminent des catégories entières de bugs
- Séparer les décisions des actions rend les règles métier testables
- Des erreurs structurées permettent de diagnostiquer les pannes
- Des modifications ciblées maintiennent la base de code facile à faire évoluer
Les frameworks et les bibliothèques apparaissent puis disparaissent, mais ces principes s’appliquent de la même manière en JavaScript, TypeScript, Python, Java, Go ou tout autre langage.
Points clés
- Lorsque vous terminez une fonction, un modèle, une intégration ou une demande de fusion, demandez-vous non seulement si elle fonctionne, mais aussi si le développeur suivant peut la modifier en toute sécurité.
- Les clauses de protection, des noms précis et des types précis réduisent la quantité d’informations que le lecteur doit mémoriser.