Inyección de dependencias en Python sin frameworks pesados

La inyección de dependencias (DI) es engañosamente simple: en vez de que una función o clase cree sus propias dependencias, las recibe desde fuera. Eso es todo. El patrón no requiere frameworks, ni XML, ni anotaciones mágicas. En Python puro, la forma más honesta de DI es un parámetro de función.

Lo que hace interesante al patrón no es el mecanismo sino lo que habilita: cuando una función recibe su base de datos, su cliente HTTP o su logger, puedes sustituirlos por un mock en tests sin tocar ni una línea del código de producción. El contrato entre el llamador y el llamado se vuelve explícito.

El diseño tiene lógica interna clara: una función que construye sus dependencias internamente tiene dos responsabilidades (construir + ejecutar), viola el principio de responsabilidad única y hace imposible el reemplazo sin patches globales. Cuando separas la construcción del uso, el grafo de dependencias queda en manos del llamador, que es quien sabe qué implementación concreta necesita en cada contexto.

¿Cuándo lo necesitas? En cuanto tengas código que quieras testear de forma aislada, o lógica de negocio que hoy usa PostgreSQL pero mañana podría usar SQLite en tests y quizás un stub en memoria en benchmarks. Si tu función hace import requests y llama requests.get(...) internamente, estás atascado. Si recibe un cliente como parámetro, eres libre.

Lo que se rompe si lo ignoras es predecible: tests que necesitan conectarse a servicios reales, mocks implementados con unittest.mock.patch sobre rutas de importación frágiles, y código imposible de reutilizar en contextos distintos porque sus dependencias están hardcodeadas.

# notification_service.py
from __future__ import annotations

import smtplib
from dataclasses import dataclass, field
from email.message import EmailMessage
from typing import Protocol


# Definimos el contrato, no la implementación concreta.
# Protocol permite duck typing estructural: cualquier objeto
# con este método cumple la interfaz, sin herencia explícita.
class EmailSender(Protocol):
    def send(self, to: str, subject: str, body: str) -> None:
        ...


# Implementación real que habla con un servidor SMTP.
@dataclass
class SmtpEmailSender:
    host: str
    port: int = 587

    def send(self, to: str, subject: str, body: str) -> None:
        msg = EmailMessage()
        msg["To"] = to
        msg["Subject"] = subject
        msg.set_content(body)
        with smtplib.SMTP(self.host, self.port) as server:
            server.send_message(msg)


# Implementación ligera para tests: captura los mensajes
# sin tocar la red.
@dataclass
class InMemoryEmailSender:
    sent: list[dict] = field(default_factory=list)

    def send(self, to: str, subject: str, body: str) -> None:
        self.sent.append({"to": to, "subject": subject, "body": body})


# El servicio de negocio recibe su dependencia como parámetro.
# El valor por defecto construye la implementación de producción
# con configuración razonable, así el llamador no está obligado
# a pasar nada en el path normal de uso.
class UserNotificationService:
    def __init__(
        self,
        sender: EmailSender = SmtpEmailSender(host="smtp.example.com"),
    ) -> None:
        self._sender = sender

    def welcome(self, user_email: str, username: str) -> None:
        self._sender.send(
            to=user_email,
            subject="Bienvenido",
            body=f"Hola {username}, tu cuenta está activa.",
        )

    def password_reset(self, user_email: str, token: str) -> None:
        self._sender.send(
            to=user_email,
            subject="Restablece tu contraseña",
            body=f"Usa este token: {token}",
        )


# --- Uso en producción ---
# service = UserNotificationService()          # usa SmtpEmailSender por defecto
# service.welcome("ana@example.com", "Ana")

# --- Uso en tests ---
def test_welcome_email_sends_correct_content():
    fake_sender = InMemoryEmailSender()
    service = UserNotificationService(sender=fake_sender)

    service.welcome("ana@example.com", "Ana")

    assert len(fake_sender.sent) == 1
    assert fake_sender.sent[0]["to"] == "ana@example.com"
    assert "Ana" in fake_sender.sent[0]["body"]


def test_password_reset_includes_token():
    fake_sender = InMemoryEmailSender()
    service = UserNotificationService(sender=fake_sender)

    service.password_reset("ana@example.com", "abc-123")

    assert "abc-123" in fake_sender.sent[0]["body"]

Qué está pasando en cada decisión

EmailSender como Protocol en vez de clase abstracta es la elección que hace que todo fluya con naturalidad pythónica. SmtpEmailSender e InMemoryEmailSender no heredan de nada; simplemente implementan el método send. Python verifica la compatibilidad estructuralmente cuando el type checker corre, no en tiempo de ejecución. Si mañana alguien escribe un sender que loguea a disco, no tiene que tocar notification_service.py.

El valor por defecto en __init__ es un detalle que vale la pena notar: hace que el servicio sea usable sin configuración adicional en el camino feliz de producción. El coste es que el valor por defecto se evalúa una sola vez al cargar el módulo (comportamiento estándar de Python para argumentos por defecto con objetos mutables complejos), así que si SmtpEmailSender tuviera estado mutable compartido podría ser un problema. Aquí es inofensivo porque es stateless.

El test no usa unittest.mock.patch, no parchea rutas de importación, no requiere fixtures complejos. Es código Python normal que construye un objeto con una dependencia falsa y verifica el resultado. Eso es lo que DI compra directamente.

DI en FastAPI: Depends() como inyector ligero

FastAPI lleva esta idea más lejos con Depends(). No es magia: es un mecanismo que resuelve el grafo de dependencias de cada request y las pasa a los handlers como parámetros.

# api.py  — requiere: pip install fastapi
from __future__ import annotations

from fastapi import Depends, FastAPI

app = FastAPI()


# Una "dependencia" en FastAPI es simplemente un callable.
# Puede ser una función, una clase, incluso un generador
# si necesitas cleanup (útil para conexiones de BD).
def get_email_sender() -> InMemoryEmailSender:
    # En producción reemplazarías esto con SmtpEmailSender
    # o lo leerías de la configuración de la app.
    return InMemoryEmailSender()


@app.post("/users/{user_id}/welcome")
async def send_welcome(
    user_id: str,
    sender: InMemoryEmailSender = Depends(get_email_sender),
) -> dict:
    service = UserNotificationService(sender=sender)
    service.welcome(f"{user_id}@example.com", user_id)
    return {"status": "sent"}

Lo que FastAPI añade sobre el patrón manual es la resolución automática del grafo: si get_email_sender depende de get_db_connection que depende de get_settings, FastAPI lo resuelve en cadena sin que el handler sepa nada de eso. También maneja scopes por request y cleanup de recursos. Para sobreescribir dependencias en tests, usas app.dependency_overrides:

# En tu test con TestClient
from fastapi.testclient import TestClient

fake_sender = InMemoryEmailSender()
app.dependency_overrides[get_email_sender] = lambda: fake_sender

client = TestClient(app)
response = client.post("/users/ana/welcome")
assert response.status_code == 200
assert len(fake_sender.sent) == 1

Cuándo un contenedor de DI añade valor real

Librerías como dependency-injector o injector resuelven un problema específico: cuando tienes decenas de servicios con grafos de dependencias profundos y quieres que la configuración de qué implementación va con qué sea declarativa y centralizada.

El contenedor te da autobindings, scopes (singleton, transient, per-request), decorators para inyección automática, y a veces introspección del grafo completo. El coste es indirección y una curva de aprendizaje.

Para un proyecto con 5-10 servicios, pasar dependencias como argumentos con valores por defecto razonables es suficiente y el código es completamente obvio. Para una aplicación con 40+ servicios donde el grafo crece solo, donde necesitas swappear implementaciones por entorno (dev/staging/prod) de forma centralizada, o donde la gestión de scopes es compleja, el contenedor gana. La pregunta no es “¿es bueno dependency-injector?” sino “¿tengo el problema que resuelve?”. La mayoría de los proyectos medianos no lo tienen, y añadir el contenedor crea una capa de abstracción que hace el código más difícil de seguir sin beneficio tangible.

Errores que debes conocer

Error: usar un objeto mutable como valor por defecto en __init__ pensando que cada instancia obtiene uno nuevo.

# ❌ Wrong
class Service:
    def __init__(self, cache: dict = {}):  # Este dict es compartido por TODAS las instancias
        self._cache = cache

# ✅ Right
class Service:
    def __init__(self, cache: dict | None = None):
        self._cache = cache if cache is not None else {}

El dict vacío {} como default se evalúa una vez cuando Python carga la clase; todas las instancias comparten el mismo objeto, lo que produce bugs de estado compartido muy difíciles de depurar.

Error: inyectar la clase concreta en vez del Protocol, acoplando el código de negocio a la implementación.

# ❌ Wrong
class UserNotificationService:
    def __init__(self, sender: SmtpEmailSender) -> None:  # Hardcodeado a SMTP
        self._sender = sender

# ✅ Right
class UserNotificationService:
    def __init__(self, sender: EmailSender) -> None:  # Acepta cualquier implementación
        self._sender = sender

Tipar contra el Protocol en vez de la clase concreta es lo que hace que el test con InMemoryEmailSender funcione sin herencia y sin patches.

204

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio