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 :

Restez en MVT classique quand :

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.

Besoin d'aide sur votre projet ?

Je peux vous accompagner dans le développement ou l'optimisation de votre application.

Me contacter