Clean Architecture en Python : Guide Pratique
La Clean Architecture, popularisée par Robert Martin (Uncle Bob), est une approche architecturale qui vise à créer des systèmes logiciels maintenables, testables et indépendants des détails techniques.
Qu'est-ce que la Clean Architecture ?
La Clean Architecture organise le code en couches concentriques, chaque couche ayant des responsabilités spécifiques et des règles de dépendance strictes.
Les Couches Principales
- Entités (Entities) - Règles métier générales
- Cas d'usage (Use Cases) - Règles métier spécifiques à l'application
- Adaptateurs d'interface (Interface Adapters)
- Frameworks et pilotes (Frameworks & Drivers)
Implémentation en Python
Structure du Projet
project/
├── entities/
│ └── user.py
├── use_cases/
│ └── user_registration.py
├── interfaces/
│ ├── repositories/
│ │ └── user_repository.py
│ └── presenters/
│ └── user_presenter.py
├── adapters/
│ ├── repositories/
│ │ └── sql_user_repository.py
│ └── controllers/
│ └── user_controller.py
└── frameworks/
└── web/
└── flask_app.py
1. Couche Entités
# entities/user.py
from dataclasses import dataclass
from typing import Optional
import re
@dataclass
class User:
email: str
name: str
id: Optional[int] = None
def __post_init__(self):
self.validate()
def validate(self):
if not self.email or not self._is_valid_email(self.email):
raise ValueError("Email invalide")
if not self.name or len(self.name.strip()) < 2:
raise ValueError("Nom invalide")
def _is_valid_email(self, email: str) -> bool:
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
return re.match(pattern, email) is not None
def change_email(self, new_email: str):
old_email = self.email
self.email = new_email
try:
self.validate()
except ValueError:
self.email = old_email
raise
2. Couche Cas d'Usage
# use_cases/user_registration.py
from abc import ABC, abstractmethod
from entities.user import User
class UserRepository(ABC):
@abstractmethod
def save(self, user: User) -> User:
pass
@abstractmethod
def find_by_email(self, email: str) -> Optional[User]:
pass
class UserRegistrationUseCase:
def __init__(self, user_repository: UserRepository):
self.user_repository = user_repository
def register_user(self, email: str, name: str) -> User:
# Vérifier si l'utilisateur existe déjà
existing_user = self.user_repository.find_by_email(email)
if existing_user:
raise ValueError("Un utilisateur avec cet email existe déjà")
# Créer et sauvegarder le nouvel utilisateur
user = User(email=email, name=name)
return self.user_repository.save(user)
3. Couche Adaptateurs
# adapters/repositories/sql_user_repository.py
from typing import Optional
import sqlite3
from interfaces.repositories.user_repository import UserRepository
from entities.user import User
class SQLUserRepository(UserRepository):
def __init__(self, db_path: str):
self.db_path = db_path
self._init_db()
def _init_db(self):
with sqlite3.connect(self.db_path) as conn:
conn.execute("""
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
name TEXT NOT NULL
)
""")
def save(self, user: User) -> User:
with sqlite3.connect(self.db_path) as conn:
cursor = conn.execute(
"INSERT INTO users (email, name) VALUES (?, ?)",
(user.email, user.name)
)
user.id = cursor.lastrowid
return user
def find_by_email(self, email: str) -> Optional[User]:
with sqlite3.connect(self.db_path) as conn:
row = conn.execute(
"SELECT id, email, name FROM users WHERE email = ?",
(email,)
).fetchone()
if row:
return User(id=row[0], email=row[1], name=row[2])
return None
4. Couche Framework
# frameworks/web/flask_app.py
from flask import Flask, request, jsonify
from adapters.repositories.sql_user_repository import SQLUserRepository
from use_cases.user_registration import UserRegistrationUseCase
app = Flask(__name__)
# Injection de dépendances
user_repository = SQLUserRepository("users.db")
user_registration_use_case = UserRegistrationUseCase(user_repository)
@app.route('/users', methods=['POST'])
def register_user():
try:
data = request.get_json()
user = user_registration_use_case.register_user(
email=data['email'],
name=data['name']
)
return jsonify({
'id': user.id,
'email': user.email,
'name': user.name
}), 201
except ValueError as e:
return jsonify({'error': str(e)}), 400
except KeyError as e:
return jsonify({'error': f'Champ manquant: {e}'}), 400
if __name__ == '__main__':
app.run(debug=True)
Avantages de cette Approche
1. Testabilité
# tests/test_user_registration.py
import pytest
from unittest.mock import Mock
from entities.user import User
from use_cases.user_registration import UserRegistrationUseCase
def test_register_new_user():
# Arrange
mock_repository = Mock()
mock_repository.find_by_email.return_value = None
mock_repository.save.return_value = User(
id=1, email="test@example.com", name="Test User"
)
use_case = UserRegistrationUseCase(mock_repository)
# Act
result = use_case.register_user("test@example.com", "Test User")
# Assert
assert result.email == "test@example.com"
assert result.name == "Test User"
mock_repository.save.assert_called_once()
def test_register_existing_user():
# Arrange
mock_repository = Mock()
mock_repository.find_by_email.return_value = User(
id=1, email="test@example.com", name="Existing User"
)
use_case = UserRegistrationUseCase(mock_repository)
# Act & Assert
with pytest.raises(ValueError, match="existe déjà"):
use_case.register_user("test@example.com", "Test User")
2. Indépendance des Frameworks
Le code métier n'est pas couplé à Flask, SQLite ou toute autre technologie spécifique.
3. Facilité de Maintenance
Chaque couche a une responsabilité claire et peut être modifiée indépendamment.
Bonnes Pratiques
1. Dependency Injection
# dependency_injection.py
from typing import Protocol
class Container:
def __init__(self):
self._services = {}
def register(self, interface: type, implementation: object):
self._services[interface] = implementation
def get(self, interface: type):
return self._services.get(interface)
# Usage
container = Container()
container.register(UserRepository, SQLUserRepository("users.db"))
2. Validation Centralisée
# entities/validators.py
class ValidationError(Exception):
pass
class EmailValidator:
@staticmethod
def validate(email: str) -> bool:
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
if not re.match(pattern, email):
raise ValidationError("Format d'email invalide")
return True
3. Gestion des Erreurs
# common/errors.py
class DomainError(Exception):
"""Erreur métier de base"""
pass
class UserAlreadyExistsError(DomainError):
"""Utilisateur déjà existant"""
pass
class InvalidEmailError(DomainError):
"""Email invalide"""
pass
Conclusion
La Clean Architecture en Python offre :
- Séparation claire des responsabilités
- Code hautement testable
- Indépendance vis-à-vis des frameworks
- Facilité de maintenance et d'évolution
Cette approche demande un investissement initial en termes de structure, mais elle paie sur le long terme en rendant le code plus robuste et évolutif. Elle est particulièrement recommandée pour les projets complexes ou destinés à durer dans le temps.
Top comments (0)