Django Apps modulaires : bonnes pratiques
La philosophie "pluggable apps" de Django promet des composants réutilisables entre projets. En pratique, la plupart des apps deviennent des dépendances tentaculaires impossibles à extraire. Comment structurer de vraies apps modulaires ?
Le problème des apps Django
Django encourage la création d'apps pour séparer les responsabilités. Mais sans discipline, ces apps deviennent rapidement couplées : imports croisés, modèles qui référencent d'autres apps, vues qui dépendent de contextes externes.
Symptôme classique : Vous voulez réutiliser votre app "notifications" dans un autre projet, mais elle importe 5 autres apps de votre projet actuel. L'extraction devient un cauchemar.
Une app vraiment modulaire doit pouvoir être supprimée du INSTALLED_APPS sans casser le reste du projet. C'est le test ultime.
Couplage vs Cohésion
Une bonne app a une forte cohésion (tout ce qu'elle contient sert le même objectif) et un faible couplage (peu de dépendances vers l'extérieur). Inversement, une mauvaise app fait un peu de tout et dépend de tout le projet.
Structure d'une app modulaire
Voici la structure recommandée pour une app autonome et professionnelle :
Structure d'app modulaire
myapp/
├── __init__.py
├── apps.py # Configuration AppConfig
├── models/ # Modèles découpés en fichiers
│ ├── __init__.py
│ ├── product.py
│ └── category.py
├── services/ # Logique métier
│ ├── __init__.py
│ └── product_service.py
├── api/ # Endpoints REST (si DRF)
│ ├── __init__.py
│ ├── serializers.py
│ ├── views.py
│ └── urls.py
├── views/ # Vues Django classiques
│ ├── __init__.py
│ └── product_views.py
├── templates/
│ └── myapp/ # Namespace dans les templates
│ └── product_list.html
├── static/
│ └── myapp/ # Namespace dans les statics
│ └── css/
├── migrations/
├── admin.py
├── urls.py # URLs de l'app
├── signals.py # Signaux et receivers
├── constants.py # Constantes de l'app
└── exceptions.py # Exceptions custom
Le dossier models/
Pour les apps avec plusieurs modèles, éclater models.py en un dossier rend le code plus navigable :
myapp/models/__init__.py
from .product import Product, ProductVariant
from .category import Category
# Expose tous les modèles pour les imports habituels
__all__ = ['Product', 'ProductVariant', 'Category']
Les imports restent identiques depuis l'extérieur : from myapp.models import Product.
AppConfig : le centre névralgique
Depuis Django 3.2, AppConfig est automatiquement découvert. C'est le point d'entrée de configuration de votre app.
products/apps.py
from django.apps import AppConfig
class ProductsConfig(AppConfig):
# Chemin complet du module
name = 'products'
# Label unique (utile si conflit de noms)
label = 'products'
# Nom affiché dans l'admin
verbose_name = 'Catalogue produits'
# Type de clé primaire par défaut
default_auto_field = 'django.db.models.BigAutoField'
def ready(self):
# Importer les signaux au démarrage
from . import signals # noqa: F401
# Enregistrer des checks custom
from .checks import check_products_config
from django.core import checks
checks.register(check_products_config, 'products')
La méthode ready() est appelée une seule fois au démarrage. C'est l'endroit idéal pour connecter les signaux et effectuer l'initialisation de l'app.
Hériter d'AppConfig pour personnaliser
Pour une app réutilisable, permettez aux utilisateurs de personnaliser le comportement :
Personnalisation dans le projet
# myproject/apps.py
from products.apps import ProductsConfig
class CustomProductsConfig(ProductsConfig):
verbose_name = 'Nos Produits'
def ready(self):
super().ready()
# Ajout de comportement spécifique au projet
from .signals import custom_product_handler
from products.models import Product
from django.db.models.signals import post_save
post_save.connect(custom_product_handler, sender=Product)
# settings.py
INSTALLED_APPS = [
'myproject.apps.CustomProductsConfig',
# ...
]
Gérer les dépendances entre apps
C'est le point crucial. Comment une app peut-elle communiquer avec d'autres sans créer de couplage fort ?
Règle 1 : Dépendances unidirectionnelles
Si l'app A dépend de B, alors B ne doit jamais importer A. Dessinez un graphe de dépendances : il ne doit pas y avoir de cycles.
Dépendances autorisées
# orders/models.py - Order dépend de products, OK
from products.models import Product
class OrderLine(models.Model):
product = models.ForeignKey(
Product,
on_delete=models.PROTECT
)
# INTERDIT : products/models.py importerait orders
# Cela créerait un cycle de dépendances
Règle 2 : Références par string
Pour les ForeignKey vers des apps optionnelles, utilisez la notation string :
Référence par string
from django.conf import settings
class Comment(models.Model):
# Utilise AUTH_USER_MODEL au lieu d'un import direct
author = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE
)
# Référence string pour éviter les imports circulaires
product = models.ForeignKey(
'products.Product',
on_delete=models.CASCADE
)
Règle 3 : Signaux pour la communication inverse
Quand B a besoin de réagir aux événements de A sans que A connaisse B, utilisez les signaux :
notifications/signals.py
from django.db.models.signals import post_save
from django.dispatch import receiver
# L'app notifications écoute les événements de orders
# orders ne sait pas que notifications existe
@receiver(post_save, sender='orders.Order')
def notify_order_created(sender, instance, created, **kwargs):
if created:
from .services import NotificationService
NotificationService.send_order_confirmation(instance)
Pattern : Signaux custom
Définissez vos propres signaux pour des événements métier. L'app émet le signal, les autres apps peuvent y réagir sans créer de dépendance.
orders/signals.py
import django.dispatch
# Signaux custom de l'app orders
order_completed = django.dispatch.Signal()
order_cancelled = django.dispatch.Signal()
# Émis dans le service
class OrderService:
@staticmethod
def complete_order(order):
order.status = 'completed'
order.save()
# Notifie tous les listeners sans les connaître
order_completed.send(
sender=Order,
order=order,
user=order.user
)
Settings et configuration
Une app modulaire doit être configurable sans modifier son code source.
Pattern : settings avec valeurs par défaut
products/conf.py
from django.conf import settings
class ProductsSettings:
"""
Configuration de l'app products.
Toutes les valeurs ont des défauts sensés.
"""
@property
def MAX_IMAGES_PER_PRODUCT(self):
return getattr(settings, 'PRODUCTS_MAX_IMAGES', 10)
@property
def ENABLE_REVIEWS(self):
return getattr(settings, 'PRODUCTS_ENABLE_REVIEWS', True)
@property
def IMAGE_BACKEND(self):
return getattr(
settings,
'PRODUCTS_IMAGE_BACKEND',
'products.backends.DefaultImageBackend'
)
@property
def CURRENCY(self):
return getattr(settings, 'PRODUCTS_CURRENCY', 'EUR')
# Instance singleton
products_settings = ProductsSettings()
Utilisation dans le code de l'app :
products/services/image_service.py
from ..conf import products_settings
from django.utils.module_loading import import_string
class ImageService:
def __init__(self):
# Charge le backend configuré dynamiquement
backend_class = import_string(products_settings.IMAGE_BACKEND)
self.backend = backend_class()
def add_image(self, product, image_file):
if product.images.count() >= products_settings.MAX_IMAGES_PER_PRODUCT:
raise ValueError(f"Maximum {products_settings.MAX_IMAGES_PER_PRODUCT} images")
return self.backend.process(image_file)
L'utilisateur de l'app peut override n'importe quelle valeur dans son settings.py avec le préfixe PRODUCTS_.
URLs modulaires
Chaque app doit définir ses propres URLs avec un namespace pour éviter les conflits :
products/urls.py
from django.urls import path
from . import views
# Le namespace pour les reverse URLs
app_name = 'products'
urlpatterns = [
path('', views.ProductListView.as_view(), name='list'),
path('<slug:slug>/', views.ProductDetailView.as_view(), name='detail'),
path('category/<slug:slug>/', views.CategoryView.as_view(), name='category'),
]
myproject/urls.py
from django.urls import path, include
urlpatterns = [
path('products/', include('products.urls')),
path('orders/', include('orders.urls')),
path('api/', include('products.api.urls')),
]
# Dans les templates
# {% url 'products:detail' slug=product.slug %}
URLs conditionnelles selon la config
products/urls.py
from django.urls import path
from .conf import products_settings
from . import views
app_name = 'products'
urlpatterns = [
path('', views.ProductListView.as_view(), name='list'),
path('<slug:slug>/', views.ProductDetailView.as_view(), name='detail'),
]
# Ajoute les URLs de reviews si la feature est activée
if products_settings.ENABLE_REVIEWS:
urlpatterns += [
path('<slug:slug>/reviews/', views.ReviewListView.as_view(), name='reviews'),
path('<slug:slug>/reviews/add/', views.ReviewCreateView.as_view(), name='review_add'),
]
Templates et assets namespaced
Toujours placer templates et fichiers statiques dans un sous-dossier du nom de l'app :
Structure des templates
products/
├── templates/
│ └── products/ # <-- Namespace obligatoire
│ ├── base.html # Base de l'app (extends projet)
│ ├── product_list.html
│ ├── product_detail.html
│ └── includes/
│ └── product_card.html
└── static/
└── products/ # <-- Namespace obligatoire
├── css/
│ └── products.css
└── js/
└── product-gallery.js
Sans namespace, si deux apps ont un templates/list.html, Django prendra le premier trouvé selon l'ordre dans INSTALLED_APPS. Le namespace évite ces collisions.
Templates extensibles
Permettez aux utilisateurs de l'app de customiser les templates sans les modifier :
products/templates/products/product_detail.html
{% extends products_base_template|default:"products/base.html" %}
{% block products_content %}
<!-- Contenu par défaut -->
<div class="product-detail">
{% block product_header %}
<h1>{{ product.name }}</h1>
{% endblock %}
{% block product_gallery %}
{% include "products/includes/gallery.html" %}
{% endblock %}
{% block product_info %}
<p>{{ product.description }}</p>
{% endblock %}
</div>
{% endblock %}
L'utilisateur peut override un bloc spécifique dans son projet sans copier tout le template.
Exemple complet : app Products
Voici une app products modulaire complète illustrant tous les patterns :
products/models/product.py
from django.db import models
from django.urls import reverse
from django.conf import settings
class Product(models.Model):
class Status(models.TextChoices):
DRAFT = 'draft', 'Brouillon'
PUBLISHED = 'published', 'Publié'
ARCHIVED = 'archived', 'Archivé'
name = models.CharField(max_length=200)
slug = models.SlugField(unique=True)
description = models.TextField(blank=True)
price = models.DecimalField(max_digits=10, decimal_places=2)
status = models.CharField(
max_length=20,
choices=Status.choices,
default=Status.DRAFT
)
# Référence par string pour éviter import direct
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True
)
category = models.ForeignKey(
'products.Category',
on_delete=models.SET_NULL,
null=True,
related_name='products'
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['-created_at']
indexes = [
models.Index(fields=['status', '-created_at']),
models.Index(fields=['slug']),
]
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse('products:detail', kwargs={'slug': self.slug})
products/services/product_service.py
from django.db import transaction
from django.utils.text import slugify
from ..models import Product
from ..signals import product_published
class ProductService:
"""
Toute la logique métier des produits.
Les vues restent minces et appellent ce service.
"""
@staticmethod
def create_product(*, name: str, price, user, **kwargs) -> Product:
"""Crée un produit avec slug auto-généré."""
slug = kwargs.pop('slug', None) or slugify(name)
# Assure l'unicité du slug
base_slug = slug
counter = 1
while Product.objects.filter(slug=slug).exists():
slug = f'{base_slug}-{counter}'
counter += 1
return Product.objects.create(
name=name,
slug=slug,
price=price,
created_by=user,
**kwargs
)
@staticmethod
@transaction.atomic
def publish_product(product: Product) -> Product:
"""Publie un produit et émet le signal."""
product.status = Product.Status.PUBLISHED
product.save(update_fields=['status', 'updated_at'])
# Notifie les listeners (analytics, cache, etc.)
product_published.send(sender=Product, product=product)
return product
@staticmethod
def get_published_products(category=None):
"""Récupère les produits publiés avec optimisations."""
qs = Product.objects.filter(
status=Product.Status.PUBLISHED
).select_related('category', 'created_by')
if category:
qs = qs.filter(category=category)
return qs
products/signals.py
import django.dispatch
# Signaux custom de l'app
product_published = django.dispatch.Signal()
product_archived = django.dispatch.Signal()
products/views/product_views.py
from django.views.generic import ListView, DetailView
from django.contrib.auth.mixins import LoginRequiredMixin
from ..models import Product, Category
from ..services import ProductService
class ProductListView(ListView):
"""Liste des produits publiés."""
template_name = 'products/product_list.html'
context_object_name = 'products'
paginate_by = 20
def get_queryset(self):
category_slug = self.kwargs.get('category_slug')
category = None
if category_slug:
category = Category.objects.filter(slug=category_slug).first()
return ProductService.get_published_products(category=category)
class ProductDetailView(DetailView):
"""Détail d'un produit."""
template_name = 'products/product_detail.html'
context_object_name = 'product'
slug_url_kwarg = 'slug'
def get_queryset(self):
return Product.objects.filter(
status=Product.Status.PUBLISHED
).select_related('category')
Checklist d'une app modulaire
Avant de considérer une app comme "modulaire", vérifiez ces points :
- Indépendance : L'app peut être retirée du
INSTALLED_APPSsans erreur d'import - AppConfig : Configuration centralisée avec
ready()pour les signaux - Settings : Toutes les valeurs configurables ont des défauts sensés
- Pas de cycles : Graphe de dépendances unidirectionnel
- Namespaces : Templates et static dans des sous-dossiers nommés
- URLs nommées :
app_namedéfini pour les reverse URLs - Documentation : README avec installation et configuration
- Tests : Tests autonomes qui ne dépendent pas d'autres apps
Le test de la "valise"
Imaginez que vous devez "emballer" votre app pour la donner à un collègue sur un autre projet. Si vous pouvez le faire en copiant juste le dossier de l'app et en ajoutant quelques lignes dans settings.py, c'est une vraie app modulaire.
Apps tierces : s'en inspirer
Les meilleures apps Django open source illustrent ces patterns. Étudiez leur structure :
- django-allauth : Gestion auth avec configuration très flexible
- django-filter : Filtrage avec intégration DRF optionnelle
- django-extensions : Commandes management modulaires
- django-debug-toolbar : Middleware et panels configurables
Ces apps sont utilisées dans des milliers de projets différents. Leur modularité n'est pas un accident : c'est le fruit d'une discipline architecturale rigoureuse que vous pouvez appliquer à vos propres apps.
Besoin d'aide sur votre projet ?
Je peux vous accompagner dans le développement ou l'optimisation de votre application.
Me contacter