Django : Generic Views ou vues fonctionnelles, comment choisir ?
Les Generic Views de Django peuvent réduire le code répétitif et donner une structure prévisible à une vue. Mais cette abstraction n'améliore la lisibilité que tant qu'elle reste plus simple à comprendre que le code qu'elle remplace.
Lorsqu'on développe avec Django, une question finit souvent par apparaître : faut-il écrire ses vues avec de simples fonctions ou utiliser les vues basées sur des classes fournies par le framework ?
Les deux approches sont parfaitement valides.
La documentation Django présente d'ailleurs les Class-Based Views comme une alternative aux vues fonctionnelles, pas comme leur remplacement.
La question n'est donc pas :
Les Class-Based Views sont-elles plus avancées ?
Elle est plutôt :
Quelle abstraction permet de comprendre le comportement de cette vue avec le moins d'effort ?
C'est sur ce critère que les Generic Views deviennent particulièrement intéressantes.
Lorsqu'un besoin correspond à un pattern déjà prévu par Django — afficher une liste, un objet, un formulaire ou modifier un modèle — elles peuvent supprimer une partie de la mécanique répétitive et donner une structure prévisible au code.
Mais cette abstraction a un coût : une partie du comportement n'est plus visible directement dans notre classe.
Le bon choix dépend donc moins du nombre de lignes écrites que de la quantité de raisonnement nécessaire pour comprendre la vue.
Function-Based View : tout le comportement sous les yeux
Une vue Django peut être une simple fonction.
from django.shortcuts import render
from .models import Article
def article_list(request):
articles = Article.objects.filter(published=True)
return render(
request,
"articles/list.html",
{"articles": articles},
)
Le déroulement est explicite :
- Django appelle la fonction ;
- la fonction récupère les articles ;
- elle construit le contexte ;
- elle retourne une réponse.
Pour une vue courte, cette propriété est précieuse.
Il n'y a pas besoin de connaître une hiérarchie de classes ou de rechercher quelle méthode héritée intervient dans le traitement.
Le comportement est visible de haut en bas.
Avec une Generic View, on décrit davantage l'intention
La même page peut être écrite avec une ListView.
from django.views.generic import ListView
from .models import Article
class ArticleListView(ListView):
model = Article
template_name = "articles/list.html"
context_object_name = "articles"
def get_queryset(self):
return Article.objects.filter(published=True)
Cette fois, nous ne décrivons plus entièrement le déroulement de la requête.
Nous indiquons plutôt à Django :
- il s'agit d'une liste ;
- elle concerne des
Article; - elle utilise ce template ;
- la sélection des objets suit cette règle.
Une partie du travail est donc déléguée au framework.
Django se charge notamment d'appeler les différentes étapes nécessaires pour obtenir le queryset, préparer le contexte et produire la réponse.
C'est l'une des différences essentielles entre les deux approches :
une Function-Based View montre principalement le flux d'exécution, tandis qu'une Generic View peut exprimer plus directement le type de problème traité.
La lisibilité ne se mesure pas au nombre de lignes
Il serait facile de comparer les deux exemples précédents et de conclure que la meilleure solution est celle qui contient le moins de code.
Ce serait un critère assez fragile.
Une abstraction peut réduire le nombre de lignes tout en augmentant la quantité de connaissances nécessaires pour comprendre le programme.
L'intérêt principal des Generic Views est plutôt leur structure prévisible.
Avec une ListView, certains noms ont une signification claire :
model
queryset
ordering
paginate_by
get_queryset()
get_context_data()
Lorsque cette convention est connue, elle sert de vocabulaire commun.
Je veux comprendre quelles données sont affichées ?
Je regarde get_queryset().
Je veux savoir quelles informations supplémentaires sont transmises au template ?
Je regarde get_context_data().
Je veux comprendre une personnalisation qui intervient avant le traitement de la méthode HTTP ?
Je peux regarder dispatch().
Cette prévisibilité devient intéressante dans une base de code où plusieurs vues suivent les mêmes conventions.
La lisibilité ne vient donc pas seulement de ce qui est écrit.
Elle peut également venir de ce que l'équipe sait déjà grâce au framework.
View et Generic View ne désignent pas exactement la même chose
Avant d'aller plus loin, une distinction est utile.
Toutes les Class-Based Views ne sont pas des Generic Views.
View est la classe de base sur laquelle reposent les autres vues basées sur des classes.
Elle permet notamment de séparer les méthodes HTTP :
from django.views import View
class LoginView(View):
def get(self, request):
...
def post(self, request):
...
Avec une vue fonctionnelle, la même distinction pourrait être écrite ainsi :
def login_view(request):
if request.method == "POST":
...
else:
...
La version basée sur View fournit une organisation naturelle par méthode HTTP.
Mais elle ne résout pas encore un problème métier particulier.
Une ListView, une DetailView ou une FormView vont plus loin : elles encapsulent des patterns courants du développement web.
On peut donc voir plusieurs niveaux d'abstraction :
Function-Based View
│
▼
View
│
▼
Generic Views
(ListView, FormView, DetailView, ...)
Ce diagramme ne représente pas une hiérarchie de qualité.
Plus bas ne signifie pas meilleur.
Il représente simplement des niveaux différents de comportement déjà fourni par Django.
Chercher d'abord si Django connaît déjà le problème
C'est probablement le critère que j'utilise le plus volontiers.
Avant d'écrire toute la mécanique d'une vue, il peut être utile de se demander :
Django possède-t-il déjà une abstraction correspondant à ce que je veux faire ?
Pour une page essentiellement statique :
from django.views.generic import TemplateView
class HomeView(TemplateView):
template_name = "home.html"
Pour une liste :
ListView
Pour afficher un objet :
DetailView
Pour traiter un formulaire :
FormView
Pour créer, modifier ou supprimer un objet :
CreateView
UpdateView
DeleteView
L'objectif n'est pas d'utiliser une classe parce qu'une classe existe.
Il est d'éviter de réimplémenter un comportement standard lorsque le framework fournit déjà une abstraction adaptée.
Les formulaires montrent particulièrement bien l'intérêt des Generic Views
Le traitement d'un formulaire possède généralement plusieurs chemins :
- un
GETaffiche le formulaire initial ; - un
POSTinvalide réaffiche le formulaire avec ses erreurs ; - un
POSTvalide déclenche le traitement puis généralement une redirection.
Il est parfaitement possible de gérer cela dans une Function-Based View.
from django.shortcuts import redirect, render
from .forms import ContactForm
def contact(request):
if request.method == "POST":
form = ContactForm(request.POST)
if form.is_valid():
form.send_email()
return redirect("contact-success")
else:
form = ContactForm()
return render(
request,
"contact.html",
{"form": form},
)
Le code reste compréhensible.
Mais ce workflow est également suffisamment courant pour que Django fournisse FormView.
from django.urls import reverse_lazy
from django.views.generic.edit import FormView
from .forms import ContactForm
class ContactView(FormView):
template_name = "contact.html"
form_class = ContactForm
success_url = reverse_lazy("contact-success")
def form_valid(self, form):
form.send_email()
return super().form_valid(form)
Cette version ne supprime pas le comportement.
Elle déplace simplement la partie standard dans Django et laisse apparaître ce qui est spécifique à l'application :
form.send_email()
C'est précisément le type de situation où une Generic View peut améliorer la lecture.
Le développeur n'a pas besoin de relire une nouvelle implémentation du cycle GET / POST / validation à chaque formulaire.
Il peut se concentrer sur ce que celui-ci fait de particulier.
Utiliser davantage le framework peut aussi recentrer les tests
Ce raisonnement a une conséquence intéressante sur les tests.
Si j'utilise une FormView, je n'ai normalement pas besoin d'écrire un test uniquement pour vérifier que Django appelle form_valid() lorsqu'un formulaire est valide.
Ce comportement appartient au framework.
Ce que je dois tester, c'est le comportement que mon application ajoute.
Prenons une liste limitée à l'utilisateur connecté :
class ArticleListView(ListView):
model = Article
def get_queryset(self):
return Article.objects.filter(
author=self.request.user,
)
Le test intéressant n'est pas :
Django sait-il appeler
get_queryset()?
La question utile est :
Un utilisateur peut-il voir les articles appartenant à un autre utilisateur ?
C'est cette règle qui appartient à l'application.
Même raisonnement si nous ajoutons une permission, une transformation ou une action dans form_valid().
Utiliser une Generic View n'améliore pas automatiquement la couverture des tests.
En revanche, s'appuyer sur davantage de comportements standards peut réduire la quantité de mécanique personnalisée qu'il faut maintenir et tester soi-même.
Les tests peuvent alors se concentrer davantage sur nos règles.
Il faut toutefois distinguer extension du framework et réimplémentation
Les Class-Based Views proposent de nombreux points d'extension.
Il est par exemple possible de surcharger dispatch().
Mais le simple fait qu'un hook existe ne signifie pas qu'il soit toujours le meilleur endroit pour ajouter un comportement.
Prenons l'authentification.
On pourrait écrire :
def dispatch(self, request, *args, **kwargs):
if not request.user.is_authenticated:
return redirect("login")
return super().dispatch(request, *args, **kwargs)
Cela fonctionnerait, mais Django fournit déjà une abstraction pour ce besoin :
from django.contrib.auth.mixins import LoginRequiredMixin
from django.views.generic import ListView
class ReservationListView(LoginRequiredMixin, ListView):
model = Reservation
Le second code décrit plus directement l'intention :
cette vue nécessite un utilisateur authentifié.
C'est un bon exemple du principe général de l'article.
Avant d'ajouter du code dans un hook, il vaut la peine de vérifier si le framework possède déjà une abstraction plus explicite.
Les hooks peuvent aider à organiser le code, mais ils ne doivent pas tout absorber
Une Class-Based View fournit naturellement différents points d'extension :
dispatch();get_queryset();get_context_data();form_valid();form_invalid();get_object().
Cela peut donner une structure utile aux personnalisations.
Mais cette structure peut aussi devenir une excuse pour déposer toute la logique dans la vue.
Imaginons une ListView qui commence simplement :
class ReservationListView(ListView):
model = Reservation
template_name = "reservations/list.html"
Puis le projet évolue.
get_context_data() finit par contenir :
- des calculs de dates ;
- plusieurs requêtes ;
- des règles de disponibilité ;
- des transformations ;
- des permissions ;
- différents cas métier.
On arrive alors à une méthode de plusieurs dizaines de lignes.
La classe est toujours une Generic View.
Le code n'est pas pour autant devenu plus maintenable.
Une règle que je trouve utile est :
La vue coordonne. La logique métier complexe vit ailleurs.
Par exemple :
class ReservationListView(ListView):
model = Reservation
template_name = "reservations/list.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["available_slots"] = get_available_slots(
date=self.selected_date,
)
return context
Le calcul des disponibilités peut alors vivre dans une fonction, un service ou un autre composant adapté au projet.
Il peut être testé indépendamment de la couche HTTP.
La vue conserve son rôle d'orchestration.
Même chose pour les logs
Les hooks d'une Class-Based View peuvent être pratiques pour journaliser un événement spécifique à cette vue.
Cela ne signifie pas pour autant que tous les logs doivent être ajoutés dans dispatch() ou get_queryset().
Un log d'accès HTTP générique sera souvent mieux placé dans :
- le serveur web ;
- un middleware ;
- ou l'outil d'observabilité utilisé par l'application.
Un événement métier peut, lui, avoir davantage de sens près de la logique métier qui le déclenche.
La question reste la même :
Quelle couche possède réellement cette responsabilité ?
Une Generic View fournit des emplacements techniques pratiques.
Elle ne remplace pas une réflexion sur l'architecture du code.
Le coût des Class-Based Views : une partie du comportement devient implicite
C'est le principal contre-argument.
Avec une Function-Based View courte, l'exécution peut souvent être comprise simplement en lisant la fonction.
Avec une Generic View, le comportement réel dépend aussi :
- des classes parentes ;
- des mixins ;
- des méthodes héritées ;
- des méthodes surchargées ;
- de l'ordre de résolution des méthodes, ou MRO.
Django documente d'ailleurs explicitement la chaîne d'héritage de ses Generic Views, car elle devient importante dès que l'on cherche à comprendre leur fonctionnement plus en profondeur.
Et les mixins ont eux aussi un coût.
Une classe composée de plusieurs comportements réutilisables peut être très élégante lorsqu'ils sont bien maîtrisés.
Mais si comprendre dix lignes de code oblige à parcourir cinq classes parentes pour savoir quelle méthode sera réellement appelée, l'abstraction commence à travailler contre nous.
Une Function-Based View peut donc être le meilleur choix
Prenons un endpoint très court :
from django.http import JsonResponse
def healthcheck(request):
if database_is_available():
return JsonResponse({"status": "ok"})
return JsonResponse(
{"status": "error"},
status=503,
)
Transformer systématiquement cette fonction en classe n'améliore pas nécessairement le code.
class HealthCheckView(View):
def get(self, request):
...
La fonction possède ici une qualité difficile à battre :
tout son comportement tient dans quelques lignes et se lit de haut en bas.
Utiliser une Function-Based View dans ce cas n'est pas un retour vers une approche moins avancée.
C'est simplement choisir l'abstraction la plus petite qui exprime clairement le problème.
Un critère pratique pour choisir
Plutôt que d'avoir une règle « fonctions contre classes », je trouve plus utile de raisonner par besoin.
| Besoin | Premier choix à examiner | Pourquoi |
|---|---|---|
| Rendre un template simple | TemplateView |
L'intention est immédiatement visible |
| Afficher une liste d'objets | ListView |
Queryset, pagination et contexte suivent un pattern connu |
| Afficher un objet | DetailView |
La récupération d'un objet est déjà encadrée |
| Traiter un formulaire classique | FormView |
Le cycle GET / POST / validation est déjà fourni |
| Créer ou modifier un modèle | CreateView / UpdateView |
Une grande partie du workflow CRUD est standard |
| Supprimer un objet | DeleteView |
Le workflow de confirmation et suppression est déjà structuré |
| Séparer plusieurs méthodes HTTP | View |
get(), post(), etc. rendent le flux explicite |
| Endpoint court ou très spécifique | Function-Based View | Le flux complet peut être plus simple à lire directement |
Ce tableau n'est pas une règle absolue.
Il indique surtout où commencer la réflexion.
Le signal d'alerte : commencer à se battre contre la Generic View
Une Generic View est intéressante tant que le problème ressemble au pattern qu'elle représente.
Une ListView est excellente pour afficher une liste.
Mais si elle commence à gérer :
- plusieurs formulaires indépendants ;
- des actions HTTP très différentes ;
- une succession importante de mixins ;
- des règles conditionnelles difficiles à faire entrer dans son cycle de vie ;
- de nombreuses surcharges simplement pour contourner son comportement par défaut ;
il faut peut-être reconsidérer l'abstraction.
À ce moment-là, revenir à View, voire à une Function-Based View, peut rendre le comportement beaucoup plus évident.
Ce n'est pas renoncer aux fonctionnalités de Django.
C'est reconnaître que l'abstraction choisie ne correspond plus au problème.
Refactorer lorsqu'un pattern apparaît
Il n'est pas non plus nécessaire de trouver l'abstraction parfaite dès la première ligne.
Une petite fonctionnalité peut très bien commencer ainsi :
def reservation(request):
...
Puis le besoin devient plus clair.
On découvre que la vue sert essentiellement à afficher un formulaire.
Ou une liste.
Ou à créer un objet.
À ce moment-là, un refactoring vers FormView, ListView ou CreateView peut avoir du sens.
Mais ce refactoring doit apporter quelque chose.
Par exemple :
- rendre l'intention plus évidente ;
- supprimer de la mécanique répétitive ;
- standardiser plusieurs vues similaires ;
- mieux séparer les responsabilités ;
- faciliter la lecture pour les développeurs qui connaissent Django.
Transformer une fonction en classe uniquement pour pouvoir dire que le projet utilise des Class-Based Views ne constitue pas une amélioration en soi.
Le framework doit réduire la quantité de choses à comprendre
Le débat entre Function-Based Views et Generic Views est parfois présenté comme une question de style.
Je pense qu'il est plus intéressant de le voir comme une question de budget de complexité.
Une Generic View nous permet de ne pas réécrire certains comportements.
En échange, elle demande de connaître les conventions et une partie du cycle de vie de Django.
Une Function-Based View nous montre davantage de comportement directement.
En échange, elle peut nous obliger à réécrire des mécanismes que le framework sait déjà gérer.
Il n'existe donc pas de gagnant universel.
Ma règle serait plutôt :
Chercher d'abord l'abstraction Django la plus proche du problème. L'utiliser tant qu'elle réduit l'effort nécessaire pour comprendre le comportement. Et choisir quelque chose de plus direct dès qu'il faut commencer à se battre contre elle.
Une ListView de 150 lignes remplie de logique métier peut être beaucoup moins lisible qu'une fonction de 15 lignes.
À l'inverse, réimplémenter manuellement le cycle d'un formulaire, la pagination ou la récupération standard d'un objet peut ajouter du code sans apporter davantage de clarté.
Le meilleur critère n'est donc probablement pas le nombre de lignes.
C'est la quantité de contexte qu'un développeur doit garder en tête pour répondre à une question simple :
Que fait réellement cette vue ?