A estas alturas en el desarrollo de software, existen muchos patrones de diseño y arquitecturas que buscan mejorar la forma en la que se construye y diseña el software. Y sin duda alguna, dentro de muchos de estos patrones y arquitecturas siempre sale a relucir la famosísima inyección de dependencias. Porque sí, hay patrones de diseño que usan otros patrones de diseño.
En este post veremos qué es y por qué muchos frameworks la usan, y te vas a dar cuenta de que quizá, sin saberlo, tú ya la usas en tu día a día.
¿Qué es la inyección de dependencias?
La inyección de dependencias es un patrón de diseño que, básicamente, nos dice que si tenemos una clase (o varias) que para hacer su tarea requieren de un objeto ajeno a ellas, no deberían crear esa instancia por su cuenta. En lugar de eso, al crear la instancia de la clase, ya se le debe pasar la instancia del objeto del que depende, quitándole así a la clase dependiente la responsabilidad de construir esas instancias.
Veamos un ejemplo con código, imaginemos que vamos crear un servicio para crear usuario en un sistema y tenemos que enviarle una notificación de bienvenida con algún método.
Definimos la entidad de User
:
from dataclasses import dataclass
@dataclass
class User:
"""
Represents a user with basic personal information.
"""
name: str
email: str
phone: str
password: str
id: int | None = None
Luego una clase abstracta NotifyService
que nos va a servir como contrato para cada método notificador y unos métodos notificadores.
from abc import ABC, abstractmethod
class NotifyService(ABC):
"""
Abstract base class for notification services.
Enforces a standard interface for sending messages to users.
"""
@abstractmethod
def send_message(self, user: User) -> None:
"""
Sends a welcome message to the specified user.
:param user: The user to notify.
"""
pass
class EmailService(NotifyService):
"""
Notification service that sends welcome messages via email.
"""
def send_message(self, user):
"""
Sends a welcome email to the user's email address.
:param user: The user to notify.
"""
print(f"Sending welcome email to: {user.email}")
class SMSService(NotifyService):
"""
Notification service that sends welcome messages via SMS.
"""
def send_message(self, user):
"""
Sends a welcome SMS to the user's phone number.
:param user: The user to notify.
"""
print(f"Sending welcome SMS to: {user.phone}")
class PushService(NotifyService):
"""
Notification service that sends welcome messages via push notification.
"""
def send_message(self, user):
"""
Sends a welcome push notification to the user.
:param user: The user to notify.
"""
print(f"Sending welcome push notification to: {user.name}")
Finalmente, nuestro servicio UserService
sin aplicar inyección de dependencias.
class UserService:
"""
Service responsible for user creation and welcome notification.
"""
def create_user(self, user: User, notify_method: Literal['email', 'sms', 'push']):
"""
Creates a new user and sends a welcome message using the chosen notification method.
:param user: The user to be created.
:param notify_method: The preferred notification channel ('email', 'sms', or 'push').
"""
# Logic to create the user (simulating saving to a database)
db_user = User.id = randint(1, 199)
# Send welcome message using selected method
if notify_method == 'email':
return EmailService().send_message(user)
elif notify_method == 'sms':
return SMSService().send_message(user)
elif notify_method == 'push':
return PushService().send_message(user)
Esto funciona, obvio… pero ¿es la mejor forma en términos de código limpio y desacoplado? Definitivamente no.
Como explicaba al inicio, la clase UserService
, en su método create_user()
, tiene la responsabilidad de estar creando instancias del servicio de notificaciones que se requiera, lo cual rompe con algunos principios SOLID, como el principio de abierto/cerrado (Open/Closed Principle) y el principio de responsabilidad única (Single Responsibility Principle). Y eso, claramente, no es lo que queremos.
Ahora sí, veamos cómo aplicamos la inyección de dependencias para salvarnos de este pequeño horror de código.
class UserService:
"""
Service responsible for user creation and welcome notification.
This version follows the dependency injection principle by receiving
a NotifyService implementation from the outside, instead of instantiating it.
"""
def __init__(self, notifier: NotifyService):
"""
Initializes the service with a specific notification strategy.
:param notifier: An instance of a class that implements NotifyService.
"""
self.notifier = notifier
def create_user(self, user: User):
"""
Creates a new user and sends a welcome message using the injected notifier.
:param user: The user to be created.
"""
# Simulate saving the user to a database
user.id = randint(1, 199)
# Send welcome notification
self.notifier.send_message(user)
Genial, creo que la diferencia es notable: nuestro UserService
se ve mucho más limpio y cumple con los principios de responsabilidad única y abierto/cerrado. Ahora, al momento de crear la instancia de nuestro UserService
, es cuando se debe pasar la instancia del notificador preferido, y el servicio ya no tiene que preocuparse ni saber nada de él, simplemente usarlo con libertad.
Ahora bien, algo muy importante: nota cómo en el constructor del servicio el tipo del notificador es la interfaz (en este caso, clase abstracta) NotifyService
. Las interfaces o clases abstractas son una inyección de esteroides para este patrón, ya que ayudan muchísimo a mantener un desacoplamiento mayor. Así, las clases dependientes no tienen que preocuparse por definir qué clases se pueden inyectar, sino que simplemente se define un contrato, y con eso se asegura que cualquier cosa que se inyecte cumple con lo necesario.
Si no tuviéramos una clase abstracta que define ese contrato, tendríamos que hacer algo como esto:
def __init__(self, notifier: EmailService | SMSService | PushService):
"""
Initializes the service with a specific notification strategy.
:param notifier: An instance of a class that implements NotifyService.
"""
self.notifier = notifier
Esto funciona, claro, pero no es para nada escalable. En cambio, si tenemos un contrato claro (la interfaz), el código es mucho más limpio, extensible y abierto a cambios sin tocar lo que ya existe.
Extra: Contenedores de Dependencias
Ya vimos que inyectar dependencias manualmente nos da mucho orden y claridad, pero… ¿qué pasa cuando tienes muchas clases que dependen de otras clases, que dependen de otras clases…? Estar creando cada instancia y pasándola a mano se puede volver aburrido, y como somos vagos, preferimos no hacerlo.
Y es aquí donde entran los contenedores de dependencias (o DI containers). Un contenedor de dependencias es básicamente un lugar donde registramos clases o servicios, y ese contenedor se encarga de crear las instancias por nosotros y resolver todas las dependencias que necesiten. Hasta el día de hoy se me hace magia negra, no mentiré.
Este concepto es muy común en frameworks como ASP.NET Core, Angular o FastAPI, donde podemos declarar las dependencias y simplemente decir "necesito esto", y el framework se encarga del resto.
Construyendo un contenedor casero
Vamos a construir un pequeño contenedor de dependencias casero y feo, pero que muestra la idea. Cabe decir que son conceptos y cositas un poquito avanzadas, ya que usaremos cosas como metaprogramación, introspección y recursividad para lograrlo. Pero dándole una estudiada, queda claro.
Primero, definamos el contenedor de dependencias.
import inspect
from typing import Callable, Type
class MySuperDIContainer:
"""
A container that uses constructor inspection to automatically resolve dependencies.
"""
def __init__(self):
self._registry = {}
def register(self, cls: Type, provider: Callable = None):
"""
Register a class with an optional provider.
If no provider is given, the class itself is used.
"""
self._registry[cls] = provider or cls
def resolve(self, cls: Type):
"""
Automatically resolves dependencies by inspecting the constructor.
"""
# Checamos si la clase está registrada
provider = self._registry.get(cls)
# Si no está registrada, lanzamos un error
if not provider:
raise ValueError(f"{cls} is not registered")
# Obtenemos la firma del constructor
sig = inspect.signature(provider)
kwargs = {}
# Iteramos sobre los parámetros del constructor
for name, param in sig.parameters.items():
# Guardamos el tipo de parámetro
param_type = param.annotation
# Si el tipo de parámetro es un tipo registrado, lo resolvemos
if param_type in self._registry:
# Aquí aplicamos la recursividad
kwargs[name] = self.resolve(param_type)
# Finalmente, resolvemos el proveedor
return provider(**kwargs)
Ahora, para verlo en acción tenemos que hacer algo así.
# Creamos una instancia del contenedor
my_super_di_container = MySuperDIContainer()
# Registramos las clases (esto es lo que se puede inyectar)
my_super_di_container.register(NotifyService, EmailService)
# Registramos el servicio de usuario para que se pueda resolver
my_super_di_container.register(UserService)
# Ya no se pasa nada a mano, el contenedor se encarga de resolver las dependencias
user_service = my_super_di_container.resolve(UserService)
user = User(name="Alice", email="[email protected]", phone="555-1234", password="secret")
user_service.create_user(user)
# Output: Sending welcome email to: [email protected]
Como vemos, inyectar dependencias manualmente es excelente para proyectos pequeños o medianos, pero cuando empiezas a tener múltiples servicios que dependen de otros servicios, el paso lógico es usar un contenedor de dependencias inteligente.
Con algo de introspección y metaprogramación, como vimos arriba, podemos crear un contenedor que no solo almacena dependencias, sino que también resuelve automáticamente lo que necesita cada clase.
Esto nos permite escribir código más limpio, más declarativo y, sobre todo, más mantenible y escalable.
Conclusión
La inyección de dependencias no es solo un patrón bonito de arquitectura, es una forma poderosa de hacer que nuestro código sea más mantenible, flexible y limpio. Empezar inyectando manualmente ya es un gran paso hacia un mejor diseño, pero cuando el proyecto crece y las dependencias se empiezan a encadenar, usar un contenedor se vuelve casi obligatorio.
Ya sea que estés trabajando con frameworks que traen su propio sistema de inyección (como FastAPI, Angular o ASP.NET Core), o que te animes a construir tu propio contenedor casero como el que vimos aquí, lo importante es entender el por qué detrás de estas herramientas: desacoplar tu código, delegar responsabilidades y dejar de repetir lo mismo una y otra vez.
Como todo en desarrollo de software, no se trata de usar patrones por usarlos, sino de aplicarlos cuando realmente te hacen la vida más fácil. Y créeme, este patrón sí lo hace.