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_APPS sans 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_name dé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