Iniezione di dipendenze: un modo Python

Pattern DI in Python per codice pulito e testabile

Indice

Iniezione di dipendenze (DI) è un modello di progettazione fondamentale che promuove codice pulito, testabile e mantenibile in applicazioni Python.

Che tu stia costruendo API REST con FastAPI, implementando test unitari o lavorando con funzioni AWS Lambda, comprendere l’iniezione di dipendenze migliorerà significativamente la qualità del tuo codice.

python packages

Cosa è l’iniezione di dipendenze?

L’iniezione di dipendenze è un modello di progettazione in cui i componenti ricevono le loro dipendenze da fonti esterne piuttosto che crearle internamente. Questo approccio decoppia i componenti, rendendo il tuo codice più modulare, testabile e mantenibile.

In Python, l’iniezione di dipendenze è particolarmente potente grazie alla natura dinamica del linguaggio e al supporto per i protocolli, le classi base astratte e il duck typing. La flessibilità di Python significa che puoi implementare i pattern DI senza framework pesanti, sebbene siano disponibili framework quando necessari.

Perché utilizzare l’iniezione di dipendenze in Python?

Migliore testabilità: Iniettando le dipendenze, puoi facilmente sostituire le implementazioni reali con mock o test double. Questo ti permette di scrivere test unitari veloci, isolati e che non richiedono servizi esterni come database o API. Quando scrivi test unitari completi, l’iniezione di dipendenze rende banale sostituire le dipendenze reali con test double.

Migliore manutenibilità: Le dipendenze diventano esplicite nel tuo codice. Quando guardi un costruttore, vedi immediatamente cosa richiede un componente. Questo rende il codicebase più facile da comprendere e modificare.

Decoupling: I componenti dipendono da astrazioni (protocolli o ABC) piuttosto che da implementazioni concrete. Questo significa che puoi cambiare le implementazioni senza influenzare il codice dipendente.

Flessibilità: Puoi configurare diverse implementazioni per diversi ambienti (sviluppo, test, produzione) senza modificare la logica aziendale. Questo è particolarmente utile quando si distribuiscono applicazioni Python su diversi piattaforme, che siano AWS Lambda o server tradizionali.

Iniezione di costruttore: Il modo Python

Il modo più comune e idiomatico per implementare l’iniezione di dipendenze in Python è attraverso l’iniezione di costruttore — accettando le dipendenze come parametri nel metodo __init__.

Esempio base

Ecco un esempio semplice che dimostra l’iniezione di costruttore:

from typing import Protocol
from abc import ABC, abstractmethod

# Definizione di un protocollo per il repository
class UserRepository(Protocol):
    def find_by_id(self, user_id: int) -> 'User | None':
        ...
    
    def save(self, user: 'User') -> 'User':
        ...

# Il servizio dipende dal protocollo del repository
class UserService:
    def __init__(self, repo: UserRepository):
        self.repo = repo
    
    def get_user(self, user_id: int) -> 'User | None':
        return self.repo.find_by_id(user_id)

Questo modello rende chiaro che UserService richiede un UserRepository. Non puoi creare un UserService senza fornire un repository, il che prevene errori di runtime da dipendenze mancanti.

Multiple Dependencies

Quando un componente ha più dipendenze, aggiungile semplicemente come parametri del costruttore:

class EmailService(Protocol):
    def send(self, to: str, subject: str, body: str) -> None:
        ...

class Logger(Protocol):
    def info(self, msg: str) -> None:
        ...
    
    def error(self, msg: str, err: Exception) -> None:
        ...

class OrderService:
    def __init__(
        self,
        repo: OrderRepository,
        email_svc: EmailService,
        logger: Logger,
        payment_svc: PaymentService,
    ):
        self.repo = repo
        self.email_svc = email_svc
        self.logger = logger
        self.payment_svc = payment_svc

Utilizzo di Protocol e Classi Base Astratte

Uno dei principi chiave nell’implementare l’iniezione di dipendenze è il Principio di Inversione delle Dipendenze (DIP): i moduli di alto livello non devono dipendere da moduli di basso livello; entrambi devono dipendere da astrazioni.

In Python, puoi definire astrazioni utilizzando Protocol (typing strutturale) o Classi Base Astratte (ABC) (typing nominale).

Protocol (Python 3.8+)

I Protocol utilizzano il typing strutturale — se un oggetto ha i metodi richiesti, soddisfa il protocollo:

from typing import Protocol

class PaymentProcessor(Protocol):
    def process_payment(self, amount: float) -> bool:
        ...

# Qualsiasi classe con il metodo process_payment soddisfa questo protocollo
class CreditCardProcessor:
    def process_payment(self, amount: float) -> bool:
        # Logica per carta di credito
        return True

class PayPalProcessor:
    def process_payment(self, amount: float) -> bool:
        # Logica per PayPal
        return True

# Il servizio accetta qualsiasi PaymentProcessor
class OrderService:
    def __init__(self, payment_processor: PaymentProcessor):
        self.payment_processor = payment_processor

Classi Base Astratte

Le ABC utilizzano il typing nominale — le classi devono ereditare esplicitamente dall’ABC:

from abc import ABC, abstractmethod

class PaymentProcessor(ABC):
    @abstractmethod
    def process_payment(self, amount: float) -> bool:
        pass

class CreditCardProcessor(PaymentProcessor):
    def process_payment(self, amount: float) -> bool:
        return True

Quando utilizzare Protocol vs ABC: Utilizza Protocol quando desideri typing strutturale e flessibilità. Utilizza ABC quando devi imporre gerarchie di ereditarietà o fornire implementazioni predefinite.

Esempio reale: Astrazione del database

Quando lavori con database in applicazioni Python, spesso devi astrarre le operazioni del database. Ecco come l’iniezione di dipendenze aiuta:

from typing import Protocol, Optional
from contextlib import contextmanager

class Database(Protocol):
    @contextmanager
    def transaction(self):
        ...
    
    def execute(self, query: str, params: dict) -> None:
        ...
    
    def fetch_one(self, query: str, params: dict) -> Optional[dict]:
        ...

# Il repository dipende dall'astrazione
class UserRepository:
    def __init__(self, db: Database):
        self.db = db
    
    def find_by_id(self, user_id: int) -> Optional['User']:
        result = self.db.fetch_one(
            "SELECT * FROM users WHERE id = :id",
            {"id": user_id}
        )
        if result:
            return User(**result)
        return None

Questo modello ti permette di sostituire le implementazioni del database (PostgreSQL, SQLite, MongoDB) senza modificare il codice del repository.

Il Pattern Root di Composizione

Il Root di Composizione è il punto in cui assembli tutte le dipendenze all’ingresso dell’applicazione (tipicamente main.py o il factory dell’applicazione). Questo centralizza la configurazione delle dipendenze e rende il grafico delle dipendenze esplicito.

def create_app() -> FastAPI:
    app = FastAPI()
    
    # Inizializza le dipendenze dell'infrastruttura
    db = init_database()
    logger = init_logger()
    
    # Inizializza i repository
    user_repo = UserRepository(db)
    order_repo = OrderRepository(db)
    
    # Inizializza i servizi con le dipendenze
    email_svc = EmailService(logger)
    payment_svc = PaymentService(logger)
    user_svc = UserService(user_repo, logger)
    order_svc = OrderService(order_repo, email_svc, logger, payment_svc)
    
    # Inizializza i gestori HTTP
    user_handler = UserHandler(user_svc)
    order_handler = OrderHandler(order_svc)
    
    # Collega le route
    app.include_router(user_handler.router)
    app.include_router(order_handler.router)
    
    return app

Questo approccio rende chiaro come è strutturata l’applicazione e da dove provengono le dipendenze. È particolarmente utile quando si costruiscono applicazioni seguendo principi di architettura pulita, dove devi coordinare diversi strati di dipendenze.

Framework di Iniezione di Dipendenze

Per applicazioni più grandi con grafici complessi di dipendenze, gestire le dipendenze manualmente può diventare oneroso. Python ha diversi framework DI che possono aiutare:

Dependency Injector

Dependency Injector è un framework popolare che fornisce un approccio basato su container per l’iniezione di dipendenze.

Installazione:

pip install dependency-injector

Esempio:

from dependency_injector import containers, providers
from dependency_injector.wiring import inject, Provide

class Container(containers.DeclarativeContainer):
    # Configurazione
    config = providers.Configuration()
    
    # Database
    db = providers.Singleton(
        Database,
        connection_string=config.database.url
    )
    
    # Repository
    user_repository = providers.Factory(
        UserRepository,
        db=db
    )
    
    # Servizi
    user_service = providers.Factory(
        UserService,
        repo=user_repository
    )

# Utilizzo
container = Container()
container.config.database.url.from_env("DATABASE_URL")
user_service = container.user_service()

Injector

Injector è una libreria leggera ispirata a Guice di Google, che si concentra sulla semplicità.

Installazione:

pip install injector

Esempio:

from injector import Injector, inject, Module, provider

class DatabaseModule(Module):
    @provider
    def provide_db(self) -> Database:
        return Database(connection_string="...")

class UserModule(Module):
    @inject
    def __init__(self, repo: UserRepository):
        self.repo = repo

injector = Injector([DatabaseModule()])
user_service = injector.get(UserService)

Quando utilizzare i framework

Utilizza un framework quando:

  • Il grafico delle dipendenze è complesso con molti componenti interdipendenti
  • Hai molte implementazioni dell’interfaccia che devono essere selezionate in base alla configurazione
  • Vuoi una risoluzione automatica delle dipendenze
  • Stai costruendo un’applicazione grande dove l’incatenamento manuale diventa error-prone

Mantieni l’iniezione manuale quando:

  • L’applicazione è piccola o di media dimensione
  • Il grafico delle dipendenze è semplice e facile da seguire
  • Vuoi mantenere le dipendenze minimali e esplicite
  • Preferisci il codice esplicito rispetto alla magia del framework

Testing con Iniezione di Dipendenze

Uno dei principali vantaggi dell’iniezione di dipendenze è la migliorata testabilità. Ecco come DI rende più facile il testing:

Esempio di test unitario

from unittest.mock import Mock
import pytest

# Implementazione mock per il testing
class MockUserRepository:
    def __init__(self):
        self.users = {}
        self.error = None
    
    def find_by_id(self, user_id: int) -> Optional['User']:
        if self.error:
            raise self.error
        return self.users.get(user_id)
    
    def save(self, user: 'User') -> 'User':
        if self.error:
            raise self.error
        self.users[user.id] = user
        return user

# Test utilizzando il mock
def test_user_service_get_user():
    mock_repo = MockUserRepository()
    mock_repo.users[1] = User(id=1, name="John", email="john@example.com")
    
    service = UserService(mock_repo)
    
    user = service.get_user(1)
    assert user is not None
    assert user.name == "John"

Questo test esegue velocemente, non richiede un database e testa la tua logica aziendale in isolamento. Quando lavori con test unitari in Python, l’iniezione di dipendenze rende facile creare test double e verificare le interazioni.

Utilizzo di fixture pytest

Le fixture pytest funzionano ottimamente con l’iniezione di dipendenze:

@pytest.fixture
def mock_user_repository():
    return MockUserRepository()

@pytest.fixture
def user_service(mock_user_repository):
    return UserService(mock_user_repository)

def test_user_service_get_user(user_service, mock_user_repository):
    user = User(id=1, name="John", email="john@example.com")
    mock_user_repository.users[1] = user
    
    result = user_service.get_user(1)
    assert result.name == "John"

Pattern comuni e buone pratiche

1. Utilizza la segregazione delle interfacce

Mantieni i protocolli e le interfacce piccoli e focalizzati su ciò che il client effettivamente necessita:

# Buono: Il client necessita solo di leggere gli utenti
class UserReader(Protocol):
    def find_by_id(self, user_id: int) -> Optional['User']:
        ...
    
    def find_by_email(self, email: str) -> Optional['User']:
        ...

# Interfaccia separata per la scrittura
class UserWriter(Protocol):
    def save(self, user: 'User') -> 'User':
        ...
    
    def delete(self, user_id: int) -> bool:
        ...

2. Valida le dipendenze nei costruttori

I costruttori devono validare le dipendenze e sollevare errori chiari se l’inizializzazione fallisce:

class UserService:
    def __init__(self, repo: UserRepository):
        if repo is None:
            raise ValueError("user repository cannot be None")
        self.repo = repo

3. Utilizza gli hint di tipo

Gli hint di tipo rendono le dipendenze esplicite e aiutano con il supporto dell’IDE e il controllo del tipo statico:

from typing import Protocol, Optional

class UserService:
    def __init__(
        self,
        repo: UserRepository,
        logger: Logger,
        email_service: EmailService,
    ) -> None:
        self.repo = repo
        self.logger = logger
        self.email_service = email_service

4. Evita l’iniezione eccessiva

Non iniettare dipendenze che sono veramente dettagli interni di implementazione. Se un componente crea e gestisce i propri oggetti helper, va bene:

# Buono: L'helper interno non necessita dell'iniezione
class UserService:
    def __init__(self, repo: UserRepository):
        self.repo = repo
        # Cache interna - non necessita dell'iniezione
        self._cache: dict[int, User] = {}

5. Documenta le dipendenze

Utilizza le docstring per documentare il motivo per cui sono necessarie le dipendenze e eventuali vincoli:

class UserService:
    """UserService gestisce la logica aziendale relativa agli utenti.
    
    Args:
        repo: UserRepository per l'accesso ai dati. Deve essere thread-safe
            se utilizzato in contesti concorrenti.
        logger: Logger per il tracciamento degli errori e il debug.
    """
    def __init__(self, repo: UserRepository, logger: Logger):
        self.repo = repo
        self.logger = logger

Iniezione di dipendenze con FastAPI

FastAPI ha un supporto integrato per l’iniezione di dipendenze attraverso il meccanismo Depends:

from fastapi import FastAPI, Depends
from typing import Annotated

app = FastAPI()

def get_user_repository() -> UserRepository:
    db = get_database()
    return UserRepository(db)

def get_user_service(
    repo: Annotated[UserRepository, Depends(get_user_repository)]
) -> UserService:
    return UserService(repo)

@app.get("/users/{user_id}")
def get_user(
    user_id: int,
    service: Annotated[UserService, Depends(get_user_service)]
):
    user = service.get_user(user_id)
    if not user:
        raise HTTPException(status_code=404)
    return user

Il sistema di iniezione di dipendenze di FastAPI gestisce automaticamente il grafico delle dipendenze, rendendo facile costruire API pulite e mantenibili.

Quando NON utilizzare l’iniezione di dipendenze

L’iniezione di dipendenze è uno strumento potente, ma non è sempre necessaria:

Saltare l’iniezione di dipendenze per:

  • Oggetti semplici o classi dati
  • Funzioni interne o utilità
  • Script unici o piccole utilità
  • Quando l’istanziazione diretta è più chiara e semplice

Esempio di quando NON utilizzare l’iniezione di dipendenze:

# Classe dati semplice - non necessita di DI
from dataclasses import dataclass

@dataclass
class Point:
    x: float
    y: float

# Utilità semplice - non necessita di DI
def format_currency(amount: float) -> str:
    return f"${amount:.2f}"

Integrazione con l’ecosistema Python

L’iniezione di dipendenze funziona in modo impeccabile con altri pattern e strumenti Python. Quando si costruiscono applicazioni che utilizzano pacchetti Python o framework di test unitari, puoi iniettare questi servizi nella tua logica aziendale:

class ReportService:
    def __init__(
        self,
        pdf_generator: PDFGenerator,
        repo: ReportRepository,
        logger: Logger,
    ):
        self.pdf_generator = pdf_generator
        self.repo = repo
        self.logger = logger
    
    def generate_report(self, report_id: int) -> bytes:
        report_data = self.repo.get_by_id(report_id)
        pdf = self.pdf_generator.generate(report_data)
        self.logger.info(f"Generated report {report_id}")
        return pdf

Questo ti permette di sostituire le implementazioni o utilizzare mock durante i test.

Iniezione di dipendenze asincrona

La sintassi async/await di Python funziona bene con l’iniezione di dipendenze:

from typing import Protocol
import asyncio

class AsyncUserRepository(Protocol):
    async def find_by_id(self, user_id: int) -> Optional['User']:
        ...
    
    async def save(self, user: 'User') -> 'User':
        ...

class AsyncUserService:
    def __init__(self, repo: AsyncUserRepository):
        self.repo = repo
    
    async def get_user(self, user_id: int) -> Optional['User']:
        return await self.repo.find_by_id(user_id)
    
    async def get_users_batch(self, user_ids: list[int]) -> list['User']:
        tasks = [self.repo.find_by_id(uid) for uid in user_ids]
        results = await asyncio.gather(*tasks)
        return [u for u in results if u is not None]

Conclusione

L’iniezione di dipendenze è un pilastro per scrivere codice Python mantenibile e testabile. Seguendo i pattern descritti in questo articolo — iniezione di costruttore, progettazione basata su protocollo e il pattern root di composizione — creerai applicazioni più facili da comprendere, testare e modificare.

Inizia con l’iniezione manuale di costruttore per applicazioni piccole o di media dimensione, e considera framework come dependency-injector o injector quando il grafico delle dipendenze cresce. Ricorda che l’obiettivo è chiarezza e testabilità, non complessità per il proprio conto.

Per ulteriori risorse di sviluppo Python, consulta il nostro Python Cheatsheet per un riferimento rapido sulla sintassi Python e i pattern comuni.

Risorse esterne