Clean Architecture
Pour aller plus loin
Ce chapitre aborde des concepts d'architecture logicielle avancés. Il n'est pas nécessaire de tout maîtriser immédiatement, mais comprendre ces principes vous permettra de mieux structurer vos projets à mesure qu'ils grandissent.
La Clean Architecture, popularisée par Robert C. Martin, est une organisation du code en couches concentriques où le domaine métier est au centre et les détails techniques (framework, base de données, UI) sont à la périphérie.
Principe fondamental
Le principe clé : les dépendances vont vers l'intérieur. Le domaine ne dépend de rien. Les couches externes dépendent des couches internes, jamais l'inverse.
Les trois couches
| Couche | Contenu | Dépendances |
|---|---|---|
| Domaine | Entités, Value Objects, Domain Services | Aucune |
| Application | Use Cases, interfaces abstraites | Domaine |
| Infrastructure | Repository (implémentation), Mailer, Framework | Application + Domaine |
Le Domaine
Le domaine contient les règles métier de l'application, indépendamment de tout framework ou technologie.
Entités
Les entités sont des objets avec une identité stable (unique). Deux entités avec les mêmes attributs mais des identifiants différents sont considérées comme distinctes.
from dataclasses import dataclass, field
from datetime import datetime
@dataclass
class User:
"""Entité User - identifiée par son id"""
id: int
email: str
username: str
hashed_password: str
created_at: datetime = field(default_factory=datetime.now)
def verify_password(self, password: str) -> bool:
"""Logique métier : vérification du mot de passe"""
return check_password_hash(self.hashed_password, password)
def change_email(self, new_email: str) -> None:
"""Logique métier : changement d'email"""
if not self._is_valid_email(new_email):
raise ValueError("Format d'email invalide")
self.email = new_email
@staticmethod
def _is_valid_email(email: str) -> bool:
return '@' in email and '.' in email.split('@')[1]
Value Objects
Les Value Objects sont des objets immuables sans identité particulière. Deux Value Objects avec les mêmes attributs sont considérés comme identiques.
from dataclasses import dataclass
@dataclass(frozen=True) # frozen=True rend l'objet immuable
class Price:
"""Value Object - défini par ses attributs, pas par un id"""
amount: float
currency: str = "EUR"
def __post_init__(self):
if self.amount < 0:
raise ValueError("Le montant ne peut pas être négatif")
def add(self, other: 'Price') -> 'Price':
if self.currency != other.currency:
raise ValueError("Impossible d'additionner deux devises différentes")
return Price(amount=self.amount + other.amount, currency=self.currency)
Domain Services
Lorsqu'une logique métier implique plusieurs entités, on utilise un Domain Service :
class PricingService:
"""Domain Service : logique entre entités"""
def calculate_order_total(self, order_lines: list['OrderLine']) -> Price:
"""Calculer le total d'une commande"""
total = Price(amount=0)
for line in order_lines:
line_total = Price(amount=line.unit_price.amount * line.quantity)
total = total.add(line_total)
return total
Agrégats
Un agrégat est une entité qui contient d'autres entités. L'agrégat garantit la cohérence de l'ensemble.
@dataclass
class Order:
"""Agrégat : Order contient des OrderLine"""
id: int
user_id: int
lines: list['OrderLine'] = field(default_factory=list)
status: str = "pending"
def add_line(self, product_id: int, quantity: int, unit_price: Price) -> None:
"""Ajouter une ligne à la commande"""
if self.status != "pending":
raise ValueError("Impossible de modifier une commande finalisée")
line = OrderLine(
product_id=product_id,
quantity=quantity,
unit_price=unit_price
)
self.lines.append(line)
def total(self) -> Price:
"""Calculer le total de la commande"""
pricing = PricingService()
return pricing.calculate_order_total(self.lines)
@dataclass
class OrderLine:
"""Entité contenue dans l'agrégat Order"""
product_id: int
quantity: int
unit_price: Price
Quelle est la différence entre une Entité et un Value Object ?
La couche Application
La couche application contient les cas d'usage (use cases) et les interfaces abstraites. Elle orchestre les interactions entre le domaine et l'infrastructure.
from abc import ABC, abstractmethod
# Interface abstraite du repository (dans la couche Application)
class UserRepositoryInterface(ABC):
@abstractmethod
def find_by_email(self, email: str) -> User | None:
pass
@abstractmethod
def create(self, user: User) -> User:
pass
La couche Infrastructure
La couche infrastructure fournit les implémentations concrètes des interfaces définies dans la couche application :
# Implémentation concrète (dans la couche Infrastructure)
class SQLiteUserRepository(UserRepositoryInterface):
def __init__(self, db):
self.db = db
def find_by_email(self, email: str) -> User | None:
cursor = self.db.execute(
"SELECT * FROM users WHERE email = ?", (email,)
)
row = cursor.fetchone()
if not row:
return None
return User(
id=row['id'],
email=row['email'],
username=row['username'],
hashed_password=row['hashed_password']
)
def create(self, user: User) -> User:
cursor = self.db.execute(
"""INSERT INTO users (email, username, hashed_password)
VALUES (?, ?, ?)""",
(user.email, user.username, user.hashed_password)
)
self.db.commit()
user.id = cursor.lastrowid
return user
Exemple complet : RegisterUser
Suivons le parcours complet d'une inscription utilisateur à travers toutes les couches :
1. Le DTO (couche Application)
from pydantic import BaseModel, field_validator
class RegisterUserDTO(BaseModel):
email: str
username: str
password: str
@field_validator('password')
@classmethod
def password_must_be_strong(cls, v: str) -> str:
if len(v) < 8:
raise ValueError("Le mot de passe doit contenir au moins 8 caractères")
return v
2. Le Use Case (couche Application)
class RegisterUserUseCase:
"""Cas d'usage : inscription d'un utilisateur"""
def __init__(self, user_repository: UserRepositoryInterface):
self.user_repository = user_repository
def execute(self, dto: RegisterUserDTO) -> User:
# Vérifier si l'utilisateur existe déjà
existing = self.user_repository.find_by_email(dto.email)
if existing:
raise UserAlreadyExistsError(f"L'email {dto.email} est déjà utilisé")
# Créer l'entité User
user = User(
id=0, # sera attribué par la base
email=dto.email,
username=dto.username,
hashed_password=generate_password_hash(dto.password)
)
# Persister via le repository
return self.user_repository.create(user)
3. Le Controller (couche Infrastructure / Framework)
from flask import request, jsonify
from http import HTTPStatus
from pydantic import ValidationError
@users_bp.route('/register', methods=['POST'])
def register():
"""Controller : inscription d'un utilisateur"""
# 1. Récupérer et valider les données avec le DTO
data = request.get_json()
try:
dto = RegisterUserDTO(**data)
except ValidationError as e:
return jsonify({"error": e.errors()}), HTTPStatus.BAD_REQUEST
# 2. Exécuter le use case
try:
user = register_use_case.execute(dto)
except UserAlreadyExistsError as e:
return jsonify({"error": str(e)}), HTTPStatus.BAD_REQUEST
# 3. Mapper vers la réponse (ne pas exposer le mot de passe !)
response = UserMapper.entity_to_response(user)
return jsonify(response), HTTPStatus.CREATED
4. Le Mapper
class UserMapper:
@staticmethod
def entity_to_response(user: User) -> dict:
"""Convertir une entité User en réponse API"""
return {
"id": user.id,
"email": user.email,
"username": user.username,
"createdAt": user.created_at.isoformat()
}
Flux complet
Dans la Clean Architecture, pourquoi le domaine ne dépend-il d'aucune autre couche ?
Structure de fichiers en Clean Architecture
my_api/
├── domain/ # Couche Domaine
│ ├── entities/
│ │ ├── user.py # Entité User
│ │ └── order.py # Agrégat Order
│ ├── value_objects/
│ │ └── price.py # Value Object Price
│ └── services/
│ └── pricing.py # Domain Service
├── application/ # Couche Application
│ ├── use_cases/
│ │ ├── register_user.py # Use Case
│ │ └── create_order.py
│ ├── interfaces/
│ │ ├── user_repository.py # Interface abstraite
│ │ └── order_repository.py
│ └── dtos/
│ ├── register_user_dto.py
│ └── create_order_dto.py
├── infrastructure/ # Couche Infrastructure
│ ├── repositories/
│ │ ├── sqlite_user_repository.py # Implémentation concrète
│ │ └── sqlite_order_repository.py
│ ├── mappers/
│ │ └── user_mapper.py
│ └── flask/
│ ├── app.py # Configuration Flask
│ └── routes/
│ ├── users.py # Routes + Controllers
│ └── orders.py
└── config.py
À retenir
Points clés
- La Clean Architecture organise le code en couches concentriques : Domaine → Application → Infrastructure
- Les dépendances vont vers l'intérieur : le domaine ne dépend de rien
- Les Entités ont une identité unique ; les Value Objects sont immuables et définis par leurs attributs
- Les Use Cases orchestrent la logique en s'appuyant sur des interfaces abstraites
- L'Infrastructure fournit les implémentations concrètes (repository, framework)
- Le domaine peut être testé sans base de données ni framework