Hasta ahora hemos tratado los beans como objetos que aparecen y funcionan. Esta lección abre esa caja. Veremos qué es exactamente un bean y qué es el contenedor que los custodia; cuántas instancias crea Spring de cada uno y por qué esa decisión —el ámbito— condiciona cómo debes escribir tus clases; qué ocurre, paso a paso, entre que se llama al constructor y el bean queda disponible; cómo enganchar código en el momento de nacer y en el de morir; y cuál es el punto de extensión, el BeanPostProcessor, sobre el que se apoyan las transacciones, la caché y prácticamente toda la aparente magia de Spring. En CicloUrbana construiremos un CacheDisponibilidad que precarga al arrancar y libera recursos al cerrar, y razonaremos por qué EstacionRepositorioEnMemoria tuvo que usar un ConcurrentHashMap.

Contenido

  1. Qué es un bean y qué es el contenedor
  2. BeanFactory frente a ApplicationContext
  3. Los ámbitos de Spring
  4. El ámbito singleton y el peligro del estado mutable
  5. prototype dentro de un singleton: el problema clásico
  6. El ciclo de vida completo de un bean
  7. @PostConstruct y @PreDestroy en CicloUrbana
  8. Inicialización perezosa
  9. BeanPostProcessor: el punto de extensión
  10. Errores Comunes y Consejos
  11. Ejercicios

  1. Qué es un bean y qué es el contenedor

Un bean es, sencillamente, un objeto Java cuyo ciclo de vida gestiona Spring: el contenedor lo instancia, le inyecta sus dependencias, lo inicializa, lo mantiene disponible mientras haga falta y lo destruye al cerrar. No hay ninguna interfaz que implementar ni ninguna clase base de la que heredar; lo único que hace bean a un objeto es estar registrado en el contenedor.

De ahí se sigue una distinción que conviene tener bien clara:

Bean Objeto normal
Quién lo crea El contenedor Tú, con new
Cuántos hay Los que dicte su ámbito Tantos como new escribas
Recibe inyección Sí No
Le funcionan @Transactional, @Cacheable, @Async Sí (vía proxy) No
Ejemplos en CicloUrbana EstacionService, TarifaEstandar, EstacionController Estacion, Alquiler, un BigDecimal

Esa última fila explica un error muy frecuente: anotar con @Transactional un método de un objeto creado con new y no entender por qué no se abre transacción. Sin bean no hay proxy, y sin proxy no hay comportamiento añadido.

El contenedor es el objeto que guarda el registro de beans, sabe construirlos y resuelve sus dependencias. En Spring hay dos niveles.

  1. BeanFactory frente a ApplicationContext

flowchart TD
    BF["BeanFactory<br/>getBean(), containsBean()<br/>ciclo de vida básico"] --> AC["ApplicationContext<br/>extiende BeanFactory"]
    AC --> F1["Publicación de eventos<br/>ApplicationEventPublisher"]
    AC --> F2["Resolución de mensajes i18n<br/>MessageSource"]
    AC --> F3["Acceso a recursos<br/>ResourceLoader"]
    AC --> F4["Environment y propiedades"]
    AC --> F5["Procesamiento automático de<br/>BeanPostProcessor y BeanFactoryPostProcessor"]
    AC --> F6["Precreación de singletons<br/>en el arranque"]
BeanFactory ApplicationContext
Función Contenedor mínimo: crear y servir beans Contenedor completo de aplicación
Creación de singletons Perezosa (al pedirlos) Anticipada, en el arranque
Eventos, i18n, recursos, Environment No Sí
Registro automático de post-procesadores Manual Automático
Uso en Spring Boot Interno El que usas siempre

En Spring Boot el contenedor real es siempre un ApplicationContext (en concreto, para CicloUrbana, un AnnotationConfigServletWebServerApplicationContext, como vimos en el log de arranque de la lección 01-05). BeanFactory es la interfaz subyacente, útil de conocer porque muchos mensajes de error la mencionan.

Un detalle con consecuencias prácticas: el ApplicationContext crea todos los singletons durante el arranque, no cuando se usan por primera vez. Es lo que permite que un bean mal configurado reviente el arranque en lugar de la primera petición de un cliente. Es una decisión de diseño deliberada: fallar pronto y en voz alta.

  1. Los ámbitos de Spring

El ámbito (scope) responde a la pregunta: ¿cuántas instancias de este bean existen y cuánto viven?

Ámbito Instancias Vida Disponible en
singleton Una por contenedor Todo el ciclo de la aplicación Siempre (por defecto)
prototype Una por cada petición al contenedor Hasta que el recolector de basura la libere Siempre
request Una por petición HTTP La petición HTTP Solo aplicaciones web
session Una por sesión HTTP La sesión del usuario Solo aplicaciones web
application Una por ServletContext Toda la aplicación web Solo aplicaciones web
websocket Una por sesión WebSocket La sesión WebSocket Con WebSocket

Se declara con @Scope:

package com.ciclourbana.alquileres;

import org.springframework.beans.factory.config.ConfigurableBeanFactory;
import org.springframework.context.annotation.Scope;
import org.springframework.stereotype.Component;

import java.time.Instant;
import java.util.ArrayList;
import java.util.List;

/**
 * Acumula los eventos de una simulación de demanda. Cada simulación
 * necesita su propio acumulador con estado, así que NO puede ser singleton.
 */
@Component
@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)   // equivale a @Scope("prototype")
public class SesionSimulacion {

    private final Instant inicio = Instant.now();
    private final List<String> eventos = new ArrayList<>();

    public void registrar(String evento) {
        eventos.add(evento);
    }

    public List<String> eventos() {
        return List.copyOf(eventos);
    }

    public Instant inicio() {
        return inicio;
    }
}

Los ámbitos web se declaran igual, aunque para ellos existen atajos más legibles:

@Component
@RequestScope    // = @Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class ContextoPeticion { /* datos de la petición HTTP en curso */ }

@Component
@SessionScope    // = @Scope(value = "session", proxyMode = ...)
public class CarritoRecargas { /* estado por usuario, entre peticiones */ }

Una advertencia importante sobre estos dos: la mayoría de las APIs REST no deberían usarlos. Un servicio REST es, por definición, sin estado; guardar información en un bean de sesión rompe esa propiedad y complica el escalado horizontal (dos instancias detrás de un balanceador no comparten sesión). En CicloUrbana no usaremos session: el estado del usuario viajará en el token JWT que construiremos en el módulo 5.

Y una diferencia crítica entre singleton y prototype que sorprende a mucha gente:

flowchart LR
    subgraph S["singleton"]
        C1["Contenedor"] -->|crea, inicializa,<br/>destruye| B1["1 instancia"]
    end
    subgraph P["prototype"]
        C2["Contenedor"] -->|crea e inicializa| B2["instancia 1"]
        C2 -->|crea e inicializa| B3["instancia 2"]
        C2 -.->|"NO la destruye:<br/>no llama a @PreDestroy"| B2
    end

Spring no gestiona la destrucción de los beans prototype. Los crea, los inyecta y los olvida. Sus métodos @PreDestroy nunca se ejecutan. Si un bean prototype abre un recurso (un fichero, una conexión), eres tú quien debe cerrarlo.

  1. El ámbito singleton y el peligro del estado mutable

El ámbito por defecto es singleton: una sola instancia compartida por todo el contenedor. Y aquí está la consecuencia más importante de toda esta lección: en una aplicación web, esa única instancia la usan todas las peticiones HTTP a la vez, cada una en un hilo distinto de Tomcat.

Por eso la regla de oro es: los beans no deben tener estado mutable.

Un ejemplo de lo que no hay que hacer, escrito sobre nuestro EstacionService:

@Service
public class EstacionService {

    private final EstacionRepositorio estacionRepositorio;

    // ¡PELIGRO! Campo mutable en un singleton compartido por todos los hilos
    private int ultimoIdConsultado;
    private List<Estacion> resultadoParcial = new ArrayList<>();

    public List<Estacion> buscarPorCapacidadMinima(int minima) {
        resultadoParcial.clear();                       // hilo A limpia...
        for (Estacion e : estacionRepositorio.buscarTodas()) {
            if (e.capacidad() >= minima) {
                resultadoParcial.add(e);                // ...mientras hilo B añade
            }
        }
        return resultadoParcial;                        // resultado impredecible
    }
}

Con una sola petición funciona perfectamente. Con dos peticiones simultáneas devuelve resultados mezclados, o lanza una ConcurrentModificationException, o —lo peor— devuelve datos de otro usuario. Y es un error que no aparece en desarrollo: solo se manifiesta en producción, bajo carga, de forma intermitente. Es una de las clases de bug más caras de diagnosticar.

La versión correcta usa solo variables locales, que viven en la pila de cada hilo y no se comparten:

@Service
public class EstacionService {

    private final EstacionRepositorio estacionRepositorio;   // final e inmutable

    public EstacionService(EstacionRepositorio estacionRepositorio) {
        this.estacionRepositorio = estacionRepositorio;
    }

    public List<Estacion> buscarPorCapacidadMinima(int minima) {
        // Todo el estado es local al hilo: seguro por construcción
        return estacionRepositorio.buscarTodas().stream()
                .filter(e -> e.capacidad() >= minima)
                .sorted(Comparator.comparing(Estacion::nombre))
                .toList();
    }
}

Las tres formas legítimas de tener estado en un singleton:

Forma Ejemplo en CicloUrbana
Campos final con objetos inmutables private final EstacionRepositorio repositorio;
Estructuras concurrentes private final Map<Long, Estacion> porId = new ConcurrentHashMap<>();
Contadores atómicos private final AtomicLong siguienteId = new AtomicLong(0);

Ahora se entiende por qué en la lección 02-01 escribimos EstacionRepositorioEnMemoria con ConcurrentHashMap y AtomicLong en lugar de HashMap y long: es un singleton, y su estado lo tocan varios hilos. Con un HashMap normal, dos inserciones simultáneas pueden corromper la estructura interna y provocar bucles infinitos en las lecturas posteriores.

Si de verdad necesitas estado por usuario o por petición, las opciones son: pasarlo como parámetro (casi siempre la mejor), usar un bean prototype, o —en el caso de una web con sesión— un bean session.

  1. prototype dentro de un singleton: el problema clásico

Este es el error más citado del contenedor de Spring, y merece entenderlo bien.

@Service                                    // singleton
public class SimuladorDemanda {

    private final SesionSimulacion sesion;  // prototype

    public SimuladorDemanda(SesionSimulacion sesion) {
        this.sesion = sesion;               // se inyecta UNA vez, al arrancar
    }

    public void simular() {
        sesion.registrar("simulación ejecutada");   // ¡siempre la MISMA sesión!
    }
}

La inyección ocurre una sola vez, cuando se construye el singleton. La instancia prototype recibida queda congelada en el campo durante toda la vida de la aplicación. El ámbito prototype se pierde por completo: en la práctica, ese bean se comporta como un singleton.

Solución 1: ObjectProvider (la recomendada)

Ya lo conocimos en la lección 02-02 como forma de expresar dependencias opcionales. Su otra virtud es la resolución perezosa en cada llamada:

package com.ciclourbana.alquileres;

import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Service;

@Service
public class SimuladorDemanda {

    private final ObjectProvider<SesionSimulacion> proveedorSesiones;

    public SimuladorDemanda(ObjectProvider<SesionSimulacion> proveedorSesiones) {
        this.proveedorSesiones = proveedorSesiones;
    }

    public void simular(String nombreEscenario) {
        // getObject() pide una instancia NUEVA al contenedor en cada llamada
        SesionSimulacion sesion = proveedorSesiones.getObject();
        sesion.registrar("escenario: " + nombreEscenario);
        sesion.registrar("estaciones evaluadas: 4");
        // La sesión muere cuando salga de ámbito: Spring no la destruye
    }
}

Solución 2: @Lookup

Spring puede sobrescribir un método abstracto para que devuelva una instancia nueva. Lo hace generando una subclase por CGLIB:

@Service
public abstract class SimuladorDemanda {

    public void simular(String nombreEscenario) {
        SesionSimulacion sesion = nuevaSesion();   // instancia nueva cada vez
        sesion.registrar("escenario: " + nombreEscenario);
    }

    /** Spring implementa este método por nosotros. */
    @Lookup
    protected abstract SesionSimulacion nuevaSesion();
}

Solución 3: proxy de ámbito

@Component
@Scope(value = "prototype", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class SesionSimulacion { /* ... */ }

Con proxyMode, lo que se inyecta en el singleton no es la instancia sino un proxy que, en cada invocación de método, resuelve la instancia correcta. Es la opción obligatoria para los ámbitos request y session (por eso @RequestScope y @SessionScope lo llevan de serie): un singleton se construye en el arranque, cuando todavía no hay ninguna petición HTTP viva de la que obtener el bean.

Comparadas:

Solución Legibilidad Cuándo usarla
ObjectProvider Explícita: se ve que se pide una instancia Preferida en código propio
@Lookup Elegante pero exige clase abstracta Casos heredados o APIs que no admiten ObjectProvider
proxyMode Transparente, pero oculta lo que pasa Obligatoria para request/session

  1. El ciclo de vida completo de un bean

Este es el recorrido íntegro de un bean singleton, desde que Spring decide crearlo hasta que la aplicación se cierra:

flowchart TD
    A["1. Instanciación<br/>se invoca el constructor<br/>(inyección por constructor)"] --> B["2. Inyección de setters y campos<br/>@Autowired, @Value"]
    B --> C["3. Interfaces *Aware*<br/>BeanNameAware.setBeanName()<br/>BeanFactoryAware<br/>ApplicationContextAware"]
    C --> D["4. BeanPostProcessor<br/>postProcessBeforeInitialization()"]
    D --> E["5. @PostConstruct"]
    E --> F["6. InitializingBean.afterPropertiesSet()"]
    F --> G["7. init-method<br/>@Bean(initMethod = ...)"]
    G --> H["8. BeanPostProcessor<br/>postProcessAfterInitialization()<br/>← aquí se envuelve en proxies AOP"]
    H --> I["BEAN LISTO PARA USARSE"]
    I --> J["9. @PreDestroy"]
    J --> K["10. DisposableBean.destroy()"]
    K --> L["11. destroy-method<br/>@Bean(destroyMethod = ...)"]
    L --> M["Bean destruido"]

Los tres bloques de tres pasos merecen un comentario.

Inicialización (5, 6, 7). Hay tres formas de ejecutar código al nacer un bean, y se ejecutan en ese orden. Son redundantes; usa siempre @PostConstruct salvo que tengas un motivo:

Mecanismo Acoplamiento a Spring Recomendación
@PostConstruct (jakarta.annotation) Ninguno: es un estándar de Jakarta EE Úsalo
InitializingBean.afterPropertiesSet() Alto: obliga a implementar una interfaz de Spring Evítalo
@Bean(initMethod = "arrancar") Ninguno en la clase Para clases de terceros que no puedes anotar

Destrucción (9, 10, 11). El mismo esquema con @PreDestroy. Con dos advertencias: solo se ejecutan si el contexto se cierra de forma ordenada (recuerda server.shutdown=graceful de la lección 01-05; un kill -9 no ejecuta nada), y nunca se ejecutan en beans prototype.

Post-procesamiento (4 y 8). Es donde ocurre casi todo lo interesante, y por eso tiene apartado propio más abajo.

Un detalle sobre las interfaces Aware: permiten que el bean reciba infraestructura del contenedor.

@Component
public class DiagnosticoBean implements BeanNameAware, ApplicationContextAware {

    private String nombreBean;
    private ApplicationContext contexto;

    @Override
    public void setBeanName(String name) {
        this.nombreBean = name;      // "diagnosticoBean"
    }

    @Override
    public void setApplicationContext(ApplicationContext contexto) {
        this.contexto = contexto;
    }
}

Úsalas con moderación: acoplan tu clase a Spring. ApplicationContextAware en particular suele ser un olor a service locator; casi siempre es preferible inyectar lo que necesitas.

  1. @PostConstruct y @PreDestroy en CicloUrbana

Vamos a construir una pieza útil: una caché de disponibilidad de la red que se precalcula al arrancar y libera sus recursos al cerrar.

Deja clara una cosa antes de empezar: esto es una caché manual, escrita a mano para ilustrar el ciclo de vida. La caché declarativa de Spring, con @Cacheable y @CacheEvict, es otra cosa y se estudia en la lección 09-02.

package com.ciclourbana.estaciones;

import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;

import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;

/**
 * Caché en memoria de los anclajes libres por estación.
 *
 * Precalcula los valores al arrancar (@PostConstruct) y vuelca
 * estadísticas al cerrar (@PreDestroy). Es una caché manual: la
 * caché declarativa con @Cacheable se estudia en la lección 09-02.
 */
@Component
public class CacheDisponibilidad {

    private static final Logger log = LoggerFactory.getLogger(CacheDisponibilidad.class);

    private final EstacionRepositorio estacionRepositorio;

    // ConcurrentHashMap: el bean es singleton y lo leen varios hilos
    private final Map<Long, Integer> anclajesLibres = new ConcurrentHashMap<>();
    private final AtomicLong aciertos = new AtomicLong();
    private final AtomicLong fallos = new AtomicLong();

    private Instant momentoCarga;

    public CacheDisponibilidad(EstacionRepositorio estacionRepositorio) {
        this.estacionRepositorio = estacionRepositorio;
        // OJO: aquí NO precargamos. El constructor solo asigna dependencias.
        log.debug("CacheDisponibilidad construida (aún sin datos)");
    }

    /**
     * Se ejecuta después de que todas las dependencias estén inyectadas.
     * Es el lugar correcto para trabajo de inicialización pesado.
     */
    @PostConstruct
    public void precargar() {
        Instant inicio = Instant.now();

        estacionRepositorio.buscarTodas().forEach(estacion ->
                // Simulación: al arrancar consideramos la mitad de anclajes ocupados
                anclajesLibres.put(estacion.id(), estacion.capacidad() / 2));

        this.momentoCarga = Instant.now();
        log.info("Caché de disponibilidad precargada con {} estaciones en {} ms",
                anclajesLibres.size(),
                Duration.between(inicio, momentoCarga).toMillis());
    }

    public int anclajesLibres(Long estacionId) {
        Integer valor = anclajesLibres.get(estacionId);
        if (valor == null) {
            fallos.incrementAndGet();
            return recalcular(estacionId);
        }
        aciertos.incrementAndGet();
        return valor;
    }

    public void actualizar(Long estacionId, int libres) {
        anclajesLibres.put(estacionId, libres);
    }

    private int recalcular(Long estacionId) {
        int libres = estacionRepositorio.buscarPorId(estacionId)
                .map(estacion -> estacion.capacidad() / 2)
                .orElse(0);
        anclajesLibres.put(estacionId, libres);
        return libres;
    }

    /**
     * Se ejecuta al cerrar el contexto de forma ordenada.
     * Aquí se liberarían conexiones, hilos o ficheros abiertos.
     */
    @PreDestroy
    public void liberar() {
        log.info("Cerrando caché de disponibilidad tras {} de servicio",
                Duration.between(momentoCarga, Instant.now()));
        log.info("Estadísticas: {} aciertos, {} fallos ({}% de acierto)",
                aciertos.get(), fallos.get(), porcentajeAcierto());
        anclajesLibres.clear();
    }

    private long porcentajeAcierto() {
        long total = aciertos.get() + fallos.get();
        return total == 0 ? 0 : (aciertos.get() * 100) / total;
    }
}

Un detalle de orden que causa problemas reales: el @PostConstruct de este bean se ejecuta antes de que corran los CommandLineRunner. Y nuestro CargadorEstacionesDemo es un CommandLineRunner. Resultado: cuando precargar() se ejecuta, el repositorio todavía está vacío y la caché nace sin datos.

sequenceDiagram
    participant SA as SpringApplication
    participant Ctx as ApplicationContext
    participant C as CacheDisponibilidad
    participant R as CargadorEstacionesDemo

    SA->>Ctx: refresh()
    Ctx->>C: constructor
    Ctx->>C: @PostConstruct precargar()
    Note over C: Repositorio vacío:<br/>0 estaciones cacheadas
    Ctx-->>SA: contexto listo
    SA->>R: run() — carga las 4 estaciones
    Note over R: Ahora sí hay datos,<br/>pero la caché ya se pobló

Esta es exactamente la clase de dependencia temporal que hay que saber ver. Tres formas de resolverla:

  1. Precargar en un ApplicationReadyEvent en lugar de en @PostConstruct: se publica después de los runners.
  2. Poblar el repositorio antes, en el propio @PostConstruct de un bean del que la caché dependa.
  3. Aceptar la caché vacía y dejar que se llene bajo demanda con recalcular(), que es lo que ya hace nuestra implementación.

Para CicloUrbana escogemos la primera, porque es la más explícita:

@Component
public class CalentadorCache {

    private final CacheDisponibilidad cache;
    private final EstacionRepositorio repositorio;

    public CalentadorCache(CacheDisponibilidad cache, EstacionRepositorio repositorio) {
        this.cache = cache;
        this.repositorio = repositorio;
    }

    /** ApplicationReadyEvent se publica DESPUÉS de los CommandLineRunner. */
    @EventListener(ApplicationReadyEvent.class)
    public void calentar() {
        repositorio.buscarTodas().forEach(e ->
                cache.actualizar(e.id(), e.capacidad() / 2));
    }
}

Comprobarlo en marcha

./mvnw spring-boot:run

En el arranque verás el @PostConstruct, y al parar con Ctrl+C verás el @PreDestroy:

c.c.estaciones.CacheDisponibilidad : Caché de disponibilidad precargada con 0 estaciones en 1 ms
c.c.e.CargadorEstacionesDemo       : Cargadas 4 estaciones, 108 anclajes en total
...
c.c.estaciones.CacheDisponibilidad : Cerrando caché de disponibilidad tras PT2M14S de servicio
c.c.estaciones.CacheDisponibilidad : Estadísticas: 12 aciertos, 4 fallos (75% de acierto)

  1. Inicialización perezosa

Por defecto, el ApplicationContext crea todos los singletons durante el arranque. Se puede cambiar bean a bean:

@Component
@Lazy   // no se crea hasta que alguien lo pida por primera vez
public class GeneradorInformesMensuales { /* proceso costoso, se usa una vez al mes */ }

O globalmente:

# src/main/resources/application.properties
spring.main.lazy-initialization=true

Sus contrapartidas, en una tabla:

Aspecto Con creación anticipada (por defecto) Con inicialización perezosa
Tiempo de arranque Mayor Menor
Detección de errores de configuración En el arranque En la primera petición
Latencia de la primera petición Baja Alta (se construye la cadena de beans)
Memoria al arrancar Toda la necesaria Solo la de los beans usados
Uso recomendado Producción Desarrollo, pruebas puntuales

La contrapartida es seria: con inicialización perezosa, un bean mal configurado no revienta el arranque; revienta cuando un usuario real toca ese camino de código. Se pierde exactamente la propiedad que hace útil a Spring Boot en producción.

Recomendación práctica para CicloUrbana: no la actives globalmente. Si tu arranque tarda demasiado, el problema casi nunca son los beans sino algo concreto —una conexión a base de datos, un cliente externo que hace una llamada al arrancar— y la solución correcta es un @Lazy puntual sobre ese bean, o mover el trabajo pesado a un ApplicationReadyEvent.

Un matiz sobre @Lazy: si un bean perezoso es inyectado por constructor en un bean no perezoso, tendrá que crearse igualmente al construir a este último... salvo que el punto de inyección lleve también @Lazy, en cuyo caso Spring inyecta un proxy y difiere la creación real.

  1. BeanPostProcessor: el punto de extensión

Un BeanPostProcessor es un bean especial al que el contenedor da la oportunidad de inspeccionar o sustituir cada bean justo antes y justo después de su inicialización. Es el gancho sobre el que se apoya la mitad de Spring:

Qué parece magia Qué BeanPostProcessor lo hace
@Autowired y @Value funcionan AutowiredAnnotationBeanPostProcessor
@PostConstruct y @PreDestroy se ejecutan CommonAnnotationBeanPostProcessor
@Transactional abre transacciones InfrastructureAdvisorAutoProxyCreator
@Cacheable, @Async, @Scheduled Sus respectivos post-procesadores
@Repository traduce excepciones PersistenceExceptionTranslationPostProcessor

La interfaz tiene dos métodos, ambos con implementación por defecto:

public interface BeanPostProcessor {

    default Object postProcessBeforeInitialization(Object bean, String beanName) {
        return bean;   // antes de @PostConstruct
    }

    default Object postProcessAfterInitialization(Object bean, String beanName) {
        return bean;   // después de @PostConstruct; aquí se devuelven los proxies
    }
}

La clave está en que ambos devuelven un objeto. Si devuelves algo distinto del bean recibido, ese será el objeto que quede registrado. Así es exactamente como Spring sustituye tu EstacionService por un proxy que abre transacciones antes de cada método.

Un BeanPostProcessor propio para CicloUrbana

Vamos a medir cuánto tarda en inicializarse cada bean del proyecto, para detectar arranques lentos:

package com.ciclourbana.comun;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.config.BeanPostProcessor;
import org.springframework.stereotype.Component;

import java.util.HashMap;
import java.util.Map;

/**
 * Cronometra la inicialización de los beans de com.ciclourbana y avisa
 * de los que tardan más del umbral. Herramienta de diagnóstico de arranque.
 */
@Component
public class CronometroInicializacionBeans implements BeanPostProcessor {

    private static final Logger log =
            LoggerFactory.getLogger(CronometroInicializacionBeans.class);

    private static final long UMBRAL_MS = 50;

    // El post-procesador se ejecuta en el hilo de arranque: HashMap basta
    private final Map<String, Long> inicios = new HashMap<>();

    @Override
    public Object postProcessBeforeInitialization(Object bean, String nombreBean) {
        if (esNuestro(bean)) {
            inicios.put(nombreBean, System.nanoTime());
        }
        return bean;   // devolvemos el mismo bean: no lo sustituimos
    }

    @Override
    public Object postProcessAfterInitialization(Object bean, String nombreBean) {
        Long inicio = inicios.remove(nombreBean);
        if (inicio != null) {
            long ms = (System.nanoTime() - inicio) / 1_000_000;
            if (ms >= UMBRAL_MS) {
                log.warn("Bean lento: '{}' tardó {} ms en inicializarse", nombreBean, ms);
            } else {
                log.debug("Bean '{}' inicializado en {} ms", nombreBean, ms);
            }
        }
        return bean;
    }

    private boolean esNuestro(Object bean) {
        return bean.getClass().getName().startsWith("com.ciclourbana");
    }
}

Actívalo en modo depuración para ver todos los tiempos:

logging.level.com.ciclourbana.comun.CronometroInicializacionBeans=DEBUG
c.c.c.CronometroInicializacionBeans : Bean 'estacionRepositorioEnMemoria' inicializado en 2 ms
c.c.c.CronometroInicializacionBeans : Bean 'estacionService' inicializado en 1 ms
c.c.c.CronometroInicializacionBeans : Bean 'cacheDisponibilidad' inicializado en 4 ms
c.c.c.CronometroInicializacionBeans : Bean 'tarifaEstandar' inicializado en 0 ms

Dos advertencias importantes sobre los BeanPostProcessor:

Se crean muy pronto, antes que los beans normales. Por eso no debes inyectar en ellos dependencias que necesiten configuración compleja: podrías forzar la creación prematura de otros beans y saltarte su propio post-procesado (Spring avisa en el log con un mensaje del tipo "is not eligible for getting processed by all BeanPostProcessors").

Se ejecutan para todos los beans, incluidos los internos de Spring. Son cientos. Filtra siempre por paquete o por anotación, como hace nuestro esNuestro(...), o generarás un log inservible.

Su pariente cercano, el BeanFactoryPostProcessor, actúa un paso antes: modifica las BeanDefinition (las recetas) antes de que se instancie nada. Es lo que usa Spring para resolver los ${...} de las propiedades. Rara vez necesitarás escribir uno.

Errores Comunes y Consejos

Estado mutable en un singleton. El error más grave de esta lección. Un campo no final y no concurrente en un @Service es una bomba de relojería que solo estalla bajo carga. Regla mecánica: en un bean, todo campo debería ser final, y si su contenido es mutable, debe ser una estructura concurrente.

Trabajo pesado en el constructor. El constructor solo debe asignar dependencias. Consultas, conexiones o cálculos van en @PostConstruct o, mejor aún, en un ApplicationReadyEvent. Un constructor que falla deja el contexto en un estado difícil de diagnosticar.

Esperar @PreDestroy en un bean prototype. No se ejecuta nunca. Si el bean gestiona un recurso, ciérralo tú.

Usar javax.annotation.PostConstruct. En Spring Boot 3 el paquete es jakarta.annotation. Es el mismo cambio javax→jakarta que vimos en la lección 01-01. Con el import antiguo el método simplemente no se ejecuta, sin ningún error: silencioso y desconcertante.

Inyectar un prototype por constructor en un singleton. Se congela la primera instancia y el ámbito se pierde. Usa ObjectProvider.

Usar @SessionScope en una API REST. Rompe la ausencia de estado y complica el escalado. El estado del usuario va en el token (módulo 5).

Activar spring.main.lazy-initialization=true en producción. Cambias errores de arranque por errores en la cara del usuario. Úsala, como mucho, en desarrollo.

Consejo: si dudas del ámbito, es singleton. El 95 % de los beans de una aplicación real son singletons sin estado. Cuando te veas tentado de usar prototype, pregúntate primero si el estado no puede viajar como parámetro de método.

Consejo: usa @PostConstruct para validar la configuración. Comprobar al arrancar que un parámetro crítico tiene sentido convierte un error de producción en un fallo de arranque. En la lección 02-05 veremos que Bean Validation hace esto mismo de forma declarativa.

Consejo: para diagnosticar el ciclo de vida, sube el nivel de log. logging.level.org.springframework.beans.factory=DEBUG muestra la creación de cada bean en orden. Es ruidoso, pero resuelve dudas en minutos.

Ejercicios

Ejercicio 1: demostrar los ámbitos

Crea dos componentes que solo registren su identidad: ContadorSingleton (ámbito por defecto) y ContadorPrototype (@Scope("prototype")), ambos con un campo id generado en el constructor. Desde un CommandLineRunner, pide tres veces cada uno al contexto y comprueba por consola que el singleton siempre tiene el mismo id y el prototype uno distinto cada vez. Añade un @PreDestroy a ambos y observa cuál se ejecuta al cerrar.

Ejercicio 2: arreglar un singleton con estado

Parte de esta clase, que tiene un fallo de concurrencia, y corrígela sin cambiar su API pública. Después demuestra el problema lanzando 100 hilos simultáneos contra el método original.

@Service
public class RegistroAlquileresActivos {

    private int totalIniciados = 0;
    private final List<String> matriculasActivas = new ArrayList<>();

    public void iniciar(String matricula) {
        matriculasActivas.add(matricula);
        totalIniciados++;
    }

    public void finalizar(String matricula) {
        matriculasActivas.remove(matricula);
    }

    public int activos() {
        return matriculasActivas.size();
    }

    public int totalIniciados() {
        return totalIniciados;
    }
}

Ejercicio 3: un BeanPostProcessor que valida una anotación propia

Crea una anotación @RequiereInicializacion y un BeanPostProcessor que, para cada bean del paquete com.ciclourbana marcado con ella, compruebe en postProcessAfterInitialization que un método boolean estaInicializado() devuelve true. Si no lo está, el arranque debe fallar con un mensaje claro. Aplícalo a CacheDisponibilidad.


Soluciones

Solución 1

package com.ciclourbana.comun;

import jakarta.annotation.PreDestroy;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;

import java.util.UUID;

@Component
public class ContadorSingleton {

    private static final Logger log = LoggerFactory.getLogger(ContadorSingleton.class);

    private final String id = UUID.randomUUID().toString().substring(0, 8);

    public ContadorSingleton() {
        log.info("Construido ContadorSingleton {}", id);
    }

    public String id() {
        return id;
    }

    @PreDestroy
    public void alDestruir() {
        log.info("Destruido ContadorSingleton {}", id);
    }
}
package com.ciclourbana.comun;

import jakarta.annotation.PreDestroy;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Scope;
import org.springframework.stereotype.Component;

import java.util.UUID;

@Component
@Scope("prototype")
public class ContadorPrototype {

    private static final Logger log = LoggerFactory.getLogger(ContadorPrototype.class);

    private final String id = UUID.randomUUID().toString().substring(0, 8);

    public String id() {
        return id;
    }

    @PreDestroy
    public void alDestruir() {
        // NUNCA se ejecuta: Spring no gestiona la destrucción de los prototype
        log.info("Destruido ContadorPrototype {}", id);
    }
}
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(50)
public class DemostracionAmbitos implements CommandLineRunner {

    private static final Logger log = LoggerFactory.getLogger(DemostracionAmbitos.class);

    private final ApplicationContext contexto;

    public DemostracionAmbitos(ApplicationContext contexto) {
        this.contexto = contexto;
    }

    @Override
    public void run(String... args) {
        for (int i = 1; i <= 3; i++) {
            log.info("Petición {} -> singleton: {} | prototype: {}",
                    i,
                    contexto.getBean(ContadorSingleton.class).id(),
                    contexto.getBean(ContadorPrototype.class).id());
        }
    }
}

Salida:

Petición 1 -> singleton: a3f91c02 | prototype: 7b2e4d81
Petición 2 -> singleton: a3f91c02 | prototype: c14a9f30
Petición 3 -> singleton: a3f91c02 | prototype: 55d0e7b2
...
Destruido ContadorSingleton a3f91c02      <- solo aparece el del singleton

Comentario: el @PreDestroy del prototype no aparece en ninguna de las tres instancias, confirmando lo dicho en el apartado 3. Nota además que aquí sí usamos contexto.getBean(...): en una demostración didáctica es legítimo, pero recuerda que en lógica de negocio es el antipatrón service locator.

Solución 2

Diagnóstico: hay dos condiciones de carrera. matriculasActivas es un ArrayList (no seguro para hilos: dos add simultáneos pueden perder un elemento o corromper el array interno) y totalIniciados++ no es atómico (es leer, sumar y escribir; dos hilos pueden leer el mismo valor y perder un incremento).

package com.ciclourbana.alquileres;

import org.springframework.stereotype.Service;

import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;

/**
 * Registro de alquileres en curso. Es un singleton compartido por
 * todos los hilos de Tomcat, así que todo su estado debe ser concurrente.
 */
@Service
public class RegistroAlquileresActivos {

    // Set concurrente: además evita duplicados, que en este dominio
    // son un error (una bicicleta no puede alquilarse dos veces)
    private final Set<String> matriculasActivas = ConcurrentHashMap.newKeySet();

    // Incremento atómico: sin pérdida de actualizaciones
    private final AtomicInteger totalIniciados = new AtomicInteger();

    public void iniciar(String matricula) {
        if (matriculasActivas.add(matricula)) {   // add devuelve false si ya estaba
            totalIniciados.incrementAndGet();
        }
    }

    public void finalizar(String matricula) {
        matriculasActivas.remove(matricula);
    }

    public int activos() {
        return matriculasActivas.size();
    }

    public int totalIniciados() {
        return totalIniciados.get();
    }
}

Y la demostración del problema con la versión original:

package com.ciclourbana.alquileres;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

@Component
public class PruebaConcurrencia implements CommandLineRunner {

    private static final Logger log = LoggerFactory.getLogger(PruebaConcurrencia.class);

    private final RegistroAlquileresActivos registro;

    public PruebaConcurrencia(RegistroAlquileresActivos registro) {
        this.registro = registro;
    }

    @Override
    public void run(String... args) throws InterruptedException {
        int hilos = 100;
        CountDownLatch salida = new CountDownLatch(1);
        CountDownLatch fin = new CountDownLatch(hilos);

        try (ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < hilos; i++) {
                String matricula = String.format("RB-%04d", i);
                pool.submit(() -> {
                    try {
                        salida.await();                 // todos arrancan a la vez
                        registro.iniciar(matricula);
                    } catch (InterruptedException e) {
                        Thread.currentThread().interrupt();
                    } finally {
                        fin.countDown();
                    }
                });
            }
            salida.countDown();
            fin.await();
        }

        log.info("Esperados 100 | activos: {} | totalIniciados: {}",
                registro.activos(), registro.totalIniciados());
    }
}

Con la versión original verás salidas como activos: 97 | totalIniciados: 94, y variarán en cada ejecución. Con la corregida, siempre 100 | 100.

Comentario y consejo: Executors.newVirtualThreadPerTaskExecutor() es de Java 21 y hace estas pruebas triviales de escribir. El CountDownLatch de salida es importante: sin él los hilos arrancan escalonados y la carrera puede no manifestarse, dando la falsa impresión de que el código original es correcto. Los bugs de concurrencia son así: no reproducirlos no significa que no existan.

Solución 3

package com.ciclourbana.comun;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
 * Marca un bean que debe estar operativo al terminar su inicialización.
 * La clase anotada debe exponer un método público boolean estaInicializado().
 */
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface RequiereInicializacion {

    /** Descripción usada en el mensaje de error. */
    String value() default "";
}
package com.ciclourbana.comun;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.BeanInitializationException;
import org.springframework.beans.factory.config.BeanPostProcessor;
import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.stereotype.Component;

import java.lang.reflect.Method;

@Component
public class VerificadorInicializacion implements BeanPostProcessor {

    private static final Logger log = LoggerFactory.getLogger(VerificadorInicializacion.class);

    @Override
    public Object postProcessAfterInitialization(Object bean, String nombreBean) {
        // AnnotationUtils atraviesa proxies y jerarquías de clases
        RequiereInicializacion marca =
                AnnotationUtils.findAnnotation(bean.getClass(), RequiereInicializacion.class);

        if (marca == null) {
            return bean;
        }

        try {
            Method metodo = bean.getClass().getMethod("estaInicializado");
            boolean listo = (boolean) metodo.invoke(bean);

            if (!listo) {
                throw new BeanInitializationException(
                        "El bean '" + nombreBean + "' no quedó inicializado. "
                                + marca.value());
            }
            log.info("Bean '{}' verificado: inicialización correcta", nombreBean);

        } catch (NoSuchMethodException e) {
            throw new BeanInitializationException(
                    "El bean '" + nombreBean + "' lleva @RequiereInicializacion "
                            + "pero no expone un método público boolean estaInicializado()", e);
        } catch (ReflectiveOperationException e) {
            throw new BeanInitializationException(
                    "No se pudo verificar la inicialización de '" + nombreBean + "'", e);
        }

        return bean;   // no sustituimos el bean, solo lo validamos
    }
}

Y la aplicación sobre la caché:

@Component
@RequiereInicializacion("La caché de disponibilidad debe precargarse antes de aceptar tráfico.")
public class CacheDisponibilidad {

    // ... el resto igual que en el apartado 7 ...

    /** Contrato exigido por @RequiereInicializacion. */
    public boolean estaInicializado() {
        return momentoCarga != null;
    }
}

Salida en un arranque correcto:

c.c.comun.VerificadorInicializacion : Bean 'cacheDisponibilidad' verificado: inicialización correcta

Y si comentas la línea this.momentoCarga = Instant.now() del @PostConstruct:

***************************
APPLICATION FAILED TO START
***************************

org.springframework.beans.factory.BeanInitializationException: El bean
'cacheDisponibilidad' no quedó inicializado. La caché de disponibilidad
debe precargarse antes de aceptar tráfico.

Comentario: fíjate en el orden. El BeanPostProcessor actúa en postProcessAfterInitialization, es decir, después del @PostConstruct, que es precisamente donde tiene sentido comprobar que la inicialización funcionó. Si lo hubiéramos puesto en postProcessBeforeInitialization, momentoCarga sería siempre null y todos los beans fallarían.

Este ejercicio, en pequeño, es exactamente el mecanismo por el que @Transactional o @Cacheable funcionan: una anotación que por sí sola no hace nada, más un post-procesador que la detecta y actúa. La única diferencia es que aquellos, en lugar de devolver el mismo bean, devuelven un proxy que lo envuelve.

Conclusión

El contenedor ha dejado de ser una caja negra. Sabes que un bean es un objeto cuyo ciclo de vida gestiona Spring, y que esa gestión es lo único que separa a EstacionService de un objeto creado con new —y también la razón por la que las anotaciones de comportamiento solo funcionan sobre beans. Conoces la diferencia entre BeanFactory y ApplicationContext, y por qué este último crea los singletons por adelantado: para fallar en el arranque y no delante del usuario. Dominas los cinco ámbitos, sabes que Spring no destruye los prototype y sabes resolver con ObjectProvider el clásico problema de inyectar un prototype en un singleton. Sobre todo, has interiorizado la regla que más disgustos evita: un singleton no puede tener estado mutable, y por eso EstacionRepositorioEnMemoria usa ConcurrentHashMap y AtomicLong. Conoces los once pasos del ciclo de vida, has enganchado código en el nacimiento y la muerte de un bean con @PostConstruct y @PreDestroy, has descubierto la dependencia temporal entre esa precarga y los CommandLineRunner, y has escrito tu primer BeanPostProcessor, el mismo mecanismo sobre el que se apoyan las transacciones y la caché declarativa.

CicloUrbana suma ahora CacheDisponibilidad, con precarga al arrancar y volcado de estadísticas al cerrar, y CronometroInicializacionBeans como herramienta de diagnóstico del arranque.

Queda algo que hemos ido esquivando lección tras lección: los números escritos a fuego. El precio de desbloqueo 0.50, el precio por minuto 0.12, los 15 minutos gratuitos de la tarifa de estudiante, el umbral de 50 ms del cronómetro, la capacidad mínima de 8 anclajes. Todo eso son constantes compiladas dentro del código, y cambiar cualquiera de ellas obliga hoy a recompilar y volver a desplegar. La siguiente lección, Configuración de Spring Boot, ataca ese problema de raíz: el Environment y sus fuentes de propiedades, el orden exacto de precedencia entre argumentos de línea de comandos, variables de entorno y ficheros, las diferencias entre .properties y .yaml, la relajación de nombres, @Value con SpEL y —muy importante— por qué las credenciales no pueden estar nunca en el repositorio.

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