Clean Architecture in Python: Quando il Codice Diventa Arte

Clean Architecture in Python
L'elegante struttura della Clean Architecture
Matteo 10 min

la Clean Architecture promette di rendere il nostro codice pulito, mantenibile e testabile. Ma come si passa dalla teoria alla pratica quando si lavora con Python? Scopriamolo insieme, con qualche esempio concreto e, perché no, un pizzico di ironia.

La Clean Architecture: Non È Solo un Bel Disegno a Cerchi Concentrici

Se hai mai cercato “Clean Architecture” su Google, avrai sicuramente visto quel famoso diagramma con i cerchi concentrici. Bello vero? Ma la vera bellezza sta nel capire come implementarlo senza impazzire nel tentativo.

La Clean Architecture si sposa perfettamente con i principi SOLID e il Domain-Driven Design, creando quella che potremmo chiamare la “Santissima Trinità” dell’architettura software. Ma andiamo con ordine.

I Layer: Come una Cipolla, Ma Senza Farti Piangere

La Clean Architecture si basa su layers ben definiti:

  1. Entities (il cuore del sistema)

    • Oggetti di business
    • Regole di business enterprise
    • Possono essere usate da diverse applicazioni
  2. Use Cases (le regole dell’applicazione)

    • Implementano la business logic
    • Orchestrano il flusso dei dati
    • Specifici per una singola applicazione
  3. Interface Adapters (i controller e i gateway)

    • Convertono i dati nel formato più conveniente
    • Gestiscono la comunicazione tra layers
  4. Frameworks & Drivers (il mondo esterno)

    • Database
    • Web Framework
    • Dispositivi esterni

Setup del Progetto: Iniziamo con il Piede Giusto

Prima di tuffarci nel codice, vediamo come strutturare il nostro progetto. Una buona organizzazione è metà dell’opera!

Struttura delle Directory

clean_library/
├── src/
│   ├── domain/           # Entities e business rules
│   │   ├── __init__.py
│   │   ├── entities/
│   │   └── value_objects/
│   ├── application/      # Use cases
│   │   ├── __init__.py
│   │   ├── interfaces/
│   │   └── services/
│   ├── infrastructure/   # Frameworks & Drivers
│   │   ├── __init__.py
│   │   ├── database/
│   │   └── api/
│   └── interfaces/       # Interface Adapters
│       ├── __init__.py
│       ├── controllers/
│       └── repositories/
├── tests/
│   ├── unit/
│   ├── integration/
│   └── e2e/
├── pyproject.toml
└── README.md

Configurazione del Progetto

[tool.poetry]
name = "clean-library"
version = "0.1.0"
description = "Un esempio di Clean Architecture in Python"

[tool.poetry.dependencies]
python = "^3.10"
fastapi = "^0.100.0"
sqlalchemy = "^2.0.0"
pydantic = "^2.0.0"
dependency-injector = "^4.41.0"

[tool.poetry.dev-dependencies]
pytest = "^7.4.0"
pytest-cov = "^4.1.0"
black = "^23.7.0"
mypy = "^1.4.1"

Implementazione Pratica: Dalle Parole ai Fatti

Basta teoria! Vediamo come strutturare un progetto Python seguendo questi principi. Prenderemo come esempio un’applicazione per gestire una libreria (perché tutti gli esempi di programmazione devono coinvolgere o una libreria o un e-commerce, è tipo una legge non scritta).

1. Le Entities: Il Cuore Puro del Sistema

# domain/entities/book.py
from dataclasses import dataclass
from uuid import UUID, uuid4
from datetime import datetime

@dataclass(frozen=True)
class Book:
    """
    Una entity Book pura e immacolata.
    Non sa nulla del mondo esterno, proprio come un monaco in meditazione.
    """
    id: UUID
    title: str
    author: str
    isbn: str
    created_at: datetime
    updated_at: datetime | None = None
    
    @staticmethod
    def create(title: str, author: str, isbn: str) -> 'Book':
        return Book(
            id=uuid4(),
            title=title,
            author=author,
            isbn=isbn,
            created_at=datetime.utcnow()
        )
    
    def is_valid_isbn(self) -> bool:
        return len(self.isbn) == 13 and self.isbn.isdigit()
    
    def update_title(self, new_title: str) -> 'Book':
        return Book(
            id=self.id,
            title=new_title,
            author=self.author,
            isbn=self.isbn,
            created_at=self.created_at,
            updated_at=datetime.utcnow()
        )

2. I Value Objects: Piccoli ma Potenti

# domain/value_objects/money.py
from dataclasses import dataclass
from decimal import Decimal

@dataclass(frozen=True)
class Money:
    amount: Decimal
    currency: str

    def __add__(self, other: 'Money') -> 'Money':
        if self.currency != other.currency:
            raise ValueError("Cannot add different currencies")
        return Money(self.amount + other.amount, self.currency)

    def __mul__(self, multiplier: int) -> 'Money':
        return Money(self.amount * multiplier, self.currency)

# domain/value_objects/isbn.py
@dataclass(frozen=True)
class ISBN:
    value: str

    def __post_init__(self):
        if not self._is_valid():
            raise ValueError("Invalid ISBN format")

    def _is_valid(self) -> bool:
        return len(self.value) == 13 and self.value.isdigit()

3. Le Interfaces: Il Contratto Sociale del Nostro Codice

# application/interfaces/repositories.py
from abc import ABC, abstractmethod
from typing import List, Optional
from domain.entities.book import Book

class BookRepository(ABC):
    @abstractmethod
    def save(self, book: Book) -> Book:
        pass
    
    @abstractmethod
    def get_by_id(self, book_id: UUID) -> Optional[Book]:
        pass
    
    @abstractmethod
    def get_all(self) -> List[Book]:
        pass
    
    @abstractmethod
    def delete(self, book_id: UUID) -> None:
        pass

4. Gli Use Cases (casi d’uso): Dove la accade la Magia 🪄

# application/use_cases/add_book.py
from dataclasses import dataclass
from typing import Protocol
from domain.entities.book import Book

class BookRepository(Protocol):
    def save(self, book: Book) -> Book: ...

@dataclass
class AddBookRequest:
    title: str
    author: str
    isbn: str

@dataclass
class AddBookResponse:
    book: Book

class AddBookUseCase:
    """
    Come un bibliotecario efficiente ma con meno polvere addosso.
    """
    def __init__(self, book_repository: BookRepository):
        self._repository = book_repository
    
    def execute(self, request: AddBookRequest) -> AddBookResponse:
        book = Book.create(
            title=request.title,
            author=request.author,
            isbn=request.isbn
        )
        
        if not book.is_valid_isbn():
            raise ValueError("ISBN non valido! Hai provato a spegnere e riaccendere il libro?")
            
        saved_book = self._repository.save(book)
        return AddBookResponse(book=saved_book)

5. Interface Adapters: Il Diplomatico del Gruppo

# infrastructure/repositories/sql_book_repository.py
from typing import List, Optional, Callable
from sqlalchemy.orm import Session
from domain.entities.book import Book
from application.interfaces.repositories import BookRepository

class SQLBookRepository(BookRepository):
    """
    Come un traduttore simultaneo, ma per database.
    Trasforma le nostre pure entities in righe di database e viceversa.
    """
    def __init__(self, session_factory: Callable[[], Session]):
        self._session_factory = session_factory
    
    def save(self, book: Book) -> Book:
        with self._session_factory() as session:
            book_model = BookModel(
                id=book.id,
                title=book.title,
                author=book.author,
                isbn=book.isbn,
                created_at=book.created_at,
                updated_at=book.updated_at
            )
            session.add(book_model)
            session.commit()
            return book
            
    def get_by_id(self, book_id: UUID) -> Optional[Book]:
        with self._session_factory() as session:
            book_model = session.query(BookModel).filter(
                BookModel.id == book_id
            ).first()
            
            if not book_model:
                return None
                
            return Book(
                id=book_model.id,
                title=book_model.title,
                author=book_model.author,
                isbn=book_model.isbn,
                created_at=book_model.created_at,
                updated_at=book_model.updated_at
            )

6. Frameworks & Drivers

# infrastructure/api/fastapi_app.py
from fastapi import FastAPI, HTTPException, Depends
from dependency_injector import containers, providers
from dependency_injector.wiring import inject, Provide

app = FastAPI()

class Container(containers.DeclarativeContainer):
    """
    Il nostro contenitore IoC.
    Come un maggiordomo che sa sempre dove trovare tutto.
    """
    config = providers.Configuration()
    
    db = providers.Singleton(
        Database,
        db_url=config.db.url
    )
    
    book_repository = providers.Factory(
        SQLBookRepository,
        session_factory=db.provided.session
    )
    
    add_book_use_case = providers.Factory(
        AddBookUseCase,
        book_repository=book_repository
    )

@app.post("/books/")
@inject
async def add_book(
    book_data: AddBookRequest,
    use_case: AddBookUseCase = Depends(Provide[Container.add_book_use_case])
):
    try:
        response = use_case.execute(book_data)
        return {"id": str(response.book.id)}
    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e))

Testing: La Prova del Nove

Il testing in un’architettura pulita diventa quasi un piacere. Quasi.

Test delle Entities

# tests/unit/test_book.py
import pytest
from datetime import datetime
from uuid import uuid4
from domain.entities.book import Book

def test_book_creation():
    # Arrange
    book_id = uuid4()
    title = "Clean Architecture: Non È Rocket Science"
    author = "Un Dev Ottimista"
    isbn = "1234567890123"
    created_at = datetime.utcnow()
    
    # Act
    book = Book(
        id=book_id,
        title=title,
        author=author,
        isbn=isbn,
        created_at=created_at
    )
    
    # Assert
    assert book.id == book_id
    assert book.title == title
    assert book.author == author
    assert book.isbn == isbn
    assert book.created_at == created_at
    assert book.updated_at is None

def test_invalid_isbn():
    # Arrange
    book = Book.create(
        title="Test Book",
        author="Test Author",
        isbn="123"  # ISBN invalido
    )
    
    # Act & Assert
    assert not book.is_valid_isbn()

Test degli Use Cases

# tests/unit/test_add_book_use_case.py
class MockBookRepository:
    def __init__(self):
        self.books = []
    
    def save(self, book: Book) -> Book:
        self.books.append(book)
        return book

def test_add_book_use_case():
    # Arrange
    repository = MockBookRepository()
    use_case = AddBookUseCase(repository)
    request = AddBookRequest(
        title="Clean Architecture: Non È Rocket Science",
        author="Un Dev Ottimista",
        isbn="1234567890123"
    )
    
    # Act
    response = use_case.execute(request)
    
    # Assert
    assert response.book.title == request.title
    assert response.book.author == request.author
    assert response.book.isbn == request.isbn
    assert len(repository.books) == 1

Anti-Pattern e Come Evitarli

Quando si implementa la Clean Architecture, ci sono alcuni anti-pattern comuni da evitare:

1. La Tentazione del Singleton

# ❌ Non fare questo
class DatabaseConnection:
    _instance = None
    
    @classmethod
    def get_instance(cls):
        if cls._instance is None:
            cls._instance = cls()
        return cls._instance

# ✅ Usa invece Dependency Injection
class DatabaseConnection:
    def __init__(self, config: DatabaseConfig):
        self.config = config

2. Violazione della Dependency Rule

# ❌ Non fare questo
class Book:
    def save(self, session: SqlAlchemySession):
        session.add(self)
        session.commit()

# ✅ Usa invece repository pattern
class BookRepository:
    def __init__(self, session_factory: Callable[[], Session]):
        self._session_factory = session_factory
    
    def save(self, book: Book) -> Book:
        with self._session_factory() as session:
            session.add(book)
            session.commit()
            return book

3. Accoppiamento con Framework

# ❌ Non fare questo
from fastapi import HTTPException

class AddBookUseCase:
    def execute(self, request: AddBookRequest):
        try:
            # logica
        except ValueError as e:
            raise HTTPException(status_code=400, str(e))

# ✅ Usa invece eccezioni di dominio
class BookValidationError(Exception):
    pass

class AddBookUseCase:
    def execute(self, request: AddBookRequest):
        try:
            # logica
        except ValueError as e:
            raise BookValidationError(str(e))

4. Logica di Business negli Adapter

# ❌ Non fare questo
class BookController:
    def create_book(self, data: dict):
        if len(data['isbn']) != 13:
            raise ValueError("ISBN non valido")
        # resto del codice

# ✅ Sposta la logica di business nel dominio
class Book:
    def validate_isbn(self):
        if len(self.isbn) != 13:
            raise BookValidationError("ISBN non valido")

Patterns Comuni nella Clean Architecture

1. Repository Pattern

Il Repository Pattern è fondamentale nella Clean Architecture. Agisce come un’astrazione del layer di persistenza:

# domain/repositories/book_repository.py
from abc import ABC, abstractmethod
from typing import List, Optional
from domain.entities.book import Book

class BookRepository(ABC):
    @abstractmethod
    def save(self, book: Book) -> Book:
        pass
    
    @abstractmethod
    def find_by_isbn(self, isbn: str) -> Optional[Book]:
        pass
    
    @abstractmethod
    def find_all(self) -> List[Book]:
        pass

# infrastructure/repositories/sql_book_repository.py
class SQLBookRepository(BookRepository):
    def find_by_isbn(self, isbn: str) -> Optional[Book]:
        with self._session_factory() as session:
            book_model = session.query(BookModel).filter(
                BookModel.isbn == isbn
            ).first()
            return self._to_entity(book_model) if book_model else None

2. Factory Pattern

Il Factory Pattern è utilissimo per creare oggetti complessi mantenendo il codice pulito:

# domain/factories/book_factory.py
from domain.entities.book import Book
from domain.value_objects.isbn import ISBN

class BookFactory:
    @staticmethod
    def create_book(title: str, author: str, isbn: str) -> Book:
        isbn_vo = ISBN(isbn)  # Validazione dell'ISBN
        return Book.create(
            title=title,
            author=author,
            isbn=isbn_vo.value
        )

3. Decorator Pattern per Cross-Cutting Concerns

# application/decorators/logging.py
from functools import wraps
import logging

def log_execution_time(logger: logging.Logger):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            import time
            start_time = time.time()
            result = func(*args, **kwargs)
            end_time = time.time()
            logger.info(
                f"{func.__name__} took {end_time - start_time:.2f} seconds"
            )
            return result
        return wrapper
    return decorator

# Uso del decorator
class AddBookUseCase:
    @log_execution_time(logging.getLogger(__name__))
    def execute(self, request: AddBookRequest) -> AddBookResponse:
        # implementazione

Troubleshooting: Problemi Comuni e Soluzioni

1. Circular Dependencies

Problema:

# ❌ Dipendenze circolari
from domain.book import Book
from domain.author import Author

class Book:
    def __init__(self, author: Author): ...

class Author:
    def __init__(self, books: List[Book]): ...

Soluzione:

# ✅ Usa riferimenti tramite ID
class Book:
    def __init__(self, author_id: UUID): ...

class Author:
    def __init__(self, book_ids: List[UUID]): ...

2. Troppi Layer

Problema:

# ❌ Over-engineering
request  controller  validator  mapper  use_case  service  repository  database

Soluzione:

# ✅ Mantieni semplice
request  controller  use_case  repository  database

3. Gestione delle Transazioni

Problema: Dove gestire le transazioni del database mantenendo la Clean Architecture?

Soluzione: Unit of Work Pattern

# infrastructure/unit_of_work.py
from abc import ABC, abstractmethod
from contextlib import AbstractContextManager
from typing import Callable
from sqlalchemy.orm import Session

class UnitOfWork(ABC):
    @abstractmethod
    def __enter__(self):
        pass
    
    @abstractmethod
    def __exit__(self, exc_type, exc_val, exc_tb):
        pass
    
    @abstractmethod
    def commit(self):
        pass
    
    @abstractmethod
    def rollback(self):
        pass

class SqlAlchemyUnitOfWork(UnitOfWork):
    def __init__(self, session_factory: Callable[[], Session]):
        self.session_factory = session_factory
    
    def __enter__(self):
        self.session = self.session_factory()
        return self
    
    def __exit__(self, exc_type, exc_val, exc_tb):
        if exc_type is not None:
            self.rollback()
        self.session.close()
    
    def commit(self):
        self.session.commit()
    
    def rollback(self):
        self.session.rollback()

Microservizi e Clean Architecture

La Clean Architecture si sposa perfettamente con un’architettura a microservizi. Ecco come:

1. Bounded Contexts

Ogni microservizio rappresenta un bounded context del dominio:

# services/library/domain/...
# services/inventory/domain/...
# services/billing/domain/...

2. Eventi di Dominio

# domain/events.py
from dataclasses import dataclass
from datetime import datetime
from uuid import UUID

@dataclass
class DomainEvent:
    occurred_on: datetime = field(default_factory=datetime.utcnow)

@dataclass
class BookAdded(DomainEvent):
    book_id: UUID
    title: str
    isbn: str

# Pubblicazione eventi
class EventPublisher:
    def publish(self, event: DomainEvent):
        # Pubblica su message broker
        pass

Conclusione: Vale la Pena?

La Clean Architecture all’inizio può sembrare un investimento costoso e pretenzioso, ma nel lungo termine fa la differenza. Non è necessaria per ogni progetto, ma quando serve, è una benedizione.

Ricorda:

  1. Inizia semplice, complica solo quando necessario
  2. I principi sono più importanti delle regole
  3. La Clean Architecture è uno strumento, non una religione
  4. Il miglior codice è quello che puoi mantenere senza voler cambiare lavoro ogni volta che lo guardi

P.S. In futuro approfondiremo meglio alcuni argomenti trattati in questo post, si rischiava di sfociare nel faditico TLDR. Hai esperienze con la Clean Architecture in Python? Raccontami la tua! E ricorda, anche il codice più pulito ha bisogno di una spolverata ogni tanto.

content_copy Copiato