Dependency Injection: een Python-wijze

Python DI patronen voor schone, testbare code

Inhoud

Dependency injection (DI) is een fundamenteel ontwerpmodel dat de schrijfbaarheid, testbaarheid en onderhoudbaarheid van code bevordert in Python-toepassingen.

Of je nu REST APIs bouwt met FastAPI, eenheidstests implementeert of werkt met AWS Lambda functies, het begrijpen van dependency injection verbetert aanzienlijk de kwaliteit van je code.

python packages

Wat is Dependency Injection?

Dependency injection is een ontwerpmodel waarbij componenten hun afhankelijkheden van externe bronnen ontvangen in plaats van ze intern te maken. Dit benadering ontkoppelt componenten, waardoor je code modulairer, testbaarder en onderhoudbaarder wordt.

In Python is dependency injection vooral krachtig vanwege de dynamische aard van de taal en de ondersteuning voor protocollen, abstracte basisklassen en duck typing. De flexibiliteit van Python betekent dat je DI patronen kunt implementeren zonder zware frameworks, hoewel frameworks beschikbaar zijn wanneer ze nodig zijn.

Waarom gebruik je dependency injection in Python?

Verbeterde testbaarheid: Door afhankelijkheden te injecteren, kun je gemakkelijk echte implementaties vervangen door mocks of testdubbels. Dit maakt het mogelijk om eenheden te schrijven die snel, geïsoleerd zijn en geen externe services zoals databases of APIs vereisen. Wanneer je omvangrijke eenheidstests schrijft, maakt dependency injection het eenvoudig om echte afhankelijkheden te vervangen door testdubbels.

Beter onderhoud: Afhankelijkheden worden expliciet in je code. Als je een constructor bekijkt, zie je direct wat een component nodig heeft. Dit maakt de codebasis makkelijker te begrijpen en te wijzigen.

Loskoppeling: Componenten afhankelijk van abstracties (protocollen of ABCs) in plaats van concrete implementaties. Dit betekent dat je implementaties kunt veranderen zonder de afhankelijke code te beïnvloeden.

Flexibiliteit: Je kunt verschillende implementaties configureren voor verschillende omgevingen (ontwikkeling, testen, productie) zonder je zakelijke logica te wijzigen. Dit is vooral handig wanneer je Python-toepassingen implementeert op verschillende platforms, of het nu AWS Lambda is of traditionele servers.

Constructor Injection: De Python manier

De meest voorkomende en idiomatische manier om dependency injection in Python te implementeren is via constructor injection—afhankelijkheden als parameters in de __init__ methode accepteren.

Basisvoorbeeld

Hier is een eenvoudig voorbeeld dat constructor injection demonstreert:

from typing import Protocol
from abc import ABC, abstractmethod

# Definieer een protocol voor de repository
class UserRepository(Protocol):
    def find_by_id(self, user_id: int) -> 'User | None':
        ...
    
    def save(self, user: 'User') -> 'User':
        ...

# Service afhankelijk van het repository protocol
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)

Dit patroon maakt duidelijk dat UserService een UserRepository nodig heeft. Je kunt geen UserService aanmaken zonder een repository te leveren, wat runtime fouten door ontbrekende afhankelijkheden voorkomt.

Meerdere afhankelijkheden

Wanneer een component meerdere afhankelijkheden heeft, voeg ze gewoon toe als constructor parameters:

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

Het gebruik van protocollen en abstracte basisklassen

Een van de belangrijkste principes bij het implementeren van dependency injection is het Dependency Inversion Principle (DIP): hoog niveau modules mogen niet afhankelijk zijn van laag niveau modules; beide moeten afhankelijk zijn van abstracties.

In Python kun je abstracties definiëren met behulp van protocollen (structuur typen) of Abstract Base Classes (ABCs) (naam typen).

Protocollen (Python 3.8+)

Protocollen gebruiken structuur typen—als een object de vereiste methoden heeft, voldoet het aan het protocol:

from typing import Protocol

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

# Elke klasse met een process_payment methode voldoet aan dit protocol
class CreditCardProcessor:
    def process_payment(self, amount: float) -> bool:
        # Credit card logica
        return True

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

# Service accepteert elke PaymentProcessor
class OrderService:
    def __init__(self, payment_processor: PaymentProcessor):
        self.payment_processor = payment_processor

Abstracte Basisklassen

ABCs gebruiken naam typen—klassen moeten expliciet van de ABC erven:

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

Wanneer protocollen versus ABCs gebruiken: Gebruik protocollen wanneer je structuur typen en flexibiliteit wilt. Gebruik ABCs wanneer je erfgoed hiërarchieën wilt dwingen of standaardimplementaties wilt bieden.

Werkelijk voorbeeld: Database abstractie

Wanneer je werkt met databases in Python-toepassingen, heb je vaak abstractie van database operaties nodig. Hier is hoe dependency injection je helpt:

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]:
        ...

# Repository afhankelijk van de abstractie
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

Dit patroon stelt je in staat om database implementaties (PostgreSQL, SQLite, MongoDB) te wisselen zonder je repository code te wijzigen.

Het Compositie Root patroon

Het Compositie Root is waar je alle afhankelijkheden samenstelt aan het startpunt van de toepassing (meestal main.py of je toepassingsfabriek). Dit centraliseert afhankelijkheidsconfiguratie en maakt het afhankelijkheidengraaf expliciet.

def create_app() -> FastAPI:
    app = FastAPI()
    
    # Initialiseer infrastructuur afhankelijkheden
    db = init_database()
    logger = init_logger()
    
    # Initialiseer repositories
    user_repo = UserRepository(db)
    order_repo = OrderRepository(db)
    
    # Initialiseer services met afhankelijkheden
    email_svc = EmailService(logger)
    payment_svc = PaymentService(logger)
    user_svc = UserService(user_repo, logger)
    order_svc = OrderService(order_repo, email_svc, logger, payment_svc)
    
    # Initialiseer HTTP handlers
    user_handler = UserHandler(user_svc)
    order_handler = OrderHandler(order_svc)
    
    # Sluit routes aan
    app.include_router(user_handler.router)
    app.include_router(order_handler.router)
    
    return app

Deze aanpak maakt duidelijk hoe je toepassing is opgebouwd en waar afhankelijkheden vandaan komen. Het is vooral nuttig wanneer je toepassingen bouwt volgens clean architecture principes, waarbij je meerdere lagen van afhankelijkheden moet coördineren.

Dependency Injection Frameworks

Voor grotere toepassingen met complexe afhankelijkheidengrafieken, kan het beheren van afhankelijkheden handmatig vervelend worden. Python heeft verschillende DI frameworks die je kunnen helpen:

Dependency Injector

Dependency Injector is een populaire framework dat een containergebaseerde aanpak voor dependency injection biedt.

Installatie:

pip install dependency-injector

Voorbeeld:

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

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

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

Injector

Injector is een lichtgewicht bibliotheek geïnspireerd op Google’s Guice, met een focus op eenvoud.

Installatie:

pip install injector

Voorbeeld:

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)

Wanneer frameworks gebruiken

Gebruik een framework wanneer:

  • Je afhankelijkheidengraaf is complex met veel interafhankelijke componenten
  • Je meerdere implementaties van hetzelfde interface hebt die op basis van configuratie moeten worden geselecteerd
  • Je automatische afhankelijkheidsoplossing wilt
  • Je een grote toepassing bouwt waarbij handmatig aansluiten foutgevoelig wordt

Sta bij handmatige DI wanneer:

  • Je toepassing klein tot gemiddeld is
  • Je afhankelijkheidengraaf eenvoudig en makkelijk te volgen is
  • Je wilt dat afhankelijkheden minimaal en expliciet zijn
  • Je expliciete code voorkeurt boven framework magie

Testen met Dependency Injection

Een van de primaire voordelen van dependency injection is verbeterde testbaarheid. Hier is hoe DI het testen gemakkelijker maakt:

Eenheidstestvoorbeeld

from unittest.mock import Mock
import pytest

# Testimplementatie
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 met de 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"

Deze test draait snel, vereist geen database en test je zakelijke logica in isolatie. Wanneer je werkt met eenheidstesten in Python, maakt dependency injection het eenvoudig om testdubbels te maken en interacties te verifiëren.

Gebruik van pytest fixtures

pytest fixtures werken uitstekend met dependency injection:

@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"

Algemene patronen en beste praktijken

1. Gebruik Interface Segregation

Houd protocollen en interfaces klein en gericht op wat de klant echt nodig heeft:

# Goed: De klant heeft alleen leesfunctionaliteit nodig
class UserReader(Protocol):
    def find_by_id(self, user_id: int) -> Optional['User']:
        ...
    
    def find_by_email(self, email: str) -> Optional['User']:
        ...

# Ander interface voor schrijven
class UserWriter(Protocol):
    def save(self, user: 'User') -> 'User':
        ...
    
    def delete(self, user_id: int) -> bool:
        ...

2. Valideer afhankelijkheden in constructors

Constructors moeten afhankelijkheden valideren en duidelijke fouten opwerpen als initialisatie mislukt:

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

3. Gebruik typehints

Typehints maken afhankelijkheden expliciet en helpen met IDE-ondersteuning en statische typecontrole:

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. Vermijd over-injectie

Inject geen afhankelijkheden die echt interne implementatie details zijn. Als een component eigen helperobjecten creëert en beheert, is dat prima:

# Goed: Interne helper hoeft niet geïnjecteerd te worden
class UserService:
    def __init__(self, repo: UserRepository):
        self.repo = repo
        # Interne cache - hoeft niet geïnjecteerd te worden
        self._cache: dict[int, User] = {}

5. Documenteer afhankelijkheden

Gebruik docstrings om aan te geven waarom afhankelijkheden nodig zijn en welke beperkingen er zijn:

class UserService:
    """UserService beheert zakelijke logica voor gebruikers.
    
    Args:
        repo: UserRepository voor gegevens toegang. Moet thread-safe zijn
            als het in concurrente contexten wordt gebruikt.
        logger: Logger voor foutvolgging en debuggen.
    """
    def __init__(self, repo: UserRepository, logger: Logger):
        self.repo = repo
        self.logger = logger

Dependency Injection met FastAPI

FastAPI heeft ingebouwde ondersteuning voor dependency injection via zijn Depends mechanisme:

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

FastAPI’s dependency injection systeem verwerkt de afhankelijkheidengraaf automatisch, waardoor het makkelijk is om schoon, onderhoudbare APIs te bouwen.

Wanneer je dependency injection niet moet gebruiken

Dependency injection is een krachtig gereedschap, maar het is niet altijd nodig:

Overgeslagen DI voor:

  • Eenvoudige waardeobjecten of dataclasses
  • Interne helperfuncties of tools
  • Eenmalige scripts of kleine tools
  • Wanneer directe instantiatie duidelijker en eenvoudiger is

Voorbeeld van wanneer je DI niet moet gebruiken:

# Eenvoudige dataclass - geen DI nodig
from dataclasses import dataclass

@dataclass
class Point:
    x: float
    y: float

# Eenvoudige utility - geen DI nodig
def format_currency(amount: float) -> str:
    return f"${amount:.2f}"

Integratie met de Python ecosystem

Dependency injection werkt naadloos met andere Python patronen en tools. Wanneer je toepassingen bouwt die Python packages of eenheidstest frameworks gebruiken, kun je deze diensten injecteren in je zakelijke logica:

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

Dit stelt je in staat om implementaties te wisselen of mocks te gebruiken tijdens het testen.

Async Dependency Injection

Python’s async/await syntaxis werkt goed met dependency injection:

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]

Conclusie

Dependency injection is een kerncomponent van het schrijven van onderhoudbare, testbare Python code. Door de patronen die in dit artikel zijn uitgelegd—constructor injection, protocolgebaseerd ontwerp en het compositie root patroon—creëer je toepassingen die makkelijker te begrijpen, testen en wijzigen zijn.

Begin met handmatige constructor injection voor kleine tot gemiddelde toepassingen, en overweeg frameworks zoals dependency-injector of injector als je afhankelijkheidengraaf groeit. Onthoud dat het doel helderheid en testbaarheid is, niet complexiteit voor de eigen doel.

Voor meer Python ontwikkelingsresources, bekijk onze Python Cheatsheet voor snelle verwijzingen naar Python syntaxis en veelvoorkomende patronen.

Externe Resources