Architecture & Performance

Mes erreurs de débutant : j'ai appris que coder est souvent la dernière étape

Au début, faire fonctionner mon code était mon principal critère de réussite. Avec l'expérience, j'ai compris qu'une grande partie du travail se joue avant l'implémentation : comprendre le problème, les données, les risques et les compromis.

Au début de mon parcours de développeur, j'avais une définition assez simple d'un programme réussi :

Le code fonctionne, donc le travail est terminé.

Les tests passent.

Le résultat attendu est là.

Pas d'erreur visible.

Pourquoi aller plus loin ?

Puis un développeur senior avec qui je travaillais m'a demandé de refaire une implémentation qui fonctionnait pourtant correctement.

Sur le moment, je l'ai mal pris.

Je ne le montrais pas forcément, mais intérieurement la question était évidente :

Pourquoi refaire quelque chose qui fonctionne ?

Avec le recul, cette remarque a progressivement changé ma manière de développer.

J'ai compris qu'un programme qui fonctionne et un programme bien conçu ne sont pas exactement la même chose.

Mais surtout, j'ai fini par comprendre quelque chose de plus important : lorsque le problème est suffisamment complexe, écrire le code ne devrait pas nécessairement être mon premier réflexe.

Une grande partie du travail peut avoir lieu avant.

Comprendre le besoin.

Visualiser les données.

Imaginer le workflow.

Identifier les risques.

Comparer plusieurs solutions.

Accepter certains compromis.

Puis seulement transformer ce raisonnement en code.

Je ne considère donc pas réellement le code comme la dernière étape au sens littéral. L'implémentation révèle encore des problèmes et oblige parfois à revoir nos décisions.

Mais j'essaie aujourd'hui de ne plus utiliser le code comme seul moyen de découvrir ce que je suis en train de construire.

Quand "ça marche" était mon principal critère

Lorsque l'on commence à programmer, réussir à faire fonctionner quelque chose procure énormément de satisfaction.

On part d'un écran vide.

On écrit du code.

On rencontre des erreurs.

On les corrige.

Puis enfin :

Tests passed

Ou simplement :

Ça fonctionne.

À ce moment-là, il est naturel d'avoir l'impression d'avoir terminé.

C'était souvent ma manière de travailler.

J'avais un problème.

Je commençais à coder.

Puis je découvrais progressivement les difficultés.

Mon processus ressemblait à peu près à ceci :

Besoin
  ↓
Coder
  ↓
Découvrir un problème
  ↓
Modifier
  ↓
Découvrir un autre problème
  ↓
Refactorer

Cette manière de travailler n'est pas inutile.

Elle m'a même énormément appris.

Le code donne un retour concret. Certaines limites d'une idée n'apparaissent réellement qu'au moment de l'implémenter.

Le problème est plutôt de dépendre exclusivement de ce mécanisme.

Si une décision importante n'apparaît qu'après plusieurs jours de développement, son coût de modification peut déjà être élevé.

Le jour où mes tests passaient, mais où j'ai dû recommencer

Je ne me souviens plus précisément du code concerné.

Je me souviens en revanche très bien de la situation.

Le développeur senior avait défini les tests.

Mon implémentation les faisait passer.

D'un point de vue fonctionnel, la demande était donc satisfaite.

Pour moi, le raisonnement ressemblait à ceci :

Entrée
  ↓
Mon code
  ↓
Résultat correct

Le contrat était respecté.

Pourtant, il m'a demandé de refaire la solution.

Mon code faisait trop de détours.

Il existait un chemin plus simple pour obtenir le même résultat.

Son raisonnement ne s'arrêtait pas au résultat final.

Il regardait également ce qu'il se passait entre l'entrée et la sortie :

Entrée
  ↓
Quelle responsabilité traite ce problème ?
  ↓
Existe-t-il un chemin plus simple ?
  ↓
Le fonctionnement est-il compréhensible ?
  ↓
Cette solution restera-t-elle facile à modifier ?
  ↓
Résultat correct

C'est une différence importante.

Deux implémentations peuvent satisfaire exactement les mêmes tests tout en présentant des niveaux de complexité très différents.

À ce moment-là, je pensais principalement au comportement.

Lui regardait aussi la structure permettant de produire ce comportement.

Fonctionner est nécessaire, mais ce n'est que le premier filtre

Un programme doit évidemment fonctionner.

Un code parfaitement structuré qui ne répond pas au besoin reste un mauvais programme.

Mais lorsque ce premier critère est satisfait, d'autres questions apparaissent.

Le code est-il compréhensible ?

Les responsabilités sont-elles suffisamment claires ?

Une modification simple restera-t-elle simple ?

Ai-je introduit une complexité qui n'était pas nécessaire ?

Le framework ou le langage possède-t-il déjà un mécanisme correspondant à mon problème ?

Une abstraction rendrait-elle réellement le comportement plus évident ?

Et lorsque les contraintes du projet le nécessitent :

Les performances, la sécurité ou la robustesse sont-elles suffisantes ?

Ces critères paraissent secondaires lorsque l'on cherche encore à faire fonctionner sa première implémentation.

Pourtant, ils peuvent déterminer le coût du logiciel pendant plusieurs années.

C'est progressivement cette différence entre faire fonctionner et concevoir pour pouvoir continuer à faire évoluer que j'ai dû apprendre.

J'ai d'abord appris à coder. Puis j'ai dû apprendre à concevoir.

Sur un autre projet, mon objectif était justement de progresser techniquement.

Je voulais coder.

Donc j'ai beaucoup codé.

Lorsqu'une première décision posait problème, je la corrigeais.

Puis j'ajoutais une fonctionnalité.

Je découvrais qu'un choix précédent compliquait cette fonctionnalité.

Je refactorais.

Une autre évolution arrivait.

Et je recommençais.

Cette méthode m'a énormément appris.

Je ne considère pas aujourd'hui ce temps comme perdu.

Certaines conséquences restent abstraites tant qu'on ne les a pas vécues.

Il est facile de lire :

"Séparez les responsabilités."

Il est beaucoup plus marquant de devoir modifier une fonction de plusieurs centaines de lignes dont cinq comportements différents dépendent.

Il est facile de lire :

"Réfléchissez à votre modèle de données."

Il est beaucoup plus concret de découvrir qu'une décision de modélisation rend soudainement une évolution métier très difficile.

Petit à petit, j'ai compris que je pouvais déplacer une partie de ces erreurs.

Je ne pouvais pas les supprimer.

Mais je pouvais parfois les découvrir avant d'avoir construit tout ce qui en dépendait.

Aujourd'hui, j'essaie d'abord de faire fonctionner la fonctionnalité dans ma tête

Face à une fonctionnalité suffisamment importante, mon premier réflexe n'est plus systématiquement d'ouvrir immédiatement mon éditeur.

J'essaie d'abord de visualiser le système.

Pas jusqu'au moindre détail.

Juste suffisamment pour comprendre les décisions qui vont être difficiles ou coûteuses à modifier ensuite.

À quoi ressemblent les données ?

Je commence souvent par la base de données.

Quelles sont les entités ?

Quelles relations existent entre elles ?

Qu'est-ce qui est réellement obligatoire ?

Qu'est-ce qui doit être unique ?

Quelles informations évoluent ensemble ?

Quelles données doivent garder un historique ?

Une mauvaise décision de modélisation peut rapidement se propager.

Elle influence les requêtes.

Puis les vues.

Puis l'API.

Puis les validations.

Puis les tests.

Puis éventuellement le frontend.

Réfléchir au modèle ne garantit pas qu'il sera parfait.

Cela permet simplement de détecter certaines incohérences lorsqu'elles ne coûtent encore presque rien à corriger.

Quel est le workflow réel ?

J'essaie ensuite d'imaginer ce que l'utilisateur va réellement faire.

L'utilisateur arrive
        ↓
Il effectue une action
        ↓
Que doit vérifier le système ?
        ↓
Quelles données sont lues ?
        ↓
Quelles données changent ?
        ↓
Que se passe-t-il si une étape échoue ?
        ↓
Quel résultat l'utilisateur doit-il obtenir ?

Ce cheminement paraît simple.

Pourtant, il fait rapidement apparaître des questions que la description initiale de la fonctionnalité ne mentionnait pas forcément.

Que se passe-t-il si l'utilisateur revient en arrière ?

Une opération doit-elle être atomique ?

Peut-elle être répétée ?

Qui possède le droit de la déclencher ?

Quel état doit rester si la troisième étape échoue après que les deux premières ont réussi ?

Avant même d'avoir choisi les noms de mes classes, je commence à mieux comprendre ce que le système doit réellement garantir.

Quels problèmes sont suffisamment probables pour mériter mon attention ?

Je n'essaie pas de prédire tous les bugs.

Ce serait impossible.

J'essaie plutôt d'identifier les risques plausibles.

Que se passe-t-il si deux utilisateurs déclenchent l'opération simultanément ?

Si cette donnée devient facultative ?

Si une suppression doit être synchronisée ?

Si le volume augmente fortement ?

Si l'utilisateur ne possède pas la permission attendue ?

Si une étape échoue après avoir déjà modifié la base de données ?

Toutes ces questions ne nécessitent pas immédiatement une solution sophistiquée.

Leur intérêt est surtout de montrer où une décision naïve pourrait coûter cher plus tard.

Réfléchir davantage peut permettre de développer plus vite

Cela paraît paradoxal.

Une heure passée à dessiner un workflow est une heure pendant laquelle aucune fonctionnalité visible n'a été codée.

Pourtant, ce temps peut parfois accélérer le développement global.

Une décision modifiée dans quelques notes ou sur un schéma coûte très peu.

La même décision modifiée après :

  • plusieurs migrations ;
  • plusieurs vues ;
  • une API ;
  • des tests ;
  • des données déjà enregistrées ;
  • un frontend qui dépend du contrat ;

peut devenir beaucoup plus coûteuse.

Il n'existe évidemment aucune formule du type :

2 heures de réflexion
=
3 jours de développement économisés

Certaines discussions ne débouchent sur rien.

Certaines décisions prises en amont seront quand même incorrectes.

Mais réfléchir avant de construire permet au moins de déplacer une partie des erreurs vers une phase où elles coûtent moins cher.

Réfléchir avant de coder ne signifie pas chercher la solution parfaite

Ce raisonnement possède un piège évident.

Si commencer trop vite à coder peut être coûteux, passer trois semaines à imaginer toutes les évolutions possibles avant d'écrire une ligne peut l'être tout autant.

Je ne cherche donc pas :

la meilleure architecture possible.

Je cherche plutôt :

une architecture adaptée au problème actuel et raisonnablement capable d'absorber les évolutions probables.

Le mot important est probables.

Pas possibles.

Presque tout est possible dans le futur.

Une application interne de dix utilisateurs pourrait théoriquement devenir une plateforme internationale utilisée par dix millions de personnes.

Cela ne signifie pas qu'elle doit être conçue aujourd'hui comme si cette évolution était déjà prévue.

Préparer tous les futurs imaginables crée lui aussi de la complexité.

Anticiper une évolution ne signifie pas la développer

Un client peut mentionner une fonctionnalité qu'il aimerait peut-être avoir plus tard.

Cette information peut être utile.

Elle peut influencer une décision prise aujourd'hui.

Mais elle ne justifie pas nécessairement la construction immédiate de toute l'architecture correspondante.

Par exemple, je peux savoir qu'une donnée risque de devenir plus complexe.

Je peux donc éviter une décision qui rendrait cette évolution presque impossible.

Cela ne signifie pas pour autant qu'il faille déjà développer :

  • les nouvelles tables ;
  • toute la logique ;
  • l'interface ;
  • l'API ;
  • les permissions ;
  • les abstractions prévues pour ce besoin futur.

Il existe une différence importante entre :

éviter de se fermer une porte

et :

construire maintenant tout ce qui pourrait se trouver derrière cette porte.

Cette nuance est devenue importante dans ma manière de réfléchir à l'architecture.

Le contexte du projet change la bonne réponse

Une autre erreur que j'ai progressivement corrigée consiste à chercher une solution technique universelle.

Il y en a beaucoup moins qu'on ne le pense.

Prenons deux applications.

Application A

Un outil métier utilisé quotidiennement par quelques dizaines d'utilisateurs connus.

Application B

Une plateforme publique accessible à beaucoup d'utilisateurs inconnus.

Les deux peuvent être développées avec Python et Django.

Elles peuvent même partager certaines fonctionnalités.

Mais leurs priorités ne sont pas forcément les mêmes.

Pour la première, les critères dominants peuvent être :

  • la simplicité ;
  • la maintenabilité ;
  • la vitesse de développement ;
  • la facilité à intégrer les changements métier.

Pour la seconde, on peut devoir accorder beaucoup plus d'importance à :

  • la sécurité ;
  • la montée en charge ;
  • la résilience ;
  • la disponibilité ;
  • la maîtrise des performances.

La technologie ne décide donc pas seule de l'architecture.

Le contexte, les utilisateurs, le risque et les objectifs du projet en font partie.

Avant d'optimiser, il faut savoir ce que l'on veut optimiser

Le mot "optimisation" est trompeusement simple.

Optimiser quoi ?

Le temps de réponse ?

La consommation mémoire ?

Le nombre de requêtes SQL ?

Le temps de développement ?

Le coût d'infrastructure ?

La maintenabilité ?

La sécurité ?

La facilité d'évolution ?

Ces objectifs peuvent entrer en conflit.

Une solution extrêmement flexible peut demander davantage de concepts.

Une solution très performante peut nécessiter plus de complexité.

Une architecture très générique peut ralentir la livraison initiale.

Une solution volontairement simple peut rendre certaines évolutions futures plus coûteuses.

Il n'existe donc pas toujours une solution "optimale" dans l'absolu.

Il existe plutôt une solution adaptée à ce que le projet essaie réellement d'optimiser.

Et ces priorités peuvent changer au cours de sa vie.

Un programme n'est pas le problème

Cette idée paraît évidente.

Pourtant, j'ai mis du temps à réellement l'intégrer.

Quand on aime développer, on peut facilement regarder un besoin et commencer immédiatement à réfléchir en termes de :

class MyService:
    ...

ou :

def process_data():
    ...

Mais le client ne demande généralement pas une classe.

Il ne demande pas un design pattern.

Il ne demande même pas nécessairement Django, Spring ou PostgreSQL.

Il possède un problème.

Par exemple :

Je dois empêcher les doublons.

Je dois synchroniser ces données.

Je dois automatiser cette tâche.

Plusieurs personnes doivent pouvoir travailler ensemble.

Cette opération prend trop de temps aujourd'hui.

Notre programme est une réponse possible à ce problème.

Et plusieurs réponses peuvent être techniquement correctes.

La qualité du travail ne dépend donc pas uniquement de notre capacité à implémenter une solution.

Elle dépend aussi de notre capacité à comprendre quelle solution mérite d'être implémentée.

Les design patterns sont des outils, pas des objectifs

À cette période, on m'avait également conseillé d'apprendre les design patterns.

C'était un bon conseil.

Comprendre des patterns permet de reconnaître des problèmes déjà rencontrés par beaucoup d'autres développeurs et d'apprendre le vocabulaire utilisé pour certaines solutions.

Mais là encore, il existe un piège.

Le raisonnement ne devrait pas être :

J'ai appris Strategy.
        ↓
Où puis-je utiliser Strategy ?

Il devrait plutôt ressembler à :

J'ai plusieurs comportements
qui doivent pouvoir varier.
        ↓
Comment organiser cette variation ?
        ↓
Strategy m'aide-t-il réellement ici ?

Un pattern peut simplifier un problème.

Il peut aussi créer une abstraction inutile si le problème ne la justifie pas.

Le pattern doit servir la conception.

La conception ne doit pas être déformée pour permettre d'utiliser un pattern.

Les tests verts ne garantissent pas une bonne conception

L'expérience avec ce développeur senior m'a aussi appris quelque chose sur les tests.

Les tests définissaient le comportement attendu.

Ils étaient donc extrêmement utiles.

Ils me permettaient de savoir précisément ce que mon programme devait produire.

Mais ils ne pouvaient pas décider entièrement de la conception à ma place.

Plusieurs implémentations peuvent satisfaire la même suite :

Tests
   ↓
Solution A ── PASS
Solution B ── PASS
Solution C ── PASS

Pourtant :

A = simple et lisible

B = fonctionne mais fait plusieurs détours

C = inutilement abstraite et difficile à modifier

Du point de vue de certains tests fonctionnels, les trois solutions sont correctes.

La suite répond principalement à cette question :

Le comportement attendu est-il respecté ?

La conception doit également répondre à d'autres questions :

Est-ce suffisamment simple ?

Les responsabilités sont-elles au bon endroit ?

Combien coûtera la prochaine modification ?

Est-ce que l'abstraction apporte réellement quelque chose ?

Les tests et la conception se complètent.

L'un ne remplace pas l'autre.

Le refactoring n'a pas disparu de ma manière de travailler

Réfléchir avant de coder ne signifie évidemment pas que je ne refactore plus.

Ce serait irréaliste.

Certaines informations apparaissent uniquement pendant l'implémentation.

Une hypothèse métier peut se révéler fausse.

Une abstraction très élégante sur un schéma peut devenir pénible dès qu'on essaie réellement de l'utiliser.

Le besoin peut évoluer.

Le volume réel peut contredire les estimations.

Le code nous apprend lui aussi quelque chose.

Je continue donc à refactorer.

La différence est surtout que j'essaie de ne plus utiliser le refactoring comme remplacement systématique de la réflexion initiale.

Mon processus ressemble davantage à ceci :

Comprendre le besoin
        ↓
Identifier les objectifs
        ↓
Visualiser les données
        ↓
Imaginer le workflow
        ↓
Identifier les principaux risques
        ↓
Comparer quelques solutions
        ↓
Choisir les compromis
        ↓
Coder
        ↓
Tester
        ↓
Observer
        ↓
Refactorer lorsque nécessaire

Ce workflow n'est pas rigide.

Une découverte pendant l'implémentation peut me faire revenir plusieurs étapes en arrière.

Mais le code n'est plus systématiquement le premier endroit où j'essaie de comprendre le problème.

Je ne conseillerais pas à mon ancien moi d'éviter toutes les erreurs

Si je pouvais revenir en arrière et parler au développeur que j'étais au début, il serait tentant de lui donner une immense liste de règles.

Apprends ceci.

Ne fais jamais cela.

Utilise tel pattern.

Découpe toujours tes projets comme ceci.

Mais je pense que ce serait une erreur.

Une partie de ce que je comprends aujourd'hui vient précisément des solutions que j'ai dû refaire.

Du code trop compliqué que je pensais correct.

Des décisions prises trop rapidement.

Des refactorings que j'aurais peut-être pu éviter.

Des moments où quelqu'un de plus expérimenté m'a demandé de recommencer alors que j'étais persuadé d'avoir terminé.

Ces erreurs m'ont appris à reconnaître le coût des décisions techniques.

Je ne conseillerais donc pas à mon ancien moi de ne jamais se tromper.

Je lui donnerais plutôt de meilleures questions à se poser.

Avant de coder, les questions que j'essaie de me poser

Quel problème suis-je réellement en train de résoudre ?

À quoi ressemble le workflow ?

Quelles données sont nécessaires ?

Quelles données doivent rester cohérentes entre elles ?

Quels sont les objectifs prioritaires du projet ?

Quelles contraintes sont réellement importantes ?

Quelles évolutions sont probables ?

Quelles évolutions sont simplement imaginables ?

Quelles décisions seront difficiles à modifier plus tard ?

Existe-t-il plusieurs solutions raisonnables ?

Qu'est-ce que chaque solution simplifie ?

Qu'est-ce qu'elle complique ?

Quels compromis suis-je en train d'accepter ?

Puis seulement :

Comment vais-je le coder ?

Ces questions ne garantissent pas la bonne architecture.

Elles obligent simplement à considérer le programme comme une solution avant de le considérer comme du code.

Le code est une solution, pas le point de départ

Au début, je pensais principalement comme ceci :

J'ai quelque chose à développer.
        ↓
Je dois écrire du code.

Aujourd'hui, j'essaie davantage de penser :

Il existe un problème.
        ↓
Je dois le comprendre.
        ↓
Quels objectifs sont réellement importants ?
        ↓
Plusieurs solutions sont probablement possibles.
        ↓
Quels compromis impliquent-elles ?
        ↓
Je choisis une approche.
        ↓
Maintenant, je peux coder.

Le code reste évidemment une partie centrale de mon métier.

Et il reste aussi un moyen de réfléchir : certaines erreurs ne deviennent visibles qu'au contact de l'implémentation.

Mais l'expérience m'a appris que la valeur d'un développeur ne réside pas uniquement dans sa capacité à produire rapidement des lignes de code.

Elle existe aussi dans sa capacité à choisir les bonnes contraintes, à reconnaître les compromis et parfois à décider que certaines lignes n'ont tout simplement pas besoin d'être écrites.

C'est probablement la différence la plus importante entre ma manière de développer au début et celle que j'essaie d'avoir aujourd'hui :

Avant, je codais principalement pour comprendre le problème. Aujourd'hui, j'essaie d'abord de comprendre suffisamment le problème pour savoir quoi coder.

Pour aller plus loin

Backend

Comment prioriser un projet métier en retard : ce que j'ai appris en reconstruisant la confiance

Sur un projet déjà fortement en retard, la fonctionnalité la plus importante sur le papier n'est pas toujours celle qu'il faut développer en premier. J'ai appris à prioriser selon la valeur immédiate, les dépendances, le risque, la compréhension du besoin et surtout la confiance que chaque étape permet de reconstruire.

18 min de lecture