Cinq patterns d'URL pour tout : comment HTMX et les vues abstraites ont éliminé toute ma couche API
Que se passe-t-il lorsqu'on pousse les vues basées sur les classes de Django jusqu'à leur extrême logique ? On obtient cinq patterns d'URL qui prennent en charge un CRUD complet pour chaque modèle de votre application - pas de framework REST, pas de sérialiseurs, pas de routeur côté client. Juste HTMX, la convention plutôt que la configuration, et un rejet philosophique de la complexité accidentelle.
L'expérience de pensée
Je voulais répondre à une question qui me taraudait : jusqu'où peut-on pousser l'abstraction de Django avant qu'elle ne casse ?
Pas de façon académique - de façon pratique. J'ai construit une application complète de gestion de CV avec plus de 20 modèles distincts (expériences, formations, compétences, certifications, publications, distinctions, bénévolat, langues, et plus encore) et je me suis lancé le défi de servir l'ensemble à partir d'un unique jeu de cinq patterns d'URL.
Pas de framework REST. Pas d'API JSON. Pas d'étape de build JavaScript. Pas de React. Pas de gestion d'état côté client. Juste Django, HTMX, Alpine.js, et la conviction que la plupart des applications web sont dramatiquement sur-conçues.
Les cinq patterns
L'ensemble de l'application est routé via ces cinq entrées d'URL :
# Chaque modèle. Chaque opération. Cinq lignes.
urlpatterns = [
path("<str:model_name>/list",
ModelListView.as_view(), name="model_list"),
path("<str:model_name>/create",
ModelCreateView.as_view(), name="model_create"),
path("<str:model_name>/<int:pk>",
ModelDetailView.as_view(), name="model_detail"),
path("<str:model_name>/<int:pk>/update",
ModelUpdateView.as_view(), name="model_update"),
path("<str:model_name>/<int:pk>/delete",
ModelDeleteView.as_view(), name="model_delete"),
]
/experience/list, /education/create, /award/42/update, /hobby/7/delete - ils se résolvent tous à travers les mêmes cinq vues. Le paramètre model_name est la seule chose qui change.
Ce n'est pas un jouet. Vingt modèles avec create, read, update, delete complets - isolés par utilisateur, authentifiés, avec des formulaires personnalisés là où c'est nécessaire et des formulaires générés automatiquement là où ça ne l'est pas.
Comment ça fonctionne : la convention comme configuration
L'astuce est d'une simplicité embarrassante. Chaque vue résout le modèle au moment du dispatch en s'appuyant sur le registre d'applications intégré de Django :
class ModelCreateView(CreateView, LoginRequiredMixin):
template_name = "create.html"
@staticmethod
def get_model(model_name):
try:
return apps.get_model("nest", model_name)
except LookupError:
return None
def dispatch(self, request, *args, **kwargs):
self.model_name = self.kwargs.get("model_name")
self.model = self.get_model(self.model_name)
# Convention: look for ModelNameForm in globals
if form_class := globals().get(
f"{self.model.__name__}Form"
):
self.form_class = form_class
else:
self.fields = self.model.fields
return super().dispatch(request, *args, **kwargs)
def form_valid(self, form):
form.instance.user = self.request.user
return super().form_valid(form)
Trois conventions font tout le travail :
- Découverte du modèle -
apps.get_model("nest", model_name)transforme un segment d'URL en une classe de modèle Django. Aucun registre, aucun fichier de configuration, aucun décorateur. Si le modèle existe dans l'application, il est disponible. - Découverte du formulaire - l'opérateur walrus vérifie
globals()à la recherche d'une classe nommée{ModelName}Form. S'il en trouve une, il utilise le formulaire personnalisé. Sinon, il se rabat sur la génération automatique d'un formulaire à partir de l'attributfieldsdu modèle. - Isolation des utilisateurs - chaque
form_validestampillerequest.usersur l'instance, et chaqueget_querysetfiltre en fonction de lui. Aucun framework de permissions nécessaire - les utilisateurs ne peuvent tout simplement pas voir les données des autres.
Le contrat du modèle : un attribut pour les gouverner tous
Pour qu'un modèle participe au système CRUD générique, il ne lui faut qu'une seule chose : un attribut de classe fields listant les champs éditables.
class Award(BaseTimeStamp):
user = models.ForeignKey(get_user_model(),
on_delete=models.CASCADE)
title = models.CharField(max_length=255)
awarded_by = models.CharField(max_length=255)
date_awarded = models.DateField()
fields = ["title", "awarded_by", "date_awarded"]
class VolunteerExperience(BaseTimeStamp):
user = models.ForeignKey(get_user_model(),
on_delete=models.CASCADE)
organization = models.CharField(max_length=255)
role = models.CharField(max_length=255)
start_date = models.DateField()
end_date = models.DateField(null=True, blank=True)
description = models.TextField()
fields = ["organization", "role", "start_date",
"end_date", "description"]
Voilà tout le contrat. Définissez votre modèle, listez les champs, et le système générique s'occupe du reste. Ajouter une nouvelle entité à l'application - disons Patent ou MilitaryService - est un processus en trois étapes :
- Écrire le modèle avec un attribut
fields - Créer un template de détail (qui étend la carte de base)
- L'ajouter à la boucle de sections du tableau de bord
Aucune nouvelle URL. Aucune nouvelle vue. Aucun nouveau sérialiseur. Aucun nouveau point de terminaison d'API. Aucune migration de la couche de routage. Le système découvre le modèle et le sert.
HTMX : là où opère la magie
La raison pour laquelle cela fonctionne sans donner l'impression d'être une application rendue côté serveur de 2005, c'est HTMX. Chaque opération CRUD est asynchrone, chaque interaction est fluide, et la page ne se recharge jamais entièrement.
La technique clé, ce sont les Out-of-Band Swaps (hx-swap-oob). Quand vous cliquez sur « Add Experience », HTMX récupère le formulaire de création et l'insère dans un conteneur désigné - non pas en remplaçant le bouton, mais en ciblant un div n'importe où sur la page par son ID.
<!-- The create button -->
<button hx-get="/experience/create"
hx-target="#experience_section"
hx-swap="innerHTML">
Ajouter une expérience
</button>
<!-- The form submits via HTMX, not a page reload -->
<form method="post">
{{ form.as_p }}
<button hx-post="/experience/create"
hx-target="#experience_section"
hx-swap="innerHTML"
hx-headers='{"X-CSRFToken": "..."}'>Créer</button>
<button hx-get="/clean-div/experience_section"
hx-target="#experience_section"
hx-swap="innerHTML">Annuler</button>
</form>
<!-- Delete removes the card from DOM entirely -->
<button hx-delete="/experience/42/delete"
hx-target="#experience_42"
hx-swap="delete">Supprimer</button>
Le bouton d'annulation est particulièrement élégant - il interroge un point de terminaison /clean-div/{id} qui renvoie un <div> vide, fermant de fait la fenêtre modale en remplaçant son contenu par du vide. Aucune logique JavaScript d'affichage/masquage. Aucun basculement de classe CSS. Le serveur renvoie du vide, et HTMX obéit.
Alpine.js : la fine couche d'orchestration
Alpine.js ne gère qu'une seule chose : quelle section du tableau de bord est visible. Toute la gestion d'état tient dans une unique variable :
<body x-data="{ openDiv: null }">
<!-- Sidebar toggles -->
<a @click="openDiv = openDiv === 'experience'
? null : 'experience'">Emploi</a>
<a @click="openDiv = openDiv === 'education'
? null : 'education'">Formation</a>
<!-- Sections appear/disappear with transitions -->
<div x-show="openDiv === 'experience'"
x-transition:enter="transition ease-out"
x-transition:leave="transition ease-in">
{% include "list.html" %}
</div>
</body>
C'est tout. Une variable, quelques gestionnaires @click, et x-show avec des transitions. Alpine.js ne récupère pas de données, ne gère pas de formulaires et ne suit pas d'état. Il décide simplement quel panneau est visible. HTMX gère tout le reste.
Cette répartition des tâches est ce qui rend l'architecture propre. Alpine possède la visibilité. HTMX possède les données. Django possède la vérité. Personne ne marche sur les plates-bandes des autres.
Le système de formulaires à deux niveaux
Tous les modèles ne peuvent pas être servis par un formulaire plat auto-généré. Une formation a besoin d'une autocomplétion d'établissement avec filtrage par pays. Une expérience a besoin d'une sélection multiple de compétences. Le système gère cela avec un repli élégant à deux niveaux :
- Niveau 1 : formulaires personnalisés - si un
EducationFormexiste dans l'espace de noms du module, la vue l'utilise. Ces formulaires peuvent avoir des widgets personnalisés, une validation personnalisée, des champs d'autocomplétion - tout ce dont le modèle a besoin. - Niveau 2 : formulaires auto-générés - si aucun formulaire personnalisé n'existe, Django en construit un à partir de la liste
fieldsdu modèle. Les modèles simples commeHobby(juste un champ titre) n'ont jamais besoin d'un formulaire personnalisé.
Le mécanisme de découverte tient en une seule ligne avec l'opérateur morse :
if form_class := globals().get(f"{self.model.__name__}Form"):
self.form_class = form_class # custom form
else:
self.fields = self.model.fields # auto-generate
C'est la convention plutôt que la configuration dans sa forme la plus pure. Il n'y a aucun registre de formulaires, aucun décorateur, aucun dictionnaire de paramètres. Nommez votre formulaire {Model}Form, placez-le là où la vue peut le voir, et le système le détecte. Ne faites rien, et cela fonctionne quand même.
L'argument philosophique : complexité accidentelle vs essentielle
Fred Brooks distinguait la complexité essentielle (inhérente au problème) de la complexité accidentelle (introduite par nos outils et nos choix). La plupart des applications web croulent sous la complexité accidentelle.
Considérez ce qu'une stack moderne typique exige pour une opération CRUD :
- Un modèle Django
- Un sérialiseur (DRF)
- Un viewset ou une APIView
- Une route d'URL enregistrée dans le routeur
- Une interface TypeScript reflétant le sérialiseur
- Un composant React avec useState, useEffect, appels fetch
- Une route côté client dans React Router
- La gestion des erreurs des deux côtés
- États de chargement, mises à jour optimistes, invalidation de cache
Cela fait neuf artefacts pour une seule entité. Multipliez par vingt modèles et vous maintenez 180 fichiers qui doivent rester synchronisés à travers deux langages, deux environnements d'exécution et deux pipelines de déploiement.
Dans ce projet, ajouter un modèle nécessite :
- Le modèle (avec un attribut
fields) - Un template de détail (10 lignes, étendant une base)
- Une ligne dans la boucle du tableau de bord
Trois artefacts. Un langage. Un environnement d'exécution. Un déploiement. Tout le reste est géré par convention.
Là où ça casse (et là où ça ne casse pas)
Je ne prétends pas que ce pattern remplace toutes les SPA. Il s'effondre quand :
- Le fonctionnement hors ligne d'abord est requis - le SSR avec HTMX a besoin d'un serveur. Les PWA avec IndexedDB non.
- Les interactions complexes côté client - un tableau Kanban en glisser-déposer, un éditeur de documents collaboratif ou un jeu en temps réel nécessitent un état client riche.
- L'API est le produit - si vous construisez une plateforme consommée par des applications mobiles et des tiers, vous avez besoin d'une véritable couche API quoi qu'il en soit.
Mais voici le point essentiel : la plupart des applications CRUD ne sont rien de tout cela. La plupart des applications métier sont des formulaires, des listes et des vues de détail avec authentification. Elles n'ont pas besoin d'un environnement d'exécution JavaScript de 50 Ko pour afficher un tableau d'enregistrements que le serveur sait déjà rendre.
Cette expérience m'a prouvé que les vues basées sur les classes de Django, combinées à la capacité de HTMX à faire ressembler du HTML rendu côté serveur à une SPA, peuvent gérer vingt modèles avec un CRUD complet en environ 1 500 lignes de code. La stack équivalente React + DRF en représenterait cinq à dix fois plus.
Ce que j'ai appris
Construire ce projet a changé ma façon de penser l'architecture web :
- La convention l'emporte sur la configuration - la recherche de formulaire via
globals()est peu conventionnelle, mais elle a éliminé toute une couche de registre. Parfois, la solution la plus simple est la meilleure. - HTMX n'est pas un compromis - les échanges hors bande (out-of-band), les stratégies de swap (
delete,innerHTML,none) et les déclencheurs avec anti-rebond vous offrent 90 % de l'interactivité d'une SPA pour 10 % de la complexité. - Les vues génériques de Django sont sous-estimées -
CreateView,UpdateView,ListView,DeleteViewsont conçues précisément pour ce genre de composition. Le framework en a toujours été capable ; nous avons simplement cessé d'y recourir quand React est arrivé. - Le motif de l'attribut
fieldsest étonnamment puissant. Il fait à la fois office de liste des champs du formulaire et de contrat de documentation sur ce qui est éditable. Un seul attribut, deux fonctions. - Alpine.js + HTMX forment un duo parfait - Alpine gère les préoccupations purement côté client (visibilité, transitions), HTMX gère la communication serveur. Ni l'un ni l'autre n'essaie de faire le travail de son voisin.
Le meilleur code est celui que vous n'écrivez pas. Le meilleur endpoint est celui qui gère tous les modèles. Le meilleur framework est celui qui s'efface et laisse la convention faire le gros du travail.