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

  1. Qué es una anotación en Java
  2. Cómo procesa Spring las anotaciones al arrancar
  3. @SpringBootApplication desmontada
  4. Los estereotipos de Spring
  5. @Repository y la traducción de excepciones
  6. @Configuration y @Bean: la vía declarativa
  7. @ComponentScan y sus filtros
  8. Crear tu propia anotación estereotipada
  9. Refactorizando CicloUrbana: servicio y repositorio
  10. Mapa de anotaciones del curso
  11. Errores Comunes y Consejos
  12. Ejercicios

  1. 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:

@Provisional(hasta = "modulo-4")
public class EstacionRepositorioEnMemoria { /* ... */ }

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.

  1. 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.

  1. @SpringBootApplication desmontada

Nuestra 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 { }

  1. 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 @ComponentScan pueden actuar sobre "todos los @Service". Con @Component en 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.

  1. @Repository y la traducción de excepciones

Cada 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.

  1. @Configuration y @Bean: la vía declarativa

El 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.

  1. @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.

  1. 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:

@ServicioRed(area = "estaciones")
public class EstacionService { /* ... */ }

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:

  • @AliasFor conecta 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.

  1. 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:

./mvnw spring-boot:run
curl -s http://localhost:8080/api/v1/estaciones | head -5

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.

  1. 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 -> EstacionController

Comentario: 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:

Servicios del núcleo de la red: 1
  - estacionService (área: estaciones)

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

Módulo 2: Conceptos Básicos de Spring Boot

Módulo 3: Construyendo Servicios Web RESTful

Módulo 4: Acceso a Datos con Spring Boot

Módulo 5: Seguridad en Spring Boot

Módulo 6: Pruebas en Spring Boot

Módulo 7: Funciones Avanzadas de Spring Boot

Módulo 8: Despliegue de Aplicaciones Spring Boot

Módulo 9: Rendimiento y Monitoreo

Módulo 10: Mejores Prácticas y Consejos

© Copyright 2026. Todos los derechos reservados