1. Dos implementaciones de la interfaz: norma y queja de Spring
Mientras tengamos un único CourseCatalogRepository, Spring está contento: «veo el tipo — veo el único bean — lo inyecto». Pero tan pronto como en el proyecto aparece un segundo bean del mismo tipo (has añadido una implementación alternativa, has conectado una biblioteca o simplemente has creado una variante “de repuesto” para aprendizaje), el contenedor deja de adivinar. Honestamente dice: «no soy adivino».
Imaginemos que tenemos la interfaz del repositorio:
import java.util.List;
// Contrato del repositorio: devolvemos el catálogo de cursos como lista de tarjetas
public interface CourseCatalogRepository {
List<CourseCard> findAll();
}
Y por razones educativas hemos creado dos implementaciones: una normal y otra “vacía” (por ejemplo, para comprobar rápidamente el comportamiento del servicio con un catálogo vacío sin cambiar el código del servicio):
import java.util.List;
import org.springframework.stereotype.Repository;
@Repository // Registramos el bean del repositorio en el contenedor de Spring
public class EmptyCourseCatalogRepository implements CourseCatalogRepository {
public List<CourseCard> findAll() {
return List.of(); // Simulación: siempre devolvemos un catálogo vacío
}
}
import java.util.List;
import org.springframework.stereotype.Repository;
@Repository // Otro bean del mismo tipo (esto crea la ambigüedad)
public class InMemoryCourseCatalogRepository implements CourseCatalogRepository {
public List<CourseCard> findAll() {
return List.of(); // Para el ejemplo devolvemos vacío (en realidad aquí habría una lista en memoria)
}
}
Y el servicio se ve de la manera más inocente:
import org.springframework.stereotype.Service;
@Service // El servicio se construirá mediante DI (constructor injection)
public class CourseCatalogService {
public CourseCatalogService(CourseCatalogRepository repository) {
// Spring debe elegir un bean concreto de CourseCatalogRepository
}
}
Y en este punto Spring dirá algo como esto (el texto puede variar, pero el significado es el mismo):
NoUniqueBeanDefinitionException:
expected single matching bean but found 2
Es importante entender la filosofía: esto no es un “error de Spring”. Es una protección contra comportamientos aleatorios. Si el contenedor comenzara a elegir “uno cualquiera de los dos”, tu aplicación funcionaría “hoy de una manera, mañana de otra”, y eso ya no es desarrollo, sino una lotería.
2. Cómo selecciona Spring el bean: modelo mental simple
Ahora necesitamos construir un modelo comprensible: ¿qué reglas usa Spring cuando ve una dependencia “por tipo”? La buena noticia: el algoritmo básico no es complicado. La mala noticia: si no lo conoces, estarás tratando los síntomas (@Qualifier «por si acaso») y cultivas un arbusto de configuración.
Para la inyección de valor único (cuando el constructor necesita exactamente un T, no una colección), podemos tener en mente este esquema:
flowchart TD
A["Necesito un bean de tipo T"] --> B{"Candidatos por tipo"}
B -->|0| C["Error: no hay bean<br/>o Optional.empty()"]
B -->|1| D["Inyectamos el único bean"]
B -->|>1| E{"¿Hay @Qualifier?"}
E -->|sí| F["Dejamos candidatos con ese qualifier/nombre"]
E -->|no| G{"¿Hay @Primary?"}
G -->|sí| H["Tomamos el primary"]
G -->|no| I["Si no: ambigüedad"]
F --> J{"¿Después del filtro 1?"}
J -->|sí| D
J -->|no| I
I --> K["Error: NoUniqueBeanDefinitionException"]
En la práctica, también existe un matiz: a veces Spring puede intentar emparejar el nombre del parámetro del constructor con el nombre del bean. Pero esto depende de si los nombres de los parámetros se conservan en el bytecode (y de una serie de ajustes de compilación). Para un desarrollador principiante, esto es un soporte demasiado frágil, por lo que nos limitamos al conjunto básico de herramientas: @Primary, @Qualifier, Optional<T>, List<T>.
Para no confundirnos, es útil recordar en forma de tabla (esto es directamente una mini-chuleta para el día a día):
| Situación en la aplicación | Qué escribir en el constructor | Qué hará Spring |
|---|---|---|
| Una sola implementación | T dep | Inyecta sin problemas |
| Varias implementaciones, una “por defecto” | T dep + en un bean @Primary | Elige el primary |
| Varias implementaciones, se necesita una concreta | @Qualifier("...") T dep | Toma la necesaria por qualifier/nombre |
| La implementación puede no existir | Optional<T> dep | Proporciona Optional.empty() en lugar de fallar |
| Se necesitan todas las implementaciones | List<T> deps | Proporciona la lista de todos los beans de ese tipo |
A continuación analizaremos estas herramientas una por una, con la sensación de que no estás “aprendiendo anotaciones”, sino que estás dando una pista al contenedor sobre cómo debe ensamblar el grafo de dependencias.
3. @Primary: el bean “por defecto” cuando uno es realmente el principal
@Primary no es un “botón mágico para que todo funcione”. Es una forma de decir honestamente al contenedor: «sí, hay varias implementaciones, pero esta es la opción principal por defecto». Imagina que has pedido un café “como siempre”. @Primary es el barista que recuerda que “como siempre” para ti es un capuchino, no un raf con lavanda y sorpresa.
Volvamos a nuestro CourseCatalogRepository. Si realmente queremos considerar a InMemoryCourseCatalogRepository como la implementación principal, la marcamos con @Primary:
import java.util.List;
import org.springframework.context.annotation.Primary;
import org.springframework.stereotype.Repository;
@Repository // Registramos el bean del repositorio
@Primary // Marcamos este bean como "por defecto" si hay varios candidatos
public class InMemoryCourseCatalogRepository implements CourseCatalogRepository {
public List<CourseCard> findAll() {
return List.of(); // Para el ejemplo: devolvemos un catálogo vacío
}
}
Ahora el constructor del servicio se puede dejar de la manera más limpia:
import org.springframework.stereotype.Service;
@Service
public class CourseCatalogService {
public CourseCatalogService(CourseCatalogRepository repository) {
// Si hay varias implementaciones, Spring elegirá la marcada con @Primary
}
}
Y Spring elegirá al “principal”.
Aquí hay un punto psicológico importante. @Primary es bueno cuando realmente tienes una implementación principal y una auxiliar (simulación, experimento, temporal, educativa). Si tienes dos estrategias equivalentes y la elección depende del escenario, @Primary se convierte en una “política oculta” que es fácil olvidar. En ese caso, suele ser más honesto usar @Qualifier en aquellos lugares donde necesitas algo concreto.
4. @Qualifier: elegimos un bean concreto y dejamos de confiar en la suerte
@Qualifier es una forma de decir: «no necesito cualquier CourseCatalogRepository, necesito ese precisamente». Aquí es importante no convertir el código en una colección de cadenas. Sí, @Qualifier a menudo se ve como una cadena, y sí — la cadena es propensa a errores tipográficos. Pero sigue siendo mejor que una ambigüedad implícita y que la aplicación falle al inicio.
La forma más directa es referirse al nombre del bean. Si el bean se creó mediante una anotación de estereotipo (@Component, @Service, @Repository), entonces por defecto su nombre es el lowerCamelCase del nombre de la clase. Es decir, EmptyCourseCatalogRepository se convierte en emptyCourseCatalogRepository.
Entonces en el constructor quedaría así:
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
@Service
public class CourseCatalogService {
public CourseCatalogService(
@Qualifier("emptyCourseCatalogRepository") CourseCatalogRepository repo) {
// Pedimos explícitamente a Spring: toma exactamente el bean con ese nombre/qualifier
}
}
Parece simple, pero hay un matiz: empiezas a depender de un nombre que se puede “romper” fácilmente con una refactorización de la clase. Por eso a menudo es más conveniente dar un qualifier explícito, sin vincularlo al nombre de la clase. Esto se hace así: colocamos @Qualifier en el propio bean (en la clase), y usamos el mismo qualifier en la inyección.
import java.util.List;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Repository;
@Repository // Creamos el bean del repositorio
@Qualifier("empty") // Le damos un "rol": esta es la implementación vacía
public class EmptyCourseCatalogRepository implements CourseCatalogRepository {
public List<CourseCard> findAll() {
return List.of(); // Catálogo vacío como estrategia/opción explícita
}
}
Y ahora la inyección se ve más robusta:
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
@Service
public class CourseCatalogService {
public CourseCatalogService(@Qualifier("empty") CourseCatalogRepository repo) {
// Tomamos la implementación concreta por la etiqueta "empty"
}
}
Observa que aquí @Qualifier("empty") ya no es una “cadena aleatoria”. Es tu pequeña “etiqueta de rol”. Y puedes mantenerla estable incluso si renombras la clase.
Otro matiz: @Primary y @Qualifier resuelven tareas diferentes. @Primary elige “por defecto”, mientras que @Qualifier elige “concretamente”. Cuando usas @Qualifier, @Primary normalmente ya no juega ningún papel, porque has especificado explícitamente lo que necesitas.
5. Optional<T>: una dependencia que puede no existir
A veces un componente no es obligatorio. No es un “ya lo haremos luego”, sino que por diseño: el servicio debe poder vivir sin él. En Java puro, esto suele resolverse con null y una oración “ojalá no olvide comprobarlo”. En Spring se puede hacer de forma más elegante: decirle al contenedor que la dependencia es opcional, mediante Optional<T>.
Imaginemos una pequeña interfaz de infraestructura que sabe imprimir un bonito banner. En algunos entornos existe, en otros no (por ejemplo, no quieres ese banner cada vez que inicias localmente):
// Componente de infraestructura: puede estar conectado o no
public interface CatalogBannerWriter {
void writeBanner(String title);
}
El servicio que usa el banner puede expresar honestamente la dependencia como opcional:
import java.util.Optional;
import org.springframework.stereotype.Service;
@Service
public class StartupSummaryService {
private final Optional<CatalogBannerWriter> bannerWriter;
public StartupSummaryService(Optional<CatalogBannerWriter> bannerWriter) {
// Si no hay bean, Spring proporcionará Optional.empty() en lugar de fallar
this.bannerWriter = bannerWriter;
}
}
Y el uso se vuelve seguro:
public void printBanner(String title) {
// Solo imprimimos el banner si el writer realmente está presente en el contexto
bannerWriter.ifPresent(w -> w.writeBanner(title));
}
La idea clave: Optional<T> es un contrato a nivel del constructor. Le dice al lector de la clase: “el componente puede existir o no; la clase está preparada para ambas opciones”. Y Spring respalda este comportamiento: si no hay bean, no hará caer la aplicación, sino que proporcionará Optional.empty().
Es importante no confundir: Optional<T> no crea un bean. Solo hace que la ausencia sea aceptable. Si quieres que el bean esté siempre, entonces Optional no es necesario: que la dependencia sea obligatoria.
6. List<T>: inyectamos “todos los beans de este tipo”
A veces necesitamos no un componente, sino un conjunto de componentes del mismo tipo: una cadena de reglas, un conjunto de comprobaciones, varias estrategias de formateo, un pequeño pipeline. Para esto, Spring sabe inyectar colecciones, y la opción más comprensible para empezar es List<T>.
Imaginemos que tenemos reglas simples que pueden filtrar una tarjeta de curso. No estamos haciendo aquí una “gran arquitectura de reglas”, nos basta con una interfaz pequeña:
// Pequeño contrato de "regla": si se puede pasar la tarjeta más adelante en el pipeline
public interface CourseCardRule {
boolean test(CourseCard card);
}
Dos reglas pequeñas (cada una es un bean separado):
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
@Component // Registramos la regla como bean
@Order(10) // Establecemos explícitamente el orden en la lista List<CourseCardRule>
public class PublishedOnlyRule implements CourseCardRule {
public boolean test(CourseCard card) {
return card.isPublished(); // Solo pasamos los cursos publicados
}
}
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
@Component // Segunda regla — también bean
@Order(20) // Se aplicará después de PublishedOnlyRule
public class FeaturedOnlyRule implements CourseCardRule {
public boolean test(CourseCard card) {
return card.isFeatured(); // Solo pasamos los cursos "destacados"
}
}
Y ahora el servicio puede obtener todas las reglas a la vez:
import java.util.List;
import org.springframework.stereotype.Service;
@Service
public class CourseCatalogService {
private final List<CourseCardRule> rules;
public CourseCatalogService(List<CourseCardRule> rules) {
// Spring reunirá aquí todos los beans de CourseCardRule (o una lista vacía si no hay ninguno)
this.rules = rules;
}
}
Y aplicarlas como “todos deben estar de acuerdo”:
public boolean isAllowed(CourseCard card) {
// "Lógica AND": la tarjeta es aceptable si todas las reglas devuelven true
return rules.stream().allMatch(r -> r.test(card));
}
Aquí es útil conocer dos comportamientos que a menudo sorprenden a los principiantes. En primer lugar, si no hay ningún bean de CourseCardRule, Spring proporcionará una lista vacía, no hará caer la aplicación. En segundo lugar, el orden de los elementos en la lista se puede controlar mediante @Order (o la interfaz Ordered); de lo contrario, el orden puede no ser obvio, y ese es precisamente el pequeño detalle del que luego surge el “por qué a veces funciona de otra manera”.
7. Minicaso: primary, qualifier, optional y list
Reunamos todo en una sola imagen para que no parezca un “zoológico de anotaciones”. En nuestro catalog-service queremos ver varios escenarios diferentes: elegir el repositorio principal por defecto; en un lugar inyectar una implementación concreta; permitir que el servicio viva sin banner; y obtener un conjunto de reglas como lista. En la vida real, estos casos suelen aparecer gradualmente, pero aquí los mostraremos de forma compacta.
Desde el punto de vista del grafo de dependencias, se puede representar así:
flowchart LR
S["CourseCatalogService"] --> R["CourseCatalogRepository<br/>uno de varios"]
S --> L["List<CourseCardRule><br/>todas las reglas"]
SS["StartupSummaryService"] --> O["Optional<CatalogBannerWriter><br/>puede no existir"]
Ahora mostraremos lo más importante: cómo se ve esto en el código para que, por el constructor, se puedan “leer” las reglas de cableado.
Si en el proyecto hay dos repositorios y queremos tener uno por defecto, ponemos @Primary en el principal:
import java.util.List;
import org.springframework.context.annotation.Primary;
import org.springframework.stereotype.Repository;
@Repository // Bean principal del repositorio
@Primary // Elección "por defecto" para inyección de valor único
public class InMemoryCourseCatalogRepository implements CourseCatalogRepository {
public List<CourseCard> findAll() {
return List.of(); // Ejemplo: en código real aquí habría lectura desde memoria
}
}
Y el alternativo lo dejamos sin primary:
import java.util.List;
import org.springframework.stereotype.Repository;
@Repository // Bean alternativo del mismo tipo
public class EmptyCourseCatalogRepository implements CourseCatalogRepository {
public List<CourseCard> findAll() {
return List.of(); // Estrategia explícitamente vacía
}
}
Entonces el servicio principal puede recibir el repositorio “por defecto” y la lista de reglas al mismo tiempo (observa: aquí no se necesita ningún @Qualifier):
import java.util.List;
import org.springframework.stereotype.Service;
@Service
public class CourseCatalogService {
private final CourseCatalogRepository repository;
private final List<CourseCardRule> rules;
public CourseCatalogService(CourseCatalogRepository repository, List<CourseCardRule> rules) {
// repository: se elegirá el repositorio @Primary
// rules: se recopilará la lista de todos los CourseCardRule (en el orden @Order establecido)
this.repository = repository;
this.rules = rules;
}
}
Pero si en algún lugar necesitas tomar forzosamente el repositorio “vacío” (por ejemplo, estás haciendo un servicio separado para diagnóstico o un escenario muy especial), entonces usas @Qualifier. Es una operación puntual, no un estilo de “poner qualifiers en todas partes”.
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
@Service
public class EmptyCatalogPreviewService {
private final CourseCatalogRepository repository;
public EmptyCatalogPreviewService(
@Qualifier("emptyCourseCatalogRepository") CourseCatalogRepository repository) {
// Aquí conscientemente elegimos la implementación concreta, ignorando @Primary
this.repository = repository;
}
}
Y por separado mostramos cómo hacer una dependencia realmente opcional:
import java.util.Optional;
import org.springframework.stereotype.Service;
@Service
public class StartupSummaryService {
private final Optional<CatalogBannerWriter> bannerWriter;
public StartupSummaryService(Optional<CatalogBannerWriter> bannerWriter) {
// Seguro: la ausencia del bean se convierte en Optional.empty()
this.bannerWriter = bannerWriter;
}
}
Al final obtenemos una importante regla “humana”: las dependencias de valor único se resuelven con @Primary y @Qualifier, “puede no existir” se expresa con Optional, “necesito todo” se expresa con List. Y lo más agradable es que todo esto se ve directamente en las firmas de los constructores, sin necesidad de ejecutar la aplicación ni buscar por el proyecto.
8. Elegir entre candidatos y registro condicional no son lo mismo
Aquí es fácil mezclar dos tareas diferentes. @Primary y @Qualifier se necesitan cuando ambos beans ya existen en el ApplicationContext actual, y el contenedor solo tiene que decidir cuál entregar en un punto de inyección concreto. Pero hay otra situación: no quieres mantener ambas implementaciones activas al mismo tiempo. Por ejemplo, en un runtime se necesita un repositorio, y en otro, otro diferente.
Entonces el problema se resuelve antes — no en el constructor, sino en la etapa de registro de los beans. Es decir, no eliges entre dos candidatos ya existentes, sino que haces que en el entorno actual solo aparezca el candidato necesario. Para el cableado actual, basta con mantener un límite simple:
- @Primary / @Qualifier — elección entre beans ya registrados;
- condiciones de perfil/propiedad — decisión sobre qué beans en general entran en el contexto;
- si mezclas estos dos niveles, el grafo de dependencias se vuelve rápidamente confuso.
Para un cableado limpio, esta es una comprobación útil: a veces cambia el punto de elección, y a veces cambia la composición del propio grafo.
9. Errores típicos al trabajar con @Primary, @Qualifier, Optional y List
Error nº 1: poner @Qualifier “por si acaso” en cada constructor.
Así conviertes la DI en un cableado manual “cables en la pared”: cualquier refactorización del nombre del bean o reordenación de clases empieza a romper la compilación. @Qualifier debe aparecer allí donde realmente estás haciendo una elección consciente de una implementación concreta. Si no se necesita elección, es mejor dejar la inyección pura por tipo, y resolver la ambigüedad mediante @Primary o simplemente no crear el segundo bean.
Error nº 2: usar @Primary como forma de “callar” la ambigüedad sin entender quién es el principal.
Si pones @Primary solo porque Spring se queja, en esencia estás escondiendo la decisión en una anotación. Al cabo de un mes, otro desarrollador añadirá una tercera implementación, y ya nadie recordará por qué esta era la “principal”. @Primary es bueno cuando realmente es una “implementación por defecto”, no un ganador casual.
Error nº 3: pensar que Optional<T> crea el bean automáticamente.
Optional solo “permite la ausencia”. Si en el contexto no hay CatalogBannerWriter, entonces Optional estará vacío, y eso es normal. Pero si esperas que aparezca “algún writer por defecto”, entonces hay que crearlo explícitamente (por ejemplo, con un bean no operativo separado), y Optional no ayudará aquí.
Error nº 4: esperar que List<T> devuelva “un elemento” o “algo principal”.
List<T> significa “dame todos los beans de este tipo”. No es una elección, es una recopilación. Si accidentalmente añades otro CourseCardRule, entrará en la lista y el comportamiento del pipeline cambiará. Esto no es un bug de Spring, es un contrato honesto. Si necesitas uno “principal”, eso no es List, sino una inyección de valor único más @Primary/@Qualifier.
Error nº 5: confiar en el orden de los elementos en List<T> sin establecerlo explícitamente.
Hoy las reglas se aplicaron en el orden A→B, mañana B→A, y empiezas a sospechar de la fase lunar y Mercurio retrógrado. En realidad, el orden puede depender de cómo se descubrieron y registraron las clases. Si el orden es importante, usa @Order y hazlo una parte explícita del diseño.
GO TO FULL VERSION