Django Signals : architecture découplée et event-driven
Les signaux Django permettent de réagir aux événements du framework sans coupler vos composants. Comprendre leur fonctionnement ouvre la porte à une architecture plus propre, plus testable et plus extensible.
Qu'est-ce qu'un Signal ?
Un signal est un mécanisme de notification qui permet à des composants découplés de communiquer. Quand un événement se produit (sauvegarde d'un modèle, requête HTTP, etc.), Django émet un signal. Les receivers connectés à ce signal sont alors exécutés.
Le pattern Observer
Les signaux implémentent le pattern Observer : un émetteur (sender) notifie des abonnés (receivers) sans les connaître directement. Cela permet d'ajouter des comportements sans modifier le code existant.
Django fournit des signaux built-in pour les événements courants : pre_save, post_save, pre_delete, post_delete, request_started, request_finished, et bien d'autres.
Signaux de modèle : les essentiels
post_save : réagir après la sauvegarde
Le signal le plus utilisé. Il est émis après qu'un modèle soit sauvegardé en base. Parfait pour déclencher des actions secondaires.
users/signals.py
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.contrib.auth import get_user_model
from .models import Profile
from .tasks import send_welcome_email
User = get_user_model()
@receiver(post_save, sender=User)
def create_user_profile(sender, instance, created, **kwargs):
"""Crée un profil automatiquement pour chaque nouvel utilisateur."""
if created:
Profile.objects.create(user=instance)
# Envoyer l'email en tâche async
send_welcome_email.delay(instance.id)
pre_save : valider ou modifier avant sauvegarde
products/signals.py
from django.db.models.signals import pre_save
from django.dispatch import receiver
from django.utils.text import slugify
from .models import Product
@receiver(pre_save, sender=Product)
def generate_slug(sender, instance, **kwargs):
"""Génère automatiquement le slug avant sauvegarde."""
if not instance.slug:
instance.slug = slugify(instance.name)
# Gérer les doublons
counter = 1
original_slug = instance.slug
while Product.objects.filter(slug=instance.slug).exists():
instance.slug = f"{original_slug}-{counter}"
counter += 1
Attention aux boucles infinies
Appeler instance.save() dans un receiver post_save déclenche à nouveau le signal. Utilisez update_fields ou un flag pour éviter les boucles infinies.
Enregistrer les receivers correctement
Les receivers doivent être importés au démarrage de Django pour être connectés. La méthode recommandée : utiliser AppConfig.ready().
users/apps.py
from django.apps import AppConfig
class UsersConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'users'
def ready(self):
# Importer les signals pour les connecter
import users.signals # noqa: F401
Structure recommandée
Créez un fichier signals.py par application. Cela garde le code organisé et facilite le debug. Évitez de mettre les receivers directement dans models.py.
Créer des signaux personnalisés
Pour des événements métier spécifiques, créez vos propres signaux. Cela découple la logique et permet à d'autres parties de l'application de réagir.
orders/signals.py
from django.dispatch import Signal
# Définir les signaux personnalisés
order_placed = Signal() # args: order, user
order_paid = Signal() # args: order, payment
order_shipped = Signal() # args: order, tracking_number
Émettre le signal
orders/services.py
from .signals import order_placed, order_paid
class OrderService:
def create_order(self, user, cart):
order = Order.objects.create(
user=user,
total=cart.get_total()
)
for item in cart.items.all():
OrderItem.objects.create(
order=order,
product=item.product,
quantity=item.quantity
)
# Émettre le signal
order_placed.send(
sender=self.__class__,
order=order,
user=user
)
return order
Écouter le signal
notifications/receivers.py
from django.dispatch import receiver
from orders.signals import order_placed, order_paid
from .tasks import send_order_confirmation, notify_warehouse
@receiver(order_placed)
def handle_order_placed(sender, order, user, **kwargs):
"""Envoie la confirmation de commande."""
send_order_confirmation.delay(order.id)
@receiver(order_paid)
def handle_order_paid(sender, order, payment, **kwargs):
"""Notifie l'entrepôt pour préparer la commande."""
notify_warehouse.delay(order.id)
Bonnes pratiques
Garder les receivers légers
Un receiver doit exécuter une opération rapide ou déléguer à une tâche async. Ne pas bloquer le thread principal avec des opérations longues.
Mauvais vs Bon
# ❌ Mauvais : opération longue dans le receiver
@receiver(post_save, sender=Order)
def generate_invoice_pdf(sender, instance, **kwargs):
pdf = render_to_pdf(instance) # Lent !
send_email(instance.user.email, pdf) # Encore plus lent !
# ✅ Bon : déléguer à Celery
@receiver(post_save, sender=Order)
def schedule_invoice(sender, instance, created, **kwargs):
if created:
generate_and_send_invoice.delay(instance.id)
Éviter les effets de bord cachés
- Documenter les signaux : chaque signal custom doit avoir une docstring expliquant quand il est émis
- Nommer clairement :
order_placedplutôt queorder_signal - Limiter le nombre de receivers : si un signal a 10 receivers, repensez l'architecture
- Tester les receivers : ils font partie de la logique métier
Quand ne pas utiliser les signaux
Les signaux ne sont pas la solution à tout. Pour des opérations qui doivent toujours se produire ensemble, préférez un service explicite. Les signaux brillent pour les extensions et les intégrations optionnelles.
Tester les signaux
Les signaux peuvent rendre les tests complexes. Django fournit des outils pour les désactiver temporairement.
tests/test_orders.py
from django.test import TestCase
from django.db.models.signals import post_save
from unittest.mock import patch
from orders.models import Order
from orders.signals import order_placed
class OrderTestCase(TestCase):
def test_order_creation_emits_signal(self):
"""Vérifie que le signal est émis à la création."""
handler = Mock()
order_placed.connect(handler)
order = create_order(user=self.user, cart=self.cart)
handler.assert_called_once()
args, kwargs = handler.call_args
self.assertEqual(kwargs['order'], order)
order_placed.disconnect(handler)
@patch('notifications.receivers.send_order_confirmation')
def test_order_triggers_notification(self, mock_task):
"""Vérifie que la notification est déclenchée."""
order = create_order(user=self.user, cart=self.cart)
mock_task.delay.assert_called_once_with(order.id)
Alternatives aux signaux
Les signaux ne sont pas toujours la meilleure solution. Voici des alternatives selon le contexte :
- Méthodes de modèle : surcharger
save()pour une logique toujours liée au modèle - Services : encapsuler la logique métier complexe dans des classes dédiées
- Managers : utiliser des managers personnalisés pour les opérations de création
- Middleware : pour les opérations liées au cycle de requête
- Celery : pour les tâches async sans dépendance au cycle de vie des modèles
Les signaux sont un outil de découplage, pas une architecture. Utilisez-les pour permettre à des modules indépendants de communiquer, pas pour éviter d'organiser votre code. Un bon signal répond à la question : "Qui d'autre pourrait avoir besoin de savoir que cet événement s'est produit ?"
Ressources
- Documentation Django : Signals - docs.djangoproject.com
- Livre : "Two Scoops of Django" - Daniel et Audrey Feldroy
- Pattern : Observer Pattern (Gang of Four)
- Package : django-lifecycle pour une alternative aux signaux