skip to content
OphusDev

Dependency Injection and IoC

/ 6 min read

Updated:
Table of Contents

Ho voluto realizzare un piccolo esercizio di stile per mostrare come far evolvere un semplice script Python: prima scrivendolo in modo imperativo, poi applicando la Dependency Injection, e infine introducendo il principio di Inversion of Control (IoC).

I dati su cui mi sono basato sono volutamente semplici, poiché non è necessario entrare nel merito del loro significato. Il dataset di esempio è disponibile qui

Il task è molto semplice:

  • leggere i dati da un csv
  • filtrare solo le città che sono in Europa
  • salvare il risultato in un formato Json

Semplice Script

Il codice Python iniziale è molto semplice: bastano tre funzioni e un main per raggiungere l’obiettivo. È possibile vederlo direttamente qui: main.py

Non c’è molto da commentare: il flusso è lineare e immediato. Ma cosa succederebbe se la richiesta successiva fosse, ad esempio, filtrare solo le città americane? Oppure leggere i dati da un file Excel invece che da un CSV?

Potrei inventare molti altri scenari, ma ogni volta la soluzione più ovvia sarebbe creare nuove funzioni e sostituirle nella pipeline del main. Le prime due volte può anche andare bene, ma quando le modifiche diventano frequenti il codice tende a diventare poco elegante e difficile da mantenere.

Mi capita spesso di ricevere richieste di questo tipo: inizialmente sembrano fini a sé stesse, ma poi richiedono continui cambiamenti ed estensioni. Quando intravedo una situazione del genere, inizio subito a strutturare il codice utilizzando la Dependency Injection.

Dependency Injection

Qui si trova lo script rivisto usando la Dependency Injection 2_di_main.py

Partiamo dal fondo. Invece di invocare direttamente tre funzioni da un main, ho creato una classe con una sola responsabilità: esporre un metodo run().

class WorldCity:
def __init__(
self, reader: Reader,
transformer: Transformer,
exporter: Exporter
) -> None:
self.reader = reader
self.transformer = transformer
self.exporter = exporter
def run(self) -> None:
data = self.reader.read()
cleaned = self.transformer.apply(data)
self.exporter.export(cleaned)

Questa classe contiene la logica applicativa e riceve nel costruttore tre componenti che si occupano rispettivamente di:

  • leggere i dati
  • trasformarli
  • salvarli

Il typing di questi componenti non fa riferimento a implementazioni concrete, ma a interfacce generiche.

Riprendendo le ipotesi fatte in precedenza, potrei leggere i dati da un CSV nella prima versione, poi da un Excel nella seconda, o persino da un’API esterna via HTTP.

Per questo motivo definisco interfacce il più generiche possibile:

class DataItem(BaseModel):
id: int
name: str
continent: str
class Reader(ABC):
@abstractmethod
def read(self) -> list[DataItem]: ...
class Transformer(ABC):
@abstractmethod
def apply(self, data: list[DataItem]) -> list[DataItem]: ...
class Exporter(ABC):
@abstractmethod
def export(self, data: list[DataItem]) -> None: ...

In questo modo posso definire diversi tipi di Reader. È sufficiente che l’implementazione rispetti la signature definita dall’interfaccia. Per esempio posso avere un Reader che legge da un csv oppure uno che legge direttamente da un set di dati fissi

class CsvReader(Reader):
def __init__(self, path: str) -> None:
self.path = path
def read(self) -> list[DataItem]:
with Path.open(self.path, encoding="utf-8") as file:
reader = csv.reader(file, delimiter=";")
next(reader, None) # skip header
return [
DataItem(
id=int(r[0]),
name=r[1],
continent=r[2]
) for r in reader
]
class MemoryReader(Reader):
def read(self) -> list[DataItem]:
return [
DataItem(id=1, name="New York", continent="America"),
DataItem(id=2, name="Rome", continent="Europe"),
DataItem(id=7, name="London", continent="Europe"),
DataItem(id=12, name="Paris", continent="Europe"),
]

Lo stesso vale per gli oggetti che effettuano trasformazioni sui dati: posso avere un trasformatore per le città europee e uno per quelle americane.

class EuropeTransformer(Transformer):
@property
def get_continent_tag(self) -> str:
return "Europe"
def apply(self, data: list[DataItem]) -> list[DataItem]:
return [d for d in data if d.continent == self.get_continent_tag]
class AmericaTransformer(Transformer):
@property
def get_continent_tag(self) -> str:
return "America"
def apply(self, data: list[DataItem]) -> list[DataItem]:
return [d for d in data if d.continent == self.get_continent_tag]

In questa situazione posso definire nuove implementazioni senza modificare la classe WorldCity. È sufficiente iniettare, al momento dell’inizializzazione, l’implementazione più adatta al caso d’uso.

In questo esempio, agendo solo sulla costruzione della pipeline, posso decidere da dove leggere i dati e con quale criterio filtrarli:

# csv_reader = CsvReader("_samples/data.csv")
memory_reader = MemoryReader()
eu_transformer = EuropeTransformer()
# america_transformer = AmericaTransformer()
exporter = JsonExporter("output/2_export.json")
world_city = WorldCity(
reader=memory_reader,
transformer=eu_transformer,
exporter=exporter
)
world_city.run()

Un’altra differenza rispetto al primo esempio è che le interfacce dichiarano come tipo di ritorno un DataItem invece di un semplice dizionario. Questo approccio permette di avere un maggiore controllo sui dati e di evitare errori legati all’uso di chiavi non consistenti o in continua crescita.

Dependency Injection e IoC

Nella maggior parte dei casi, una Dependency Injection strutturata come nel secondo esempio è più che sufficiente. Tuttavia, trattandosi di un esercizio di stile, nel terzo esempio ho voluto implementare anche il principio di Inversion of Control (IoC), sfruttando la Dependency Injection già presente.

Il codice applicativo non cambia; ciò che viene aggiunto è la gestione dell’IoC: 3_ioc.py.

Ho definito una classe Container che si occupa di registrare le istanze e fornirle quando richiesto.

class ProviderNotFoundError(Exception):
def __init__(self, name: str) -> None:
super().__init__(f'provider "{name}" not found')
class Container:
def __init__(self) -> None:
self.providers = {}
def register(self, name: str, provider: Callable[[], Any]) -> None:
self.providers[name] = provider
def resolve(self, name: str) -> Any: # noqa: ANN401
if name not in self.providers:
raise ProviderNotFoundError(name)
return self.providers[name]()

In questo caso, la costruzione del main è leggermente più verbosa ma concettualmente simile a prima: utilizziamo sempre la Dependency Injection. La differenza principale è che le dipendenze non vengono più passate direttamente, ma risolte dal container.

Anche WorldCity viene a sua volta registrato nel container e trattato come una dipendenza:

csv_reader = CsvReader("_samples/data.csv")
# memory_reader = MemoryReader()
eu_transformer = EuropeTransformer()
# america_transformer = AmericaTransformer()
json_exporter = JsonExporter("output/3_export.json")
container = Container()
container.register("reader", lambda: csv_reader)
container.register("transformer", lambda: eu_transformer)
container.register("exporter", lambda: json_exporter)
world_city = WorldCity(
reader=container.resolve("reader"),
transformer=container.resolve("transformer"),
exporter=container.resolve("exporter"),
)
container.register("pipeline", lambda: world_city)
pipeline: WorldCity = container.resolve("pipeline")
pipeline.run()

Testing

La Dependency Injection facilita anche l’adozione del Test-Driven Development (TDD). Rispetto al semplice script, la versione del secondo esempio si presta molto meglio alla scrittura di test di integrazione, specialmente quando si introducono nuove implementazioni. È possibile partire dal test, implementare il comportamento minimo necessario e poi fare refactoring finché il test passa e continua a rimanere verde.

Conclusioni

Questo post non ha la pretesa di essere una soluzione definitiva, ma vuole mostrare come, anche partendo da uno script molto semplice, sia possibile migliorare la struttura del codice rendendolo più flessibile, estendibile e manutenibile già nelle prime fasi.

Github

I tre esempi mostrati nel post sono disponibili ai seguenti link: