Módulo 4: Patrones para crear objetos
6. Inyección de dependencias en palabras simples
Descripción
Al terminar esta lección vas a tener la idea más útil de todo el módulo, y viene con el nombre más intimidante: inyección de dependencias. La idea, en una frase: en vez de que un objeto construya lo que necesita, se lo pasas. Eso es todo. En Python es literalmente pasar un parámetro. No hace falta ningún contenedor, ninguna anotación mágica, ninguna librería, ningún archivo de configuración en XML. Vas a ver las tres formas de hacerlo, cómo cambia el checkout de Boletia, por qué las pruebas dejan de necesitar trucos, y —porque en esta guía nada viene sin su costo— dónde empieza a doler.
Esto importa por una razón que se nota en la primera semana de cualquier trabajo real: la inyección de dependencias es la diferencia entre un sistema que se puede probar y uno que no. Y no es una diferencia gradual. Un sistema donde cada función construye por dentro lo que necesita no tiene "pruebas más difíciles": tiene pruebas imposibles sin base de datos, sin red y sin credenciales, lo que en la práctica significa que tiene pocas pruebas y que nadie confía en ellas. La lección 5 te mostró ese daño desde el lado del Singleton; esta te muestra la salida.
Hay una segunda razón, menos técnica y muy práctica: el término aparece en entrevistas, en descripciones de puesto y en discusiones de arquitectura, casi siempre rodeado de jerga —"contenedor de IoC", "inversión de control", "ciclo de vida singleton scoped"— que hace pensar que es un tema grande. No lo es. Salir de esta lección pudiendo explicarlo en una frase y mostrarlo en tres líneas te pone por delante de mucha gente que sabe recitar la definición.
Conexión con el módulo: esta lección responde la tercera y última pregunta del módulo —quién construye—. Las lecciones 2 y 3 resolvieron qué construir y dejaron un pendiente explícito: el charge_order de Boletia seguía fabricándose su propio proveedor por dentro y por eso seguía siendo imposible de probar. Aquí lo cerramos. La lección 5 mostró el mismo problema en su versión más aguda —el punto de acceso global— y prometió esta alternativa. Y la lección 7, la vacuna del módulo, va a matizar esta: no todo se inyecta, y las firmas que crecen sin control tienen su propio costo.
El carpintero que trae su propio martillo
Piensa en dos formas de contratar a alguien para que arme un mueble en tu casa.
Forma uno: el carpintero fabrica sus herramientas al llegar. Suena absurdo, y lo es, pero sígueme. Llega, funde el metal, forja un martillo, lo templa, y recién entonces empieza a armar el mueble. ¿Qué implica eso? Que para que arme cualquier cosa necesitas tener una fragua en tu casa. Que si quieres que use un martillo de goma —porque el mueble es delicado y no quieres marcas— no hay manera de decírselo. Y que si quieres ver cómo trabaja sin destruir nada, no puedes: siempre va a usar el martillo de verdad, porque siempre se lo fabrica él.
Forma dos: llega con las manos vacías y tú le pasas la caja de herramientas. Ahora todo cambia. No necesitas fragua. Puedes darle el martillo de goma cuando el mueble es delicado. Y si quieres que ensaye el movimiento sin dañar nada, le das un martillo de juguete y lo observas: hace exactamente lo mismo, sin consecuencias.
Eso es la inyección de dependencias, completa. La forma uno es el objeto que construye lo que necesita. La forma dos es el objeto que lo recibe.
Fíjate en las tres ganancias de la forma dos, porque son las tres razones por las que este tema importa:
Puedes sustituir. El martillo de goma, el de juguete, el neumático. Quien arma el mueble no cambia; lo que le das, sí.
Sabes qué necesita. Cuando el carpintero te pide la caja, te dice qué hay dentro. En la forma uno, no tienes forma de saber qué va a usar hasta que lo veas trabajar. En código, esa lista es la firma de la función, y por eso decíamos en la lección 5 que la firma "miente" cuando el objeto se fabrica sus cosas.
El que decide es quien tiene el contexto. Tú sabes que el mueble es delicado; el carpintero no puede saberlo desde adentro. Quien llama a una función suele saber más del contexto que la función misma —si estamos en pruebas, si es un cliente de otro país, si esta petición es de prueba— y por eso es quien debe decidir qué piezas se usan.
Y una advertencia que la analogía también deja ver, y que la lección 7 va a desarrollar: si le pasas al carpintero cada clavo por separado en vez de la caja completa, la conversación se vuelve interminable. La inyección llevada al extremo produce funciones con doce parámetros. Hay un punto medio y hay que encontrarlo.
La idea, en tres líneas de código
Vamos a lo concreto. Esto es una dependencia construida por dentro:
# ANTES — la función se fabrica lo que necesita.
def notify_customer(customer, message):
channel = EmailChannel(smtp_host=settings.SMTP_HOST, smtp_user=settings.SMTP_USER)
channel.send(customer.email, message)
Y esto es una dependencia inyectada:
# DESPUÉS — la función recibe lo que necesita.
def notify_customer(customer, message, channel):
channel.send(customer.email, message)
Eso es todo. Se movió una línea del cuerpo a la firma. Si el nombre "inyección de dependencias" te sonaba a algo que requiere un framework, este es el momento de bajarle el volumen: es un parámetro.
Ahora, ¿qué cambió de verdad? Cuatro cosas, y vale la pena verlas una por una porque son la justificación completa del tema:
La firma dice la verdad. Antes decía "necesito un cliente y un mensaje"; en realidad necesitaba también un servidor SMTP configurado y alcanzable. Ahora dice exactamente lo que necesita.
Se puede probar sin salir a internet.
def test_notification_reaches_the_customer():
channel = RecordingChannel() # un doble que solo guarda en una lista
notify_customer(customer, "Tu boleto está listo", channel)
assert channel.sent == [("ana@example.com", "Tu boleto está listo")]
Sin parchear módulos, sin fixtures de limpieza, sin variables de entorno. Y la prueba se lee sola.
Se puede cambiar el canal sin tocar la función. Si mañana Boletia quiere notificar por SMS a quien no tenga correo, notify_customer no se entera. El que decide es quien la llama.
El acoplamiento bajó donde importa. La función ya no depende de EmailChannel ni de settings. Depende de "algo que sepa send", que es un contrato mucho más pequeño.
Las tres formas de inyectar
En Python hay tres, y las tres se usan. Elegir bien entre ellas es la mitad del oficio.
Forma 1: por parámetro de función
La más simple y la primera que hay que considerar. La dependencia entra por la firma de la función que la usa.
def charge_order(order, provider: PaymentProvider) -> PaymentResult:
return provider.charge(order)
Cuándo conviene: cuando la función es la única que usa esa dependencia, y cuando la dependencia puede cambiar entre llamadas. En el caso de charge_order es perfecto: cada orden puede tener un proveedor distinto, así que tiene que decidirse por llamada.
Forma 2: por constructor
La dependencia entra al crear el objeto y vive con él.
class CheckoutService:
"""Orquesta el proceso completo de compra.
Recibe sus piezas al construirse. A partir de ahí, todos sus métodos
las tienen disponibles sin volver a pedirlas.
"""
def __init__(self, payments, notifier, clock=datetime.now):
self._payments = payments
self._notifier = notifier
# El reloj también es una dependencia. Parece exagerado hasta que
# tienes que probar la regla de early-bird, que depende de la fecha.
self._clock = clock
def checkout(self, order) -> PaymentResult:
provider = self._payments.get(order.provider)
result = provider.charge(order)
if result.status == "succeeded":
self._notifier.notify(order.customer, "Tu compra fue confirmada")
return result
Cuándo conviene: cuando varias operaciones del mismo objeto necesitan la misma dependencia. Si CheckoutService tuviera cinco métodos y los cinco necesitaran el notificador, pasarlo cinco veces sería ruido. Esta es la forma dominante en sistemas con clases.
Mira el clock=datetime.now. Ese es un truco que vale más de lo que parece. El tiempo es una dependencia como cualquier otra —viene de afuera, no es determinista, y hace que las pruebas dependan del calendario—. Inyectarlo permite probar la regla del early-bird de Boletia sin cambiar la fecha del sistema:
def test_early_bird_discount_applies_before_the_cutoff():
service = CheckoutService(
payments=FakeRegistry(),
notifier=RecordingNotifier(),
clock=lambda: datetime(2026, 2, 15), # antes del corte del 1 de marzo
)
result = service.checkout(early_bird_order)
assert result.amount == 800.0 # con el 20% de descuento
Esa prueba corre igual hoy, mañana y en tres años. Sin el reloj inyectado, habría que parchear datetime a nivel de módulo —frágil— o esperar a marzo.
Forma 3: por atributo, después de construir
La dependencia se asigna después. Es la menos recomendable y conviene saber por qué.
service = CheckoutService()
service.notifier = RecordingNotifier() # ⚠️ se puede olvidar
El problema: existe una ventana en la que el objeto está construido pero incompleto. Si alguien lo usa antes de asignar el notificador, revienta con un AttributeError que no explica nada. El constructor, en cambio, hace imposible tener un objeto a medias.
Cuándo se usa de todos modos: cuando hay dependencias circulares —A necesita B y B necesita A— o cuando un framework construye el objeto por ti y no controlas su constructor. Las dos son situaciones reales, y las dos son señales de que quizá hay un problema de diseño más arriba.
La regla práctica: empieza por la forma 1; si la misma dependencia aparece en varios métodos, pasa a la forma 2; la forma 3 solo cuando algo externo te obligue.
Ejemplo trabajado: el checkout de Boletia, cerrando el pendiente
Vamos a retomar exactamente donde quedó la lección 3. Después del refactor de la Factory, el checkout quedó así:
# Archivo: checkout/checkout.py — como quedó en la lección 3
def charge_order(order) -> PaymentResult:
provider = get_payment_provider(order.provider) # ← se lo fabrica solo
return provider.charge(order)
def checkout(order) -> PaymentResult:
"""El flujo completo: calcular, cobrar, avisar, registrar."""
order.total = total_for(order, purchased_at=datetime.now())
result = charge_order(order)
if result.status == "succeeded":
# notify() construye sus propios canales por dentro. Mismo problema.
notify(order.customer, f"Tu compra #{order.id} fue confirmada")
mark_as_paid(order, result.external_id) # y esto abre la base de datos
return result
Intenta escribir una prueba de checkout. Necesitas: credenciales de Stripe válidas, un servidor SMTP alcanzable, una base de datos con el esquema cargado, y que hoy sea antes del 1 de marzo si el boleto es early-bird. Eso no es una prueba unitaria; es un despliegue.
Ahora hagamos el refactor. Y como en la lección 3, en pasos que dejan el sistema funcionando.
Paso 1 — Agrega los parámetros con valor por defecto. Este es el truco que hace el refactor incremental:
def charge_order(order, provider: PaymentProvider | None = None) -> PaymentResult:
# Si nadie pasa el proveedor, se construye como antes. Nada se rompió,
# y ya se puede pasar uno falso desde una prueba.
provider = provider or get_payment_provider(order.provider)
return provider.charge(order)
Los llamadores existentes no cambian. Y ya puedes escribir la primera prueba aislada de tu vida en este archivo.
Paso 2 — Agrupa lo que va junto. Cuando checkout necesita cuatro cosas, pasarlas sueltas empieza a doler. Agrúpalas:
# Archivo: app/services.py
@dataclass(frozen=True)
class Services:
"""Las piezas externas que la aplicación necesita.
Agruparlas evita que cada función tenga seis parámetros, y da un lugar
único donde ver de qué depende Boletia con lo de afuera.
"""
payments: PaymentRegistry
notifier: Notifier
orders: OrderRepository
clock: Callable[[], datetime] = datetime.now
Paso 3 — El checkout recibe sus servicios:
# Archivo: checkout/checkout.py — DESPUÉS
def checkout(order, services: Services) -> PaymentResult:
"""El flujo completo. Ahora la firma dice todo lo que hace falta para correrlo."""
order.total = total_for(order, purchased_at=services.clock())
provider = services.payments.get(order.provider)
result = provider.charge(order)
if result.status == "succeeded":
services.orders.mark_as_paid(order, result.external_id)
services.notifier.notify(order.customer, f"Tu compra #{order.id} fue confirmada")
return result
Paso 4 — Todo se construye en un solo lugar, el más cercano al arranque:
# Archivo: app.py — la raíz de composición
def build_services() -> Services:
"""El ÚNICO lugar del sistema donde se construyen las piezas de verdad.
Aquí viven las credenciales, las conexiones y las decisiones de configuración.
De aquí para abajo, nadie construye nada: todos reciben.
"""
return Services(
payments=PaymentRegistry(settings),
notifier=Notifier(channels=[
EmailChannel(smtp_host=settings.SMTP_HOST),
SmsChannel(api_key=settings.SMS_API_KEY),
]),
orders=OrderRepository(db=Database(settings.DATABASE_URL)),
)
def main():
services = build_services()
app = create_http_app(services) # las rutas reciben los servicios y los pasan
app.run()
Ese build_services() es la raíz de composición que mencionamos en la lección 5: un solo lugar, lo más cerca posible del arranque, donde se decide qué implementaciones concretas se usan. Todo lo demás recibe.
Y ahora la prueba:
# Archivo: tests/test_checkout.py
def test_a_successful_purchase_notifies_and_marks_the_order_as_paid():
services = Services(
payments=FakeRegistry(result=PaymentResult(status="succeeded", external_id="ch_1")),
notifier=RecordingNotifier(),
orders=InMemoryOrderRepository(),
clock=lambda: datetime(2026, 2, 15),
)
result = checkout(order, services)
assert result.status == "succeeded"
assert services.orders.get(order.id).status == "paid"
assert "confirmada" in services.notifier.sent[0].message
def test_a_failed_payment_does_not_notify_anyone():
services = Services(
payments=FakeRegistry(result=PaymentResult(status="failed")),
notifier=RecordingNotifier(),
orders=InMemoryOrderRepository(),
)
checkout(order, services)
# Esta es la prueba que ANTES era imposible: verificar que algo NO pasó.
assert services.notifier.sent == []
Qué esperar de este refactor. Vamos por partes, porque hay más de lo que se ve.
Lo primero y más obvio: las pruebas corren en milisegundos, sin red, sin base de datos y sin credenciales. Eso no es solo comodidad. Una suite que tarda diez segundos se corre en cada guardado; una que tarda ocho minutos y necesita servicios levantados se corre antes de irse a casa, cuando ya no da tiempo de arreglar nada.
Lo segundo, y es lo que más te va a servir en el trabajo: ahora se pueden probar los caminos que no ocurren. Mira la segunda prueba. Verifica que cuando el pago falla, no se notifica a nadie. Con dependencias construidas por dentro, esa prueba es prácticamente imposible: tendrías que hacer que Stripe falle a propósito y después revisar un buzón de correo para confirmar que no llegó nada. Con un notificador que registra, es una línea. Y los bugs de "se notificó de más" o "se cobró dos veces" son de los más caros que existen en un sistema de pagos.
Lo tercero, más sutil: la firma se convirtió en documentación. checkout(order, services) con un Services de cuatro campos te dice, sin abrir el cuerpo, que este flujo toca pagos, notificaciones, base de datos y el reloj. Es un mapa de dependencias que se mantiene solo, porque si alguien agrega una dependencia tiene que agregarla al Services y eso se ve en la revisión.
Lo cuarto, y es una consecuencia de arquitectura que aparece sola: todas las credenciales y conexiones quedaron en un archivo. Si te preguntan "¿qué servicios externos usa Boletia?", la respuesta está en build_services(). Antes, la respuesta era "hay que buscar settings. en todo el repositorio".
Ahora el costo, honestamente:
- Las firmas crecieron.
checkout(order)se convirtió encheckout(order, services). Y quien llama acheckouttiene que tener los servicios, así que también los recibe. Eso se propaga hacia arriba hasta la raíz. - Hay un objeto nuevo —
Services— que hay que mantener. - El arranque se volvió más denso.
build_services()concentra decisiones que antes estaban repartidas. Eso es bueno, pero significa que ese archivo hay que leerlo para entender el sistema.
¿Valió la pena? Aquí sí, sin dudarlo, y el argumento no es estético: el checkout es la parte del sistema donde un bug cuesta dinero real, y era la parte que no tenía pruebas. En un rincón menos crítico, el mismo refactor podría no justificarse. Es el criterio del módulo 2 otra vez: la abstracción se paga, y hay que ver contra qué.
Sin framework, sin contenedor, sin magia
Un párrafo para desactivar el miedo al término, porque la jerga que lo rodea hace pensar que hay algo más.
En el mundo de Java y C#, "inyección de dependencias" viene casi siempre acompañada de un contenedor: una librería a la que le registras qué implementación corresponde a cada interfaz, y que construye los objetos por ti resolviendo el grafo completo. Spring, .NET DI, Guice. Esos contenedores existen por una razón concreta y no por gusto: en un sistema con quinientas clases y una jerarquía de dependencias de ocho niveles, escribir el build_services() a mano sería una función de mil líneas.
En Python casi nunca llegas ahí, y hay dos razones. La primera es cultural: los sistemas Python tienden a tener menos clases y más funciones, así que el grafo es más plano. La segunda es del lenguaje: los valores por defecto, las funciones de primera clase y los dataclass hacen que el cableado manual sea corto.
Así que la recomendación práctica: empieza sin contenedor, siempre. Un build_services() escrito a mano es explícito, se lee de arriba abajo, no tiene magia, y cuando algo falla el rastro de error apunta a una línea que tú escribiste. Si algún día ese archivo llega a doscientas líneas y se vuelve difícil de mantener, existen opciones —dependency-injector, punq, o el sistema de Depends que trae FastAPI— y las vas a poder evaluar con criterio. Adoptar un contenedor antes de sentir el dolor es exactamente la abstracción prematura del módulo 2, con una librería de por medio.
Y una nota sobre la palabra "inversión de control", que aparece siempre al lado. Significa esto: normalmente tu código decide qué construye; con inyección, esa decisión se mueve hacia afuera, a quien te llama. El "control" se invirtió de adentro hacia afuera. Es el mismo concepto visto desde otro ángulo, y ya lo entendiste al entender el carpintero: quien tiene el contexto decide qué herramientas se usan.
Dobles de prueba: qué le pasas en lugar de lo real
Un apartado corto pero necesario, porque la inyección solo sirve si sabes qué inyectar en las pruebas. Los nombres varían entre equipos; estos son los tres que de verdad importan.
El falso (fake): una implementación real pero simplificada.
class InMemoryOrderRepository:
"""Guarda órdenes en un diccionario. Funciona de verdad, sin base de datos."""
def __init__(self):
self._orders: dict[int, Order] = {}
def save(self, order):
self._orders[order.id] = order
def get(self, order_id):
return self._orders.get(order_id)
def mark_as_paid(self, order, external_id):
order.status = "paid"
order.external_id = external_id
self.save(order)
Es mi favorito y el que más recomiendo. Se comporta como el real, así que las pruebas siguen siendo pruebas de comportamiento y no de llamadas. Cuesta escribirlo una vez y se reutiliza en toda la suite.
El espía (spy o recording double): registra lo que le pidieron.
class RecordingNotifier:
"""No manda nada. Guarda lo que le pidieron mandar, para poder verificarlo."""
def __init__(self):
self.sent: list[SentMessage] = []
def notify(self, customer, message):
self.sent.append(SentMessage(customer=customer, message=message))
Es el que permite verificar que algo ocurrió o —más valioso— que no ocurrió. La prueba de "un pago fallido no notifica a nadie" necesita exactamente esto.
El de comportamiento fijo (stub): siempre devuelve lo mismo.
class FailingProvider:
"""Siempre falla. Sirve para probar el camino de error sin provocar uno de verdad."""
def __init__(self):
self.call_count = 0
def charge(self, order):
self.call_count += 1
return PaymentResult(status="failed", error_message="Tarjeta rechazada")
Es la única forma razonable de probar caminos de error. Provocar un rechazo real de tarjeta en cada corrida de pruebas no es viable.
Lo que quiero que notes: ninguno de los tres necesita una librería. Son clases de diez líneas. Existen librerías de simulación —unittest.mock en la biblioteca estándar— y tienen su lugar, sobre todo para código ajeno que no puedes cambiar. Pero cuando tu propio código recibe sus dependencias, escribir un doble a mano suele ser más claro que configurar un simulador, y no se rompe cuando cambias un nombre.
Errores comunes
Inyectar todo, incluidas las cosas que nunca vas a sustituir (de criterio). Qué pasa: alguien entiende la idea, le gusta, y empieza a pasar por parámetro cada cosa que la función usa —la función de redondeo, la clase Order, el módulo json—. Las firmas llegan a diez parámetros, cada llamada arrastra una cola de argumentos, y el sistema se vuelve más difícil de leer que antes. Por qué pasa: la regla "inyecta tus dependencias" no dice qué cuenta como dependencia, y sin ese filtro se aplica a todo. Cómo detectarlo: por cada parámetro inyectado, pregúntate si alguna vez le vas a pasar algo distinto —en pruebas o en producción—. Si la respuesta honesta es "no", ese parámetro es ruido. Cómo corregirlo: inyecta lo que cruza una frontera —red, disco, base de datos, reloj, aleatoriedad— y lo que tiene más de una implementación real. El resto, constrúyelo directo. La lección 7 desarrolla este filtro con más cuidado, porque es la vacuna del módulo.
Inyectar la factory en vez del objeto (de implementación). Qué pasa: alguien pasa get_payment_provider como parámetro en vez de pasar el proveedor ya construido. La prueba entonces tiene que pasar una función falsa que devuelva un proveedor falso: dos capas de simulación para una sola dependencia. Por qué pasa: cuando la función necesita decidir el proveedor por orden —y a veces sí lo necesita— parece que no hay alternativa. Cómo detectarlo: si tus dobles de prueba devuelven otros dobles, tienes una capa de más. Cómo corregirlo: distingue los dos casos. Si el proveedor se decide una vez por petición, resuélvelo arriba —en la ruta— y pasa el objeto. Si de verdad se decide adentro y por orden, inyecta un registro (un objeto con un método get(name)) en vez de una función suelta: es más fácil de falsear y de leer. Fue lo que hicimos con PaymentRegistry en el ejemplo trabajado.
Creer que la inyección exige interfaces formales (conceptual). Qué pasa: alguien viene de Java o C# y cree que para inyectar algo primero hay que declarar una interfaz abstracta, así que escribe una ABC por cada dependencia —con una sola implementación real cada una—. Aparecen seis clases abstractas de tres líneas que solo existen para satisfacer una regla que Python no tiene. Por qué pasa: en los lenguajes de tipado nominal, sustituir un objeto por otro exige que compartan una interfaz declarada. Python usa tipado dinámico y estructural: si tiene el método, sirve. Cómo detectarlo: busca clases abstractas con un solo implementador —el olor del módulo 2, lección 6—. Cómo corregirlo: pasa el objeto y ya. Si quieres que el verificador de tipos ayude, usa Protocol, que documenta el contrato sin obligar a heredar y sin generar una clase por cada cosa. Y si el contrato es de un solo método, el "contrato" puede ser directamente una función: clock: Callable[[], datetime] no necesitó ninguna clase.
Ejercicios
Ejercicio 1 — Decide qué se inyecta. La siguiente función de Boletia usa siete cosas. Para cada una, decide si la inyectarías o no, y justifica en una línea.
def issue_ticket_pdf(order, ticket):
template = load_template("ticket.html") # (1) lee un archivo del disco
qr = generate_qr(f"https://boletia.mx/t/{ticket.id}") # (2) función pura
issued_at = datetime.now() # (3) el reloj
serial = f"{order.id}-{ticket.id}-{random.randint(1000, 9999)}" # (4) aleatorio
html = template.render(ticket=ticket, qr=qr, issued_at=issued_at, serial=serial)
pdf = HtmlToPdf().convert(html) # (5) librería externa, sin red
storage = S3Storage(bucket=settings.TICKETS_BUCKET) # (6) sube a la nube
url = storage.upload(f"tickets/{serial}.pdf", pdf)
log.info("Boleto emitido", extra={"ticket_id": ticket.id}) # (7) registro de eventos
return url
Ver solución
(1) load_template — sí, aunque con matiz. Lee del disco, así que cruza una frontera. Pero el matiz importa: probablemente lo que quieras inyectar no sea la función de carga sino la plantilla ya cargada, o mejor, un objeto renderer que sepa renderizar. Inyectar cargadores de archivos suele ser señal de que la responsabilidad está partida en el lugar equivocado.
(2) generate_qr — no. Es una función pura: mismos datos de entrada, mismo resultado, sin efectos. No hay ninguna razón para sustituirla en pruebas, y si la prueba quiere verificar el QR, lo puede verificar sobre la salida real. Inyectarla sería ruido.
(3) datetime.now() — sí, siempre. El reloj es la dependencia que más gente olvida y la que más pruebas frágiles produce. Un parámetro clock=datetime.now cuesta nada y salva la suite de depender del calendario.
(4) random.randint — sí. Por la misma razón que el reloj: no es determinista. Una prueba que verifique el formato del serial no puede hacerlo con un número que cambia. Inyecta un generador —rng=random.Random()— o, mejor todavía, pregúntate si el serial debería generarse aquí. La aleatoriedad y el tiempo son las dos dependencias invisibles clásicas.
(5) HtmlToPdf — sí, con reservas. No usa red, pero convertir HTML a PDF es lento, a veces requiere binarios instalados en el sistema, y produce un blob que a la prueba no le sirve para nada. Inyectarlo permite pasar un convertidor que devuelva b"fake-pdf" y probar todo lo demás. Reserva: si el objetivo de la prueba es justamente el PDF, entonces esa prueba usa el real y punto —no todas las pruebas tienen que ser unitarias—.
(6) S3Storage — sí, sin discusión. Red, credenciales, costo por operación y estado que persiste entre pruebas. Es el caso más claro de los siete. Un InMemoryStorage que guarde en un diccionario resuelve toda la suite.
(7) log — no. Es el caso legítimo que vimos en la lección 5. El registro de eventos es transversal por diseño, no guarda estado del dominio, y la biblioteca estándar ya resuelve su aislamiento en pruebas (caplog en pytest). Inyectar el logger en cada función es ceremonia.
El resultado: cuatro claros que sí, uno que sí con reservas, dos que no. Y fíjate en el criterio que emerge y que vale más que la lista: se inyecta lo que cruza una frontera —red, disco, reloj, azar— y lo que tiene más de una implementación real. Lo puro y lo transversal, no.
Una observación final sobre esta función: si necesita seis dependencias, probablemente esté haciendo demasiado. Emitir el PDF y subirlo a la nube son dos responsabilidades. Cuando la lista de dependencias es larga, la primera pregunta no es "¿cómo las inyecto?" sino "¿por qué son tantas?".
Ejercicio 2 — Refactoriza el notificador. Este es el notifier.py real de Boletia. Refactorízalo para que se pueda probar, y escribe una prueba que verifique que a un cliente sin teléfono no se le manda SMS.
# Archivo: notifications/notifier.py
def notify(customer, message):
email = EmailChannel(smtp_host=settings.SMTP_HOST, smtp_user=settings.SMTP_USER)
email.send(customer.email, message)
if customer.phone:
sms = SmsChannel(api_key=settings.SMS_API_KEY, sender=settings.SMS_SENDER)
sms.send(customer.phone, message)
if customer.push_token:
push = PushChannel(app_credentials=settings.PUSH_CREDENTIALS)
push.send(customer.push_token, message)
Ver solución
# Archivo: notifications/notifier.py
class Notifier:
"""Avisa a un cliente por todos los canales que tenga disponibles.
Recibe los canales al construirse: no sabe cómo se configuran
ni de dónde salen sus credenciales.
"""
def __init__(self, email: NotificationChannel,
sms: NotificationChannel | None = None,
push: NotificationChannel | None = None):
self._email = email
self._sms = sms
self._push = push
def notify(self, customer, message) -> None:
# El correo siempre: todo cliente de Boletia tiene correo.
self._email.send(customer.email, message)
# Los otros dos dependen de que el cliente tenga el dato
# Y de que el canal esté configurado en este despliegue.
if customer.phone and self._sms:
self._sms.send(customer.phone, message)
if customer.push_token and self._push:
self._push.send(customer.push_token, message)
# Archivo: app.py
def build_services() -> Services:
return Services(
notifier=Notifier(
email=EmailChannel(smtp_host=settings.SMTP_HOST, smtp_user=settings.SMTP_USER),
sms=SmsChannel(api_key=settings.SMS_API_KEY, sender=settings.SMS_SENDER),
push=PushChannel(app_credentials=settings.PUSH_CREDENTIALS),
),
...
)
Y la prueba pedida:
# Archivo: tests/test_notifier.py
class RecordingChannel:
def __init__(self):
self.sent = []
def send(self, destination, message):
self.sent.append((destination, message))
def test_a_customer_without_phone_gets_no_sms():
email, sms, push = RecordingChannel(), RecordingChannel(), RecordingChannel()
notifier = Notifier(email=email, sms=sms, push=push)
customer = Customer(id=1, name="Ana", email="ana@example.com",
phone=None, push_token=None)
notifier.notify(customer, "Tu compra fue confirmada")
assert email.sent == [("ana@example.com", "Tu compra fue confirmada")]
assert sms.sent == [] # ← la verificación que antes era imposible
assert push.sent == []
Dos cosas que separan una buena solución de una que solo mueve código:
El | None = None en sms y push no es solo comodidad de pruebas. Modela un hecho real: puede existir un despliegue de Boletia donde el SMS no esté configurado —una instalación de prueba, un país sin proveedor de SMS—. Antes, ese caso reventaba al llegar a la línea del SmsChannel. Ahora es una configuración válida. Al inyectar, a veces descubres que el sistema siempre pudo funcionar sin una pieza y nadie lo había modelado.
La prueba verifica una ausencia, y esa es la prueba que el código original hacía imposible. Con el notifier viejo tendrías que revisar el panel del proveedor de SMS para confirmar que no se envió nada. Con un canal que registra, es una línea.
Si quieres ir un paso más allá: el diseño con tres canales nombrados funciona, pero acopla el notificador a que existan exactamente esos tres. Una versión más flexible recibiría una lista de canales, y cada canal sabría si aplica a un cliente dado (channel.can_reach(customer)). Esa versión es mejor, y también es más abstracta —y en el módulo 6, cuando veamos Observer, va a ser exactamente el camino—. Que la primera versión sea suficiente hoy es una decisión correcta, no una limitación.
Ejercicio 3 — El costo de la propagación. En un sistema con inyección, si una función profunda necesita una dependencia nueva, todas las funciones intermedias tienen que pasarla. Boletia tiene esta cadena:
api/routes.py → checkout() → apply_pricing() → price_for() → ¿necesita el reloj?
price_for necesita saber la fecha para la regla de early-bird. ¿Cómo lo resuelves? Propón dos opciones y di cuál eliges.
Ver solución
Este problema tiene nombre —perforación de propiedades o prop drilling, tomado prestado del mundo de las interfaces— y es el costo real de la inyección de dependencias. Vale la pena tomárselo en serio en vez de negarlo.
Opción A — Pasar el reloj por toda la cadena.
def checkout(order, services): ...
def apply_pricing(order, clock): ...
def price_for(ticket, clock): ...
Cada eslabón declara lo que necesita. Es explícito y honesto. El costo: apply_pricing recibe un reloj que no usa, solo para pasárselo al siguiente. Ese parámetro es ruido en su firma.
Opción B — Resolver el valor arriba y pasar el dato, no la dependencia.
def checkout(order, services):
purchased_at = services.clock() # ← el reloj se usa UNA vez, arriba
order.total = apply_pricing(order, purchased_at)
...
def apply_pricing(order, purchased_at: datetime): ...
def price_for(ticket, purchased_at: datetime): ...
Nadie más abajo conoce el reloj. Lo que viaja es un datetime, que es un dato común y corriente.
Elijo la B, y la razón es una regla que vale la pena memorizar: pasa el valor, no la fuente del valor.
Las funciones profundas casi nunca necesitan el reloj; necesitan la fecha de compra. Y la fecha de compra debería ser una sola para toda la operación —si cada eslabón llamara a clock() por su cuenta, dos boletos de la misma orden podrían quedar con timestamps distintos, y peor: uno podría caer antes del corte del early-bird y el otro después, en la misma compra—. Resolver el valor una vez arriba no solo simplifica las firmas: elimina una clase de bug que la opción A permite.
La regla generaliza bien. ¿La función de abajo necesita la base de datos, o necesita la orden que está en la base? Casi siempre lo segundo. ¿Necesita el cliente HTTP, o necesita la respuesta? Lo segundo. Cuando una dependencia se propaga cuatro niveles hacia abajo, casi siempre es porque se está pasando la fuente donde bastaba el dato.
Y cuándo la opción A es correcta de todos modos: cuando la función profunda de verdad necesita la fuente porque la usa varias veces o de forma impredecible —un repositorio que consulta según lo que encuentre, un cliente HTTP que hace llamadas en bucle—. Ahí no hay atajo: la dependencia se pasa, y si la cadena es larga, eso es una señal de que quizá la cadena misma es demasiado profunda.
Por qué funciona: la inyección de dependencias tiene un costo real y este ejercicio te lo puso enfrente sin adornos. Quien solo conoce las ventajas la aplica en todo y produce firmas de doce parámetros. Quien conoce el costo sabe dónde detenerse —y eso es exactamente lo que la lección 7 va a formalizar.
Resumen y siguiente paso
En esta lección desarmaste el término más intimidante del módulo y descubriste que es un parámetro. Inyección de dependencias: en vez de que un objeto construya lo que necesita, se lo pasas. La analogía del carpintero deja ver las tres ganancias: puedes sustituir la herramienta, sabes qué necesita —porque está en la firma— y decide quien tiene el contexto, no quien está adentro.
Viste las tres formas de hacerlo: por parámetro de función, la primera a considerar, ideal cuando la dependencia cambia entre llamadas; por constructor, la dominante cuando varios métodos comparten la misma pieza; y por atributo, que deja una ventana peligrosa donde el objeto está construido pero incompleto y que solo se usa cuando algo externo obliga.
Cerraste el pendiente que la lección 3 había dejado abierto: el checkout de Boletia pasó de fabricarse sus dependencias a recibirlas en un Services, con todo construido en una raíz de composición —el build_services() del arranque—. Y el resultado se midió donde importa: las pruebas corren en milisegundos sin red ni credenciales, se pueden probar los caminos que no ocurren —"un pago fallido no notifica a nadie"—, la firma se convirtió en documentación viva, y todas las credenciales del sistema quedaron en un archivo.
Viste que no hace falta ningún framework: los contenedores existen para grafos de dependencias enormes y en Python casi nunca llegas ahí; empieza con un build_services() escrito a mano. Conociste los tres dobles de prueba que hacen útil todo esto —el falso, el espía y el de comportamiento fijo—, ninguno de los cuales necesita una librería. Y viste el costo real: las firmas crecen, la dependencia se propaga hacia arriba, y hay un objeto más que mantener. Con su regla de bolsillo para el caso más común: pasa el valor, no la fuente del valor.
Antes de avanzar deberías poder: explicar la inyección de dependencias en una frase, sin jerga; elegir entre las tres formas según el caso; decidir qué merece inyectarse —lo que cruza una frontera y lo que tiene varias implementaciones— y qué no —lo puro y lo transversal—; y escribir un doble de prueba de diez líneas sin usar ninguna librería de simulación.
La lección 7 es la vacuna del módulo, y va a poner en duda buena parte de lo que llevas hecho. Después de tres lecciones enseñando Factory, Builder e inyección, toca decir en voz alta lo que el módulo 2 ya había instalado: la mayoría de los objetos no necesitan nada de esto. Se construyen con un constructor y ya. Vas a salir con las señales concretas de que un patrón de creación sí hace falta, y con el antídoto contra el reflejo de "todo por factory".
Recursos
- Martin Fowler — Inversion of Control Containers and the Dependency Injection pattern — el artículo de 2004 que ordenó el vocabulario del tema. Las tres formas de inyección que viste aquí salen de ahí.
- Miško Hevery — Writing Testable Code — la guía de pruebas de Google, con la regla de "haz que el constructor no haga trabajo, solo reciba". Es la formulación más práctica que existe del tema.
- Martin Fowler — Test Double — la taxonomía de dobles de prueba (fake, stub, spy, mock, dummy) explicada con precisión. Útil para hablar el mismo idioma en un equipo.
- Documentación de FastAPI — Dependencies — un ejemplo de contenedor ligero bien diseñado en Python, por si algún día lo necesitas. Vale la pena verlo para saber qué resuelve un contenedor y qué no.