Avancé

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.

Chargement du diagramme…
Chargement du diagramme…

Les trois couches

CoucheContenuDépendances
DomaineEntités, Value Objects, Domain ServicesAucune
ApplicationUse Cases, interfaces abstraitesDomaine
InfrastructureRepository (implémentation), Mailer, FrameworkApplication + 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.

python
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.

python
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 :

python
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.

python
@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.

python
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 :

python
# 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)

python
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)

python
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)

python
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

python
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

Chargement du diagramme…

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