Skip to main content

Clean Architecture per Microservizi FastAPI

Introduzione​

Questa guida documenta l'architettura Clean/Hexagonal adottata in tutti i microservizi FastAPI del progetto VISLA. L'obiettivo Γ¨ garantire:

  • Separazione delle responsabilitΓ  - Ogni layer ha un compito specifico
  • TestabilitΓ  - Business logic isolata e facilmente mockabile
  • ManutenibilitΓ  - Cambiamenti in un layer non impattano gli altri
  • Indipendenza dal framework - Il domain non conosce FastAPI, SQLAlchemy, ecc.

Struttura Ideale di un Microservizio​

service-name/
β”œβ”€β”€ app.py # Entry point FastAPI
β”œβ”€β”€ config.py # ⚠️ LEGACY - spostare in infrastructure/config/
β”‚
β”œβ”€β”€ domain/ # 🟒 CORE - Business Logic Pura
β”‚ β”œβ”€β”€ entities/ # Modelli di dominio (dataclass)
β”‚ β”‚ β”œβ”€β”€ __init__.py
β”‚ β”‚ └── device.py # @dataclass Device, DeviceId
β”‚ β”‚
β”‚ └── ports/ # Interfacce/Contratti
β”‚ β”œβ”€β”€ inbound/ # Use Case interfaces (opzionale)
β”‚ β”‚ └── device_service.py # Protocol/ABC per use cases
β”‚ └── outbound/ # Repository interfaces
β”‚ β”œβ”€β”€ __init__.py
β”‚ └── device_repository.py # ABC DeviceRepository
β”‚
β”œβ”€β”€ application/ # πŸ”΅ USE CASES - Orchestrazione
β”‚ β”œβ”€β”€ __init__.py
β”‚ └── use_cases/
β”‚ β”œβ”€β”€ __init__.py
β”‚ β”œβ”€β”€ get_device.py # GetDeviceUseCase
β”‚ β”œβ”€β”€ create_device.py # CreateDeviceUseCase
β”‚ └── update_device.py # UpdateDeviceUseCase
β”‚
β”œβ”€β”€ infrastructure/ # 🟠 ADAPTERS - Implementazioni Concrete
β”‚ β”œβ”€β”€ config/
β”‚ β”‚ β”œβ”€β”€ __init__.py
β”‚ β”‚ β”œβ”€β”€ settings.py # Pydantic Settings (β†’ SPOSTARE config.py qui)
β”‚ β”‚ β”œβ”€β”€ database.py # SessionLocal, engine, get_db
β”‚ β”‚ └── container.py # DI Container
β”‚ β”‚
β”‚ └── adapters/
β”‚ β”œβ”€β”€ inbound/ # Driving adapters (API, CLI, Workers)
β”‚ β”‚ └── redis_consumer.py # Background workers
β”‚ β”‚
β”‚ β”œβ”€β”€ outbound/ # Driven adapters (DB, External APIs)
β”‚ β”‚ β”œβ”€β”€ orm_models.py # SQLAlchemy models
β”‚ β”‚ β”œβ”€β”€ postgres_device_repository.py
β”‚ β”‚ └── redis_cache.py
β”‚ β”‚
β”‚ └── clients/ # External service clients
β”‚ └── billing_client.py
β”‚
β”œβ”€β”€ routes/ # 🟣 HTTP ADAPTERS - FastAPI Routes
β”‚ β”œβ”€β”€ __init__.py
β”‚ β”œβ”€β”€ devices.py # APIRouter endpoints
β”‚ β”œβ”€β”€ health.py
β”‚ └── schema.py # Route per schema/migrations
β”‚
β”œβ”€β”€ tests/ # Test Suite
β”‚ β”œβ”€β”€ unit/ # Unit tests (mocked)
β”‚ β”œβ”€β”€ integration/ # Integration tests (DB)
β”‚ └── conftest.py
β”‚
β”œβ”€β”€ Dockerfile
β”œβ”€β”€ docker-compose.yml
β”œβ”€β”€ requirements.txt
β”œβ”€β”€ pytest.ini
└── README.md

Cosa Rimuovere/Spostare​

Nel tuo screenshot vedo file che andrebbero riorganizzati:

File AttualePosizione IdealeAzione
config.py (root)infrastructure/config/settings.pySpostare
routes/ (root)OK βœ…Mantenere
app.py (root)OK βœ…Mantenere (entry point)

Nota: app.py e Dockerfile rimangono nella root perchΓ© sono entry point. Ma la configurazione deve stare in infrastructure/ perchΓ© Γ¨ un dettaglio implementativo.


I Layer Spiegati​

🟒 Domain Layer (Centro)​

Il cuore dell'applicazione. Contiene:

  • Entities: Oggetti di business puri (no dipendenze esterne)
  • Ports: Interfacce/contratti che definiscono COSA fare, non COME
# domain/entities/device.py
from dataclasses import dataclass
from typing import Optional

@dataclass
class DeviceId:
value: int

@dataclass
class Device:
id: Optional[DeviceId]
name: str
protocol: str
unique_id: str

def to_dict(self) -> dict:
return {
"id": self.id.value if self.id else None,
"name": self.name,
"protocol": self.protocol,
}
# domain/ports/outbound/device_repository.py
from abc import ABC, abstractmethod
from domain.entities.device import Device, DeviceId

class DeviceRepository(ABC):
@abstractmethod
async def find_by_id(self, device_id: DeviceId) -> Optional[Device]:
pass

@abstractmethod
async def save(self, device: Device) -> Device:
pass

Regola d'Oro: Il Domain Layer NON importa mai nulla da infrastructure/, routes/, SQLAlchemy, FastAPI, ecc.


πŸ”΅ Application Layer (Use Cases)​

Contiene la logica di orchestrazione. Ogni Use Case:

  • Riceve una Request (dataclass)
  • Restituisce una Response (dataclass)
  • Usa i Ports per interagire con l'esterno
# application/use_cases/get_device.py
from dataclasses import dataclass
from domain.entities.device import Device, DeviceId
from domain.ports.outbound.device_repository import DeviceRepository

@dataclass
class GetDeviceRequest:
device_id: int
user_id: int

@dataclass
class GetDeviceResponse:
device: Device

class GetDeviceUseCase:
def __init__(self, device_repository: DeviceRepository):
self._repo = device_repository

async def execute(self, request: GetDeviceRequest) -> GetDeviceResponse:
device = await self._repo.find_by_id(DeviceId(request.device_id))
if not device:
raise DeviceNotFoundError()
return GetDeviceResponse(device=device)

Vantaggi:

  • Testabile con mock repository
  • Non conosce HTTP, DB, Redis
  • Riutilizzabile in CLI, workers, ecc.

🟠 Infrastructure Layer (Adapters)​

Implementazioni concrete dei Ports:

# infrastructure/adapters/outbound/postgres_device_repository.py
from domain.ports.outbound.device_repository import DeviceRepository
from domain.entities.device import Device, DeviceId
from infrastructure.adapters.orm_models import DeviceModel

class PostgresDeviceRepository(DeviceRepository):
def __init__(self, session_factory):
self._session_factory = session_factory

async def find_by_id(self, device_id: DeviceId) -> Optional[Device]:
with self._session_factory() as session:
model = session.get(DeviceModel, device_id.value)
if model:
return self._to_entity(model)
return None

def _to_entity(self, model: DeviceModel) -> Device:
return Device(
id=DeviceId(model.id),
name=model.name,
protocol=model.protocol,
unique_id=model.unique_id,
)

Il DI Container assembla tutto:

# infrastructure/config/container.py
class Container:
def __init__(self, session_factory):
self._session_factory = session_factory

@property
def device_repository(self) -> DeviceRepository:
return PostgresDeviceRepository(self._session_factory)

@property
def get_device_use_case(self) -> GetDeviceUseCase:
return GetDeviceUseCase(device_repository=self.device_repository)

🟣 Routes (HTTP Adapters)​

I routes FastAPI sono solo adattatori HTTP che:

  1. Validano input (Pydantic)
  2. Chiamano Use Cases
  3. Formattano output
# routes/devices.py
from fastapi import APIRouter, Depends, HTTPException
from infrastructure.config.container import get_container
from application.use_cases.get_device import GetDeviceRequest

router = APIRouter()

@router.get("/{device_id}")
async def get_device(device_id: int, current_user = Depends(get_current_user)):
container = get_container()
use_case = container.get_device_use_case

try:
response = await use_case.execute(
GetDeviceRequest(device_id=device_id, user_id=current_user.id)
)
return response.device.to_dict()
except DeviceNotFoundError:
raise HTTPException(404, "Device not found")

Dependency Rule​

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ ROUTES (HTTP) β”‚
β”‚ ↓ conosce ↓ β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ APPLICATION (Use Cases) β”‚
β”‚ ↓ conosce ↓ β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ DOMAIN β”‚
β”‚ entities, ports β”‚
β”‚ ⚠️ NON conosce nient'altro β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ INFRASTRUCTURE β”‚
β”‚ implementa i ports definiti in domain β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Le dipendenze puntano verso l'interno (verso il Domain). Mai al contrario.


Vantaggi Pratici​

1. Testing Semplificato​

# test_get_device.py
class MockDeviceRepository(DeviceRepository):
async def find_by_id(self, device_id):
return Device(id=DeviceId(1), name="Test", ...)

async def test_get_device_success():
use_case = GetDeviceUseCase(device_repository=MockDeviceRepository())
response = await use_case.execute(GetDeviceRequest(device_id=1, user_id=1))
assert response.device.name == "Test"

2. Cambio Database Trasparente​

Se domani vuoi passare da PostgreSQL a MongoDB, cambi solo l'adapter:

class MongoDeviceRepository(DeviceRepository):
# Nuova implementazione
pass

Il Use Case non cambia!

3. Riutilizzo Business Logic​

Lo stesso Use Case puΓ² essere usato da:

  • HTTP API (FastAPI routes)
  • CLI commands
  • Background workers
  • GraphQL resolvers

Checklist Refactoring​

  • config.py spostato in infrastructure/config/settings.py
  • Tutte le Entities sono @dataclass in domain/entities/
  • Repository definiti come ABC in domain/ports/outbound/
  • Ogni operazione ha un Use Case in application/use_cases/
  • ORM models solo in infrastructure/adapters/orm_models.py
  • Routes chiamano Use Cases, non repository direttamente
  • DI Container in infrastructure/config/container.py