19. Reducir acoplamiento mediante inyección de dependencias

19.1 Objetivo del tema

Una función o clase está muy acoplada cuando crea por dentro los objetos concretos que necesita para trabajar. Ese diseño dificulta las pruebas, porque obliga a usar archivos, bases de datos, correo, reloj real o servicios externos aunque solo queramos verificar una regla de negocio.

En este tema aplicaremos inyección de dependencias: pasaremos desde afuera los colaboradores que el código necesita. Así podremos reemplazar infraestructura real por objetos simples durante las pruebas.

Objetivo práctico: transformar código acoplado a servicios concretos en código configurable, testeable y más fácil de modificar.

19.2 Qué es una dependencia

Una dependencia es cualquier elemento externo que una unidad de código necesita para cumplir su trabajo: un repositorio, un cliente HTTP, un enviador de correos, una función que obtiene la fecha actual o un generador de identificadores.

El problema no es tener dependencias. El problema aparece cuando el código las construye directamente y no permite reemplazarlas.

def procesar_pago(pago):
    cliente = ClienteCorreoSMTP()
    reloj = RelojDelSistema()
    repositorio = RepositorioPagosSQLite()
    # regla de negocio mezclada con infraestructura

19.3 Código inicial acoplado

Crea el archivo src/recibos.py con una implementación deliberadamente acoplada:

from datetime import datetime


class RepositorioRecibos:
    def guardar(self, recibo):
        print(f"Guardando recibo {recibo['numero']}")


class EnviadorCorreo:
    def enviar(self, destino, asunto, cuerpo):
        print(f"Enviando correo a {destino}: {asunto}")


def emitir_recibo(pago):
    repositorio = RepositorioRecibos()
    correo = EnviadorCorreo()
    fecha = datetime.now().date().isoformat()

    recibo = {
        "numero": f"REC-{pago['id']}",
        "cliente": pago["cliente"],
        "email": pago["email"],
        "total": pago["total"],
        "fecha": fecha,
    }

    repositorio.guardar(recibo)
    correo.enviar(
        pago["email"],
        "Recibo emitido",
        f"Recibimos tu pago por {pago['total']}",
    )
    return recibo

La función mezcla la construcción del recibo, la fecha actual, la persistencia y el envío de correo.

19.4 Pruebas difíciles de escribir

La regla principal debería ser simple: dado un pago, se genera un recibo con número, cliente, total y fecha. Sin embargo, la función usa la fecha real y ejecuta efectos secundarios.

Podemos escribir una prueba, pero queda frágil porque depende del día en que se ejecuta:

from src.recibos import emitir_recibo


def test_emite_recibo_con_datos_del_pago():
    pago = {
        "id": 15,
        "cliente": "Ana",
        "email": "ana@example.com",
        "total": 12000,
    }

    recibo = emitir_recibo(pago)

    assert recibo["numero"] == "REC-15"
    assert recibo["cliente"] == "Ana"
    assert recibo["total"] == 12000
python -m pytest tests/test_recibos.py

19.5 Identificar puntos de acoplamiento

Antes de refactorizar, conviene señalar qué cosas queremos controlar desde afuera:

  • El repositorio donde se guarda el recibo.
  • El enviador de correo.
  • La fuente de fecha actual.

Estos tres elementos son colaboradores. La función no necesita saber cómo se construyen; solo necesita usarlos.

19.6 Extraer la regla pura

El primer paso seguro es separar la creación del recibo. Esta función no guarda, no envía correo y no lee el reloj del sistema:

def construir_recibo(pago, fecha):
    return {
        "numero": f"REC-{pago['id']}",
        "cliente": pago["cliente"],
        "email": pago["email"],
        "total": pago["total"],
        "fecha": fecha,
    }

Ahora podemos probar la parte más importante sin infraestructura:

from src.recibos import construir_recibo


def test_construye_recibo_con_fecha_recibida():
    pago = {"id": 15, "cliente": "Ana", "email": "ana@example.com", "total": 12000}

    recibo = construir_recibo(pago, "2026-05-11")

    assert recibo == {
        "numero": "REC-15",
        "cliente": "Ana",
        "email": "ana@example.com",
        "total": 12000,
        "fecha": "2026-05-11",
    }

19.7 Inyectar la fecha

Una forma simple de reducir acoplamiento es pasar una función que devuelva la fecha actual. En producción usaremos una función real; en pruebas, una función fija.

from datetime import datetime


def fecha_actual():
    return datetime.now().date().isoformat()


def emitir_recibo(pago, obtener_fecha=fecha_actual):
    fecha = obtener_fecha()
    recibo = construir_recibo(pago, fecha)
    # todavía falta resolver repositorio y correo
    return recibo

La dependencia ya no está escondida dentro de la función: ahora se puede reemplazar.

19.8 Probar con una dependencia falsa

Para la prueba no necesitamos modificar el reloj del sistema. Pasamos una función común que devuelve siempre la misma fecha:

from src.recibos import emitir_recibo


def test_emite_recibo_con_fecha_inyectada():
    pago = {"id": 15, "cliente": "Ana", "email": "ana@example.com", "total": 12000}

    recibo = emitir_recibo(pago, obtener_fecha=lambda: "2026-05-11")

    assert recibo["fecha"] == "2026-05-11"
python -m pytest tests/test_recibos.py

19.9 Inyectar repositorio y correo

Ahora pasamos también el repositorio y el enviador de correo. La función deja de crear clases concretas:

def emitir_recibo(pago, repositorio, correo, obtener_fecha=fecha_actual):
    recibo = construir_recibo(pago, obtener_fecha())

    repositorio.guardar(recibo)
    correo.enviar(
        pago["email"],
        "Recibo emitido",
        f"Recibimos tu pago por {pago['total']}",
    )
    return recibo

La función conserva el comportamiento, pero ahora recibe sus colaboradores desde afuera.

19.10 Objetos falsos para pruebas

Podemos crear objetos simples que registren qué ocurrió. No necesitan conectarse a nada real:

class RepositorioFalso:
    def __init__(self):
        self.recibos = []

    def guardar(self, recibo):
        self.recibos.append(recibo)


class CorreoFalso:
    def __init__(self):
        self.mensajes = []

    def enviar(self, destino, asunto, cuerpo):
        self.mensajes.append({
            "destino": destino,
            "asunto": asunto,
            "cuerpo": cuerpo,
        })

Estos objetos permiten verificar efectos secundarios sin depender de infraestructura.

19.11 Probar interacción sin servicios reales

Con dependencias inyectadas podemos verificar que se guardó el recibo y que se intentó enviar el correo correcto:

from src.recibos import emitir_recibo


def test_guarda_y_envia_recibo():
    repositorio = RepositorioFalso()
    correo = CorreoFalso()
    pago = {"id": 15, "cliente": "Ana", "email": "ana@example.com", "total": 12000}

    recibo = emitir_recibo(
        pago,
        repositorio=repositorio,
        correo=correo,
        obtener_fecha=lambda: "2026-05-11",
    )

    assert repositorio.recibos == [recibo]
    assert correo.mensajes == [{
        "destino": "ana@example.com",
        "asunto": "Recibo emitido",
        "cuerpo": "Recibimos tu pago por 12000",
    }]
python -m pytest tests/test_recibos.py

19.12 Código refactorizado completo

El módulo queda más explícito. Las clases reales siguen existiendo, pero ya no están escondidas dentro de la función principal:

from datetime import datetime


class RepositorioRecibos:
    def guardar(self, recibo):
        print(f"Guardando recibo {recibo['numero']}")


class EnviadorCorreo:
    def enviar(self, destino, asunto, cuerpo):
        print(f"Enviando correo a {destino}: {asunto}")


def fecha_actual():
    return datetime.now().date().isoformat()


def construir_recibo(pago, fecha):
    return {
        "numero": f"REC-{pago['id']}",
        "cliente": pago["cliente"],
        "email": pago["email"],
        "total": pago["total"],
        "fecha": fecha,
    }


def emitir_recibo(pago, repositorio, correo, obtener_fecha=fecha_actual):
    recibo = construir_recibo(pago, obtener_fecha())

    repositorio.guardar(recibo)
    correo.enviar(
        pago["email"],
        "Recibo emitido",
        f"Recibimos tu pago por {pago['total']}",
    )
    return recibo

19.13 Crear una función de composición

Si no queremos que toda la aplicación conozca las clases reales, podemos crear una función pequeña que arme el caso de uso de producción:

def emitir_recibo_en_produccion(pago):
    return emitir_recibo(
        pago,
        repositorio=RepositorioRecibos(),
        correo=EnviadorCorreo(),
    )

La composición queda en el borde del sistema. El núcleo sigue siendo fácil de probar.

19.14 Usar Protocol para documentar contratos

Con Protocol podemos expresar qué métodos necesita el caso de uso sin obligar a heredar de una clase base:

from typing import Protocol


class RepositorioDeRecibos(Protocol):
    def guardar(self, recibo: dict) -> None:
        ...


class ServicioDeCorreo(Protocol):
    def enviar(self, destino: str, asunto: str, cuerpo: str) -> None:
        ...


def emitir_recibo(
    pago: dict,
    repositorio: RepositorioDeRecibos,
    correo: ServicioDeCorreo,
    obtener_fecha=fecha_actual,
) -> dict:
    recibo = construir_recibo(pago, obtener_fecha())
    repositorio.guardar(recibo)
    correo.enviar(pago["email"], "Recibo emitido", f"Recibimos tu pago por {pago['total']}")
    return recibo

El beneficio principal es comunicativo: cualquier objeto que tenga esos métodos puede ser usado.

19.15 Cuándo aplicar esta refactorización

La inyección de dependencias es especialmente útil cuando una unidad de código:

  • Crea clientes HTTP, conexiones o repositorios dentro de su lógica.
  • Usa fecha, hora, números aleatorios o variables de entorno directamente.
  • Es difícil de probar sin tocar infraestructura real.
  • Mezcla reglas de negocio con efectos secundarios.
  • Necesita variantes de comportamiento según el entorno.

19.16 Cuándo no exagerar

No todas las funciones necesitan recibir diez dependencias. Si una función pura calcula un impuesto o formatea un texto, agregar objetos y configuraciones puede hacerla menos clara.

Una señal práctica: si las pruebas ya son simples, rápidas y expresivas, quizá no necesitas más inyección. Aplica este refactoring cuando reduzca fricción real.

19.17 Ejercicio propuesto

Refactoriza esta función para que el almacenamiento y la generación de identificadores se puedan reemplazar en pruebas:

from uuid import uuid4


class RepositorioTickets:
    def guardar(self, ticket):
        print(f"Ticket guardado: {ticket['id']}")


def crear_ticket(usuario, descripcion):
    ticket = {
        "id": str(uuid4()),
        "usuario": usuario,
        "descripcion": descripcion,
        "estado": "abierto",
    }

    repositorio = RepositorioTickets()
    repositorio.guardar(ticket)
    return ticket

Objetivo: poder probar el identificador y el guardado sin usar uuid4 real ni repositorio real.

19.18 Una posible solución

from uuid import uuid4


class RepositorioTickets:
    def guardar(self, ticket):
        print(f"Ticket guardado: {ticket['id']}")


def generar_id():
    return str(uuid4())


def construir_ticket(usuario, descripcion, id_ticket):
    return {
        "id": id_ticket,
        "usuario": usuario,
        "descripcion": descripcion,
        "estado": "abierto",
    }


def crear_ticket(usuario, descripcion, repositorio, generar_id_ticket=generar_id):
    ticket = construir_ticket(usuario, descripcion, generar_id_ticket())
    repositorio.guardar(ticket)
    return ticket

Prueba posible:

from src.tickets import crear_ticket


class RepositorioTicketsFalso:
    def __init__(self):
        self.tickets = []

    def guardar(self, ticket):
        self.tickets.append(ticket)


def test_crea_y_guarda_ticket_con_id_inyectado():
    repositorio = RepositorioTicketsFalso()

    ticket = crear_ticket(
        "ana",
        "No puedo ingresar",
        repositorio=repositorio,
        generar_id_ticket=lambda: "ticket-123",
    )

    assert ticket == {
        "id": "ticket-123",
        "usuario": "ana",
        "descripcion": "No puedo ingresar",
        "estado": "abierto",
    }
    assert repositorio.tickets == [ticket]
python -m pytest tests/test_tickets.py

19.19 Lista de verificación

Antes de continuar, verifica que puedes explicar estos puntos:

  • Qué significa acoplamiento en código Python.
  • Por qué crear dependencias concretas dentro de una función dificulta las pruebas.
  • Cómo pasar funciones, objetos o servicios desde afuera.
  • Cómo usar objetos falsos para verificar interacciones.
  • Qué diferencia hay entre lógica pura y efectos secundarios.
  • Por qué la composición suele quedar en los bordes del sistema.

19.20 Conclusión

En este tema reducimos acoplamiento mediante inyección de dependencias. El código dejó de crear colaboradores concretos en el centro de la lógica y empezó a recibirlos desde afuera.

En el próximo tema separaremos reglas de negocio, entrada, salida y persistencia.