Dependency Injection: een Python-wijze
Python DI patronen voor schone, testbare code
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.

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.
Nuttige links
- Python Cheatsheet
- Unit Testing in Python
- Python Design Patterns for Clean Architecture
- Structured Output - LLMs on Ollama with Qwen3 - Python and Go
- Structured output comparison across popular LLM providers - OpenAI, Gemini, Anthropic, Mistral and AWS Bedrock
- Building a Dual-Mode AWS Lambda with Python and Terraform
Externe Resources
- Dependency Injection in Python - Real Python
- How Dependency Injection in Python Improves Code Structure - Volito Digital
- Python Dependency Injection Tutorial - DataCamp
- Dependency Injector - Official Documentation
- Injector - Lightweight DI Framework
- FastAPI Dependencies - Official Docs
- SOLID Principles in Python - Software Patterns Lexicon
- Python Protocols and Structural Subtyping - PEP 544