Vous avez déjà ouvert un module pour corriger un bug tout bête, et traversé une Factory qui instancie une Strategy injectée dans un Manager qui délègue à un Service derrière une interface qui n'a qu'une seule implémentation ? Vous cherchez où se passe vraiment le traitement, vous descendez cinq couches, et au bout il y a trois lignes.
Ce code-là n'est pas né bête. Il est né intelligent. Quelqu'un a voulu bien faire : anticiper, découpler, rendre extensible. Sauf qu'au final il est plus dur à lire, plus dur à modifier et plus dur à tester que le problème qu'il résout.
Je précise tout de suite, parce que le sujet prête au contresens : ce n'est pas un plaidoyer pour le code sale. Les couches, les abstractions, les patterns sont d'excellents outils. Le souci, c'est de les sortir pour un besoin qui n'existe pas encore, et qui n'existera peut-être jamais.
Prenons un cas concret, tiré d'un logiciel hospitalier : on doit envoyer une prescription à la pharmacie. Pour l'instant, un seul canal, un appel HTTP vers le système de la pharmacie interne.
Voici ce qu'on écrit quand on veut « bien faire » :
public interface PharmacieGateway {
TypePharmacie type();
void envoyer(Prescription prescription);
}
public interface PharmacieGatewayFactory {
PharmacieGateway create(TypePharmacie type);
}
@Component
public class PharmacieGatewayFactoryImpl implements PharmacieGatewayFactory {
private final Map<TypePharmacie, PharmacieGateway> gateways;
public PharmacieGatewayFactoryImpl(List<PharmacieGateway> impls) {
this.gateways = impls.stream()
.collect(toMap(PharmacieGateway::type, identity()));
}
@Override
public PharmacieGateway create(TypePharmacie type) {
return gateways.get(type);
}
}
@Component
public class PharmacieHttpGateway implements PharmacieGateway {
// la seule implémentation qui existe réellement
private final HttpClient httpClient;
public PharmacieHttpGateway(HttpClient httpClient) {
this.httpClient = httpClient;
}
@Override
public TypePharmacie type() {
return TypePharmacie.INTERNE;
}
@Override
public void envoyer(Prescription prescription) {
httpClient.post("/pharmacie/prescriptions", prescription);
}
}
Une interface, une factory, une Map, un enum TypePharmacie, une résolution dynamique. Pour un seul canal. Demandez à l'auteur pourquoi, vous aurez toujours la même réponse :
« Au cas où on aurait un jour d'autres pharmacies : une pharmacie externe, un envoi par fax, une file de messages… Comme ça, ce sera déjà prêt. »
Ça sonne responsable. En pratique, ça coûte cher.
D'abord, on paie tout de suite pour un bénéfice hypothétique. Le code « au cas où » a un coût immédiat et certain : plus de fichiers à lire, plus d'indirections à suivre, un chemin mental plus long pour comprendre ce qui se passe. En face, le bénéfice est incertain et lointain : peut-être qu'un jour on aura une deuxième pharmacie. On échange une douleur réelle contre une assurance sur un sinistre qui n'arrivera peut-être jamais. Mises bout à bout, toutes ces petites assurances finissent par peser lourd sur une base de code.
Ensuite, et c'est le vrai piège, l'abstraction qu'on devine aujourd'hui pour demain est presque toujours la mauvaise. On a conçu PharmacieGateway en imaginant « une autre pharmacie HTTP ». Le jour où le besoin arrive vraiment, c'est un envoi asynchrone par message, rejouable, avec accusé de réception. Notre void envoyer(...) synchrone et sans retour ne colle pas. On refactore quand même, sauf qu'il faut maintenant défaire la factory, l'enum et la Map au passage. On a payé l'abstraction deux fois : pour l'écrire, puis pour la détricoter. Partir du code le plus simple aurait été plus rapide.
Enfin, il y a un coût plus sournois. Il existe deux sortes de complexité. Celle qui est essentielle : le métier est intrinsèquement compliqué, et les règles de dispensation d'un médicament le sont vraiment. Et celle qu'on ajoute nous-mêmes, avec nos couches et nos indirections. Le « au cas où » gonfle la seconde au point de noyer la première. Le lecteur dépense son énergie à comprendre la plomberie au lieu de comprendre la règle métier. Le code finit par raconter l'architecture qu'on a imaginée, plus le problème qu'on résout.
Si ce réflexe n'était qu'une erreur technique, il aurait disparu depuis longtemps. S'il persiste, c'est qu'il répond à quelque chose de plus profond que le code.
La peur de se faire reprendre. En revue, on ne se fait jamais taper sur les doigts pour avoir mis une interface de trop ; on se fait reprendre pour en avoir oublié une. L'over-engineering est l'erreur qui ne se voit pas, celle qui a l'air sérieuse et prévoyante. Le sous-engineering, lui, se paie tout de suite et en public. Entre les deux, le dev prudent surcharge : c'est le choix qui protège sa réputation, pas forcément celui qui sert le code. C'est aussi une façon de coder comme si on passait un entretien — montrer qu'on connaît ses design patterns, qu'on sait découpler, qu'on place le bon vocabulaire. Sauf qu'un entretien récompense la démonstration de savoir, un projet récompense la résolution de problème ; confondre les deux produit exactement ce code qui impressionne à la relecture et pèse à la maintenance.
La pression du scalable. On a tous en tête les conférences et les billets des géants du web qui racontent comment ils encaissent des millions de requêtes. Sauf que ces architectures répondent à des problèmes de géants. Transposées telles quelles dans une application qui verra, au grand maximum, quelques centaines d'utilisateurs, elles ne résolvent rien : elles décorent. On sort l'outillage de ceux qui ont un problème de charge, pour une charge qu'on n'aura jamais.
La transmission culturelle. Comme ce réflexe vient souvent des plus expérimentés, il se propage. Un junior qui arrive traverse le code existant pour apprendre « comment on fait ici ». Il tombe sur la factory, l'interface unique, le service qui ne fait que passer le plat, et il en conclut que coder proprement, c'est ça. Il reproduira le pattern au projet suivant, avec la meilleure volonté du monde. Le « au cas où » n'est pas seulement une dette qu'on porte, c'est une culture qu'on enseigne. Et la première facture, c'est le temps : le nouvel arrivant qui devrait saisir la règle métier en une heure en passe trois à cartographier des couches qui, au bout du compte, ne font rien.
C'est l'argument massue, et le plus glissant. Programmer contre une interface, découpler, appliquer un pattern, ce sont de vraies bonnes pratiques. Le problème n'est pas la pratique, c'est de la sortir de son contexte. Une bonne pratique répond à un problème précis ; sortie de ce problème, elle devient un rituel qu'on applique parce que « c'est bien », pas parce que ça résout quelque chose.
Cinq rituels que je croise sans arrêt côté back.
L'interface à une seule implémentation. « On programme contre une interface, pas une implémentation. » Le principe est juste, pour les frontières qu'on a de bonnes raisons d'isoler : un port qu'on va mocker en test, une dépendance externe qu'on veut pouvoir remplacer. Mais un ClientServiceImpl derrière un ClientService qui ne sera jamais ni mocké ni remplacé, ce n'est pas du découplage. C'est un fichier de plus et un « aller à l'implémentation » à chaque lecture, pour une flexibilité qui n'existe pas. L'IDE extrait une interface en trois secondes le jour où on en a vraiment besoin, et ce jour-là on saura à quoi elle doit ressembler.
Le pattern pour le pattern. Un Strategy pour une seule stratégie. Un Builder pour un objet à deux champs qu'un constructeur remplit très bien. Un Observer là où un appel de méthode direct suffisait. Une classe abstraite préparée pour des sous-types qui n'existent pas. Les design patterns sont des réponses à des problèmes récurrents, pas un vocabulaire à placer pour montrer qu'on le connaît. Les appliquer sans avoir le problème qu'ils résolvent, ça n'ajoute que du poids.
La configuration de tout. Externaliser en config une valeur qui n'a jamais changé et ne changera pas : le nombre d'éléments par page, un libellé, un seuil « au cas où le métier voudrait l'ajuster ». La valeur qui pilote le comportement n'est plus dans le code, elle est éclatée entre un application.yml, un @Value et une doc de déploiement. On a compliqué la lecture de ce qui aurait dû rester une constante, pour une souplesse que personne n'a demandée.
La couche qui ne fait que passer le plat. Le Service qui appelle le Repository sans rien ajouter, juste return repository.findById(id). On l'a créé « parce qu'on met toujours un service entre le contrôleur et le repository ». Une couche qui ne fait que transmettre n'apporte rien. Elle aura le droit d'exister le jour où elle portera de la logique, pas avant.
L'optimisation à fond pour trois données. Un cache à plusieurs niveaux, de la pagination, du chargement paresseux, des requêtes taillées au cordeau, un pool de threads dédié… pour une liste qui, dans la vraie vie, plafonnera à mille lignes. On sort l'artillerie de Big Data sur un jeu de données qu'une simple boucle traiterait en quelques millisecondes. Le code devient nettement plus dur à lire et à débugger, alors que le gain de performance est invisible à cette échelle, et souvent négatif une fois le coût du cache et de sa cohérence pris en compte. L'optimisation se justifie quand une mesure montre un problème réel, sur les volumes réels. Optimiser au jugé, « pour que ça tienne la charge » d'une charge qui n'existe pas, c'est encore une façon de payer aujourd'hui pour un cas qui n'arrivera jamais.
Le point commun de ces cinq cas : le principe est bon, mais on l'applique là où le problème qu'il résout est absent. Une bonne pratique sans son problème, c'est de la complexité déguisée en vertu.
Non, et c'est là qu'il faut être honnête, parce que la conclusion facile, « écris le code le plus bête possible », est aussi fausse que l'excès inverse.
Un PrescriptionController de 400 lignes qui parle SQL en direct, mélange règle métier et sérialisation JSON et empile trois cas dans le même if, il n'a anticipé aucun cas futur. Mais il est tout aussi impossible à faire évoluer, pour la raison opposée : rien n'y est séparé.
Les deux extrêmes échouent au même endroit : le code résiste au changement. L'un parce qu'il croule sous des abstractions inutiles, l'autre parce qu'il n'en a aucune. Le bon niveau est entre les deux.
Un bon dev back n'écrit pas du code qui devine l'avenir. Il écrit du code qui résout le problème d'aujourd'hui, mais assez propre pour qu'on puisse le faire évoluer sans le casser quand l'avenir arrive vraiment.
Reprenons notre pharmacie. La version sans devinette n'a ni interface, ni factory, ni enum :
@Component
public class PharmacieHttpClient {
private final HttpClient httpClient;
public PharmacieHttpClient(HttpClient httpClient) {
this.httpClient = httpClient;
}
public void envoyer(Prescription prescription) {
httpClient.post("/pharmacie/prescriptions", prescription);
}
}
Trois lignes de métier, une responsabilité, un nom qui dit ce que ça fait. Et ce code est déjà ouvert au changement. Le jour où une deuxième pharmacie arrive pour de vrai, avec ses contraintes réelles et non devinées, extraire une interface PharmacieGateway et introduire la bonne abstraction est un refactoring de dix minutes, guidé par un besoin concret. On concevra la bonne interface, parce qu'on aura enfin les deux cas sous les yeux.
Ce qui rend le code ouvert au changement, ce n'est donc pas l'échafaudage anticipé. Ce sont des qualités plus modestes :
PharmacieHttpClient dit ce qu'il est. Pas AbstractGatewayResolverManager.La meilleure préparation au futur n'est pas d'ajouter du code, c'est de garder le code simple et refactorable. Le code simple se plie ; le code sur-anticipé casse.
Il faut être honnête avec l'argument de cet article, parce qu'il a une limite. Tout le raisonnement tient sur une prémisse : le jour où le vrai besoin arrive, refactorer coûtera dix minutes. C'est vrai pour l'essentiel du code : le code interne, qu'on contrôle de bout en bout, dont on peut changer tous les appelants d'un seul coup. Là, attendre est toujours le bon pari.
Mais toutes les décisions ne se rejouent pas aussi facilement. Certaines sont des portes à sens unique.
Sur ces frontières-là, un peu d'anticipation n'est pas du « au cas où », c'est de la prudence justifiée : le coût d'y revenir plus tard est réel et élevé, pas hypothétique. La question à se poser est simple : cette décision est-elle réversible ? Si je me trompe, est-ce que je corrige en dix minutes, ou est-ce que je traîne mon erreur pendant des mois ? Pour une porte à double sens, la quasi-totalité du code, on tranche vite et on ajuste plus tard. Pour une porte à sens unique, on a le droit de réfléchir à deux fois avant de la franchir.
Ce qui ne contredit pas le principe, ça le précise : le « au cas où » reste une faute partout où le retour arrière est gratuit, c'est-à-dire presque partout. L'anticipation redevient légitime là, et seulement là, où se tromper coûte cher à défaire.
Avant même de se poser la moindre question, il y a des signaux qui devraient allumer un voyant : les nôtres, quand on code, ou ceux qu'on repère en revue. Le plus fiable, c'est le vocabulaire. Quand la justification d'un bout de code commence par l'une de ces phrases, il y a de bonnes chances qu'on soit en train de payer pour un cas qui n'existe pas :
Et quelques signaux dans le code lui-même : une interface créée dans le même commit que son unique implémentation ; un enum ou un switch à une seule branche ; une classe abstraite sans deuxième sous-type ; un nom en AbstractQuelqueChoseManager ou XxxResolverFactory ; une couche qu'on traverse sans y croiser la moindre ligne de métier. Aucun de ces signaux n'est une faute en soi, mais chacun mérite qu'on s'arrête.
Et qu'on se pose alors, avant d'ajouter une couche, une interface, un pattern ou une option de configuration, trois questions dans l'ordre.
Quel problème concret d'aujourd'hui est-ce que ça résout ? Si la réponse commence par « au cas où », ce n'est pas un problème d'aujourd'hui. On s'arrête là.
Combien coûte l'ajout maintenant, en lecture et en maintenance ? Ce coût-là, lui, est certain.
Et combien coûterait la même abstraction plus tard, le jour où le besoin sera réel ? Si c'est « un refactoring tranquille », alors on attend ce jour, et on concevra mieux avec le vrai cas sous les yeux.
C'est le principe YAGNI, You Aren't Gonna Need It. Il ne dit pas « ne pense jamais au futur », il dit : ne paie pas aujourd'hui pour un besoin que tu n'as pas encore. Et il n'excuse pas le code cochonné : il pousse au code le plus simple qui reste propre, celui qui accueillera l'abstraction sans douleur le jour venu.
Une dernière boussole, complémentaire de YAGNI : la règle de trois. N'abstrais pas au premier cas, ni même vraiment au deuxième : attends le troisième. Un cas, c'est un cas particulier. Deux, ça peut être une coïncidence. Au troisième, on tient enfin le motif qui se répète pour de vrai, et on sait quelle abstraction il appelle, parce qu'on a trois exemples concrets sous les yeux au lieu d'un seul imaginé. Dupliquer deux fois coûte presque toujours moins cher que d'inventer la mauvaise abstraction trop tôt et de devoir la défaire ensuite.
La complexité de trop ne vient presque jamais d'un manque de compétence. Elle vient d'un excès de bonne volonté : on anticipe, on généralise, on applique les patterns qu'on admire. L'intention est bonne, le résultat pèse.
Écrire du bon code back, ce n'est ni deviner l'avenir en empilant des couches « au cas où » (sauf sur les rares frontières qu'on ne pourra pas rejouer), ni bâcler le présent en entassant tout dans une méthode. C'est résoudre le problème d'aujourd'hui avec le code le plus simple qui reste propre, cohérent, testé, honnête dans ses noms, pour que le problème de demain, quand il arrivera vraiment, trouve une base prête à évoluer.
Le meilleur code n'est pas celui qui impressionne par sa machinerie, c'est celui qui est si clair qu'on se demande où est la difficulté. L'ingénierie backend n'est pas une course à la complexité : c'est l'art d'en mettre juste ce qu'il faut.