Clean Architecture avec Django : au-delà du MVT
Django suit le pattern MVT (Model-View-Template). Simple et efficace pour 90% des cas, mais parfois insuffisant pour les applications complexes. Découvrez comment séparer le domaine métier du framework.
Le problème avec le MVT classique
Dans une architecture Django classique, la logique métier se retrouve éparpillée entre les views, les models, et parfois même les templates. Ça fonctionne, mais crée des problèmes à l'échelle.
views.py - Le problème
# Vue trop chargée en logique métier
def create_order(request):
cart = request.session.get('cart', {})
# Validation métier dans la vue
if not cart:
return redirect('cart')
# Calculs métier dans la vue
total = sum(
Product.objects.get(id=pid).price * qty
for pid, qty in cart.items()
)
# Vérification stock dans la vue
for pid, qty in cart.items():
product = Product.objects.get(id=pid)
if product.stock < qty:
raise ValidationError(f'Stock insuffisant pour {product.name}')
# Création commande + paiement + notification...
# 100+ lignes de logique métier
Symptômes du problème
Vues de 200+ lignes : impossible à tester unitairement.
Duplication : même logique dans plusieurs vues.
Couplage fort : difficile de changer de framework ou d'interface.
L'architecture en couches
L'idée est de séparer le code en couches avec des responsabilités distinctes. Le domaine métier devient indépendant du framework.
Structure en couches
my_app/
├── domain/ # Logique métier pure
│ ├── entities.py # Objets métier
│ ├── services.py # Règles métier
│ └── exceptions.py # Exceptions métier
│
├── application/ # Orchestration
│ ├── use_cases.py # Cas d'utilisation
│ └── interfaces.py # Abstractions
│
├── infrastructure/ # Implémentations Django
│ ├── models.py # ORM Django
│ ├── repositories.py # Accès données
│ └── services.py # Services externes
│
└── presentation/ # Interface utilisateur
├── views.py # Vues Django
├── serializers.py # API (DRF)
└── forms.py # Formulaires
Règle de dépendance
Les couches internes ne connaissent pas les couches externes. Le domain ne sait rien de Django. L'application utilise des interfaces, pas des implémentations concrètes.
Couche Domain : le cœur métier
Le domain contient la logique métier pure. Aucune dépendance à Django, testable sans base de données.
Entités
domain/entities.py
from dataclasses import dataclass
from decimal import Decimal
from typing import List
@dataclass
class OrderItem:
product_id: int
product_name: str
quantity: int
unit_price: Decimal
@property
def total(self) -> Decimal:
return self.unit_price * self.quantity
@dataclass
class Order:
id: int | None
customer_id: int
items: List[OrderItem]
status: str = 'pending'
@property
def total(self) -> Decimal:
return sum(item.total for item in self.items)
def can_be_cancelled(self) -> bool:
return self.status in ['pending', 'confirmed']
Services métier
domain/services.py
from .entities import Order, OrderItem
from .exceptions import InsufficientStockError, OrderValidationError
class OrderDomainService:
"""Règles métier pour les commandes."""
def validate_order(self, order: Order, stock_checker) -> None:
"""Valide une commande selon les règles métier."""
if not order.items:
raise OrderValidationError("La commande doit contenir au moins un article")
for item in order.items:
if item.quantity <= 0:
raise OrderValidationError(f"Quantité invalide pour {item.product_name}")
available_stock = stock_checker(item.product_id)
if available_stock < item.quantity:
raise InsufficientStockError(
item.product_name,
item.quantity,
available_stock
)
def calculate_discount(self, order: Order) -> Decimal:
"""Calcule les remises selon les règles métier."""
discount = Decimal('0')
# 10% si commande > 100€
if order.total > Decimal('100'):
discount = order.total * Decimal('0.10')
return discount
Exceptions métier
domain/exceptions.py
class DomainError(Exception):
"""Base pour les erreurs métier."""
pass
class OrderValidationError(DomainError):
pass
class InsufficientStockError(DomainError):
def __init__(self, product_name, requested, available):
self.product_name = product_name
self.requested = requested
self.available = available
super().__init__(
f"Stock insuffisant pour {product_name}: "
f"demandé {requested}, disponible {available}"
)
Couche Application : orchestration
Les use cases orchestrent les appels entre le domain et l'infrastructure. Ils ne contiennent pas de logique métier, juste de la coordination.
application/use_cases.py
from dataclasses import dataclass
from typing import Protocol
from ..domain.entities import Order
from ..domain.services import OrderDomainService
# Interfaces (ports)
class OrderRepository(Protocol):
def save(self, order: Order) -> Order: ...
def get_by_id(self, order_id: int) -> Order | None: ...
class ProductRepository(Protocol):
def get_stock(self, product_id: int) -> int: ...
def reserve_stock(self, product_id: int, quantity: int) -> None: ...
class PaymentService(Protocol):
def charge(self, amount, customer_id) -> str: ...
class NotificationService(Protocol):
def send_order_confirmation(self, order: Order) -> None: ...
@dataclass
class CreateOrderUseCase:
"""Cas d'utilisation : créer une commande."""
order_repo: OrderRepository
product_repo: ProductRepository
payment_service: PaymentService
notification_service: NotificationService
order_domain_service: OrderDomainService
def execute(self, order: Order) -> Order:
# 1. Validation métier
self.order_domain_service.validate_order(
order,
stock_checker=self.product_repo.get_stock
)
# 2. Calcul remise
discount = self.order_domain_service.calculate_discount(order)
final_amount = order.total - discount
# 3. Paiement
payment_id = self.payment_service.charge(
final_amount,
order.customer_id
)
# 4. Réservation stock
for item in order.items:
self.product_repo.reserve_stock(
item.product_id,
item.quantity
)
# 5. Sauvegarde
order.status = 'confirmed'
saved_order = self.order_repo.save(order)
# 6. Notification
self.notification_service.send_order_confirmation(saved_order)
return saved_order
Protocol vs ABC
Protocol (typing) définit des interfaces structurelles (duck typing). Pas besoin d'héritage explicite, juste d'implémenter les méthodes. Plus pythonique que les ABC.
Couche Infrastructure : Django
L'infrastructure implémente les interfaces définies dans l'application. C'est ici qu'on trouve Django, l'ORM, les services externes.
infrastructure/repositories.py
from ..domain.entities import Order, OrderItem
from .models import OrderModel, OrderItemModel
class DjangoOrderRepository:
"""Implémentation Django du OrderRepository."""
def save(self, order: Order) -> Order:
if order.id:
order_model = OrderModel.objects.get(id=order.id)
order_model.status = order.status
order_model.save()
else:
order_model = OrderModel.objects.create(
customer_id=order.customer_id,
status=order.status
)
for item in order.items:
OrderItemModel.objects.create(
order=order_model,
product_id=item.product_id,
quantity=item.quantity,
unit_price=item.unit_price
)
return self._to_entity(order_model)
def get_by_id(self, order_id: int) -> Order | None:
try:
order_model = OrderModel.objects.prefetch_related('items').get(id=order_id)
return self._to_entity(order_model)
except OrderModel.DoesNotExist:
return None
def _to_entity(self, model: OrderModel) -> Order:
return Order(
id=model.id,
customer_id=model.customer_id,
status=model.status,
items=[
OrderItem(
product_id=item.product_id,
product_name=item.product.name,
quantity=item.quantity,
unit_price=item.unit_price
)
for item in model.items.select_related('product')
]
)
Couche Présentation : vues simples
Les vues deviennent des "adaptateurs" qui convertissent les requêtes HTTP en appels au use case.
presentation/views.py
from django.shortcuts import redirect, render
from django.contrib import messages
from ..application.use_cases import CreateOrderUseCase
from ..domain.exceptions import InsufficientStockError, OrderValidationError
from .dependencies import get_create_order_use_case
def create_order(request):
"""Vue simple : convertit HTTP en appel use case."""
# Conversion HTTP → entité domain
order = build_order_from_session(request)
# Injection des dépendances
use_case = get_create_order_use_case()
try:
# Exécution du use case
created_order = use_case.execute(order)
messages.success(request, "Commande créée avec succès")
return redirect('order_detail', order_id=created_order.id)
except InsufficientStockError as e:
messages.error(request, str(e))
return redirect('cart')
except OrderValidationError as e:
messages.error(request, str(e))
return redirect('cart')
Quand utiliser cette architecture ?
Ce n'est pas toujours nécessaire.
Utilisez la Clean Architecture quand :
- Logique métier complexe : règles nombreuses, calculs, validations
- Multiples interfaces : web, API, CLI, workers
- Tests critiques : besoin de tester le métier sans DB
- Équipe importante : séparation claire des responsabilités
- Longévité : projet destiné à durer et évoluer
Restez en MVT classique quand :
- CRUD simple : peu de logique métier
- Prototype/MVP : vitesse de développement prioritaire
- Petite équipe : l'overhead n'en vaut pas la peine
- Admin/back-office : Django admin suffit
Version simplifiée : Services Layer
Si la Clean Architecture complète est trop lourde, un simple services layer suffit souvent.
services.py - Version simple
# Un service qui encapsule la logique métier
class OrderService:
def create_order(self, customer, cart_items):
"""Crée une commande avec validation et paiement."""
# Validation
self._validate_stock(cart_items)
# Calcul
total = self._calculate_total(cart_items)
# Création
order = Order.objects.create(
customer=customer,
total=total
)
# ... reste de la logique
return order
def _validate_stock(self, items):
# ...
pass
def _calculate_total(self, items):
# ...
pass
Services Layer = 80% des bénéfices
Pour la plupart des projets, sortir la logique métier des vues vers des services suffit. Pas besoin d'entités séparées ni d'interfaces formelles.