DEV Community

Ulrich (Houngbe)
Ulrich (Houngbe)

Posted on

Clean Architecture en Python

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

  1. Entités (Entities) - Règles métier générales
  2. Cas d'usage (Use Cases) - Règles métier spécifiques à l'application
  3. Adaptateurs d'interface (Interface Adapters)
  4. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

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"))
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)