Clean Architecture in Python: Quando il Codice Diventa Arte
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:
Entities (il cuore del sistema)
- Oggetti di business
- Regole di business enterprise
- Possono essere usate da diverse applicazioni
Use Cases (le regole dell’applicazione)
- Implementano la business logic
- Orchestrano il flusso dei dati
- Specifici per una singola applicazione
Interface Adapters (i controller e i gateway)
- Convertono i dati nel formato più conveniente
- Gestiscono la comunicazione tra layers
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:
- Inizia semplice, complica solo quando necessario
- I principi sono più importanti delle regole
- La Clean Architecture è uno strumento, non una religione
- 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.