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
- Qué es un bean y qué es el contenedor
BeanFactoryfrente aApplicationContext- Los ámbitos de Spring
- El ámbito singleton y el peligro del estado mutable
prototypedentro de unsingleton: el problema clásico- El ciclo de vida completo de un bean
@PostConstructy@PreDestroyen CicloUrbana- Inicialización perezosa
BeanPostProcessor: el punto de extensión- Errores Comunes y Consejos
- Ejercicios
- 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.
BeanFactory frente a ApplicationContext
BeanFactory frente a ApplicationContextflowchart 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.
- 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.
- 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.
prototype dentro de un singleton: el problema clásico
prototype dentro de un singleton: el problema clásicoEste 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 |
- 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.
@PostConstruct y @PreDestroy en CicloUrbana
@PostConstruct y @PreDestroy en CicloUrbanaVamos 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:
- Precargar en un
ApplicationReadyEventen lugar de en@PostConstruct: se publica después de los runners. - Poblar el repositorio antes, en el propio
@PostConstructde un bean del que la caché dependa. - 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
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)
- 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:
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.
BeanPostProcessor: el punto de extensión
BeanPostProcessor: el punto de extensiónUn 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:
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 msDos 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 singletonComentario: 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 correctaY 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
- ¿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
