1. Cuando un solo bean no es suficiente: conjunto de estrategias
Mientras el servicio solicita un bean, toda la conversación gira en torno a la elección de un candidato. Pero a veces elegir uno es una limitación artificial. Imagine que tiene varias formas de enviar una notificación (SMS, correo electrónico, consola), y de repente aparece el requisito «en caso de error crítico — enviar a todos los canales a la vez». En ese momento, elegir un bean no solo es incómodo, sino que contradice el sentido de la tarea.
En esos momentos es muy importante no dar «un paso atrás» y empezar a escribir de nuevo if ("sms") new Sms.... Ya acordamos que las dependencias deben llegar a la clase a través del constructor, y el wiring debe vivir fuera. Por lo tanto, cuando el significado del negocio requiere un conjunto de implementaciones, debemos enseñar al contenedor: «dame no un solo NotificationSender, sino todos los remitentes».
A nivel de Spring, esto se ve como un cambio de tipo de dependencia. Y la diferencia aquí es contractual, no «puramente sintáctica».
| Lo que está escrito en el constructor | Lo que significa para el contenedor | Reacción típica de Spring |
|---|---|---|
| NotificationSender sender | «Necesito un objeto concreto» | Se necesita unicidad (o @Primary, @Qualifier) |
| List<NotificationSender> senders | «Necesito el conjunto completo de implementaciones» | Spring reúne todos los beans coincidentes en una lista |
| Set<NotificationSender> senders | «Necesito un conjunto de implementaciones sin noción de orden» | Spring reúne todos los beans coincidentes en un conjunto |
| Map<String, NotificationSender> senders | «Necesito un registro: nombre del bean → implementación» | Spring construye un map según los nombres de los beans |
Si en lecciones anteriores la ambigüedad era un error de wiring, aquí la ambigüedad es el objetivo: queremos deliberadamente obtener muchos candidatos.
2. Colecciones List<T> y Set<T> en autowiring
Antes de escribir código, es útil detenerse en una cosa simple: Spring no «lee la mente», lee el tipo de dependencia. Y cuando escribes List<NotificationSender>, en realidad le estás dando al contenedor una tarea muy clara: «encuentra todos los beans que coinciden con el tipo NotificationSender y empaquétalos en una colección».
Aquí los genéricos juegan un papel: List<NotificationSender> no es lo mismo que List<Object>. Spring mira la parametrización y busca candidatos específicamente para NotificationSender. Por lo tanto, la inyección de colecciones no es un «modo mágico del contenedor», sino un autowiring normal, solo que con un contrato diferente en el resultado.
También hay un matiz práctico agradable que a menudo sorprende a los principiantes (en el buen sentido). Si inyectas un solo bean de tipo NotificationSender y no hay candidatos, el contenedor falla con un error porque es una dependencia obligatoria. Pero si inyectas List<NotificationSender> y no hay candidatos, Spring, por lo general, proporciona una colección vacía. Esto es lógico: «dame todos» puede significar legítimamente «no hay ninguno».
Es decir, la inyección de colecciones resuelve dos tareas simultáneamente: «recoger un conjunto de estrategias» y hacerlo seguro en forma (la colección siempre está presente). Pero el significado de negocio de un conjunto vacío ya es responsabilidad suya: a veces una lista vacía es aceptable, y a veces debe llevar a un error claro.
Para que sea más fácil mantener la imagen en la cabeza, podemos imaginar el siguiente diagrama:
flowchart TD
A["Injection point: NotificationSender"] --> B{"¿Cuántos candidatos por tipo?"}
B -->|0| E["Error: no hay bean"]
B -->|1| C["Inyección de este bean"]
B -->|>1| D["Se necesita una regla de selección (@Primary / @Qualifier)"]
A2["Injection point: List<NotificationSender>"] --> F["Recoger todos los candidatos"]
F --> G["Inyección de colección (puede estar vacía)"]
Y ahora estamos listos para la práctica: aprendamos a obtener todas las implementaciones de NotificationSender y DiscountPolicy en nuestro ContextFlow.
3. List<NotificationSender>: capa de broadcast
Imaginemos una tarea que aparece sospechosamente a menudo en proyectos reales: necesitamos enviar el mismo mensaje a todos los canales. En el dominio de ContextFlow esto podría ser, por ejemplo, una «notificación crítica» o un «mensaje de diagnóstico al iniciar un escenario». No hacemos de esto una religión arquitectónica separada, simplemente lo usamos como un ejemplo comprensible donde la lista de implementaciones tiene un sentido natural.
Para empezar, recordemos (o creemos, si aún no lo han hecho) nuestro contrato de envío de notificaciones. Que viva en domain.ports, para que la capa de aplicación dependa de la interfaz y las implementaciones estén en infrastructure.
package com.example.contextflow.domain.ports;
public interface NotificationSender {
// Contrato del puerto: cualquier implementación debe poder enviar un mensaje
void send(String message);
}
Ahora supongamos que ya tenemos varias implementaciones registradas mediante component scanning. Aquí mostraré una, las demás se ven de manera similar.
package com.example.contextflow.infrastructure.notification;
import com.example.contextflow.domain.ports.NotificationSender;
import org.springframework.stereotype.Component;
@Component // Spring recogerá esta implementación como bean
class ConsoleNotificationSender implements NotificationSender {
public void send(String message) {
// Implementación más simple: solo imprimimos el mensaje en la consola
System.out.println("CONSOLE: " + message); // CONSOLE: Order created
}
}
Y ahora creemos un servicio que recibe la lista completa de remitentes y hace un «envío a todos a la vez». Observe: no pedimos «un NotificationSender», pedimos exactamente List<NotificationSender> — y con esto eliminamos el problema de ambigüedad, porque la ambigüedad ahora es deseada.
package com.example.contextflow.application.service;
import com.example.contextflow.domain.ports.NotificationSender;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class NotificationBroadcastService {
private final List<NotificationSender> senders;
public NotificationBroadcastService(List<NotificationSender> senders) {
// Se inyecta la lista de todos los NotificationSender del contexto
this.senders = senders;
}
public void broadcast(String message) {
// «Envío a todos»: llamamos a send en cada remitente
senders.forEach(s -> s.send(message));
}
}
Desde el punto de vista del contenedor Spring, aquí todo es bastante literal. Encuentra todos los beans que son de tipo NotificationSender, los recoge en una lista y los inyecta en el constructor. Nada de @Primary, nada de @Qualifier — porque no estamos pidiendo elegir a un ganador.
Para ver que realmente funciona, es conveniente agregar una pequeña llamada de diagnóstico en nuestro ScenarioRunner (o en cualquier otro runner que ya esté usando como punto de entrada de consola). Esto no es una «nueva capa», sino simplemente una demostración dentro del proyecto educativo.
package com.example.contextflow.application.scenario;
import com.example.contextflow.application.service.NotificationBroadcastService;
import org.springframework.stereotype.Component;
@Component // Componente de demostración: muestra que broadcast realmente envía a todos los canales
class ScenarioRunner {
private final NotificationBroadcastService broadcastService;
ScenarioRunner(NotificationBroadcastService broadcastService) {
// DI mediante constructor — sin new manual
this.broadcastService = broadcastService;
}
void run() {
broadcastService.broadcast("Order created");
// EMAIL: Order created
// SMS: Order created
// CONSOLE: Order created
}
}
Aquí es importante captar la «moral arquitectónica» correcta. No «evitamos» al contenedor. Simplemente le dimos otro significado a la dependencia: el servicio no necesita un solo remitente, sino un conjunto de remitentes. Esto es parte normal del pensamiento DI: el contrato de dependencia se expresa en el tipo.
4. Set<DiscountPolicy>: conjunto sin orden
Ahora veamos una mecánica muy similar, pero con una semántica diferente: Set<T>. A nivel de Spring, Set significa casi lo mismo que List: el contenedor encontrará todos los candidatos por tipo y los reunirá en una colección. La diferencia aquí es más bien para nosotros (las personas) que para el contenedor: Set le dice al lector del código «no pienses en el orden, piensa en un conjunto».
Para los descuentos esto encaja especialmente bien, porque un «conjunto de políticas disponibles» por lo general no tiene sentido como una lista ordenada. Si empezamos a depender secretamente de la posición del elemento, obtendremos una comedia: «el descuento funciona solo porque este bean resultó ser el primero». La comedia, por cierto, será corta y trágica.
Como ejemplo, tomemos la interfaz DiscountPolicy.
package com.example.contextflow.domain.ports;
public interface DiscountPolicy {
// Contrato: entrada total, salida — monto (o tamaño de descuento) según la política
int apply(int total);
}
Y creemos un servicio de diagnóstico simple que recibe todas las políticas de descuento como Set<DiscountPolicy> y, por ejemplo, imprime su cantidad y nombres de clase. Esto es útil en un proyecto educativo: aprendemos a ver que el contenedor realmente ha recogido.
package com.example.contextflow.application.service;
import com.example.contextflow.domain.ports.DiscountPolicy;
import org.springframework.stereotype.Service;
import java.util.Set;
@Service
public class PricingDiagnosticsService {
private final Set<DiscountPolicy> policies;
public PricingDiagnosticsService(Set<DiscountPolicy> policies) {
// Set enfatiza: el orden no nos importa, lo que importa es el conjunto de políticas
this.policies = policies;
}
public void printAvailablePolicies() {
// Diagnóstico: cuántas políticas están registradas en el contexto
System.out.println("Discount policies: " + policies.size()); // Discount policies: 2
// Mostramos las clases para claridad (el orden de salida no está garantizado)
policies.forEach(p -> System.out.println(p.getClass().getSimpleName()));
}
}
Si en el contenedor están NoDiscountPolicy y LoyalCustomerDiscountPolicy, verá algo como:
Discount policies: 2
NoDiscountPolicy
LoyalCustomerDiscountPolicy
Observe: no prometemos un orden de salida. Y eso es bueno. En este curso, deliberadamente no construimos lógica que dependa del orden de la colección, porque es un tema aparte (y una fuente aparte de «¿por qué funciona en mi máquina, pero no en CI?»).
Lo principal que hay que llevarse de esta sección: Set<T> es lo mismo que «dame todos» como List<T>, pero con una pista más honesta al lector: «el orden no importa, lo que importa es el conjunto».
5. Inyección de Map<String, NotificationSender>: registro por nombre de bean
La variante más sabrosa (y un poco más astuta) de la inyección de colecciones es Map<String, T>. Es útil cuando no solo necesita un «conjunto de implementaciones», sino la posibilidad de elegir la implementación deseada por clave. En Spring, esta clave por defecto es el nombre del bean.
Es importante señalar de inmediato para no tener expectativas «mágicas»: el ensamblaje automático del mapa funciona exactamente para Map<String, T>. Es decir, las claves son cadenas, y esas cadenas son los nombres de los beans. Si intenta pedir Map<NotificationChannel, NotificationSender>, Spring no «adivinará» cómo ensamblarlo, porque NotificationChannel no es un nombre de bean. Para ese caso, normalmente crea el mapa usted mismo (por ejemplo, mediante @Bean), pero hoy nos mantenemos dentro de la mecánica que Spring proporciona de fábrica.
Para empezar, veamos la mecánica de Map<String, T> de la manera más directa posible. Este registro no es útil como estilo final, sino como una demostración pura de que las claves realmente se convierten en nombres de beans.
Primero, veamos simplemente el hecho: Spring puede inyectar Map<String, NotificationSender> y las claves serán los nombres de los beans. Para eso, creemos un pequeño servicio de depuración.
package com.example.contextflow.application.service;
import com.example.contextflow.domain.ports.NotificationSender;
import org.springframework.stereotype.Service;
import java.util.Map;
@Service
public class NotificationSendersDebugService {
private final Map<String, NotificationSender> senders;
public NotificationSendersDebugService(Map<String, NotificationSender> senders) {
// Las claves del map son los nombres de los beans (por ejemplo, "emailNotificationSender")
this.senders = senders;
}
public void printSenderKeys() {
// Comprobación conveniente: qué nombres de beans exactos entraron en el mapa
System.out.println("Senders: " + senders.keySet());
// Senders: [emailNotificationSender, smsNotificationSender, consoleNotificationSender]
}
}
¿Por qué las claves son exactamente así? Porque por defecto, el nombre del bean para un componente es el nombre de la clase, pero con la primera letra en minúscula. Es decir, EmailNotificationSender se convierte en emailNotificationSender. Y esto hace que el naming no sea «cosmético», sino parte del contrato si empieza a enrutar por clave.
Ahora usemos el mapa según su propósito: construyamos un servicio que selecciona al remitente por nombre. Para la demostración, haremos un envío simple «por canal» (NotificationChannel) mediante una convención de nombres.
Primero, el modelo de dominio del canal (ya está en el proyecto según la especificación, pero lo mostraré mínimamente).
package com.example.contextflow.domain.model;
public enum NotificationChannel {
// Los canales de dominio que elige la lógica de negocio (no Spring)
EMAIL, SMS, CONSOLE
}
Y ahora el servicio de enrutamiento. Observe la idea: tomamos NotificationChannel, lo convertimos en el nombre del bean según la convención (emailNotificationSender, smsNotificationSender, consoleNotificationSender), obtenemos la implementación del mapa y enviamos el mensaje.
package com.example.contextflow.application.service;
import com.example.contextflow.domain.model.NotificationChannel;
import com.example.contextflow.domain.ports.NotificationSender;
import org.springframework.stereotype.Service;
import java.util.Map;
@Service
public class NotificationDispatchService {
private final Map<String, NotificationSender> senders;
public NotificationDispatchService(Map<String, NotificationSender> senders) {
// Inyectamos el «registro de remitentes»: nombre del bean -> implementación
this.senders = senders;
}
public void send(NotificationChannel channel, String message) {
// Convención: EMAIL -> "emailNotificationSender", SMS -> "smsNotificationSender", etc.
String beanName = channel.name().toLowerCase() + "NotificationSender";
// Obtenemos la implementación del registro por nombre del bean
NotificationSender sender = senders.get(beanName);
// En el ejemplo educativo asumimos que el nombre se encuentra (más abajo en el texto se muestra una variante con verificación)
sender.send(message); // por ejemplo: SMS: Order cancelled
}
}
Sí, hay un riesgo: si renombra el bean o la clase, la convención se rompe. Pero en el contexto de la lección, es un buen compromiso educativo, porque permite visualmente vincular Map<String, T> con los nombres de beans y sentir que el «nombre del bean» es realmente parte del modelo del contenedor, no una etiqueta aleatoria.
Si desea hacerlo un poco más seguro (y ya está empezando a pensar como ingeniero — felicidades, eso es progreso), puede agregar una verificación de null y lanzar un error claro. En el código educativo corto, al menos así:
// Protección contra la situación en que la convención no coincide con los nombres reales de los beans
NotificationSender sender = senders.get(beanName);
if (sender == null) {
throw new IllegalArgumentException("Unknown channel: " + channel);
}
sender.send(message);
La idea principal no es hacer un super-enrutador, sino ver que Map<String, T> es un «registro integrado» que el contenedor recopila para usted, y esto a menudo es mucho mejor que arrastrar ApplicationContext dentro del código de negocio y buscar beans manualmente.
Esta es una variante deliberadamente mecánica de enrutamiento. Muestra bien la relación de Map<String, T> con los nombres de beans, pero el wiring cotidiano generalmente gana cuando la elección del canal se mantiene más explícitamente en un solo lugar, en lugar de calcularse a partir de una convención de cadenas sobre la marcha.
6. Incremento ContextFlow: gestor de notificaciones
Para que esto no se quede como un «bonito ejemplo en la pizarra», vamos a conectar nuestro NotificationDispatchService donde suena natural: en el escenario de procesamiento de pedidos. Aquí ya juega un papel diferente: no es un «servicio con un solo sender», sino un registro-enrutador educativo. Tenemos servicios de aplicación que orquestan los casos de uso, y no deben saber qué clase concreta de remitente de notificaciones está seleccionada. Solo deben conocer el «canal» y el «mensaje».
Supongamos que en OrderPlacementService queremos, después de crear un pedido, enviar una notificación al canal que llegó del comando o se eligió según una regla simple. Ahora no nos importa de dónde vino el canal; lo importante es que la elección de la implementación de NotificationSender no está dispersa por el código.
Mini-fragmento:
package com.example.contextflow.application.service;
import com.example.contextflow.domain.model.NotificationChannel;
import org.springframework.stereotype.Service;
@Service
public class OrderPlacementService {
private final NotificationDispatchService notificationDispatchService;
public OrderPlacementService(NotificationDispatchService notificationDispatchService) {
// El servicio no conoce las implementaciones concretas de los remitentes — solo el despachador
this.notificationDispatchService = notificationDispatchService;
}
public void placeOrder() {
// La lógica de negocio elige el canal, no la clase concreta del remitente
notificationDispatchService.send(NotificationChannel.SMS, "Order created");
// SMS: Order created
}
}
Aquí obtenemos una imagen ordenada: OrderPlacementService no conoce SmsNotificationSender como clase, no lo crea mediante new, no mantiene un if (channel == ...). Simplemente dice: «envía la notificación a este canal». Y el enrutamiento vive en un solo lugar: en NotificationDispatchService.
Y observe qué bien funciona el modelo DI. No arrastramos por todo el sistema un montón de cadenas @Qualifier si la selección depende de una clave. No organizamos una «democracia local» en cada servicio. Simplemente inyectamos un mapa que el contenedor ensambló por sí mismo, e hicimos a partir de esto un pequeño «despachador».
7. Errores típicos en la inyección de colecciones
La inyección de colecciones se ve tan conveniente que uno mismo se siente tentado a «hacerlo así en todas partes». Y aquí comienzan los errores clásicos que he visto muchas veces (y que yo mismo he cometido un par de veces — exclusivamente por ciencia, por supuesto).
Error nº1: inyectar una colección cuando por sentido se necesita un solo objeto.
El novato ve NoUniqueBeanDefinitionException, se frustra y decide: «voy a inyectar List<NotificationSender> y tomaré get(0)». Formalmente, el contenedor dejará de quejarse, pero arquitectónicamente esconderá la elección de la implementación en un lugar donde nadie la espera: en el índice de la lista. Esto casi garantiza errores, porque «primer elemento» no es un contrato. Si se necesita una opción estándar única, es mejor resolverlo con @Primary/@Fallback o un @Qualifier explícito, no con aritmética oculta de índices.
Error nº2: construir lógica de negocio basada en el orden de los elementos en un List.
Aunque hoy su lista «accidentalmente» llegue en un orden agradable, no es motivo para concluir «entonces siempre será así». El orden depende del registro de los beans, de la forma de configuración y de una serie de detalles que en este punto del curso deliberadamente no convertimos en un tema aparte. Si el orden es importante, debe formalizarse como un contrato explícito, no como una esperanza.
Error nº3: tratar las claves de Map<String, T> como cadenas arbitrarias.
En Map<String, NotificationSender>, las claves son los nombres de los beans. Si alguna vez se ata a esas cadenas, entonces renombrar la clase o el método @Bean se convierte en un cambio de comportamiento del programa. No es «malo», pero debe ser consciente: el nombre del bean se convierte en un contrato público entre el wiring y el código. Por lo tanto, los nombres deben ser legibles, estables y no al estilo «sender1», «sender2», «bueno, es temporal».
Error nº4: intentar controlar el contenido de la colección mediante @Primary.
@Primary y @Fallback ayudan cuando el contenedor selecciona un único bean. Pero si pide List<T> o Map<String, T>, el contenedor le entrega todo el conjunto. Y es correcto: de lo contrario, el sentido de la inyección de colecciones se rompería. Por lo tanto, no espere que «primary entre en la lista y los demás desaparezcan». No desaparecerán — y no deberían desaparecer.
Error nº5: convertir Map<String, T> en un localizador de servicios dentro de un método de negocio.
A veces los desarrolladores hacen esto: inyectan Map<String, NotificationSender> en un servicio de negocio y comienzan directamente dentro de cada caso de uso a extraer el bean deseado por cadena. Esto ya huele al enfoque de «service locator» del cual Spring precisamente nos salva. Es mucho mejor mantener el mapa en un solo lugar (en el enrutador/despachador), y que los servicios de negocio dependan de una API simple y clara: send(channel, message).
GO TO FULL VERSION