Cet article est le premier d’une série de deux billets sur mon exploration d’une migration de Front Desk vers Rust.

En 2019, j’écrivais les premières lignes Python de mon projet Front Desk, un SaaS web based pour hôteliers qui intègre une multitude d’outils pour gérer le quotidien des opérations : cahier de consignes, tickets de maintenance, objets trouvés, checklists.

Je n’avais alors aucun diplôme en informatique. Django et Python m’étaient complètement inconnus, et je n’avais aucune compétence en design frontend, en architecture logicielle ou en modélisation de base de données. J’étais en formation de développeur Python chez OpenClassrooms, diplôme que j’ai obtenu en 2021, et je construisais Front Desk en parallèle de mes cours.

Malgré de nombreux efforts de refactoring backend depuis plusieurs années, et une tentative de sortir d’un frontend Bootstrap vieillissant aux trop nombreuses dépendances, je me suis lancé dans une exploration d’une migration vers Rust.

Django, l’heureuse découverte

Ce que le framework m’a donné avant tout, c’est un cadre de travail, sans mauvais jeu de mots. Les requêtes HTTP allaient dans les vues, la modélisation des données dans les models, le frontend dans les templates. Je n’avais pas à me demander où poser les choses, ce qui m’a laissé plus de mon temps sur la logique métier et sur ce qui avait de valeur pour l’utilisateur, surtout qu’à l’époque Front Desk n’était qu’un outil interne utilisé par mes collégues, je n’avais que faire des bonnes pratiques.

L’admin en est le meilleur exemple. Elle m’a servi de back-office pendant des années : créer un nouvel établissement, ouvrir un compte utilisateur, corriger un contenu, tout se pilotait depuis une interface que je n’ai jamais eu à écrire.

Je pouvais même y donner des accès à mon associée, qui n’est pas technique, et la laisser gérer ce qui la concernait. Le reste des batteries included a joué le même rôle : l’authentification, les permissions, la protection CSRF, l’échappement des templates, et bien plus tard le CSP, autant de choses dont je ne savais même pas qu’il fallait s’en soucier, et que le framework faisait à ma place.

Les vues basées sur les classes permettent d’ailleurs d’écrire un CRUD complet en quelques lignes. À l’époque, Django ne les poussait pas autant qu’aujourd’hui, et j’écrivais surtout des vues sous forme de fonctions.

L’ORM mérite une mention à part. Il est excellent, sûr, et facile à optimiser. Avec les migrations, il m’a évité pendant des années d’écrire du SQL que j’aurais mal écrit ou pas compris, ce qui au passage à masquer le vrai cycle d’une requête en base de données.

Il faut aussi rendre à Python ce qui lui appartient : c’est le langage qui m’a permis de décrocher mes premiers postes de développeur backend, et pour se lancer dans la programmation, il reste incontournable.

Ce qui a fini par coincer

Avant de taper sur le framework, une précision : une bonne partie de la dette est la mienne, et elle va plus loin que je ne le pensais. En relisant mes modèles aujourd’hui, deux erreurs sautent aux yeux.

La première, c’est d’avoir encodé des états dans des booléens indépendants. Mon modèle Property porte à la fois is_active, is_deactivated, is_overdue et is_trial. Rien n’empêche un établissement d’être actif et désactivé en même temps, et l’état d’abonnement se répartit sur quatre champs qui peuvent se contredire, là où un seul enum aurait rendu ces situations impossibles. La sanction est tombée des années plus tard : quand j’ai voulu mesurer combien d’établissements étaient réellement actifs, mes propres flags se sont révélés inexploitables. J’ai dû recalculer l’activité à partir des données réelles, parce que le modèle ne disait plus la vérité. Une machine à états, ou même une simple chaîne à valeurs finies, aurait résolu le problème dès le premier jour.

Pire la source de vérité était eclatée à plusieurs endroits, lors de mes nombreux refactoring je laissais des méthodes et property qui souvent se contredisaient.

La deuxième, c’est le modèle fourre-tout. Mon Notebook, le cœur du produit, sert à la fois de consigne et d’événement, la bascule se faisant sur un booléen is_event. Il traîne donc une heure de début, une heure de fin, une date de fin, un lieu, une récurrence : des colonnes qui ne servent qu’à moins de 1 % des lignes, sur des centaines de milliers d’enregistrements.

Puis il y a la surcharge fonctionnelle, le feature creep, un phénomène bien documenté quand on lance un nouveau projet. Quand un client s’inscrit sur votre plateforme, c’est un établissement, donc un contrat qui peut représenter plusieurs milliers d’euros. Au début, vous n’avez presque pas d’utilisateurs, et vous acceptez tout. Y compris les demandes les plus incongrues : J’aime bien ce bouton d’action, mais je le préférais en haut à droite plutôt qu’a gauche, et cette feature X serait décéciser dans mon choix d’une solution, vous pouvez la déveloper ? Sur le moment, c’est une stratégie défensive parfaitement rationnelle, on ne veut pas perdre un gros contrat.

Sur la durée, elle ne tient pas. La fonctionnalité réclamée par l’établissement A ne convient pas à l’établissement B, qui demande sa variante. On se retrouve avec des lignes de code partout, un modèle qui gonfle, des cas particuliers qui se croisent, et un codebase qu’on ne maintient plus qu’à contrecœur. Ce sont ces « oui » qu’on retrouve sept ans plus tard sous forme de colonnes vides dans la base. Et elles ne dorment pas tranquillement : ces colonnes sont rattachées à d’autres modèles, prises dans des contraintes et des clés étrangères, il faut les traîner à chaque migration et composer avec elles à chaque nouvelle fonctionnalité. Parfois ça casse, et on se retrouve à ouvrir un shell à 2 heures du matin pour nettoyer une migration restée à moitié appliquée.

Cela dit de nombreuses fonctionnalités ont vu le jour suite à des échanges utilisateurs, il n’y a rien de plus utile pour un dévelopeur solo que le retour des utilisateurs actifs.

Django n’a pas été pensé pour l’async. Quand je l’ai choisi, tout était synchrone : une requête, un thread, du début à la fin. On déployait derrière un serveur WSGI, une norme qui ne connaît tout simplement pas la notion de tâche asynchrone. ASGI, qui l’a rendue possible, n’est arrivé que bien plus tard, et brancher quoi que ce soit d’asynchrone avant ça relevait de la débrouille. Conséquence directe, tout ce qui prend un peu de temps doit sortir du cycle requête/réponse et partir dans une file. J’ai donc traîné Celery, avec son broker, son Redis, ses workers et la supervision qui va avec, pour des choses qui sont de simples tâches détachées dans un runtime async : envoyer un mail de notification, pousser une notification, appeler une API tierce. Cette infrastructure grignotait facilement 40 % de mon budget d’infra, pour un usage qui restait ponctuel : cher payé pour le peu que ça servait. Django a depuis rattrapé une partie du terrain, mais sept ans de code écrit dans le paradigme d’origine ne se convertissent pas avec un async def sans repenser toute son archi.

Django a aussi raté le train du frontend, et ce n’est pas complètement sa faute. Le framework est né dans la presse en ligne, pour servir des pages de contenu statiques, et il a toujours laissé le front plus complexe aux développeurs : à eux de choisir ce qu’ils mettent au-dessus du HTML. C’était une position tenable tant que le rendu serveur suffisait. Elle l’est nettement moins pour un outil de travail utilisé toute la journée, où des dizaines d’interactions ont lieu.

Le problème, c’est que Django n’offre rien de ce qui rend un front moderne maintenable et agréable : pas de composants réutilisables et isolés, pas d’étape de build, donc pas de bundling, pas de tree-shaking, pas de minification ni de CSS scopé sans y greffer une chaîne d’outils externe. On finit avec du Bootstrap, des fichiers JS ajoutés au fil des besoins, du jQuery qu’on n’ose plus retirer, et des templates qu’on duplique parce qu’il n’y a pas de vraie notion de composant. Les formulaires en sont le meilleur exemple : ils marchent très bien tant qu’on accepte le rendu par défaut, et dès qu’on veut une mise en page correcte, des champs sur deux colonnes ou un widget un peu soigné, on empile les hacks pour reprendre le contrôle du HTML que le framework voulait générer à notre place.

Il y avait pourtant une place à prendre, et Rails l’a prise avec Hotwire, livré dans la boîte depuis la version 7. Django n’a jamais fait ce choix, ni adoubé HTMX, ni doté le framework d’un vrai système de composants. Les projets qui ont besoin de plus d’interactivité qu’un site de contenu se débrouillent donc seuls, et j’ai fini par en avoir assez de lutter à contre-courant, d’autant que travailler avec Svelte n’a rien de désagréable au final.

Django encourage massivement l’héritage, et au début c’est enthousiasmant : trois lignes de code et vous avez un formulaire, la pagination, une vue de liste complète. On a l’impression d’une forme de magie, ce qui d’ailleurs a été souvent reproché à Ruby On Rails.

Le problème arrive quand on sort du cadre prévu, ce qui finit toujours par arriver. On se met à hériter d’une classe de base, puis à empiler les mixins, et le comportement devient très difficile à suivre : on ne sait plus d’où vient un bout de code, ni dans quel ordre les choses s’exécutent, le fameux MRO de Python, que Django exploite à fond avec ses mixins.

Le sujet est d’ailleurs si obscur qu’un site entier existe pour s’y retrouver, ccbv.co.uk, qui déplie chaque vue générique et son ordre de résolution. Avec le recul, ça aurait dû être un signal d’alarme. Ces abstractions ne se rejoignent même pas entre elles : une permission écrite pour une vue basée sur les classes n’est pas réutilisable telle quelle dans DRF, qui a son propre système, et on finit par écrire la même règle métier deux fois, de deux façons différentes.

Le typage, enfin, est un combat. Les outils existent, mypy, Pylance, django-stubs, et les bibliothèques ne manquent pas. Mais typer un langage dynamique n’a jamais été évident, et Django en rajoute une couche avec ses métaclasses et sa métaprogrammation. Le typage d’un ORM qui fabrique ses attributs à l’exécution restera toujours une approximation. C’est peut-être un skill issue de ma part, mais passer du temps à débugger un paquet de typage pour un langage dynamique n’a jamais été ni agréable ni gratifiant.

Ce détail devient un vrai sujet à l’heure des workflows agentiques. Quand une partie du code est écrite par une machine, on a besoin d’un langage qui contraint, d’un typage qui tient, et d’une CI dont le verdict est prévisible. Un « ça passe chez moi » ne suffit plus quand ce n’est pas moi qui ai écrit la ligne, “if it compiles, it works” prend tout son sens quand on a essayé Python/Rust.

Il n’y a pas non plus d’architecture au-delà du MVT. À force de travailler avec d’autres frameworks, une organisation finit par s’imposer : une couche qui parle à la base, une couche qui orchestre le métier, une couche qui gère l’entrée/sortie HTTP. Django ne l’encourage pas, et il a de bonnes raisons de ne pas le faire, puisque le manager est déjà une forme de repository et que la vue tient lieu de couche d’orchestration. Sauf qu’en pratique, sur un projet qui dure, le métier se retrouve dispersé entre les vues, les modèles, les signaux et les formulaires, sans qu’aucune règle ne dise où une chose doit vivre. La structure imposée par le framework s’arrête au découpage en apps, et elle ne se transpose nulle part ailleurs.

Rien de tout cela ne fait de Django un mauvais choix, et je veux être clair là-dessus : je continue de le recommander, à des collègues comme à des clients. C’est un framework que je connais bien, qui a fait ses preuves, et l’admin reste un argument que peu d’écosystèmes peuvent offrir out of the box : pouvoir donner à un chef de projet de quoi piloter les données sans écrire une ligne de code, c’est un vrai avantage.

Ce que j’observe, en revanche, c’est un glissement. Django est de plus en plus utilisé pour son admin et son ORM, et pour rien d’autre ; je ne vois quasiment plus de projet qui l’exploite dans toute son étendue, formulaires, templating, permissions, auth. FastAPI prend la place, parce qu’il est plus léger et qu’il correspond mieux à ce que font les gens aujourd’hui : une API d’un côté, un front en React, Vue ou Svelte de l’autre.

Réécrire au lieu de refactorer

Le conseil qu’on entend partout, c’est qu’une réécriture est une mauvaise idée. Qu’il vaut mieux refactorer l’existant, remplacer les briques une par une, et ne jamais repartir de zéro. La plupart du temps, ce conseil est bon, et je ne peux que recommander l’article de Martin Fowler sur le Strangler Fig Pattern.

Il suppose seulement une chose : que le code existant vaille la peine d’être gardé. Dans mon cas, ce n’était pas une réalité. J’estime qu’environ 60 % de ce qui tournait n’a plus lieu d’être, et après de nombreuses analyses, l’architecture de la base de données est de toute façon à refaire. Refactorer, ici, ça voulait dire porter avec soin du code mort d’une structure vers une autre.

Deux choses très différentes se cachent dans ces 60 %. Il y a d’abord Django lui-même, ses paquets et une partie de ses fonctionnalités, à commencer par les formulaires, dont je n’ai plus l’usage dès lors que le front est une application à part. J’avais surtout pris l’habitude de dépendre de trop de bibliothèques. django-allauth, très lourd et très opinionated, qui impose sa façon de gérer les comptes. Ou django-fsm, sur lequel reposait le workflow de mes tickets, et dont le support a fini par être abandonné alors que j’en dépendais fortement.

Chaque paquet est une décision qu’on délègue, c’est une dette qu’on découvre toujours trop tard, une vraie catastrophe si notre projet est fortement couplé à cette dépendance.

il y a ensuite mon propre code, celui qui ne sert plus à rien : la dette accumulée, les fonctionnalités mortes, les champs que personne ne remplit, les utilitaires pas vraiment utile et surtout l’abstraction, qu’est-ce que j’adorais faire des classes à tout va pour éviter de répêter une seule ligne de code.

Ce qui reste, le code métier qui fait vraiment tourner Front Desk, c’est celui que je porte. Et celui-là, grâce à l’IA, se porte avec une très grande exactitude grace à une suite de tests et une documentation très bien maintenu.

L’autre chose qui a changé, et qui pèse lourd dans l’arbitrage, c’est justement que le développement se fait aujourd’hui en grande partie avec des agents et l’IA. Ça n’existait pas en 2019, on utilisait encore StackOverflow comme des hommes de Néandertal.

C’est un changement de paradigme dans la façon de créer et de maintenir un logiciel, et on ne peut pas raisonner comme si de rien n’était : le coût d’une réécriture n’est plus celui d’hier. Pour un développeur solo, c’est ce qui rend le chantier envisageable.

Avec une nuance importante, parce qu’elle décide de tout. L’IA entre les mains d’un développeur expérimenté donne des gains de productivité, et parfois de qualité. Entre les mains d’un junior qui ne sait pas juger ce qu’on lui rend, elle mène droit dans le mur. Ce levier ne fonctionne que si on est capable de relire ce qui sort. On y reviendra en détail dans le prochain article, et vous verrez que migrer un projet comme le mien ne coûte pas cher.

Pourquoi Rust

J’ai été introduit à Rust indirectement, par l’outillage Python. Ruff, un linter extrêmement rapide, puis Pydantic v2 (dont le cœur, pydantic-core, est écrit en Rust) et uv sont apparus coup sur coup. Tous étaient d’un autre ordre de grandeur en vitesse. Le point commun de ces trois outils, c’est qu’aucun n’était écrit en Python.

Ça ne m’a pas converti pour autant. Au départ j’avais une mauvaise image du langage, et elle venait surtout de sa communauté : le réflexe rewrite it in Rust appliqué à n’importe quoi, un subreddit un peu trop pédant, l’idée qu’il fallait tout réécrire. Ça m’a tenu à distance plus longtemps que la réputation de difficulté du langage, qui revenait elle aussi dans toutes les discussions.

Ce n’est donc pas un choix porté par une tendance. Je l’ai utilisé pendant environ un an de façon parcellaire, sur du tooling, pour experimenter. J’ai aussi travaillé les Rustlings, une suite d’exercices en ligne de commande où l’on corrige des snippets de code les uns après les autres. C’est une vraie leçon de pédagogie, et c’est ce qui m’a fait entrer dans le langage pour de bon. Le Rust Book a fait le reste : gratuit, complet, faisant autorité, il évite de se perdre dans des ouvrages tiers à la qualité douteuse.

Ce qui a tranché, c’est le portage. Quand on a deux codebases qui font la même chose, avec la même logique métier, on peut enfin comparer autre chose que des opinions.

Ce que la comparaison montre, c’est la correction par défaut. Le compilateur impose le typage, et une grande partie des erreurs qui, en Python, attendent tranquillement de vous tomber dessus en production (AttributeError bonjour..) sont ici attrapées avant même que le programme tourne. Sans entrer dans le détail, l’exemple le plus parlant est le type Result<T, E> : il garantit qu’une fonction rend soit une erreur, soit un objet du type attendu, et le compilateur refuse de compiler tant que les deux cas ne sont pas traités. C’est impossible à obtenir en Python. On peut objecter que Python a mypy, django-stubs et le reste, et c’est vrai, le gain est réel. Mais c’est un empilement de configuration et de contournements pour rattraper quelque chose que le langage ne fait pas nativement.

Ma plus grosse appréhension, c’était de perdre l’ORM. Elle ne s’est pas confirmée. Avec sqlx, on écrit du SQL, et les requêtes sont vérifiées à la compilation contre le schéma réel de la base. En me faisant la main sur SQL, je me suis rendu compte qu’on ne perd pas grand-chose, et que le code y gagne en clarté puisqu’on a sous les yeux exactement ce qui sera exécuté. Ça m’a surtout fait voir ce que l’ORM me cachait : le coût réel d’une jointure, le nombre de requêtes qu’une page déclenche vraiment, et les N+1 que je produisais sans le savoir. select_related et prefetch_related existent précisément pour les éviter, mais tant qu’on ne voit pas le SQL, on ne sait ni quand ils sont nécessaires, ni ce qu’ils coûtent. Sur les performances, ce sont des sujets de premier ordre, et j’ai passé des années à ne pas les voir.

J’ai aussi exploré Go pendant trois mois, sans accrocher : sa syntaxe, sa gestion verbeuse des erreurs, et l’absence de véritables enums (Go n’a que des constantes typées via iota, pas de sum types comparables à ceux de Rust). Et pour être honnête, je me suis dit que tant qu’à me faire du mal, autant taper le langage le plus exigeant et gagner en performance au passage.

À moins de faire de l’embarqué ou du bas niveau, le développement web ne sollicite pas non plus les aspects les plus ardus du langage. Les lifetimes complexes, le unsafe, la gestion fine de la mémoire : un backend HTTP les rencontre rarement. On passe l’essentiel de son temps sur des structs, des enums et du pattern matching.

Reste l’inconvénient qu’on met toujours en avant : il faut compiler, et la compilation peut être lente. Sur mon usage, un backend d’API avec Axum et sqlx, je ne l’ai pas encore ressentie comme une vraie gêne au quotidien.

Et puis il y a une raison qu’on n’ose pas souvent donner : c’est fun. Rust est un langage neuf pour moi, avec un paradigme que je n’avais jamais pratiqué sérieusement, très loin de l’objet dans lequel j’ai passé dix ans. Quand on est seul aux commandes, le plaisir de développer n’est pas un détail.

Ça mérite quand même d’être encadré. Front Desk est utilisé par de vrais clients, ce n’est pas un bac à sable. Je peux m’autoriser cette exploration parce que je suis le seul décideur technique sur ce projet, que le rythme n’est contraint par personne d’autre que moi, et que je crois sincèrement à ce que Rust apporte à l’heure de l’agent coding.

Ce que je pense de Python aujourd’hui

HOT TAKE, et j’assume.

L’argument historique en faveur de Python, c’est qu’il est facile à écrire et agréable à lire. C’était décisif quand chaque ligne sortait d’un cerveau de dev. Aujourd’hui, une grande partie du code n’est plus écrite à la main, et cet argument perd beaucoup de sa force : ce qui compte, c’est ce qu’on peut garantir sur le code une fois qu’il existe. Apprendre les bases de Rust représente un vrai coût, je ne le nie pas. Mais une fois ce cap passé, je vois mal ce qu’apporte encore Python pour un backend qu’on compte garder dix ans.

Le procès en illisibilité fait à Rust ne tient pas vraiment non plus. Après un an, on s’aperçoit qu’il n’est pas si dur à lire, parce que les signatures, les types et les mots-clés explicites disent exactement ce que fait le code, là où trois lignes de Django cachent un mécanisme entier. Le langage a bien des méthodes et de l’encapsulation, mais il n’a pas d’héritage, et c’est tout le sujet : on passe des paramètres à une fonction au lieu d’empiler des classes de base. Ce style, plus fonctionnel, coupe court aux abstractions en cascade qui m’ont tant coûté côté Django.

Où j’en suis, et la suite

Il faut le dire clairement : c’est un projet exploratoire. Rien n’est joué, et je n’ai pas éteint Django. Cela dit, les voyants sont au vert, et Rust a dépassé mes attentes. Sur les performances, évidemment, mais surtout sur l’expérience de développement, que je n’attendais pas à ce niveau : Cargo est un excellent outil, l’écosystème de crates est solide, et le confort quotidien n’a rien à voir avec la réputation d’austérité du langage.

Le prochain article rentre dans la méthode : comment j’ai utilisé sept ans de données de production pour décider quoi porter et quoi jeter, l’architecture que j’ai adoptée, mon revirement complet sur HTMX, la place que prennent les agents IA dans ce chantier, et surtout ce que tout ça coûte réellement.