Cerramos el módulo 1 con una promesa: dejar de usar Spring Boot por imitación. En CicloUrbana ya hemos escrito @SpringBootApplication, @RestController, @Component, @Order y @EventListener sin explicar qué son ni cómo funcionan. Esta lección salda esa deuda. Veremos qué es exactamente una anotación en Java, cómo Spring las lee en el arranque (spoiler: con reflexión y un poco de análisis de bytecode, no con magia), desmontaremos @SpringBootApplication pieza a pieza, compararemos los cinco estereotipos, entenderemos cuándo declarar beans con @Bean en vez de con @Component y crearemos nuestra propia anotación estereotipada. Terminaremos aplicando todo esto a CicloUrbana: partiremos el provisional AlmacenEstaciones en un EstacionService y un EstacionRepositorioEnMemoria, que es la estructura que el proyecto arrastrará durante el resto del curso.
Contenido
- Qué es una anotación en Java
- Cómo procesa Spring las anotaciones al arrancar
@SpringBootApplicationdesmontada- Los estereotipos de Spring
@Repositoryy la traducción de excepciones@Configurationy@Bean: la vía declarativa@ComponentScany sus filtros- Crear tu propia anotación estereotipada
- Refactorizando CicloUrbana: servicio y repositorio
- Mapa de anotaciones del curso
- Errores Comunes y Consejos
- Ejercicios
- Qué es una anotación en Java
Una anotación es metadato adjunto al código: información sobre una clase, un método, un campo o un parámetro que el compilador guarda en el fichero .class y que otro programa puede leer después. Una anotación, por sí sola, no hace absolutamente nada. No ejecuta código, no cambia el comportamiento del método, no genera instancias. Es una etiqueta.
Esto es una anotación completa, escrita a mano:
package com.ciclourbana.comun;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.TYPE) // se puede poner sobre clases/interfaces
@Retention(RetentionPolicy.RUNTIME) // sobrevive hasta la ejecución
public @interface Provisional {
String hasta() default "modulo-4"; // atributo con valor por defecto
}Y así se usa:
Tres detalles que conviene tener claros:
| Elemento | Qué significa |
|---|---|
@interface |
La palabra clave que declara una anotación (no es una interfaz normal). |
@Target |
Dónde se puede colocar: TYPE (clase), METHOD, FIELD, PARAMETER, ANNOTATION_TYPE... |
@Retention |
Hasta cuándo vive. SOURCE (la descarta el compilador), CLASS (queda en el .class pero no se carga), RUNTIME (accesible por reflexión). |
Todas las anotaciones de Spring son RUNTIME. Es la condición imprescindible para que el contenedor pueda encontrarlas mientras la aplicación arranca. Si fueran SOURCE, como @Override, Spring no vería nada.
Leer una anotación en tiempo de ejecución es tan sencillo como esto:
Class<?> clase = EstacionRepositorioEnMemoria.class;
Provisional marca = clase.getAnnotation(Provisional.class);
if (marca != null) {
System.out.println("Clase provisional hasta: " + marca.hasta());
// -> Clase provisional hasta: modulo-4
}Ese fragmento —getAnnotation sobre un Class<?>— es, conceptualmente, todo lo que Spring hace. El resto es escala e ingeniería.
- Cómo procesa Spring las anotaciones al arrancar
Cuando en el módulo 1 vimos la fase refresh() del arranque, pasamos por encima de lo que ocurre dentro. Ahora podemos concretarlo. El recorrido es este:
flowchart TD
A["@SpringBootApplication en CicloUrbanaApplication"] --> B["@ComponentScan determina<br/>el paquete base: com.ciclourbana"]
B --> C["ClassPathScanningCandidateComponentProvider<br/>recorre los .class del classpath"]
C --> D["Lee cada .class con ASM<br/>(sin cargar la clase en memoria)"]
D --> E{"¿Tiene @Component<br/>directa o meta-anotada?"}
E -- No --> F["Se descarta"]
E -- Sí --> G["Crea una BeanDefinition:<br/>nombre, clase, ámbito, dependencias"]
G --> H["Registro en el BeanDefinitionRegistry"]
H --> I["BeanFactoryPostProcessor<br/>ajustan las definiciones"]
I --> J["Instanciación de los singletons<br/>resolviendo constructores"]
J --> K["BeanPostProcessor:<br/>@Autowired, @Value, proxies AOP"]
K --> L["Beans listos en el ApplicationContext"]
Vale la pena subrayar tres puntos de este flujo:
El escaneo no carga las clases. Spring usa ASM, una librería de análisis de bytecode, para inspeccionar los ficheros .class sin pasarlos por el ClassLoader. Así puede descartar rápidamente las miles de clases del classpath que no le interesan, sin el coste de cargarlas todas.
Lo que se registra primero es una BeanDefinition, no un objeto. Una BeanDefinition es una receta: qué clase instanciar, con qué constructor, con qué ámbito, con qué método de inicialización. Los objetos reales se crean después, en un segundo paso. Esta separación en dos fases es la que permite que Spring detecte errores de configuración (dependencias que faltan, ambigüedades) antes de construir nada.
Las anotaciones se resuelven de forma transitiva. Spring no busca literalmente @Component; busca @Component o cualquier anotación que a su vez esté anotada con @Component. A eso se le llama meta-anotación, y es el mecanismo sobre el que se construye todo el sistema de estereotipos. Lo usaremos nosotros mismos en el apartado 8.
La conclusión práctica: una anotación de Spring solo tiene efecto si la clase está donde el escaneo la ve, o si otro mecanismo la registra. Una clase con @Service fuera del paquete base es texto muerto. Ese es el origen de la mitad de los "no me funciona" de los primeros días.
@SpringBootApplication desmontada
@SpringBootApplication desmontadaNuestra clase principal es esta:
package com.ciclourbana;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class CicloUrbanaApplication {
public static void main(String[] args) {
SpringApplication.run(CicloUrbanaApplication.class, args);
}
}@SpringBootApplication es una meta-anotación de conveniencia. Su declaración real, simplificada, es:
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@SpringBootConfiguration
@EnableAutoConfiguration
@ComponentScan(excludeFilters = {
@Filter(type = FilterType.CUSTOM, classes = TypeExcludeFilter.class),
@Filter(type = FilterType.CUSTOM, classes = AutoConfigurationExcludeFilter.class)
})
public @interface SpringBootApplication {
// atributos: exclude, excludeName, scanBasePackages, proxyBeanMethods...
}Es decir, escribir @SpringBootApplication equivale exactamente a escribir estas tres anotaciones:
package com.ciclourbana;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.context.annotation.ComponentScan;
@SpringBootConfiguration // 1. esta clase es una clase de configuración
@EnableAutoConfiguration // 2. activa la autoconfiguración
@ComponentScan // 3. escanea este paquete y los de debajo
public class CicloUrbanaApplication {
public static void main(String[] args) {
SpringApplication.run(CicloUrbanaApplication.class, args);
}
}Puedes hacer esta sustitución en CicloUrbana ahora mismo, arrancar y comprobar que todo funciona igual. Es un ejercicio excelente para interiorizar que no hay nada oculto.
Qué aporta cada una:
| Anotación | Responsabilidad |
|---|---|
@SpringBootConfiguration |
Es un @Configuration especializado. Marca la clase como fuente de definiciones de beans y, además, sirve de ancla: las pruebas de integración (@SpringBootTest, módulo 6) buscan hacia arriba en el árbol de paquetes hasta encontrarla. Solo debe haber una por aplicación. |
@EnableAutoConfiguration |
Dispara el mecanismo por el cual Spring Boot registra beans en función de lo que encuentra en el classpath. Es el motor de "traigo spring-boot-starter-web y aparece Tomcat". Lo abriremos por completo en la lección 02-06. |
@ComponentScan |
Sin atributos, escanea el paquete de la clase anotada y todos sus subpaquetes. Por eso CicloUrbanaApplication vive en com.ciclourbana y no en com.ciclourbana.app. |
Los dos excludeFilters que trae de serie evitan que el escaneo recoja clases de prueba y clases de autoconfiguración por accidente. No son algo que tengas que tocar.
Cuándo desmontar la meta-anotación
En proyectos reales casi siempre se deja @SpringBootApplication. Las excepciones habituales:
// Escanear un paquete adicional fuera del árbol de com.ciclourbana
@SpringBootApplication(scanBasePackages = {"com.ciclourbana", "com.ribalta.pagos"})
public class CicloUrbanaApplication { }
// Excluir una autoconfiguración concreta (lo veremos en 02-06)
@SpringBootApplication(exclude = DataSourceAutoConfiguration.class)
public class CicloUrbanaApplication { }
- Los estereotipos de Spring
Un estereotipo es una anotación que marca una clase como candidata a bean e indica, además, qué papel juega en la arquitectura. Todos derivan de @Component:
flowchart TD
C["@Component<br/>(bean genérico)"] --> S["@Service<br/>lógica de negocio"]
C --> R["@Repository<br/>acceso a datos"]
C --> Ctrl["@Controller<br/>capa web MVC"]
Ctrl --> RC["@RestController<br/>= @Controller + @ResponseBody"]
C --> Cfg["@Configuration<br/>fábrica de beans"]
Comparados en una tabla:
| Anotación | Qué añade sobre @Component |
Cuándo usarla en CicloUrbana |
|---|---|---|
@Component |
Nada. Es la base genérica. | Piezas que no encajan en ninguna capa: AvisoArranque, LectorArgumentos, utilidades. |
@Service |
Nada funcional: semántica. Declara que la clase contiene lógica de negocio. | EstacionService, AlquilerService: reglas de la red de Ribalta. |
@Repository |
Traducción automática de excepciones de persistencia (apartado 5). | EstacionRepositorioEnMemoria hoy; los repositorios JPA del módulo 4. |
@Controller |
Lo detecta el DispatcherServlet como manejador de peticiones; sus métodos devuelven por defecto nombres de vista. |
No lo usaremos: CicloUrbana es una API, no una web con plantillas. |
@RestController |
@Controller + @ResponseBody: el retorno de cada método se serializa al cuerpo de la respuesta. |
EstacionController y todos los controladores del módulo 3. |
Un punto que suele desconcertar: @Service no hace nada técnicamente. Si sustituyes @Service por @Component en EstacionService, la aplicación funciona igual. Entonces, ¿por qué usarlo?
- Comunica intención. Quien abre el fichero sabe en qué capa está sin leer una línea de código.
- Permite filtrar. Herramientas de análisis, aspectos AOP y filtros de
@ComponentScanpueden actuar sobre "todos los@Service". Con@Componenten todo el proyecto, esa distinción se pierde. - Es la convención universal del ecosistema. Un desarrollador Spring espera encontrarla.
La única de la lista que sí aporta comportamiento propio es @Repository.
@Repository y la traducción de excepciones
@Repository y la traducción de excepcionesCada tecnología de persistencia lanza sus propias excepciones: JDBC lanza SQLException, JPA lanza PersistenceException, Hibernate lanza ConstraintViolationException. Si tu capa de servicio captura esas excepciones, queda acoplada a la tecnología: cambiar de JDBC a JPA obligaría a reescribir los catch.
Spring resuelve esto con una jerarquía propia de excepciones no comprobadas que cuelga de DataAccessException:
DataAccessException ├── DataIntegrityViolationException (violación de restricción, clave duplicada) ├── DuplicateKeyException ├── EmptyResultDataAccessException (se esperaba una fila y no hubo ninguna) ├── OptimisticLockingFailureException └── CannotAcquireLockException (timeout de bloqueo)
@Repository activa un BeanPostProcessor llamado PersistenceExceptionTranslationPostProcessor que envuelve el bean en un proxy. Ese proxy intercepta las excepciones de la tecnología subyacente y las traduce a la jerarquía de Spring:
sequenceDiagram
participant S as EstacionService
participant P as Proxy (@Repository)
participant R as EstacionRepositorioJpa
participant BD as PostgreSQL
S->>P: guardar(estacion)
P->>R: guardar(estacion)
R->>BD: INSERT ...
BD-->>R: ERROR: unique constraint
R-->>P: PersistenceException (JPA)
P-->>S: DataIntegrityViolationException (Spring)
La ventaja práctica: el servicio escribe catch (DataIntegrityViolationException e) y ese código sobrevive a un cambio de tecnología de persistencia.
// En EstacionService, en el módulo 4. Hoy todavía no aplica.
try {
return estacionRepositorio.guardar(nueva);
} catch (DataIntegrityViolationException e) {
// Vale igual si debajo hay JDBC, JPA o MongoDB
throw new EstacionDuplicadaException(nueva.nombre(), e);
}En nuestro repositorio en memoria no hay excepciones que traducir, pero anotarlo con @Repository desde hoy deja el contrato preparado para el módulo 4 y comunica correctamente su papel.
@Configuration y @Bean: la vía declarativa
@Configuration y @Bean: la vía declarativaEl escaneo de componentes solo funciona con clases que tú escribes. ¿Cómo registras como bean una clase de una librería de terceros, cuyo código fuente no puedes anotar? Ahí entra @Configuration + @Bean.
package com.ciclourbana.comun;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestClient;
import java.time.Clock;
import java.time.ZoneId;
/**
* Beans de infraestructura de CicloUrbana que provienen de librerías
* externas y, por tanto, no se pueden anotar con @Component.
*/
@Configuration
public class ConfiguracionComun {
/**
* Reloj centralizado. Inyectar un Clock en lugar de llamar a
* LocalDateTime.now() permite fijar la hora en las pruebas (módulo 6).
* El nombre del bean es el del método: "relojRibalta".
*/
@Bean
public Clock relojRibalta() {
return Clock.system(ZoneId.of("Europe/Madrid"));
}
/**
* Cliente HTTP para llamar al servicio municipal de tráfico.
* Lo usaremos de verdad en el módulo 7; aquí sirve de ejemplo
* de bean construido a mano con un builder.
*/
@Bean
public RestClient clienteTraficoRibalta() {
return RestClient.builder()
.baseUrl("https://trafico.ribalta.example/api")
.build();
}
}Cómo funciona: en el arranque, Spring detecta la clase (que también es un @Component por meta-anotación, así que la recoge el escaneo), invoca cada método @Bean y registra el objeto devuelto en el contenedor. El nombre del bean es el nombre del método salvo que indiques otro con @Bean("otroNombre").
La diferencia clave: proxyBeanMethods
Un método @Bean puede llamar a otro:
@Configuration
public class ConfiguracionComun {
@Bean
public Clock relojRibalta() {
return Clock.system(ZoneId.of("Europe/Madrid"));
}
@Bean
public RegistroAuditoria registroAuditoria() {
return new RegistroAuditoria(relojRibalta()); // ¿crea un segundo Clock?
}
}En Java puro, relojRibalta() se ejecutaría otra vez y habría dos relojes. Con @Configuration, no: Spring genera un subclase proxy CGLIB de la clase de configuración que intercepta las llamadas a los métodos @Bean y devuelve el singleton ya registrado. Ese comportamiento se llama modo full y es el valor por defecto (proxyBeanMethods = true).
Si tus métodos @Bean nunca se llaman entre sí, puedes desactivarlo y ahorrar la generación del proxy:
@Configuration(proxyBeanMethods = false) // modo "lite": arranque algo más rápido
public class ConfiguracionComun { }Las clases de autoconfiguración del propio Spring Boot usan proxyBeanMethods = false de forma sistemática, precisamente por eso. Lo veremos en la lección 02-06.
@Component o @Bean: cómo decidir
| Criterio | @Component / @Service / @Repository |
@Configuration + @Bean |
|---|---|---|
| Quién escribe la clase | Tú | Tú o un tercero |
| ¿Puedes anotar el código fuente? | Sí | No hace falta |
| Construcción | Spring llama al constructor | Tú escribes el new o el builder |
| Lógica en la creación | No cabe | Sí: condicionales, builders, parámetros calculados |
| Nombre del bean | Nombre de clase en minúscula inicial (estacionService) |
Nombre del método |
| Dónde suele vivir | Junto a su funcionalidad | En com.ciclourbana.comun u otro paquete de configuración |
Regla práctica para CicloUrbana: clase propia → estereotipo; clase ajena o construcción no trivial → @Bean.
@ComponentScan y sus filtros
@ComponentScan y sus filtros@ComponentScan sin atributos escanea el paquete de la clase anotada hacia abajo. Sus atributos más útiles:
@ComponentScan(
basePackages = {"com.ciclourbana", "com.ribalta.compartido"},
// alternativa con seguridad de tipos: si mueves la clase, el paquete se ajusta solo
basePackageClasses = {CicloUrbanaApplication.class},
includeFilters = @ComponentScan.Filter(
type = FilterType.ANNOTATION,
classes = ServicioRed.class),
excludeFilters = @ComponentScan.Filter(
type = FilterType.REGEX,
pattern = "com\\.ciclourbana\\..*\\.legado\\..*")
)Los tipos de filtro disponibles:
FilterType |
Criterio | Ejemplo |
|---|---|---|
ANNOTATION |
Presencia de una anotación | classes = ServicioRed.class |
ASSIGNABLE_TYPE |
Ser subtipo de una clase/interfaz | classes = CalculadoraTarifa.class |
ASPECTJ |
Expresión AspectJ | pattern = "com.ciclourbana..*Service" |
REGEX |
Expresión regular sobre el nombre completo | pattern = ".*RepositorioMock" |
CUSTOM |
Implementación propia de TypeFilter |
classes = MiFiltro.class |
Un uso realista: excluir del arranque normal unas clases de simulación que solo queremos en desarrollo.
@SpringBootApplication
@ComponentScan(excludeFilters = @ComponentScan.Filter(
type = FilterType.REGEX,
pattern = "com\\.ciclourbana\\.simulacion\\..*"))
public class CicloUrbanaApplication { }Dicho esto, para activar o desactivar conjuntos de beans según el entorno, el mecanismo idiomático no es este sino los perfiles (@Profile), que se estudian en la lección 07-02. Los filtros de escaneo son para casos estructurales, no para variar el entorno.
Y una advertencia de rendimiento: @ComponentScan("com") o, peor, un paquete raíz vacío, obliga a Spring a recorrer todo el classpath, incluidas las dependencias. El arranque se dispara. Mantén siempre el escaneo acotado a tu paquete base.
- Crear tu propia anotación estereotipada
Como Spring resuelve las anotaciones de forma transitiva, puedes crear estereotipos propios que sean, a la vez, @Component. En CicloUrbana vamos a marcar los servicios que forman el núcleo de la red (los que gestionan estaciones, bicicletas y alquileres) para poder localizarlos y, más adelante, aplicarles políticas transversales.
package com.ciclourbana.comun;
import org.springframework.core.annotation.AliasFor;
import org.springframework.stereotype.Service;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Estereotipo propio: marca un servicio del núcleo operativo de la red
* de Ribalta. Es un @Service a todos los efectos, más una etiqueta
* semántica que permite localizarlos y tratarlos en bloque.
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Service // <-- la meta-anotación clave
public @interface ServicioRed {
/** Área funcional a la que pertenece el servicio. */
String area() default "general";
/** Permite fijar el nombre del bean, igual que @Service. */
@AliasFor(annotation = Service.class, attribute = "value")
String value() default "";
}Y ahora:
Spring registra EstacionService como bean exactamente igual que si llevara @Service, porque @ServicioRed está meta-anotada con @Service, que a su vez lo está con @Component.
Dos detalles técnicos:
@AliasForconecta un atributo de tu anotación con el de la anotación meta. Sin él,@ServicioRed("nombreBean")no tendría efecto sobre el nombre del bean.- El atributo
area()no lo interpreta Spring: es tuyo. Puedes leerlo por reflexión, o usarlo como criterio en un filtro de escaneo o en un aspecto.
Recuperar todos los beans marcados es trivial:
Map<String, Object> serviciosNucleo =
contexto.getBeansWithAnnotation(ServicioRed.class);
// {estacionService=..., alquilerService=...}¿Merece la pena? En un proyecto pequeño, no: añade una indirección. En una base de código grande, un estereotipo propio bien elegido documenta la arquitectura y da un punto de enganche único para métricas, auditoría o reglas de acceso. Úsalo con criterio, no por deporte.
- Refactorizando CicloUrbana: servicio y repositorio
Hasta ahora el controlador hablaba directamente con AlmacenEstaciones, un @Component que hacía de todo. Es hora de separar responsabilidades correctamente, porque esta estructura es la que sostendrá el resto del curso:
flowchart LR
C["EstacionController<br/>@RestController<br/>(HTTP)"] --> S["EstacionService<br/>@Service<br/>(reglas de negocio)"]
S --> I["EstacionRepositorio<br/>«interfaz»"]
I -.implementa.-> R["EstacionRepositorioEnMemoria<br/>@Repository"]
I -.-> J["EstacionRepositorioJpa<br/>(módulo 4)"]
La interfaz del repositorio
package com.ciclourbana.estaciones;
import java.util.List;
import java.util.Optional;
/**
* Contrato de acceso a datos de estaciones. La interfaz permite
* sustituir la implementación en memoria por la de JPA (módulo 4)
* sin tocar EstacionService.
*/
public interface EstacionRepositorio {
List<Estacion> buscarTodas();
Optional<Estacion> buscarPorId(Long id);
Estacion guardar(Estacion estacion);
long contar();
}La implementación en memoria
package com.ciclourbana.estaciones;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
/**
* Implementación provisional en memoria del repositorio de estaciones.
* Se sustituirá por Spring Data JPA en el módulo 4.
*
* Usa ConcurrentHashMap porque el bean es singleton y varias peticiones
* HTTP concurrentes lo comparten (se razona a fondo en la lección 02-03).
*/
@Repository
public class EstacionRepositorioEnMemoria implements EstacionRepositorio {
private final Map<Long, Estacion> porId = new ConcurrentHashMap<>();
private final AtomicLong siguienteId = new AtomicLong(0);
@Override
public List<Estacion> buscarTodas() {
return List.copyOf(porId.values());
}
@Override
public Optional<Estacion> buscarPorId(Long id) {
return Optional.ofNullable(porId.get(id));
}
@Override
public Estacion guardar(Estacion estacion) {
Long id = (estacion.id() != null)
? estacion.id()
: siguienteId.incrementAndGet();
// El record es inmutable: si asignamos id, construimos uno nuevo
Estacion aGuardar = (estacion.id() != null) ? estacion : new Estacion(
id, estacion.nombre(), estacion.direccion(),
estacion.capacidad(), estacion.latitud(), estacion.longitud());
porId.put(id, aGuardar);
siguienteId.updateAndGet(actual -> Math.max(actual, id));
return aGuardar;
}
@Override
public long contar() {
return porId.size();
}
}El servicio
package com.ciclourbana.estaciones;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.util.Comparator;
import java.util.List;
import java.util.Optional;
/**
* Lógica de negocio de las estaciones de la red de Ribalta.
* No conoce HTTP (eso es del controlador) ni cómo se almacenan
* los datos (eso es del repositorio).
*/
@Service
public class EstacionService {
private static final Logger log = LoggerFactory.getLogger(EstacionService.class);
private final EstacionRepositorio estacionRepositorio;
// Inyección por constructor: se explica a fondo en la lección 02-02
public EstacionService(EstacionRepositorio estacionRepositorio) {
this.estacionRepositorio = estacionRepositorio;
}
public List<Estacion> listarTodas() {
return estacionRepositorio.buscarTodas().stream()
.sorted(Comparator.comparing(Estacion::nombre))
.toList();
}
public Optional<Estacion> buscarPorId(Long id) {
return estacionRepositorio.buscarPorId(id);
}
public Estacion registrar(Estacion estacion) {
// Regla de negocio de Ribalta: ninguna estación con menos de 8 anclajes
if (estacion.capacidad() < 8) {
throw new IllegalArgumentException(
"Una estación de Ribalta requiere al menos 8 anclajes; recibidos: "
+ estacion.capacidad());
}
Estacion guardada = estacionRepositorio.guardar(estacion);
log.info("Estación registrada: {} ({} anclajes)",
guardada.nombre(), guardada.capacidad());
return guardada;
}
public int capacidadTotalRed() {
return estacionRepositorio.buscarTodas().stream()
.mapToInt(Estacion::capacidad)
.sum();
}
public long contar() {
return estacionRepositorio.contar();
}
}El controlador, ahora apoyado en el servicio
package com.ciclourbana.estaciones;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/v1/estaciones")
public class EstacionController {
private final EstacionService estacionService;
public EstacionController(EstacionService estacionService) {
this.estacionService = estacionService;
}
@GetMapping
public List<Estacion> listarEstaciones() {
return estacionService.listarTodas();
}
}Fíjate en el reparto: el controlador solo traduce HTTP, el servicio aplica reglas y ordena, el repositorio guarda y recupera. Cada capa depende de la de abajo y ninguna de la de arriba.
Finalmente, el CargadorEstacionesDemo del módulo 1 pasa a usar el servicio y AlmacenEstaciones desaparece del proyecto:
package com.ciclourbana.estaciones;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
@Component
@Order(1)
public class CargadorEstacionesDemo implements CommandLineRunner {
private static final Logger log = LoggerFactory.getLogger(CargadorEstacionesDemo.class);
private final EstacionService estacionService;
public CargadorEstacionesDemo(EstacionService estacionService) {
this.estacionService = estacionService;
}
@Override
public void run(String... args) {
log.info("Cargando estaciones de demostración de Ribalta...");
estacionService.registrar(new Estacion(1L, "Plaza Mayor",
"Plaza Mayor, 1", 24, 40.4168, -3.7038));
estacionService.registrar(new Estacion(2L, "Estación Norte",
"Avenida de la Estación 3", 30, 40.4290, -3.7020));
estacionService.registrar(new Estacion(3L, "Parque del Río",
"Paseo Fluvial 12", 18, 40.4105, -3.6950));
estacionService.registrar(new Estacion(4L, "Universidad",
"Campus Sur, acceso B", 36, 40.4402, -3.7255));
log.info("Cargadas {} estaciones, {} anclajes en total",
estacionService.contar(), estacionService.capacidadTotalRed());
}
}Recuerda ajustar también AvisoArranque en com.ciclourbana.comun para que inyecte EstacionService en lugar del desaparecido AlmacenEstaciones.
Comprueba que el conjunto sigue en pie:
Y observa que ahora las estaciones salen ordenadas alfabéticamente: "Estación Norte", "Parque del Río", "Plaza Mayor", "Universidad". Esa ordenación es una decisión de negocio, y por eso vive en el servicio y no en el controlador ni en el repositorio.
- Mapa de anotaciones del curso
Este es el catálogo de las anotaciones que irán apareciendo, con el punto del curso donde se estudian en profundidad. Vuelve a esta tabla cuando te encuentres una que no reconozcas.
| Anotación | Para qué sirve | Dónde se estudia |
|---|---|---|
@SpringBootApplication, @Component, @Service, @Repository, @Configuration, @Bean, @ComponentScan |
Declaración de beans | 02-01 (esta lección) |
@Autowired, @Qualifier, @Primary, @Order |
Inyección y resolución de dependencias | 02-02 |
@Scope, @PostConstruct, @PreDestroy, @Lazy |
Ámbito y ciclo de vida | 02-03 |
@Value, @PropertySource |
Configuración por propiedades | 02-04 |
@ConfigurationProperties, @EnableConfigurationProperties, @ConfigurationPropertiesScan, @Validated |
Propiedades tipadas | 02-05 |
@EnableAutoConfiguration, @ConditionalOnClass, @ConditionalOnMissingBean, @ConditionalOnProperty, @AutoConfiguration |
Autoconfiguración y starters | 02-06 |
@RestController, @RequestMapping, @GetMapping, @PostMapping, @PutMapping, @DeleteMapping, @PathVariable, @RequestParam, @RequestBody, @ResponseStatus |
Capa web REST | Módulo 3 |
@Valid, @NotBlank, @Min, @Positive, @Email |
Validación de entrada | 03-04 |
@RestControllerAdvice, @ExceptionHandler |
Manejo global de errores | 03-06 |
@Entity, @Id, @GeneratedValue, @Column, @OneToMany, @ManyToOne, @Query, @Transactional |
Persistencia con JPA | Módulo 4 |
@EnableWebSecurity, @PreAuthorize, @PostAuthorize, @Secured |
Seguridad | Módulo 5 |
@SpringBootTest, @WebMvcTest, @DataJpaTest, @MockitoBean, @Testcontainers |
Pruebas | Módulo 6 |
@Profile, @Scheduled, @Async, @EnableScheduling, @EnableAsync |
Perfiles, tareas y asincronía | Módulo 7 |
@Cacheable, @CacheEvict, @Timed |
Rendimiento y monitorización | Módulo 9 |
Errores Comunes y Consejos
La clase con @Service está fuera del paquete base. Si EstacionService acabara en com.otraempresa.servicios, el escaneo no la vería y el arranque fallaría con NoSuchBeanDefinitionException. Regla: todo el código de la aplicación cuelga del paquete de CicloUrbanaApplication.
Anotar la interfaz en vez de la implementación. @Repository sobre EstacionRepositorio (la interfaz) no crea ningún bean: Spring necesita una clase que pueda instanciar. La anotación va en EstacionRepositorioEnMemoria. La excepción son las interfaces de Spring Data JPA, donde el propio framework genera la implementación (módulo 4).
Dos @SpringBootApplication en el mismo proyecto. Suele pasar al copiar una clase principal en src/test. Provoca escaneos duplicados o errores en las pruebas de integración. Debe haber exactamente una.
Esperar que @Service haga algo mágico. No añade transacciones, ni caché, ni reintentos. Cada uno de esos comportamientos requiere su propia anotación (@Transactional, @Cacheable, @Retryable) y su correspondiente activación.
Poner @Component en clases de dominio. Estacion es un dato, no un bean. Los objetos de dominio se crean con new tantas veces como haga falta; los beans son componentes de infraestructura, únicos y gestionados. Confundirlo lleva a diseños imposibles de razonar.
Consejo: nombra los beans por convención, no a mano. @Service("miServicioDeEstaciones") no aporta nada frente al nombre por defecto estacionService. Reserva los nombres explícitos para cuando de verdad haya que desambiguar (lección 02-02).
Consejo: usa basePackageClasses en lugar de cadenas. @ComponentScan(basePackageClasses = EstacionService.class) se refactoriza solo si mueves el paquete; @ComponentScan("com.ciclourbana.estaciones") se queda obsoleto en silencio.
Consejo: @Configuration(proxyBeanMethods = false) si no hay llamadas entre métodos @Bean. Es gratis y ahorra la generación del proxy CGLIB en el arranque.
Ejercicios
Ejercicio 1: sustituir @SpringBootApplication por sus tres componentes
Reemplaza @SpringBootApplication en CicloUrbanaApplication por las tres anotaciones equivalentes. Después, comprueba con un CommandLineRunner temporal que el contexto contiene los beans esperados: estacionService, estacionRepositorioEnMemoria y estacionController.
Ejercicio 2: un bean de librería externa con @Bean
Crea com.ciclourbana.comun.ConfiguracionComun con dos beans que no puedes anotar porque son de librerías: un Clock fijado a Europe/Madrid y un java.util.Random con semilla fija (útil para simulaciones reproducibles). Inyecta el Clock en un nuevo componente RelojRed que exponga un método instanteActual(), y registra en el log la hora al arrancar.
Ejercicio 3: estereotipo propio con detección al arranque
Crea la anotación @ServicioRed del apartado 8, aplícala a EstacionService con area = "estaciones" y escribe un CommandLineRunner llamado InventarioServiciosRed que liste por consola todos los beans marcados con ella, mostrando el nombre del bean y su área.
Soluciones
Solución 1
package com.ciclourbana;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.context.annotation.ComponentScan;
@SpringBootConfiguration // = @Configuration especializado, ancla de las pruebas
@EnableAutoConfiguration // activa la autoconfiguración (lección 02-06)
@ComponentScan // escanea com.ciclourbana y subpaquetes
public class CicloUrbanaApplication {
public static void main(String[] args) {
SpringApplication.run(CicloUrbanaApplication.class, args);
}
}Y el verificador temporal:
package com.ciclourbana.comun;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.ApplicationContext;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
@Component
@Order(100) // después del cargador de estaciones, que es @Order(1)
public class VerificadorBeans implements CommandLineRunner {
private static final Logger log = LoggerFactory.getLogger(VerificadorBeans.class);
private final ApplicationContext contexto;
public VerificadorBeans(ApplicationContext contexto) {
this.contexto = contexto;
}
@Override
public void run(String... args) {
for (String nombre : new String[]{
"estacionService", "estacionRepositorioEnMemoria", "estacionController"}) {
log.info("Bean '{}' presente: {} -> {}",
nombre,
contexto.containsBean(nombre),
contexto.containsBean(nombre)
? contexto.getBean(nombre).getClass().getSimpleName()
: "N/D");
}
}
}Salida esperada:
Bean 'estacionService' presente: true -> EstacionService
Bean 'estacionRepositorioEnMemoria' presente: true -> EstacionRepositorioEnMemoria
Bean 'estacionController' presente: true -> EstacionControllerComentario: los nombres por defecto se derivan del nombre de clase con la inicial en minúscula. La aplicación se comporta de forma idéntica, lo que demuestra que @SpringBootApplication no es más que un atajo.
Solución 2
package com.ciclourbana.comun;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.time.Clock;
import java.time.ZoneId;
import java.util.Random;
@Configuration(proxyBeanMethods = false) // ningún método @Bean llama a otro
public class ConfiguracionComun {
/** Reloj de la red. Inyectarlo permite congelar el tiempo en las pruebas. */
@Bean
public Clock relojRibalta() {
return Clock.system(ZoneId.of("Europe/Madrid"));
}
/** Semilla fija: las simulaciones de demanda son reproducibles. */
@Bean
public Random aleatorioSimulacion() {
return new Random(42L);
}
}package com.ciclourbana.comun;
import org.springframework.stereotype.Component;
import java.time.Clock;
import java.time.LocalDateTime;
@Component
public class RelojRed {
private final Clock reloj;
public RelojRed(Clock reloj) {
this.reloj = reloj;
}
public LocalDateTime instanteActual() {
return LocalDateTime.now(reloj);
}
}package com.ciclourbana.comun;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
@Component
public class AvisoHoraArranque implements CommandLineRunner {
private static final Logger log = LoggerFactory.getLogger(AvisoHoraArranque.class);
private final RelojRed relojRed;
public AvisoHoraArranque(RelojRed relojRed) {
this.relojRed = relojRed;
}
@Override
public void run(String... args) {
log.info("CicloUrbana arranca a las {} (hora de Ribalta)",
relojRed.instanteActual());
}
}Comentario: Clock y Random son clases del JDK; no podemos anotarlas, así que la única vía es @Bean. El beneficio real del Clock inyectado aparecerá en el módulo 6: podrás sustituirlo por Clock.fixed(...) y probar reglas de tarifa dependientes de la hora sin esperar a que den las 22:00.
Solución 3
package com.ciclourbana.comun;
import org.springframework.core.annotation.AliasFor;
import org.springframework.stereotype.Service;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Service
public @interface ServicioRed {
String area() default "general";
@AliasFor(annotation = Service.class, attribute = "value")
String value() default "";
}// En EstacionService, sustituyendo @Service:
@ServicioRed(area = "estaciones")
public class EstacionService { /* ... */ }package com.ciclourbana.comun;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.ApplicationContext;
import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.util.Map;
@Component
@Order(200)
public class InventarioServiciosRed implements CommandLineRunner {
private static final Logger log = LoggerFactory.getLogger(InventarioServiciosRed.class);
private final ApplicationContext contexto;
public InventarioServiciosRed(ApplicationContext contexto) {
this.contexto = contexto;
}
@Override
public void run(String... args) {
Map<String, Object> servicios = contexto.getBeansWithAnnotation(ServicioRed.class);
log.info("Servicios del núcleo de la red: {}", servicios.size());
servicios.forEach((nombre, bean) -> {
// findAnnotation atraviesa proxies y jerarquías; getAnnotation no siempre
ServicioRed marca = AnnotationUtils.findAnnotation(
bean.getClass(), ServicioRed.class);
log.info(" - {} (área: {})", nombre,
marca != null ? marca.area() : "desconocida");
});
}
}Salida esperada:
Comentario y error frecuente: si usas bean.getClass().getAnnotation(ServicioRed.class) y el bean está envuelto en un proxy (algo habitual en cuanto aparezcan @Transactional o @Cacheable), obtendrás null porque la clase real es la del proxy generado. AnnotationUtils.findAnnotation —o AnnotatedElementUtils para meta-anotaciones— busca a lo largo de la jerarquía y es la opción robusta.
Conclusión
Las anotaciones han dejado de ser magia. Sabes que son metadatos con retención RUNTIME, que Spring los localiza escaneando el classpath con ASM, que el resultado del escaneo son BeanDefinition —recetas, no objetos— y que los objetos se construyen en una segunda fase. Has desmontado @SpringBootApplication en sus tres componentes y comprobado que la aplicación se comporta igual. Conoces los cinco estereotipos, sabes que solo @Repository aporta comportamiento propio (la traducción de excepciones de persistencia) y que los demás valen por lo que comunican. Sabes cuándo usar un estereotipo y cuándo @Configuration + @Bean, y por qué las clases de configuración se envuelven en un proxy CGLIB salvo que declares proxyBeanMethods = false. Y has creado tu propio estereotipo apoyándote en la resolución transitiva de meta-anotaciones.
CicloUrbana ha ganado, además, su primera arquitectura de capas de verdad: EstacionController habla HTTP, EstacionService aplica las reglas de la red de Ribalta y EstacionRepositorioEnMemoria guarda los datos tras la interfaz EstacionRepositorio, lista para que el módulo 4 la reemplace por JPA sin tocar nada más arriba.
Queda un cabo suelto evidente. Hemos escrito public EstacionService(EstacionRepositorio estacionRepositorio) y Spring ha encontrado, por sí solo, la implementación correcta. ¿Cómo? ¿Y qué habría pasado si hubiera dos implementaciones de EstacionRepositorio en el classpath? Ese es el tema de la lección siguiente, Inyección de Dependencias en Spring Boot: los tres tipos de inyección y por qué solo uno es recomendable, la resolución por tipo, @Qualifier y @Primary, la inyección de colecciones de implementaciones y qué hacer cuando dos beans se necesitan mutuamente. Lo aplicaremos construyendo la primera pieza del sistema de tarifas de Ribalta.
Curso de Spring Boot
Módulo 1: Introducción a Spring Boot
- ¿Qué es Spring Boot?
- Configuración de tu Entorno de Desarrollo
- Creando tu Primera Aplicación Spring Boot
- Entendiendo la Estructura del Proyecto
- El Arranque y el Ciclo de Vida de la Aplicación
Módulo 2: Conceptos Básicos de Spring Boot
- Anotaciones de Spring Boot
- Inyección de Dependencias en Spring Boot
- Ámbito y Ciclo de Vida de los Beans
- Configuración de Spring Boot
- Propiedades de Spring Boot
- Autoconfiguración y Starters por Dentro
Módulo 3: Construyendo Servicios Web RESTful
- Introducción a los Servicios Web RESTful
- Creando Controladores REST
- Manejo de Métodos HTTP
- Validación de Datos de Entrada
- DTOs y Mapeo entre Capas
- Manejo de Excepciones en REST
- Documentar la API con OpenAPI
Módulo 4: Acceso a Datos con Spring Boot
- Introducción a Spring Data JPA
- Configuración de Fuentes de Datos
- Creación de Entidades JPA
- Relaciones entre Entidades
- Uso de Repositorios de Spring Data
- Métodos de Consulta en Spring Data JPA
- Transacciones y Gestión de la Persistencia
- Migraciones de Esquema con Flyway
Módulo 5: Seguridad en Spring Boot
- Introducción a Spring Security
- Configuración de Spring Security
- Autenticación y Autorización de Usuarios
- Implementación de Autenticación JWT
- Seguridad a Nivel de Método y Endurecimiento de la API
Módulo 6: Pruebas en Spring Boot
- Introducción a las Pruebas
- Pruebas Unitarias con JUnit
- Simulación con Mockito
- Pruebas de Integración
- Pruebas con Testcontainers
Módulo 7: Funciones Avanzadas de Spring Boot
- Spring Boot Actuator
- Perfiles de Spring Boot
- Tareas Programadas y Ejecución Asíncrona
- Spring Boot con Docker
- Spring Boot y Microservicios
- Comunicación entre Servicios y Tolerancia a Fallos
Módulo 8: Despliegue de Aplicaciones Spring Boot
- Introducción al Despliegue
- Desplegando en Heroku
- Desplegando en AWS
- Desplegando en Kubernetes
- Integración y Entrega Continua
Módulo 9: Rendimiento y Monitoreo
- Ajuste de Rendimiento
- Caché con Spring Cache
- Monitoreo con Spring Boot Actuator
- Uso de Prometheus y Grafana
- Gestión de Registros y Logs
- Trazabilidad Distribuida
