Spring Framework y Spring Boot 3: del contenedor IoC a una API REST de producción
Este es el módulo más importante del plan y también el que más gente aprueba sin entender. Se puede escribir una API con Spring Boot copiando anotaciones de Stack Overflow, y funcionará… hasta el primer incidente a las tres de la mañana. Aquí vamos a abrir la caja: qué hace exactamente el contenedor, por qué existen los proxies, de dónde sale la autoconfiguración, cómo se diseña una API que aguanta clientes reales y qué hay que configurar antes de desplegar. Al terminar deberías ser capaz de explicar cualquier «magia» de Spring en términos de objetos Java normales.
1 · Qué es Spring y qué problema resolvió
Para entender Spring hay que entender contra qué nació. En 2002, montar una aplicación empresarial en Java significaba EJB 2: por cada componente de negocio escribías una interfaz remota, una interfaz home, la clase de implementación y dos o tres descriptores XML; heredabas de clases del servidor de aplicaciones (por lo que no podías probar nada sin arrancar el servidor, y arrancarlo tardaba minutos); y el despliegue estaba atado a WebLogic, WebSphere o JBoss.
Rod Johnson publicó Expert One-on-One J2EE Design and Development con una tesis incómoda para la
época: la mayor parte de esa infraestructura no aportaba valor y se podía sustituir por objetos Java
normales (POJOs) coordinados por un contenedor ligero. Ese código de ejemplo se convirtió en Spring
Framework 1.0 (2004). La idea que lo cambió todo no fue técnica sino de diseño: tu código de negocio no
debe depender del framework. Spring se encarga de crear objetos, conectarlos y decorarlos con
transacciones, seguridad o caché; tu clase sigue siendo una clase que puedes instanciar con new en
un test.
1.1 Del XML infinito a «cero configuración»
El propio Spring pasó por su etapa de exceso de ceremonia. Merece la pena verlo porque explica por qué las anotaciones que hoy usas sin pensar existen.
2004 Spring 1.x IoC + AOP + JDBC/ORM templates. Configuración: XML, mucho XML.
2006 Spring 2.x Espacios de nombres XML (<tx:annotation-driven/>), AspectJ.
2009 Spring 3.x Anotaciones y @Configuration (JavaConfig), SpEL, REST en MVC. Java 5+.
2013 Spring 4.x Java 8, WebSocket, @RestController, soporte de genéricos en inyección.
2014 Boot 1.x ¡Autoconfiguración, starters, servidor embebido, Actuator! Fin del WAR.
2017 Spring 5.x Reactivo (WebFlux, Reactor), Kotlin, Java 8 baseline.
2018 Boot 2.x Micrometer, Actuator 2, HikariCP por defecto, configuración relajada.
2022 Spring 6.0 Java 17 baseline · javax → jakarta · AOT y GraalVM · ProblemDetail · Observability
Boot 3.0 Todo lo anterior + imagen nativa de primera clase.
2023 Boot 3.1/3.2 Docker Compose y Testcontainers en dev · RestClient · virtual threads (Java 21).
2024 Boot 3.3/3.4 CDS para arranque rápido · @MockitoBean · métricas y tracing más finos.
2025+ Spring 7 / Boot 4 Java 17 mínimo (recomendado 21+), API HTTP unificada, más AOT.
1.2 Spring Framework vs Spring Boot vs Spring Cloud
Es la primera pregunta de casi cualquier entrevista y muchísima gente la responde mal. Son tres capas que se apilan, no tres alternativas.
| Proyecto | Qué aporta | Ejemplos concretos | Analogía |
|---|---|---|---|
| Spring Framework | El núcleo: contenedor IoC/DI, AOP, abstracción de transacciones, MVC, WebFlux, acceso a datos, validación, SpEL, planificación. | ApplicationContext, @Component, @Transactional,
DispatcherServlet, JdbcTemplate, RestClient. |
El motor y el chasis. |
| Spring Boot | Opinión y ergonomía sobre el núcleo: autoconfiguración, starters, servidor embebido, configuración externalizada, Actuator, empaquetado ejecutable. | @SpringBootApplication, spring-boot-starter-web,
application.yml, /actuator/health, java -jar app.jar. |
El coche montado, con el cuadro de mandos y la llave puesta. |
| Spring Cloud | Patrones de sistemas distribuidos sobre Boot: descubrimiento, configuración centralizada, gateway, resiliencia, mensajería, trazas distribuidas. | Spring Cloud Config, Gateway, OpenFeign,
Resilience4j, Stream, Sleuth→Micrometer Tracing. |
La red de carreteras, las señales y la grúa. |
1.3 Versiones, baseline de Java y el salto javax → jakarta
El cambio más disruptivo de los últimos diez años en el ecosistema Java no fue técnico, fue legal. Cuando Oracle
donó Java EE a la Eclipse Foundation, no cedió la marca Java: la especificación pasó a llamarse
Jakarta EE y, a partir de Jakarta EE 9, todos los paquetes javax.*
pasaron a jakarta.*. No es un alias ni hay compatibilidad hacia atrás: son clases con otro
nombre completamente cualificado.
// ❌ Spring Boot 2.x / Java EE — NO compila en Boot 3
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.validation.constraints.NotBlank;
import javax.servlet.http.HttpServletRequest;
import javax.annotation.PostConstruct;
// ✅ Spring Boot 3.x / Jakarta EE 9+
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.validation.constraints.NotBlank;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.annotation.PostConstruct;
import. Cada librería de
terceros que use la API de servlets, JPA, validación o JMS necesita una versión compilada contra
jakarta. Si una dependencia antigua no la tiene, se queda fuera. Herramientas que ayudan:
OpenRewrite con la receta
UpgradeSpringBoot_3_x (reescribe imports y configuración automáticamente) y el
spring-boot-properties-migrator, que avisa en el arranque de las propiedades renombradas.
| Versión | Java mínimo | Namespace | Estado en 2026 | Nota |
|---|---|---|---|---|
| Boot 2.7 | 8 | javax | Fin de soporte comercial | Migrar ya; es deuda técnica con fecha de caducidad. |
| Boot 3.0–3.1 | 17 | jakarta | Sin soporte OSS | 3.1 trae RestClient y Docker Compose en dev. |
| Boot 3.2 | 17 | jakarta | Legado | Virtual threads con spring.threads.virtual.enabled. |
| Boot 3.3–3.5 | 17 (21 recomendado) | jakarta | Objetivo razonable hoy | CDS, @MockitoBean, mejoras de observabilidad. |
| Boot 4 / Spring 7 | 17+ (21/25 recomendado) | jakarta | Actual | API HTTP unificada, más AOT, módulos reorganizados. |
Recomendación para el plan: estudia y practica con Spring Boot 3.5.x sobre Java 21. Es lo que más vas a encontrar en entrevistas y en proyectos reales durante 2026, tiene virtual threads, y todo lo que aprendas se traslada casi literalmente a Boot 4. El detalle de las novedades más recientes está en el módulo 11.
2 · Inversión de control y el contenedor
2.1 Qué es IoC y por qué te importa
Inversión de control significa que el control sobre la creación y el ensamblado de los objetos pasa de tu código al contenedor. La inyección de dependencias es la técnica concreta con la que Spring lo consigue: en lugar de que un objeto busque o construya lo que necesita, se lo entregan.
// ❌ SIN IoC: la clase decide qué implementación usa y cómo se construye
public class ServicioPedidos {
private final RepositorioPedidos repo = new RepositorioPedidosPostgres("jdbc:...", "user", "pass");
private final PasarelaPago pasarela = new PasarelaStripe("sk_live_..."); // ¡en el código!
// Problemas: (1) para un test necesito Postgres y Stripe de verdad;
// (2) cambiar de pasarela obliga a editar esta clase;
// (3) las credenciales están en el código fuente;
// (4) cada instancia abre su propia conexión.
}
// ✅ CON IoC: la clase declara QUÉ necesita, no CÓMO se obtiene
@Service
public class ServicioPedidos {
private final RepositorioPedidos repo; // interfaz: no sé quién la implementa
private final PasarelaPago pasarela;
public ServicioPedidos(RepositorioPedidos repo, PasarelaPago pasarela) {
this.repo = repo;
this.pasarela = pasarela;
}
// En producción entra la implementación real; en un test, un doble.
// La clase de negocio no ha cambiado ni una línea.
}
La analogía que suele funcionar: el restaurante.
Sin IoC eres un cocinero que, para cada plato, sale a comprar los ingredientes, elige el proveedor, negocia el precio y friega la sartén. Sabes cocinar, pero el 80% de tu tiempo se va en logística, y si el proveedor cierra tienes que reescribir la receta.
Con IoC eres un cocinero en una cocina profesional: escribes en la receta «necesito 200 g de harina de fuerza y un horno a 200°». Alguien (el contenedor) se encarga de que la harina y el horno estén ahí cuando empiezas. Puedes probar la receta con harina de otro proveedor sin cambiar la receta. Y si el restaurante decide comprar harina ecológica, tú no te enteras.
La inversión del nombre es esa: antes tú llamabas a la infraestructura; ahora la infraestructura te construye y te llama a ti. En la literatura se llama también el principio de Hollywood: «no nos llames, nosotros te llamamos».
El beneficio real no es escribir menos new. Es este:
- Testabilidad: una clase con dependencias inyectadas se prueba con objetos falsos en milisegundos y sin arrancar nada.
- Sustituibilidad: cambiar de proveedor de pagos es añadir una implementación, no editar el dominio (principio abierto/cerrado).
- Ciclo de vida gestionado: el contenedor cierra pools, ejecuta hooks de apagado y reutiliza instancias caras.
- Punto de intercepción: como el contenedor construye los objetos, puede envolverlos en
proxies. De ahí salen
@Transactional,@Cacheable,@Asyncy la seguridad a nivel de método.
2.2 ApplicationContext vs BeanFactory
Un bean es simplemente un objeto que el contenedor crea y gestiona. El contenedor tiene dos interfaces principales, y en una entrevista te pueden preguntar la diferencia.
BeanFactory | ApplicationContext | |
|---|---|---|
| Rol | Contenedor mínimo: registrar y obtener beans, DI y ciclo de vida básico. | Superinterfaz de BeanFactory con todo lo «empresarial». |
| Instanciación | Perezosa (bajo demanda). | Anticipada: crea los singletons al arrancar y falla rápido si algo está mal configurado. |
| Extras | Ninguno. | Publicación de eventos, internacionalización (MessageSource), acceso a recursos (Resource), jerarquía de contextos, detección automática de BeanPostProcessor. |
| Cuándo lo usas | Casi nunca directamente (entornos con memoria muy limitada). | Siempre. Es lo que devuelve SpringApplication.run(...). |
@SpringBootApplication
public class TiendaApplication {
public static void main(String[] args) {
// run() devuelve el ApplicationContext ya arrancado
ConfigurableApplicationContext contexto = SpringApplication.run(TiendaApplication.class, args);
// Útil solo para depurar y aprender: en código de negocio esto es un ANTIPATRÓN
System.out.println("Beans registrados: " + contexto.getBeanDefinitionCount());
Arrays.stream(contexto.getBeanDefinitionNames())
.filter(n -> n.startsWith("com.tienda"))
.sorted()
.forEach(System.out::println);
}
}
contexto.getBean(ServicioPagos.class)
dentro de la lógica de negocio deshace todo lo bueno de la inyección: la dependencia se vuelve invisible, no la
puedes sustituir en un test y el compilador ya no te ayuda. Si necesitas resolver una implementación en tiempo
de ejecución, inyecta un Map<String, Estrategia> o un ObjectProvider (sección 2.5).
La instanciación anticipada de los singletons merece una nota, porque es una decisión de diseño deliberada: es preferible que la aplicación no arranque si falta una propiedad o hay un bean ambiguo, a que arranque y falle a las tres horas con la primera petición que toque ese camino. Es la filosofía de fail fast aplicada al despliegue.
2.3 Declarar beans: estereotipos y @Bean
Hay dos formas de decirle a Spring «esto es un bean», y no son intercambiables.
| Anotación | Dónde va | Semántica añadida | Cuándo usarla |
|---|---|---|---|
@Component | Clase | Ninguna: bean genérico. | Componentes técnicos: mappers, utilidades con estado inyectado, adaptadores. |
@Service | Clase | Documental: lógica de negocio / caso de uso. | Servicios de aplicación. Es un @Component con intención. |
@Repository | Clase | Sí la tiene: activa la traducción de excepciones de persistencia a la jerarquía DataAccessException. | Acceso a datos. En Spring Data lo pone el propio framework. |
@Controller | Clase | Sí: la detecta RequestMappingHandlerMapping. | MVC con vistas. Para APIs, @RestController (= @Controller + @ResponseBody). |
@Configuration | Clase | Sí: la clase se proxifica para que los métodos @Bean respeten el scope. | Definir beans a mano y agrupar configuración. |
@Bean | Método de una @Configuration | El valor devuelto se registra como bean. | Clases de terceros que no puedes anotar, o construcción con lógica. |
// ── Opción A: estereotipo + escaneo. Para TU código ──────────────────────────
@Service
public class CalculadoraIva {
private final BigDecimal tipoGeneral;
public CalculadoraIva(@Value("${tienda.iva.general:0.21}") BigDecimal tipoGeneral) {
this.tipoGeneral = tipoGeneral;
}
public Dinero aplicar(Dinero base) { return base.multiplicar(BigDecimal.ONE.add(tipoGeneral)); }
}
// ── Opción B: @Bean en una @Configuration. Para clases de TERCEROS o con lógica ──
@Configuration(proxyBeanMethods = false) // false: más rápido si los @Bean no se llaman entre sí
public class ClientesConfig {
@Bean
public RestClient clienteAlmacen(RestClient.Builder builder,
AlmacenProperties props) {
// No podemos anotar RestClient (es de Spring) y necesitamos lógica de construcción
return builder
.baseUrl(props.url())
.requestFactory(factoriaConTimeouts(props.conexion(), props.lectura()))
.defaultHeader("X-Origen", "tienda-api")
.build();
}
@Bean
ClientHttpRequestFactory factoriaConTimeouts(Duration conexion, Duration lectura) {
var settings = ClientHttpRequestFactorySettings.DEFAULTS
.withConnectTimeout(conexion)
.withReadTimeout(lectura);
return ClientHttpRequestFactories.get(settings);
}
// Un @Bean puede ser condicional, elegir implementación y declarar destrucción
@Bean(destroyMethod = "close")
@ConditionalOnProperty(name = "tienda.metricas.exportador", havingValue = "otlp")
OtlpMeterRegistry registroOtlp(OtlpConfig config) {
return new OtlpMeterRegistry(config, Clock.SYSTEM);
}
}
@Configuration: por defecto Spring crea un proxy CGLIB de la clase de
configuración para que, si un método @Bean llama a otro, se devuelva el singleton ya
creado en lugar de un objeto nuevo. Si tus métodos @Bean no se llaman entre sí, pon
@Configuration(proxyBeanMethods = false): te ahorras la creación del proxy y aceleras el arranque.
Es lo que hacen todas las autoconfiguraciones de Boot.
2.4 Escaneo de componentes: dónde busca Spring
@SpringBootApplication incluye un @ComponentScan sin argumentos, y eso significa
«escanea el paquete de esta clase y todos sus subpaquetes». De ahí la regla que evita el 90% de los
NoSuchBeanDefinitionException de los principiantes: la clase principal va en la raíz del
paquete base.
✅ CORRECTO ❌ INCORRECTO
com.tienda com.tienda.config
├── TiendaApplication.java ← raíz └── TiendaApplication.java ← ¡escondida!
├── pedidos/ com.tienda
│ ├── PedidoController.java ├── pedidos/ ← NO se escanea
│ └── PedidoService.java └── catalogo/ ← NO se escanea
└── catalogo/
└── CatalogoService.java Síntoma: "No qualifying bean of type PedidoService"
// Casos en los que sí hay que tocar el escaneo (pocos y bien justificados)
@SpringBootApplication(scanBasePackages = { "com.tienda", "com.corporacion.auditoria" })
public class TiendaApplication { }
// Excluir por tipo o por patrón: útil en tests o para desactivar un módulo
@ComponentScan(
basePackages = "com.tienda",
excludeFilters = @ComponentScan.Filter(type = FilterType.REGEX, pattern = "com\\.tienda\\.legacy\\..*")
)
class ConfiguracionAcotada { }
com o com.empresa: Spring
recorrerá miles de clases de todos los jars, el arranque se irá a decenas de segundos y acabarás registrando
beans que no querías. Si necesitas compartir componentes entre servicios, la solución correcta es un
starter propio con autoconfiguración (sección 3.6), no un escaneo global.
2.5 Tipos de inyección: por qué el constructor siempre gana
// ── ✅ 1. POR CONSTRUCTOR (la única que deberías usar) ───────────────────────
@Service
public class ServicioPedidos {
private final RepositorioPedidos repo; // final: inmutable y visible entre hilos
private final PasarelaPago pasarela;
// Desde Spring 4.3 @Autowired es OPCIONAL si hay un solo constructor
public ServicioPedidos(RepositorioPedidos repo, PasarelaPago pasarela) {
this.repo = Objects.requireNonNull(repo);
this.pasarela = Objects.requireNonNull(pasarela);
}
}
// Con Lombok, sin boilerplate y sin perder ninguna ventaja
@Service
@RequiredArgsConstructor // genera el constructor con todos los campos final
public class ServicioFacturas {
private final RepositorioFacturas repo;
private final CalculadoraIva iva;
}
// ── ⚠️ 2. POR SETTER: solo para dependencias realmente OPCIONALES ────────────
@Service
public class ServicioNotificaciones {
private Notificador push = Notificador.noOperativo(); // valor por defecto sensato
@Autowired(required = false)
public void setPush(Notificador push) { this.push = push; }
}
// ── ❌ 3. POR CAMPO: cómoda de escribir, cara de mantener ────────────────────
@Service
public class ServicioMalo {
@Autowired private RepositorioPedidos repo; // no puede ser final
@Autowired private PasarelaPago pasarela; // dependencia oculta
@Autowired private ApplicationContext contexto; // ya de paso, un service locator
}
Los seis argumentos contra la inyección por campo, en orden de importancia:
- Oculta el coste de la clase. Un constructor con nueve parámetros grita «esta clase hace
demasiado»; nueve
@Autowiredpasan desapercibidos. La firma del constructor es tu métrica de cohesión gratuita. - Impide
final. Sinfinalno hay inmutabilidad ni garantías de visibilidad entre hilos, y cualquiera puede reasignar el campo por reflexión o por error. - Ata la clase a Spring. Con constructor,
new ServicioPedidos(fake, fake)en un test unitario funciona. Con inyección por campo necesitas un contexto de Spring o reflexión, y tus tests pasan de milisegundos a segundos. - Esconde las dependencias circulares hasta que explotan en tiempo de ejecución, en lugar de fallar en el arranque.
- No puedes validar en construcción. Con constructor puedes lanzar si un parámetro no cumple una condición; con campo, el objeto existe a medio construir.
- Invita al service locator: cuando inyectar es «gratis», acaba entrando el
ApplicationContext.
@Autowired en campos con una regla de ArchUnit o Checkstyle en el
pipeline. Es una de las poquísimas reglas automáticas que mejora la arquitectura sin discusión:
noFields().should().beAnnotatedWith(Autowired.class).
2.6 Varios candidatos: @Qualifier, @Primary, @Order
Cuando hay dos beans del mismo tipo, Spring no adivina: falla con
NoUniqueBeanDefinitionException. Tienes cuatro formas de resolverlo, de mejor a peor.
public interface PasarelaPago { Recibo cobrar(Dinero importe, Tarjeta tarjeta); }
@Component("stripe") class PasarelaStripe implements PasarelaPago { /* ... */ }
@Component("redsys") class PasarelaRedsys implements PasarelaPago { /* ... */ }
// ── Opción 1 (la mejor): @Qualifier TIPADO con una anotación propia ──────────
@Qualifier
@Retention(RetentionPolicy.RUNTIME)
@Target({ ElementType.TYPE, ElementType.PARAMETER, ElementType.METHOD, ElementType.FIELD })
public @interface Nacional { }
@Component @Nacional
class PasarelaRedsysTipada implements PasarelaPago { /* ... */ }
@Service
class ServicioCobros {
private final PasarelaPago pasarela;
// El compilador y el IDE entienden @Nacional; un String mal escrito solo falla en runtime
ServicioCobros(@Nacional PasarelaPago pasarela) { this.pasarela = pasarela; }
}
// ── Opción 2: @Qualifier con nombre (frágil pero muy usada) ──────────────────
@Service
class ServicioCobrosPorNombre {
ServicioCobrosPorNombre(@Qualifier("stripe") PasarelaPago pasarela) { /* ... */ }
}
// ── Opción 3: @Primary para "el habitual" y @Qualifier para la excepción ─────
@Component @Primary
class PasarelaStripePrimaria implements PasarelaPago { /* ... */ }
// ── Opción 4 (❌): renombrar el parámetro para que coincida con el nombre del bean
// Funciona porque Spring cae de vuelta al nombre... hasta que compilas sin -parameters
// o alguien renombra la variable en un refactor. No lo hagas.
Inyectar todas las implementaciones es a menudo mejor que elegir una: es el patrón Strategy sin
switch.
@Service
public class DespachadorDePagos {
private final Map<String, PasarelaPago> porNombre; // clave = nombre del bean
private final List<ValidadorPago> validadores; // ordenados por @Order
public DespachadorDePagos(Map<String, PasarelaPago> porNombre, List<ValidadorPago> validadores) {
this.porNombre = porNombre;
this.validadores = validadores; // ¡el orden de la lista lo decide @Order!
}
public Recibo cobrar(String proveedor, Dinero importe, Tarjeta tarjeta) {
validadores.forEach(v -> v.validar(importe, tarjeta));
PasarelaPago pasarela = porNombre.get(proveedor);
if (pasarela == null) throw new ProveedorNoSoportado(proveedor);
return pasarela.cobrar(importe, tarjeta);
}
}
@Component @Order(1) class ValidadorImporteMaximo implements ValidadorPago { /* ... */ }
@Component @Order(2) class ValidadorPaisPermitido implements ValidadorPago { /* ... */ }
@Component @Order(Ordered.LOWEST_PRECEDENCE) class ValidadorAntifraude implements ValidadorPago { }
Y para dependencias que pueden no existir, ObjectProvider es la herramienta correcta:
@Service
public class ServicioAuditoria {
private final ObjectProvider<ExportadorSiem> exportador; // puede haber 0, 1 o N
public ServicioAuditoria(ObjectProvider<ExportadorSiem> exportador) {
this.exportador = exportador;
}
public void registrar(Evento evento) {
guardarEnBd(evento);
// getIfAvailable: null si no hay bean; ifAvailable: lambda solo si existe
exportador.ifAvailable(e -> e.enviar(evento));
// Otras variantes útiles:
// exportador.getIfUnique() → null si hay más de uno
// exportador.getObject() → obtiene una instancia NUEVA si es prototype
// exportador.orderedStream() → todos, ordenados por @Order, de forma perezosa
}
}
Optional: Optional<MiBean> como parámetro de constructor
también funciona y resuelve el caso «puede no haber ninguno», pero ObjectProvider además te da
resolución perezosa (no fuerza la creación al arrancar), soporte para prototypes y
acceso ordenado a varios candidatos. Para un solo bean opcional, Optional es más legible; para el
resto, ObjectProvider.
2.7 Scopes y la trampa del prototype en un singleton
| Scope | Una instancia por… | Uso real | Cuidado con |
|---|---|---|---|
singleton (por defecto) | Contenedor | El 99% de tus beans: servicios, repositorios, controladores. | Se comparte entre todos los hilos: cero estado mutable. |
prototype | Cada petición al contenedor | Objetos con estado de corta vida creados por el contenedor. | Spring no gestiona su destrucción: @PreDestroy no se ejecuta. |
request | Petición HTTP | Datos del usuario de la petición actual. | Necesita scoped proxy; falla fuera de una petición. |
session | Sesión HTTP | Carrito de la compra en apps con estado. | Consume memoria y rompe la escalabilidad horizontal sin sesión distribuida. |
application | ServletContext | Casi nunca; comparte entre varios contextos de Spring. | Difícil de razonar. Mejor un singleton. |
websocket | Sesión WebSocket | Estado por conexión en apps de mensajería. | Fugas si no se cierran las sesiones. |
// ❌ LA TRAMPA CLÁSICA: prototype inyectado en singleton
@Component
@Scope("prototype")
class Cronometro {
private final long inicio = System.nanoTime();
long msTranscurridos() { return (System.nanoTime() - inicio) / 1_000_000; }
}
@Service
class ServicioLento {
private final Cronometro cronometro; // ¡SE INYECTA UNA SOLA VEZ!
ServicioLento(Cronometro cronometro) { this.cronometro = cronometro; }
void procesar() {
// Siempre mide desde el arranque de la aplicación, no desde esta llamada.
// El scope "prototype" no sirve de nada: la inyección ocurrió una única vez.
log.info("tardó {} ms", cronometro.msTranscurridos());
}
}
// ✅ SOLUCIÓN 1: ObjectProvider — pide una instancia nueva cuando la necesitas
@Service
class ServicioLentoOk {
private final ObjectProvider<Cronometro> cronometros;
ServicioLentoOk(ObjectProvider<Cronometro> cronometros) { this.cronometros = cronometros; }
void procesar() {
Cronometro c = cronometros.getObject(); // instancia NUEVA en cada llamada
hacerTrabajo();
log.info("tardó {} ms", c.msTranscurridos());
}
}
// ✅ SOLUCIÓN 2: @Lookup — Spring sobrescribe el método por reflexión
@Service
abstract class ServicioLentoLookup {
@Lookup protected abstract Cronometro nuevoCronometro(); // devuelve uno nuevo cada vez
void procesar() {
Cronometro c = nuevoCronometro();
hacerTrabajo();
log.info("tardó {} ms", c.msTranscurridos());
}
}
// ✅ SOLUCIÓN 3 (la que yo elegiría): no meter el contenedor donde no hace falta
@Service
class ServicioLentoSencillo {
void procesar() {
long t0 = System.nanoTime(); // es un long, no un bean
hacerTrabajo();
log.info("tardó {} ms", (System.nanoTime() - t0) / 1_000_000);
}
}
// Scope de petición con proxy: obligatorio si lo inyectas en un singleton
@Component
@Scope(value = WebApplicationContext.SCOPE_REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS)
public class ContextoPeticion {
private String usuario;
private String traceId;
// getters/setters
}
// El proxy resuelve la instancia correcta en cada llamada, pero:
// · lanza IllegalStateException("No thread-bound request found") si se usa desde
// un @Scheduled, un hilo @Async o un consumidor de Kafka;
// · añade una indirección por llamada.
// Regla práctica: PASA EL DATO COMO PARÁMETRO en lugar de inyectar contexto de petición.
private int contador;, un SimpleDateFormat como campo, un StringBuilder
reutilizado o una List que se va rellenando. Bajo carga, dos peticiones pisan los datos de la otra
y aparecen bugs imposibles de reproducir en local. Si de verdad necesitas un contador, usa
AtomicLong o —mejor— una métrica de Micrometer (sección 10.3).
2.8 Ciclo de vida completo de un bean
Este diagrama explica de dónde salen los proxies, por qué @PostConstruct ve las dependencias ya
inyectadas y por qué algunas anotaciones no funcionan si te llamas a ti mismo. Merece la pena memorizarlo.
┌────────────────────────────────────────────────────────────────────────────────┐
│ CICLO DE VIDA DE UN BEAN SINGLETON │
└────────────────────────────────────────────────────────────────────────────────┘
[1] Lectura de definiciones @Component escaneados, @Bean, imports de
(BeanDefinition) autoconfiguración → aún NO hay objetos
│
▼
[2] BeanFactoryPostProcessor Puede MODIFICAR las definiciones antes de crear
(p. ej. PropertySourcesPlaceholderConfigurer resuelve los ${...})
│
▼
[3] Instanciación new MiBean(dep1, dep2) ← inyección por CONSTRUCTOR
│
▼
[4] Populate properties inyección por setter y por campo (@Autowired, @Value)
│
▼
[5] Interfaces *Aware setBeanName, setBeanClassLoader, setBeanFactory,
setEnvironment, setApplicationContext
│
▼
[6] BeanPostProcessor postProcessBeforeInitialization(bean, nombre)
ANTES → aquí actúa, p. ej., @ConfigurationProperties binding
│
▼
[7] Inicialización a) @PostConstruct (jakarta.annotation)
(en este orden) b) InitializingBean.afterPropertiesSet()
c) initMethod del @Bean
│
▼
[8] BeanPostProcessor postProcessAfterInitialization(bean, nombre)
DESPUÉS ★ AQUÍ NACEN LOS PROXIES ★
@Transactional, @Cacheable, @Async, @PreAuthorize…
│ El contenedor guarda el PROXY, no tu objeto.
▼
[9] BEAN LISTO ────────► se guarda en el caché de singletons y se sirve a quien lo pida
│
▼
[10] ContextRefreshedEvent → ApplicationStartedEvent → ApplicationReadyEvent
│ (ApplicationRunner / CommandLineRunner se ejecutan aquí)
▼
[11] Cierre (SIGTERM, ctx.close()) a) @PreDestroy
b) DisposableBean.destroy()
c) destroyMethod del @Bean
(orden INVERSO al de creación)
⚠️ Los beans "prototype" recorren [3]…[9] en cada petición, pero el contenedor
NO los registra: el paso [11] nunca se ejecuta para ellos.
@Component
public class DemostracionCicloDeVida implements InitializingBean, DisposableBean, BeanNameAware {
private final AlmacenRemoto almacen;
private volatile boolean listo;
// [3] Constructor: las dependencias YA están disponibles. Ideal para validar.
public DemostracionCicloDeVida(AlmacenRemoto almacen) {
this.almacen = Objects.requireNonNull(almacen, "almacen es obligatorio");
log.info("[3] constructor");
// ❌ NO hagas trabajo pesado aquí (conexiones, precarga): retrasa el arranque
// y el objeto todavía no está proxificado ni completamente configurado.
}
// [5] Aware: rara vez necesario; acopla tu clase al framework
@Override public void setBeanName(String name) { log.info("[5] me llamo {}", name); }
// [7a] Buen sitio para inicializar caché local o validar configuración
@PostConstruct
void inicializar() {
log.info("[7a] @PostConstruct");
// ⚠️ Aquí THIS NO ESTÁ PROXIFICADO todavía: llamar a un método @Transactional
// o @Async propio desde aquí NO aplica el aspecto (el proxy nace en [8]).
}
@Override public void afterPropertiesSet() { log.info("[7b] afterPropertiesSet"); }
// [10] El sitio correcto para trabajo pesado o que necesite la app completamente arriba
@EventListener(ApplicationReadyEvent.class)
void alEstarLista() {
log.info("[10] ApplicationReadyEvent: precargando caché");
almacen.precargar();
this.listo = true;
}
// [11a] Liberar recursos. Se ejecuta con el apagado ORDENADO (SIGTERM), no con kill -9
@PreDestroy
void cerrar() {
log.info("[11a] @PreDestroy: cerrando conexiones");
almacen.cerrar();
}
@Override public void destroy() { log.info("[11b] destroy()"); }
}
@Transactional,
@Cacheable, @Async, @Retryable y la seguridad a nivel de método vive en
ese proxy. Cuando entiendes esto, la sección de AOP (7) y la mitad de los errores comunes (15) dejan de ser
misteriosos.
2.9 BeanFactoryPostProcessor, BeanPostProcessor, @Lazy y FactoryBean
| Extensión | Actúa sobre | Cuándo | Ejemplo en Spring |
|---|---|---|---|
BeanFactoryPostProcessor | Las definiciones (metadatos) | Paso [2], antes de crear nada | PropertySourcesPlaceholderConfigurer, ConfigurationClassPostProcessor |
BeanPostProcessor | Las instancias ya creadas | Pasos [6] y [8] | AutowiredAnnotationBeanPostProcessor, AnnotationAwareAspectJAutoProxyCreator |
// BeanPostProcessor propio: envolver todos los repositorios en un cronómetro
@Component
public class CronometroDeRepositorios implements BeanPostProcessor {
private final MeterRegistry registro;
// ⚠️ IMPORTANTE: un BeanPostProcessor se crea MUY pronto. Si inyectas beans de
// negocio por constructor, forzarás su creación anticipada y verás el aviso
// "is not eligible for getting processed by all BeanPostProcessors".
// Con ObjectProvider la resolución es perezosa y el problema desaparece.
public CronometroDeRepositorios(ObjectProvider<MeterRegistry> registro) {
this.registro = registro.getObject();
}
@Override
public Object postProcessAfterInitialization(Object bean, String nombre) {
if (!(bean instanceof Repositorio)) return bean;
return Proxy.newProxyInstance(
bean.getClass().getClassLoader(),
bean.getClass().getInterfaces(),
(proxy, metodo, args) -> {
Timer.Sample muestra = Timer.start(registro);
try {
return metodo.invoke(bean, args);
} finally {
muestra.stop(registro.timer("repositorio.duracion",
"clase", bean.getClass().getSimpleName(),
"metodo", metodo.getName()));
}
});
}
}
// BeanFactoryPostProcessor: cambiar una definición sin tocar el código original
@Component
class ForzarLazyEnLegacy implements BeanFactoryPostProcessor {
@Override
public void postProcessBeanFactory(ConfigurableListableBeanFactory factory) {
for (String nombre : factory.getBeanDefinitionNames()) {
BeanDefinition def = factory.getBeanDefinition(nombre);
if (String.valueOf(def.getBeanClassName()).contains(".legacy.")) {
def.setLazyInit(true); // no se crea hasta que alguien lo pida
}
}
}
}
// @Lazy: retrasar la creación hasta el primer uso
@Component
@Lazy // este bean no se crea al arrancar
class GeneradorDeInformesPesado {
GeneradorDeInformesPesado() { cargarPlantillasDe30Mb(); }
}
@Service
class ServicioInformes {
// @Lazy en el punto de inyección: se inyecta un proxy y el bean real se crea al primer uso
ServicioInformes(@Lazy GeneradorDeInformesPesado generador) { /* ... */ }
}
// FactoryBean: cuando la construcción del objeto es un algoritmo, no un new
@Component("clienteSap")
class ClienteSapFactoryBean implements FactoryBean<ClienteSap> {
private final SapProperties props;
ClienteSapFactoryBean(SapProperties props) { this.props = props; }
@Override public ClienteSap getObject() throws Exception {
return ClienteSap.builder()
.destino(props.destino())
.certificado(cargarCertificado(props.rutaCertificado()))
.reintentos(props.reintentos())
.build();
}
@Override public Class<?> getObjectType() { return ClienteSap.class; }
@Override public boolean isSingleton() { return true; }
}
// Al inyectar "clienteSap" recibes un ClienteSap, no el FactoryBean.
// Para obtener la propia factoría: contexto.getBean("&clienteSap")
// Nota: hoy un método @Bean cubre el 95% de estos casos y es mucho más legible.
// FactoryBean sigue siendo útil para integraciones y para librerías (lo usa Spring Data
// internamente para crear las implementaciones de tus interfaces de repositorio).
2.10 Dependencias circulares: por qué Spring Boot 3 se niega
A necesita B en su constructor ┌─────┐ necesita ┌─────┐
B necesita A en su constructor │ A │ ──────────► │ B │
└─────┘ ◄────────── └─────┘
Para construir A hago falta B, y para construir B hago falta A.
No hay ningún orden válido: el grafo tiene un ciclo. Spring falla al arrancar.
Con inyección por campo o setter el ciclo se puede resolver a medias (Spring inyecta un objeto a medio construir), y eso es peor que fallar: obtienes un bean que funciona casi siempre. Desde Spring Boot 2.6 los ciclos están prohibidos por defecto, y eso es una buena noticia: un ciclo casi nunca es un problema técnico, es una señal de que dos clases comparten una responsabilidad que no habéis nombrado.
// ❌ EL CICLO
@Service
class ServicioUsuarios {
private final ServicioNotificaciones notificaciones;
ServicioUsuarios(ServicioNotificaciones notificaciones) { this.notificaciones = notificaciones; }
void registrar(Usuario u) { guardar(u); notificaciones.bienvenida(u); }
Usuario buscar(String id) { return repo.findById(id).orElseThrow(); }
}
@Service
class ServicioNotificaciones {
private final ServicioUsuarios usuarios; // ← ciclo: solo para leer las preferencias
ServicioNotificaciones(ServicioUsuarios usuarios) { this.usuarios = usuarios; }
void bienvenida(Usuario u) {
var prefs = usuarios.buscar(u.id()).preferencias();
enviar(u.email(), plantilla(prefs));
}
}
// ✅ SOLUCIÓN 1 (la mejor): romper el ciclo pasando el dato como parámetro
@Service
class ServicioNotificacionesOk {
void bienvenida(Usuario usuario, Preferencias prefs) { // recibe lo que necesita
enviar(usuario.email(), plantilla(prefs));
}
}
// ✅ SOLUCIÓN 2: extraer la responsabilidad compartida a un tercer bean
@Service
class ConsultaPreferencias { // ni Usuarios ni Notificaciones dependen del otro
Preferencias de(String usuarioId) { /* ... */ }
}
// ✅ SOLUCIÓN 3 (la más elegante en dominios ricos): invertir con un evento
@Service
class ServicioUsuariosEventos {
private final ApplicationEventPublisher eventos;
ServicioUsuariosEventos(ApplicationEventPublisher eventos) { this.eventos = eventos; }
@Transactional
public void registrar(Usuario u) {
repo.guardar(u);
eventos.publishEvent(new UsuarioRegistrado(u.id(), u.email())); // no conoce al oyente
}
}
@Component
class OyenteBienvenida {
@TransactionalEventListener // se ejecuta DESPUÉS del commit
void al(UsuarioRegistrado evento) { correo.bienvenida(evento.email()); }
}
// ⚠️ SOLUCIÓN 4 (parche, no arreglo): @Lazy en uno de los dos lados
@Service
class ServicioNotificacionesParche {
ServicioNotificacionesParche(@Lazy ServicioUsuarios usuarios) { /* ... */ }
}
// ❌ SOLUCIÓN 5 (nunca en un proyecto nuevo): rendirse por configuración
// spring.main.allow-circular-references=true
// Solo como escalón intermedio en una migración desde Boot 2.5 o anterior,
// y con un ticket abierto para eliminarla.
2.11 Eventos de aplicación: desacoplar dentro del proceso
Los eventos de Spring son un observer síncrono por defecto dentro del mismo proceso. Son la forma más barata de desacoplar «lo que pasó» de «lo que hay que hacer cuando pasa», y el paso natural previo a sacar esas reacciones a una cola (módulo 08).
// 1. El evento: un record inmutable. No necesita extender nada desde Spring 4.2
public record PedidoConfirmado(String pedidoId, String clienteId, Dinero total, Instant momento) { }
// 2. El publicador
@Service
public class ConfirmarPedido {
private final RepositorioPedidos pedidos;
private final ApplicationEventPublisher eventos;
public ConfirmarPedido(RepositorioPedidos pedidos, ApplicationEventPublisher eventos) {
this.pedidos = pedidos;
this.eventos = eventos;
}
@Transactional
public void ejecutar(String pedidoId) {
Pedido pedido = pedidos.buscar(pedidoId).orElseThrow(() -> new PedidoNoEncontrado(pedidoId));
pedido.confirmar();
pedidos.guardar(pedido);
eventos.publishEvent(new PedidoConfirmado(pedido.id(), pedido.clienteId(),
pedido.total(), Instant.now()));
}
}
// 3. Los oyentes
@Component
class OyentesDePedido {
// Síncrono y DENTRO de la transacción del publicador.
// Si este método lanza, la transacción del publicador hace ROLLBACK.
@EventListener
void actualizarInventario(PedidoConfirmado evento) { inventario.reservar(evento.pedidoId()); }
// Después del COMMIT: para efectos externos que no deben ocurrir si se deshace todo.
// ¡Esta es la anotación que evita el bug de "le mandé el correo y luego falló el guardado"!
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
void enviarConfirmacion(PedidoConfirmado evento) { correo.confirmacion(evento.clienteId()); }
// Asíncrono: no bloquea al publicador. Necesita @EnableAsync y un executor propio.
// ⚠️ Un oyente @Async NO participa en la transacción del publicador, y sus excepciones
// no llegan a quien publicó: hay que gestionarlas aquí.
@Async("eventosExecutor")
@EventListener
void indexarEnBuscador(PedidoConfirmado evento) {
try { buscador.indexar(evento.pedidoId()); }
catch (Exception e) { log.error("Fallo indexando {}", evento.pedidoId(), e); }
}
// Condicional con SpEL y con orden entre oyentes del mismo evento
@Order(1)
@EventListener(condition = "#evento.total().esMayorQue(1000)")
void avisarAGrandesCuentas(PedidoConfirmado evento) { comercial.avisar(evento); }
}
| Evento del ciclo de vida | Cuándo se publica | Para qué sirve |
|---|---|---|
ApplicationStartingEvent | Antes de casi todo | Configurar logging o banderas del sistema. |
ApplicationEnvironmentPreparedEvent | Environment listo, contexto no | Descifrar propiedades, añadir PropertySource. |
ContextRefreshedEvent | Beans creados | Validaciones que necesiten el contexto completo. |
ApplicationStartedEvent | Contexto refrescado, antes de los runners | Poco habitual. |
ApplicationReadyEvent | Todo listo y aceptando tráfico | El sitio correcto para precargar cachés, avisar a un registro de servicios o lanzar trabajo inicial. |
ApplicationFailedEvent | El arranque falló | Notificar el fallo antes de morir. |
ContextClosedEvent | Cierre ordenado | Vaciar buffers, desregistrarse. |
3 · Spring Boot: la autoconfiguración explicada de verdad
«Spring Boot es magia» es la respuesta que suspende una entrevista. No hay magia: hay un fichero de texto, unas anotaciones condicionales y un orden de evaluación. Vamos a verlo hasta el fondo, porque entenderlo es la diferencia entre configurar por prueba y error y saber exactamente qué está pasando.
3.1 @SpringBootApplication desmontado
// Lo que escribes:
@SpringBootApplication
public class TiendaApplication {
public static void main(String[] args) { SpringApplication.run(TiendaApplication.class, args); }
}
// Lo que significa (es una anotación compuesta):
@SpringBootConfiguration // = @Configuration + marca de "configuración principal"
@EnableAutoConfiguration // activa el mecanismo de autoconfiguración
@ComponentScan( // escanea ESTE paquete y sus subpaquetes
excludeFilters = {
@Filter(type = FilterType.CUSTOM, classes = TypeExcludeFilter.class),
@Filter(type = FilterType.CUSTOM, classes = AutoConfigurationExcludeFilter.class)
})
public @interface SpringBootApplication { }
@SpringBootConfiguration: es un@Configurationnormal, pero solo puede haber uno y los tests lo localizan automáticamente subiendo por el árbol de paquetes (es la razón por la que@SpringBootTest«encuentra» tu aplicación sin que le digas nada).@EnableAutoConfiguration: importa elAutoConfigurationImportSelector, que es el verdadero protagonista de esta sección.@ComponentScan: sin argumentos, escanea desde el paquete de la clase anotada. ElAutoConfigurationExcludeFilterevita que tus clases@Configurationse confundan con autoconfiguraciones, y elTypeExcludeFilteres el que permite que los slices de test (@WebMvcTest) recorten el contexto.
3.2 De spring.factories a AutoConfiguration.imports
Cada starter del ecosistema trae, dentro de su jar, un fichero de texto plano que lista sus clases de
autoconfiguración. En Spring Boot 2.x era META-INF/spring.factories (un
.properties con una clave enorme); en Boot 3.x es un fichero por línea, más rápido de leer y más
fácil de mantener.
spring-boot-autoconfigure-3.5.x.jar
└── META-INF/
└── spring/
└── org.springframework.boot.autoconfigure.AutoConfiguration.imports
├── org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration
├── org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration
├── org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration
├── org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration
└── … (≈ 150 líneas más)
FLUJO COMPLETO DEL ARRANQUE
───────────────────────────
SpringApplication.run()
│
├─► crea el Environment (lee args, variables de entorno, application.yml…)
├─► crea el ApplicationContext
├─► registra la clase principal como BeanDefinition
│
├─► ConfigurationClassPostProcessor procesa @Configuration
│ │
│ ├─ 1. TUS clases @Configuration y @Component ← PRIMERO
│ │
│ └─ 2. @EnableAutoConfiguration
│ └─ AutoConfigurationImportSelector
│ ├─ lee TODOS los AutoConfiguration.imports del classpath
│ ├─ elimina las excluidas (exclude, spring.autoconfigure.exclude)
│ ├─ filtra por @Conditional* ────────► ★ AQUÍ SE DECIDE TODO ★
│ └─ ordena (@AutoConfiguration before/after, @AutoConfigureOrder)
│ └─ registra las supervivientes ← DESPUÉS que las tuyas
│
├─► instancia los singletons (ver ciclo de vida, sección 2.8)
├─► arranca Tomcat y publica los endpoints
└─► ApplicationReadyEvent
@ConditionalOnMissingBean. Por eso «definir tu propio bean»
siempre gana: cuando la autoconfiguración se evalúa, tu bean ya existe y ella se aparta sin decir nada. No hay
que desactivar nada ni pelearse con el framework.
3.3 Las anotaciones @Conditional*
| Anotación | Se cumple si… | Ejemplo real en Boot |
|---|---|---|
@ConditionalOnClass | La clase está en el classpath | DataSourceAutoConfiguration requiere DataSource y EmbeddedDatabaseType. |
@ConditionalOnMissingClass | La clase no está | Elegir una alternativa cuando falta una librería. |
@ConditionalOnBean | Ya existe un bean de ese tipo/nombre | JpaRepositoriesAutoConfiguration necesita un DataSource. |
@ConditionalOnMissingBean | No existe ese bean | El mecanismo que te deja sobrescribir cualquier valor por defecto. |
@ConditionalOnProperty | Una propiedad tiene cierto valor | management.endpoints.web.exposure..., spring.cache.type. |
@ConditionalOnWebApplication | Es web (SERVLET o REACTIVE) | WebMvcAutoConfiguration solo si type = SERVLET. |
@ConditionalOnNotWebApplication | Es una app de consola | Batch, CLI. |
@ConditionalOnResource | Existe un recurso | @ConditionalOnResource(resources = "classpath:banner.txt"). |
@ConditionalOnExpression | Una expresión SpEL es cierta | Condiciones compuestas. |
@ConditionalOnJava | Versión de la JVM | Activar virtual threads solo en Java 21+. |
@ConditionalOnThreading | Plataforma o virtual | Elegir executor según spring.threads.virtual.enabled. |
@ConditionalOnCloudPlatform | Se detecta la plataforma | KUBERNETES para activar las probes de Actuator. |
// Así es (simplificado) una autoconfiguración REAL de Spring Boot.
// Lee el patrón con calma: es el mismo que usarás en tu starter.
@AutoConfiguration
@ConditionalOnClass({ DataSource.class, EmbeddedDatabaseType.class }) // ¿hay JDBC?
@ConditionalOnMissingBean(type = "io.r2dbc.spi.ConnectionFactory") // ¿no es reactivo?
@EnableConfigurationProperties(DataSourceProperties.class)
public class DataSourceAutoConfiguration {
@Configuration(proxyBeanMethods = false)
@ConditionalOnMissingBean(DataSource.class) // ← si tú declaras uno, esto se salta
@ConditionalOnProperty(name = "spring.datasource.type",
havingValue = "com.zaxxer.hikari.HikariDataSource",
matchIfMissing = true) // Hikari es el valor por defecto
static class Hikari {
@Bean
HikariDataSource dataSource(DataSourceProperties propiedades) {
HikariDataSource ds = propiedades.initializeDataSourceBuilder()
.type(HikariDataSource.class).build();
if (StringUtils.hasText(propiedades.getName())) ds.setPoolName(propiedades.getName());
return ds;
}
}
}
3.4 Depurar la autoconfiguración: --debug y el informe de condiciones
# El informe de evaluación de condiciones: la herramienta que casi nadie usa
java -jar app.jar --debug
# o en el yml: debug: true
# o desde el IDE, en los argumentos de programa
# ¿Por qué está mi Redis autoconfigurado / por qué NO lo está?
java -jar app.jar --debug 2>&1 | grep -A 5 "RedisAutoConfiguration"
# En caliente, sin reiniciar (necesita Actuator):
curl -s localhost:8080/actuator/conditions | jq '.contexts.application.positiveMatches | keys'
curl -s localhost:8080/actuator/conditions | jq '.contexts.application.negativeMatches.RedisAutoConfiguration'
# ¿Qué beans hay realmente y quién los creó?
curl -s localhost:8080/actuator/beans | jq '.contexts.application.beans | keys | length'
# ¿De dónde sale este valor de configuración?
curl -s localhost:8080/actuator/env/spring.datasource.url | jq
============================
CONDITIONS EVALUATION REPORT
============================
Positive matches: ← se aplicó, y por qué
-----------------
DataSourceAutoConfiguration matched:
- @ConditionalOnClass found required classes 'javax.sql.DataSource',
'org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType' (OnClassCondition)
JacksonAutoConfiguration#jacksonObjectMapper matched:
- @ConditionalOnMissingBean (types: com.fasterxml.jackson.databind.ObjectMapper;
SearchStrategy: all) did not find any beans (OnBeanCondition)
Negative matches: ← NO se aplicó, y por qué (lo más útil al depurar)
-----------------
RedisAutoConfiguration:
Did not match:
- @ConditionalOnClass did not find required class
'org.springframework.data.redis.core.RedisOperations' (OnClassCondition)
MongoAutoConfiguration:
Did not match:
- @ConditionalOnClass did not find required class 'com.mongodb.client.MongoClient'
Exclusions: ← lo que has excluido tú explícitamente
-----------
org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration
3.5 Excluir y sobrescribir
// 1. Excluir por anotación (verificado en compilación: si te equivocas, no compila)
@SpringBootApplication(exclude = {
SecurityAutoConfiguration.class,
DataSourceAutoConfiguration.class
})
public class TiendaApplication { }
// 2. Excluir por nombre (para clases que no están en el classpath de compilación)
@SpringBootApplication(excludeName = "org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration")
class OtraApp { }
# 3. Excluir por configuración: lo más flexible, se puede hacer por perfil
spring:
autoconfigure:
exclude:
- org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration
- org.springframework.boot.autoconfigure.mail.MailSenderAutoConfiguration
// 4. LA FORMA PREFERIDA: no excluir nada, simplemente declarar tu bean.
// Gracias a @ConditionalOnMissingBean, la autoconfiguración se aparta sola.
@Configuration(proxyBeanMethods = false)
public class JacksonConfig {
@Bean
ObjectMapper objectMapper() { // sustituye al autoconfigurado por completo
return JsonMapper.builder()
.addModule(new JavaTimeModule())
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
.serializationInclusion(JsonInclude.Include.NON_NULL)
.build();
}
}
// 5. MEJOR TODAVÍA que sustituir: PERSONALIZAR con un Customizer.
// Así conservas todo lo que Boot configura por ti y solo cambias lo que te interesa.
@Configuration(proxyBeanMethods = false)
class JacksonPersonalizado {
@Bean
Jackson2ObjectMapperBuilderCustomizer ajustes() {
return builder -> builder
.featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
.serializationInclusion(JsonInclude.Include.NON_NULL)
.simpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX");
}
}
application.yml; (2) un *Customizer o un
WebMvcConfigurer; (3) declarar tu propio bean del tipo concreto; (4)
excluir la autoconfiguración completa. La opción 4 es la que más gente elige primero y casi
siempre es la peor: excluir WebMvcAutoConfiguration para cambiar un formato de fecha te deja sin
conversores, sin recursos estáticos y sin la mitad de MVC.
3.6 Crear tu propio starter, paso a paso
Este es el ejercicio que separa a quien «usa Spring» de quien «entiende Spring». Supongamos que en tu empresa hay quince microservicios y todos necesitan la misma auditoría: registrar quién llama a qué, con qué duración y con qué resultado. Copiar la clase quince veces es deuda técnica; un starter es la solución.
auditoria-spring-boot-starter/ ← el módulo "starter": solo dependencias
├── pom.xml (no lleva código: es un metapaquete)
│
auditoria-spring-boot-autoconfigure/ ← el módulo con el código
├── pom.xml
└── src/main/
├── java/com/corp/auditoria/
│ ├── AuditoriaAutoConfiguration.java
│ ├── AuditoriaProperties.java
│ ├── AuditoriaAspecto.java
│ ├── Auditado.java (la anotación pública)
│ └── ExportadorAuditoria.java (la abstracción, para que se pueda sustituir)
└── resources/META-INF/
├── spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
└── spring-configuration-metadata.json (ayuda del IDE; se genera solo)
CONVENCIÓN DE NOMBRES (importante y muy vigilada por la comunidad):
✅ <nombre>-spring-boot-starter → starters de TERCEROS (el tuyo)
❌ spring-boot-starter-<nombre> → RESERVADO para los oficiales de Spring
<!-- auditoria-spring-boot-autoconfigure/pom.xml (fragmento relevante) -->
<dependencies>
<!-- optional=true es LA CLAVE del patrón: si el usuario no usa AOP,
no le arrastramos la dependencia y nuestra @ConditionalOnClass no se cumple -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<optional>true</optional>
</dependency>
<!-- Genera spring-configuration-metadata.json: autocompletado en el IDE -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
// ── 1. Propiedades: un record inmutable, validado y documentado ──────────────
@ConfigurationProperties(prefix = "corp.auditoria")
@Validated
public record AuditoriaProperties(
/** Activa o desactiva toda la auditoría. Por defecto, activada. */
@DefaultValue("true") boolean habilitada,
/** Destino de los registros: LOG, BD o SIEM. */
@DefaultValue("LOG") Destino destino,
/** Operaciones más lentas que este umbral se registran como WARN. */
@DefaultValue("2s") Duration umbralLento,
/** Tamaño máximo del payload que se guarda; el resto se trunca. */
@DefaultValue("8KB") DataSize payloadMaximo,
/** Campos que NUNCA se registran (se sustituyen por ***). */
@DefaultValue({ "password", "tarjeta", "cvv", "token" }) List<String> camposSensibles,
@NotNull @Valid Reintentos reintentos
) {
public enum Destino { LOG, BD, SIEM }
public record Reintentos(@Min(0) @Max(10) @DefaultValue("3") int maximo,
@DefaultValue("200ms") Duration espera) { }
}
// ── 2. La autoconfiguración: TODO condicional, nada impuesto ─────────────────
@AutoConfiguration
@ConditionalOnClass(Aspect.class) // ¿hay AOP?
@ConditionalOnProperty(prefix = "corp.auditoria", name = "habilitada",
havingValue = "true", matchIfMissing = true) // interruptor general
@EnableConfigurationProperties(AuditoriaProperties.class)
public class AuditoriaAutoConfiguration {
@Bean
@ConditionalOnMissingBean // el usuario puede poner el suyo
public ExportadorAuditoria exportadorAuditoria(AuditoriaProperties props,
ObjectProvider<JdbcTemplate> jdbc) {
return switch (props.destino()) {
case LOG -> new ExportadorLog();
case BD -> new ExportadorJdbc(jdbc.getObject()); // falla claro si falta
case SIEM -> new ExportadorSiem(props);
};
}
@Bean
@ConditionalOnMissingBean
public AuditoriaAspecto auditoriaAspecto(ExportadorAuditoria exportador,
AuditoriaProperties props) {
return new AuditoriaAspecto(exportador, props);
}
// Configuración anidada que solo aplica en aplicaciones web
@Configuration(proxyBeanMethods = false)
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
static class Web {
@Bean
@ConditionalOnMissingBean
FilterRegistrationBean<TraceIdFilter> traceIdFilter() {
var registro = new FilterRegistrationBean<>(new TraceIdFilter());
registro.setOrder(Ordered.HIGHEST_PRECEDENCE);
return registro;
}
}
}
// ── 3. El registro: META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
com.corp.auditoria.AuditoriaAutoConfiguration
// ── 4. Los tests: ApplicationContextRunner, la joya oculta de Spring Boot ────
class AuditoriaAutoConfigurationTest {
private final ApplicationContextRunner runner = new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(AuditoriaAutoConfiguration.class));
@Test
void se_activa_por_defecto() {
runner.run(contexto -> assertThat(contexto).hasSingleBean(AuditoriaAspecto.class));
}
@Test
void se_puede_desactivar() {
runner.withPropertyValues("corp.auditoria.habilitada=false")
.run(contexto -> assertThat(contexto).doesNotHaveBean(AuditoriaAspecto.class));
}
@Test
void el_usuario_puede_sustituir_el_exportador() {
runner.withUserConfiguration(ExportadorPropio.class)
.run(contexto -> assertThat(contexto).getBean(ExportadorAuditoria.class)
.isInstanceOf(MiExportador.class));
}
@Test
void no_se_activa_sin_aop_en_el_classpath() {
runner.withClassLoader(new FilteredClassLoader(Aspect.class))
.run(contexto -> assertThat(contexto).doesNotHaveBean(AuditoriaAspecto.class));
}
@Test
void falla_con_propiedades_invalidas() {
runner.withPropertyValues("corp.auditoria.reintentos.maximo=99")
.run(contexto -> assertThat(contexto).hasFailed()
.getFailure().hasMessageContaining("must be less than or equal to 10"));
}
}
@ConditionalOnMissingBean en cada bean, para
que se pueda sustituir; (3) dependencias optional, para no arrastrar el mundo;
(4) ningún @ComponentScan dentro del starter (rompe el aislamiento y provoca
escaneos inesperados); (5) propiedades con prefijo propio, documentadas y validadas. Y prueba
siempre los cuatro escenarios: activado, desactivado, sustituido y sin la librería.
4 · Estructura del proyecto y herramientas
4.1 Crear el proyecto
# Opción 1: la web (start.spring.io). Lo más común y suficiente.
# Opción 2: la misma API, por curl — automatizable y reproducible
curl https://start.spring.io/starter.zip \
-d type=maven-project \
-d language=java \
-d bootVersion=3.5.0 \
-d javaVersion=21 \
-d groupId=com.tienda \
-d artifactId=tienda-api \
-d packageName=com.tienda \
-d name=tienda-api \
-d dependencies=web,data-jpa,postgresql,validation,actuator,cache,testcontainers,devtools \
-o tienda-api.zip && unzip tienda-api.zip -d tienda-api
# Opción 3: Spring CLI (útil para prototipos rápidos)
sdk install springboot # con SDKMAN!
spring init --build=maven --java-version=21 --dependencies=web,actuator tienda-api
spring --version
# Comprobación inmediata de que todo está en su sitio
cd tienda-api
./mvnw -q spring-boot:run
curl -s localhost:8080/actuator/health | jq
4.2 Estructura de carpetas: por feature o por capa técnica
❌ POR CAPA TÉCNICA (lo que hacen casi todos los tutoriales)
com.tienda
├── controller/ PedidoController, ClienteController, ProductoController, PagoController…
├── service/ PedidoService, ClienteService, ProductoService, PagoService…
├── repository/ PedidoRepository, ClienteRepository, ProductoRepository…
├── model/ Pedido, Cliente, Producto, Pago, Linea, Direccion…
├── dto/ 30 clases mezcladas de entrada y salida
└── util/ el cajón de sastre donde muere la cohesión
Problemas: para tocar UNA funcionalidad abres 5 carpetas; nada se puede hacer
package-private (todo tiene que ser public para verse entre paquetes); es imposible
saber qué se puede borrar; y extraer un módulo o un microservicio es cirugía.
✅ POR FEATURE / DOMINIO (lo que hacen los proyectos que sobreviven)
com.tienda
├── TiendaApplication.java
├── config/ ← configuración transversal, poco y bien
│ ├── JacksonConfig.java
│ ├── CacheConfig.java
│ └── OpenApiConfig.java
├── shared/ ← lo genuinamente común (no un cajón)
│ ├── error/ProblemDetailAdvice.java
│ ├── web/TraceIdFilter.java
│ └── tipos/Dinero.java
├── pedidos/ ← TODO lo de pedidos, junto
│ ├── PedidoController.java (public: es la frontera)
│ ├── CrearPedido.java (package-private: nadie de fuera lo usa)
│ ├── ConfirmarPedido.java
│ ├── Pedido.java
│ ├── PedidoRepository.java
│ ├── dto/CrearPedidoRequest.java
│ ├── dto/PedidoResponse.java
│ └── PedidoNoEncontrado.java
├── catalogo/
└── pagos/
Ventajas: alta cohesión, acoplamiento explícito, encapsulación real con
package-private, y si mañana "pagos" se convierte en un microservicio, ya sabes
qué carpeta mover.
✅✅ HEXAGONAL, para dominios complejos (ver también el módulo 08)
com.tienda.pedidos
├── domain/ Pedido, EstadoPedido, ReglaDescuento ← Java PURO, cero Spring
├── application/ CrearPedido, ConfirmarPedido, puertos ← casos de uso
│ └── port/ RepositorioPedidos (out), CrearPedidoUseCase (in)
└── infrastructure/
├── web/ PedidoController, DTOs
├── persistence/ PedidoJpaEntity, PedidoJpaAdapter
└── messaging/ PedidoEventPublisher
El coste real: más clases y más mapeo. Vale la pena cuando la lógica de negocio
es rica; es sobreingeniería en un CRUD de tres tablas. Sé honesto sobre cuál tienes.
4.3 pom.xml comentado línea a línea
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<!-- ① EL PARENT: la pieza que más trabajo te ahorra.
Aporta: (a) un BOM con las versiones COMPATIBLES de ~400 librerías,
(b) plugins preconfigurados (compiler, surefire, jar, resources),
(c) filtrado de recursos con @...@ en application.yml,
(d) java.version como propiedad única. -->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.0</version>
<relativePath/>
</parent>
<groupId>com.tienda</groupId>
<artifactId>tienda-api</artifactId>
<version>1.0.0-SNAPSHOT</version>
<properties>
<java.version>21</java.version>
<!-- Sobrescribir una versión del BOM: solo con un motivo escrito -->
<springdoc.version>2.6.0</springdoc.version>
<testcontainers.version>1.20.4</testcontainers.version>
</properties>
<dependencies>
<!-- ② STARTERS: agrupaciones de dependencias, SIN versión (la pone el BOM) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<!-- trae: spring-web, spring-webmvc, jackson, tomcat embebido, logging -->
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
<!-- OJO: desde Boot 2.3 NO viene incluido en starter-web. Hay que pedirlo. -->
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
<!-- No es opcional en producción. Ver sección 10. -->
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
<!-- Exportador de métricas para Prometheus -->
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Documentación OpenAPI (esta sí lleva versión: no está en el BOM de Boot) -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>${springdoc.version}</version>
</dependency>
<!-- ③ SCOPES bien puestos -->
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope> <!-- no se compila contra el driver -->
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional> <!-- nunca llega al jar de producción -->
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<!-- JUnit 5, AssertJ, Mockito, Hamcrest, JsonPath, spring-test, awaitility -->
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<!-- ④ BOM adicional para alinear un ecosistema entero -->
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-bom</artifactId>
<version>${testcontainers.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<!-- Genera BOOT-INF/classes/META-INF/build-info.properties,
que Actuator publica en /actuator/info: versión y fecha exactas
del artefacto en producción. Imprescindible para diagnosticar. -->
<image>
<name>registry.corp/tienda-api:${project.version}</name>
</image>
</configuration>
<executions>
<execution><goals><goal>build-info</goal></goals></execution>
</executions>
</plugin>
</plugins>
</build>
</project>
# Metas del spring-boot-maven-plugin que conviene conocer
./mvnw spring-boot:run # arranca con recarga si hay devtools
./mvnw spring-boot:run -Dspring-boot.run.profiles=local
./mvnw package # repackage: crea el "fat jar" ejecutable
./mvnw spring-boot:build-image # imagen OCI con buildpacks, SIN Dockerfile
./mvnw spring-boot:start / spring-boot:stop # arranque en background para tests de integración
# El jar por capas (layered jar): capas ordenadas de menos a más volátil.
# En Docker esto significa que un cambio en tu código NO invalida la capa de dependencias.
java -Djarmode=tools -jar app.jar list-layers
java -Djarmode=tools -jar app.jar extract --destination extraido
# (en Boot 3.2 y anteriores: -Djarmode=layertools ... extract)
# Ver el árbol de dependencias: la herramienta para depurar conflictos de versiones
./mvnw dependency:tree -Dincludes=com.fasterxml.jackson.core
./mvnw dependency:analyze # declaradas y no usadas, usadas y no declaradas
4.4 Ficheros que hay que conocer
src/main/resources/
├── application.yml configuración común a TODOS los entornos
├── application-local.yml perfil de desarrollo (H2, logs a DEBUG, CORS abierto)
├── application-test.yml perfil de test automático
├── application-prod.yml producción (sin secretos: solo referencias)
├── banner.txt el ASCII art del arranque (o spring.main.banner-mode: off)
├── logback-spring.xml logging: usa el "-spring" para tener perfiles y ${...}
├── static/ recursos servidos tal cual en /
├── templates/ plantillas Thymeleaf (si hay vistas)
├── messages.properties i18n (MessageSource)
└── db/migration/V1__esquema.sql Flyway (módulo 05)
<!-- logback-spring.xml: JSON en producción, legible en local. La clave: springProfile -->
<configuration>
<include resource="org/springframework/boot/logging/logback/defaults.xml"/>
<springProperty scope="context" name="appName" source="spring.application.name"/>
<springProfile name="local | test">
<appender name="CONSOLA" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<!-- traceId/spanId los rellena Micrometer Tracing (sección 10.4) -->
<pattern>%d{HH:mm:ss.SSS} %highlight(%-5level) [%15.15thread] [%X{traceId:-}] %cyan(%-40.40logger{39}) : %msg%n</pattern>
</encoder>
</appender>
<root level="INFO"><appender-ref ref="CONSOLA"/></root>
<logger name="com.tienda" level="DEBUG"/>
<logger name="org.hibernate.SQL" level="DEBUG"/>
</springProfile>
<springProfile name="prod">
<!-- Una línea JSON por evento: lo que esperan Loki, Elastic o CloudWatch -->
<appender name="JSON" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
<includeMdcKeyName>traceId</includeMdcKeyName>
<includeMdcKeyName>spanId</includeMdcKeyName>
<customFields>{"servicio":"${appName}"}</customFields>
</encoder>
</appender>
<root level="INFO"><appender-ref ref="JSON"/></root>
</springProfile>
</configuration>
logging.structured.format.console=ecs (o logstash, o gelf) lo hace de
forma nativa. Si empiezas hoy un proyecto, usa eso y ahórrate una dependencia.
4.5 Maven vs Gradle, devtools y recarga
| Maven | Gradle | |
|---|---|---|
| Configuración | XML declarativo, muy predecible | Kotlin/Groovy DSL, muy flexible |
| Velocidad | Suficiente; con -T 1C paraleliza módulos | Más rápido en repos grandes: caché de build e incremental |
| Curva | Baja: todos los proyectos se parecen | Más alta: un build puede ser un programa |
| Cuándo elegirlo | Servicios estándar, equipos grandes, CI simple | Monorepos, builds con lógica, Android, muchos módulos |
Para aprender Spring, Maven. Todos los tutoriales y la mayoría de los proyectos corporativos lo usan, y la
estructura del pom.xml es la misma en todas partes. Usa siempre el wrapper
(./mvnw, ./gradlew) para que la versión de la herramienta esté fijada en el repositorio.
# DevTools: reinicio automático al recompilar (NO es hot swap real: reinicia el contexto,
# pero solo el classloader de tu aplicación, así que tarda ~1 s en lugar de ~5 s).
# En IntelliJ: activa "Build project automatically" y "Allow auto-make while running".
spring:
devtools:
restart:
enabled: true
additional-exclude: static/**,public/**,templates/** # cambios que NO reinician
poll-interval: 2s
quiet-period: 1s
livereload:
enabled: true # refresca el navegador con la extensión LiveReload
# Extra muy cómodo desde Boot 3.1: levanta el docker-compose.yml al arrancar
docker:
compose:
enabled: true
lifecycle-management: start-and-stop
optional y con scope runtime precisamente para que el
repackage lo excluya del jar final; si lo copias como dependencia normal, se irá al contenedor.
5 · Configuración externalizada
Un mismo artefacto tiene que funcionar en local, en integración, en preproducción y en producción sin
recompilar. Esa es la promesa de la configuración externalizada, y es también uno de los doce factores del
manifiesto 12-factor app. Spring Boot lo resuelve con un Environment que agrega varias
fuentes con una prioridad definida.
5.1 Orden de precedencia (de mayor a menor)
| # | Fuente | Ejemplo | Uso típico |
|---|---|---|---|
| 1 | @TestPropertySource y properties de @SpringBootTest | @SpringBootTest(properties = "tienda.iva=0") | Solo tests. |
| 2 | Devtools en ~/.config/spring-boot | spring-boot-devtools.properties | Ajustes personales del desarrollador. |
| 3 | Argumentos de línea de comandos | java -jar app.jar --server.port=9090 | Sobrescribir algo puntual al lanzar. |
| 4 | SPRING_APPLICATION_JSON | SPRING_APPLICATION_JSON='{"tienda":{"iva":0.10}}' | Inyectar un bloque entero en la nube. |
| 5 | ServletConfig / ServletContext | — | Despliegues WAR (raro hoy). |
| 6 | JNDI | java:comp/env | Servidores de aplicaciones legados. |
| 7 | Propiedades del sistema Java | -Dserver.port=9090 | Scripts de arranque. |
| 8 | Variables de entorno | SERVER_PORT=9090 | El estándar en Docker y Kubernetes. |
| 9 | random.* | ${random.uuid} | Puertos y secretos de test. |
| 10 | application-{perfil}.yml fuera del jar | ./config/application-prod.yml | Configuración montada por el operador. |
| 11 | application-{perfil}.yml dentro del jar | Empaquetado | Valores por entorno versionados. |
| 12 | application.yml fuera del jar | ./config/application.yml | Base sobrescrita en el despliegue. |
| 13 | application.yml dentro del jar | Empaquetado | Tu configuración base. |
| 14 | @PropertySource | En una @Configuration | Ficheros heredados. |
| 15 | Valores por defecto | SpringApplication.setDefaultProperties | Última red de seguridad. |
# Traducción de nombres a variables de entorno (relaxed binding):
# spring.datasource.url → SPRING_DATASOURCE_URL
# tienda.pasarela.api-key → TIENDA_PASARELA_APIKEY (los guiones DESAPARECEN)
# tienda.paises[0] → TIENDA_PAISES_0_
# Mayúsculas, puntos → _, guiones eliminados. Solo funciona con @ConfigurationProperties;
# @Value NO tiene relaxed binding.
# Demostración de la precedencia en 30 segundos
export SERVER_PORT=8081
java -jar app.jar # arranca en 8081 (variable de entorno)
java -jar app.jar --server.port=8082 # arranca en 8082 (el argumento gana)
# ¿De dónde sale REALMENTE este valor? La respuesta definitiva:
curl -s localhost:8080/actuator/env/server.port | jq
# El MISMO contenido en formato .properties, por si te lo encuentras en un proyecto antiguo.
# Es equivalente al YAML, solo cambia la sintaxis: cada propiedad con su ruta completa.
spring.application.name=tienda-api
spring.datasource.url=jdbc:postgresql://localhost:5432/tienda
spring.datasource.hikari.maximum-pool-size=10
spring.jpa.open-in-view=false
server.shutdown=graceful
server.error.include-stacktrace=never
management.endpoints.web.exposure.include=health,info,metrics,prometheus
tienda.iva.general=0.21
tienda.pasarela.url=https://api.pasarela.example
tienda.pasarela.conexion=2s
tienda.pasarela.paises-permitidos[0]=ES
tienda.pasarela.paises-permitidos[1]=PT
tienda.pasarela.cabeceras-extra.X-Origen=tienda-api
# El YAML gana en cuanto hay jerarquía o listas largas: menos repetición y menos erratas.
# Elige uno de los dos formatos para todo el proyecto y prohíbe el otro.
application.properties y application.yml, gana el
.properties. Elige un formato y prohíbe el otro con una regla del linter. Segunda trampa:
spring.profiles.active escrito dentro de application-prod.yml no hace nada —un
perfil no puede activarse a sí mismo—; hay que activarlo desde fuera.
5.2 Perfiles
# application.yml — común a todo, con los valores más seguros por defecto
spring:
application:
name: tienda-api
jackson:
default-property-inclusion: non_null
threads:
virtual:
enabled: true # Java 21+
server:
shutdown: graceful # ver sección 12
error:
include-message: never # nunca filtrar detalles internos por defecto
include-stacktrace: never
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
tienda:
iva:
general: 0.21
pasarela:
url: https://api.pasarela.example
conexion: 2s
lectura: 5s
---
# Documento de perfil en el MISMO fichero (sintaxis moderna, Boot 2.4+)
spring:
config:
activate:
on-profile: local
datasource:
url: jdbc:h2:mem:tienda;MODE=PostgreSQL
jpa:
hibernate:
ddl-auto: create-drop
logging:
level:
com.tienda: DEBUG
org.hibernate.SQL: DEBUG
tienda:
pasarela:
url: http://localhost:9999/pasarela-falsa
---
spring:
config:
activate:
on-profile: prod
datasource:
url: ${DB_URL} # obligatorio: si falta, la app NO arranca. Y eso es bueno.
username: ${DB_USER}
password: ${DB_PASSWORD}
jpa:
hibernate:
ddl-auto: validate # NUNCA update ni create en producción
main:
banner-mode: off
# Perfiles COMPUESTOS (grupos): activas uno y se encienden varios
spring:
profiles:
group:
produccion: [ prod, metricas-completas, cache-redis, tracing ]
desarrollo: [ local, datos-de-prueba, swagger ]
# Con SPRING_PROFILES_ACTIVE=produccion se activan los cuatro.
# Importar ficheros extra (Boot 2.4+): muy útil para secretos montados
spring:
config:
import:
- optional:file:./config/local.yml # "optional:" evita fallar si no existe
- optional:configtree:/run/secrets/ # un fichero por propiedad (Docker/K8s secrets)
- optional:configserver:http://config:8888
// @Profile en beans: la forma limpia de sustituir infraestructura por entorno
@Configuration(proxyBeanMethods = false)
public class PasarelaConfig {
@Bean
@Profile("prod")
PasarelaPago pasarelaReal(RestClient cliente, PasarelaProperties props) {
return new PasarelaStripe(cliente, props.apiKey());
}
@Bean
@Profile("!prod") // negación: en cualquier entorno que no sea prod
PasarelaPago pasarelaSimulada() {
return (importe, tarjeta) -> new Recibo("SIMULADO-" + UUID.randomUUID(), importe);
}
@Bean
@Profile({ "local", "test" }) // varios perfiles (OR)
CommandLineRunner datosDePrueba(RepositorioProductos repo) {
return args -> repo.guardarTodos(Catalogo.deEjemplo());
}
}
@Profile en la lógica de negocio. Un if (perfil == prod)
disfrazado de anotación significa que estás probando un código distinto del que va a producción. Usa perfiles
para infraestructura (qué base de datos, qué pasarela, qué exportador de métricas) y
propiedades para comportamiento (umbrales, timeouts, interruptores de
funcionalidad). Y activa el perfil desde fuera, nunca dentro del artefacto.
5.3 @Value vs @ConfigurationProperties
// ── ❌ @Value repartido por todas partes ─────────────────────────────────────
@Service
public class ServicioPasarelaMalo {
@Value("${tienda.pasarela.url}") private String url;
@Value("${tienda.pasarela.api-key}") private String apiKey;
@Value("${tienda.pasarela.timeout:5000}") private long timeoutMs;
// Problemas: no hay validación, no hay tipos ricos, no hay agrupación,
// no hay autocompletado en el IDE, y una errata solo se descubre al arrancar
// (IllegalArgumentException: Could not resolve placeholder). Además no hay
// relaxed binding: TIENDA_PASARELA_APIKEY no rellena "tienda.pasarela.api-key".
}
// ── ✅ @ConfigurationProperties con un record inmutable ──────────────────────
@ConfigurationProperties(prefix = "tienda.pasarela")
@Validated
public record PasarelaProperties(
@NotNull URI url, // tipo rico, validado
@NotBlank @Size(min = 20) String apiKey,
@DefaultValue("2s") Duration conexion, // "2s", "PT2S", "2000ms" → Duration
@DefaultValue("5s") Duration lectura,
@DefaultValue("10MB") DataSize payloadMaximo, // "10MB", "10485760" → DataSize
@DefaultValue("3") @Min(0) @Max(10) int reintentos,
@DefaultValue("ES") List<String> paisesPermitidos, // lista
Map<String, String> cabecerasExtra, // mapa
@NotNull @Valid Circuito circuito // anidado y validado
) {
public record Circuito(@DefaultValue("50") @Min(1) @Max(100) int umbralFalloPorciento,
@DefaultValue("30s") Duration esperaAbierto) { }
// Puedes añadir lógica derivada: la configuración es un objeto de dominio más
public boolean permite(String pais) { return paisesPermitidos.contains(pais); }
}
// Registro: una sola anotación en la clase principal o en una @Configuration
@SpringBootApplication
@ConfigurationPropertiesScan // escanea @ConfigurationProperties automáticamente
public class TiendaApplication { }
// Uso: se inyecta como cualquier otro bean, y ya está validado
@Service
public class ServicioPasarela {
private final PasarelaProperties props;
public ServicioPasarela(PasarelaProperties props) { this.props = props; }
}
# Todas estas formas rellenan la MISMA propiedad (relaxed binding):
tienda.pasarela.api-key: abc # kebab-case ← la forma CANÓNICA, úsala siempre
tienda.pasarela.apiKey: abc # camelCase
tienda.pasarela.api_key: abc # snake_case
TIENDA_PASARELA_APIKEY: abc # variable de entorno
# Listas y mapas, en las dos sintaxis
tienda:
pasarela:
paises-permitidos: ES,PT,FR # forma corta
paises-permitidos: # forma larga (equivalente)
- ES
- PT
cabeceras-extra:
X-Origen: tienda-api
X-Version: "2"
# Duraciones: 2s · 500ms · 5m · 1h · PT2S (ISO-8601 también vale)
# Tamaños: 10MB · 512KB · 2GB · 1048576 (bytes si no hay sufijo)
@Value | @ConfigurationProperties | |
|---|---|---|
| Agrupación | Propiedad a propiedad | Un objeto por área funcional |
| Validación | No (a mano) | Sí, con @Validated + Bean Validation |
| Relaxed binding | No | Sí |
| Tipos ricos | Conversión básica | Duration, DataSize, URI, enums, listas, mapas, anidados |
| Metadatos en el IDE | No | Sí (con el configuration-processor) |
| SpEL | Sí (#{...}) | No |
| Inmutabilidad | Campos mutables | Records o constructor binding |
| Veredicto | Solo para un valor suelto y aislado | Por defecto, siempre esto |
5.4 Configuración por entorno y secretos
# ── Kubernetes: variables de entorno desde ConfigMap y Secret ───────────────
apiVersion: v1
kind: ConfigMap
metadata:
name: tienda-api-config
data:
SPRING_PROFILES_ACTIVE: "prod"
TIENDA_PASARELA_URL: "https://api.pasarela.example"
TIENDA_IVA_GENERAL: "0.21"
LOGGING_LEVEL_COM_TIENDA: "INFO"
---
apiVersion: v1
kind: Secret
metadata:
name: tienda-api-secrets
type: Opaque
stringData:
DB_PASSWORD: "no-esto-tampoco-va-en-git"
TIENDA_PASARELA_APIKEY: "sk_live_..."
---
apiVersion: apps/v1
kind: Deployment
spec:
template:
spec:
containers:
- name: api
image: registry.corp/tienda-api:1.0.0
envFrom:
- configMapRef: { name: tienda-api-config }
- secretRef: { name: tienda-api-secrets }
# Alternativa preferible para secretos: montarlos como ficheros y leerlos
# con spring.config.import=optional:configtree:/run/secrets/
volumeMounts:
- name: secretos
mountPath: /run/secrets
readOnly: true
application.yml, ni «comentado», ni «solo
para el entorno de pruebas», ni en un .env que se cuela por un .gitignore mal
escrito. Un secreto que ha estado en un commit está comprometido para siempre: el historial de Git es
permanente y los bots que escanean GitHub encuentran una clave de AWS en minutos. Alternativas
correctas: variables de entorno inyectadas por la plataforma, Secrets montados como ficheros, HashiCorp
Vault, AWS Secrets Manager, Sealed Secrets o SOPS. Y añade gitleaks o
detect-secrets al pipeline para que el error sea imposible. Más detalle en el
módulo 10 y en el módulo 09.
Para configuración compartida entre muchos servicios existe Spring Cloud Config: un servidor que
sirve las propiedades desde un repositorio Git, con cifrado, versionado y recarga en caliente
(@RefreshScope + /actuator/refresh). Su contrapartida honesta es que se convierte en
una dependencia de arranque de todos tus servicios, así que hay que hacerlo altamente disponible o tolerar su
caída. Se trata en el módulo 08.
@ImportRuntimeHints con un RuntimeHintsRegistrar donde declaras qué clases se usan por
reflexión y qué recursos hay que incluir. Los detalles, en el módulo 11.
6 · Web MVC y una API REST de producción
6.1 Anatomía de una petición HTTP en Spring MVC
Antes de escribir un controlador, hay que saber por dónde pasa una petición. Este diagrama es la respuesta a
media docena de preguntas de entrevista («¿filtro o interceptor?», «¿dónde se convierte el JSON?», «¿por qué mi
@ControllerAdvice no captura la excepción del filtro?»).
┌──────────────────────────────────────────────────────────────────────────────────┐
│ RECORRIDO DE UNA PETICIÓN EN SPRING MVC │
└──────────────────────────────────────────────────────────────────────────────────┘
Cliente ──HTTP──► ① TOMCAT / JETTY / UNDERTOW (servidor embebido)
· acepta la conexión, parsea HTTP
· asigna un hilo del pool (o un virtual thread)
│
▼
② CADENA DE FILTROS (jakarta.servlet.Filter)
Ve TODAS las peticiones, también /actuator y los estáticos.
Orden típico:
CharacterEncodingFilter
FormContentFilter
ServerHttpObservationFilter ← métricas y trazas
TraceIdFilter (tuyo) ← MDC para los logs
SecurityFilterChain (Spring Security, ~15 filtros)
CorsFilter
│
▼
③ DispatcherServlet (el "Front Controller")
│
├─► ④ HandlerMapping
│ ¿qué método atiende GET /api/v1/pedidos/42?
│ (RequestMappingHandlerMapping + PathPatternParser)
│ Si no hay coincidencia → 404 (o el whitelabel)
│
├─► ⑤ HandlerInterceptor.preHandle()
│ Ya SABE qué controlador va a ejecutarse (HandlerMethod).
│ Puede cortar devolviendo false.
│
├─► ⑥ HandlerAdapter (RequestMappingHandlerAdapter)
│ · resuelve los ARGUMENTOS con ArgumentResolvers:
│ @PathVariable · @RequestParam · @RequestHeader
│ @RequestBody → HttpMessageConverter (Jackson)
│ · valida con @Valid → MethodArgumentNotValidException
│ · ★ AQUÍ SE EJECUTA TU MÉTODO ★
│ (y dentro, los proxies de @Transactional, @Cacheable…)
│ · convierte el RETORNO con ReturnValueHandlers
│ objeto → HttpMessageConverter → JSON
│
├─► ⑦ HandlerInterceptor.postHandle() / afterCompletion()
│
├─► ⑧ ¿Excepción?
│ HandlerExceptionResolver
│ └─ ExceptionHandlerExceptionResolver
│ └─ tu @RestControllerAdvice / @ExceptionHandler
│ → ProblemDetail (RFC 9457)
│ ⚠️ Una excepción lanzada en un FILTRO (②) NO pasa por aquí:
│ el DispatcherServlet ni se ha ejecutado.
│
└─► ⑨ ViewResolver (solo MVC con vistas: Thymeleaf, JSP)
Con @RestController este paso NO existe.
│
▼
Cliente ◄──HTTP── respuesta (cuerpo + cabeceras + status)
6.2 Diseñar los recursos y elegir bien el código de estado
REST no es «JSON por HTTP». Las dos reglas que más impacto tienen: los recursos son sustantivos en plural y el verbo va en el método HTTP, no en la URL.
❌ MAL ✅ BIEN
GET /api/getPedidos GET /api/v1/pedidos
POST /api/crearPedido POST /api/v1/pedidos
POST /api/pedido/borrar/42 DELETE /api/v1/pedidos/42
GET /api/pedidoPorCliente?id=7 GET /api/v1/clientes/7/pedidos
POST /api/actualizarEstadoPedido PATCH /api/v1/pedidos/42 (o ↓)
GET /api/pedidos/42/lineas/getAll POST /api/v1/pedidos/42/confirmacion
GET /api/v1/pedidos/42/lineas
Filtrado, orden y paginación van en la QUERY, no en la ruta:
GET /api/v1/pedidos?estado=CONFIRMADO&desde=2026-01-01&page=0&size=20&sort=fecha,desc
Y para las acciones que no encajan en un CRUD, dos opciones legítimas:
· sub-recurso que representa el hecho: POST /api/v1/pedidos/42/confirmacion
· PATCH con el cambio de estado: PATCH /api/v1/pedidos/42 {"estado":"CONFIRMADO"}
Elige una y sé consistente en toda la API.
| Verbo | Idempotente | Seguro | Éxito | Uso |
|---|---|---|---|---|
GET | Sí | Sí | 200, 204 si vacío | Leer. Jamás modificar estado. |
POST | No | No | 201 + Location | Crear o ejecutar una acción. |
PUT | Sí | No | 200 / 204 | Reemplazar el recurso completo. |
PATCH | Depende | No | 200 / 204 | Modificación parcial. |
DELETE | Sí | No | 204 | Borrar. Repetirlo devuelve 204 o 404, elige y documenta. |
HEAD | Sí | Sí | 200 | Como GET sin cuerpo (comprobar existencia). |
OPTIONS | Sí | Sí | 200 | Preflight de CORS. |
| Código | Nombre | Cuándo usarlo exactamente |
|---|---|---|
| 200 | OK | Lectura o actualización con cuerpo de respuesta. |
| 201 | Created | Recurso creado. Obligatorio devolver cabecera Location. |
| 202 | Accepted | Aceptado para procesar de forma asíncrona; devuelve una URL de seguimiento. |
| 204 | No Content | Éxito sin cuerpo: DELETE, o PUT sin representación. |
| 206 | Partial Content | Descargas por rangos. |
| 301 / 308 | Moved Permanently | El recurso cambió de sitio para siempre. |
| 304 | Not Modified | Respuesta a If-None-Match/If-Modified-Since: ahorra ancho de banda. |
| 400 | Bad Request | Sintaxis o validación incorrecta. JSON malformado, campo obligatorio ausente. |
| 401 | Unauthorized | No autenticado (mal nombrado: significa «no sé quién eres»). |
| 403 | Forbidden | Autenticado pero sin permiso. «Sé quién eres y no puedes». |
| 404 | Not Found | El recurso no existe. También para ocultar existencia a quien no tiene permiso. |
| 405 | Method Not Allowed | La ruta existe pero no con ese verbo. Spring lo devuelve solo. |
| 406 | Not Acceptable | No puedes producir el Accept solicitado. |
| 409 | Conflict | Conflicto de estado: duplicado, edición concurrente, transición inválida. |
| 410 | Gone | Existió y se eliminó permanentemente (útil al retirar una versión de API). |
| 412 | Precondition Failed | If-Match no coincide: bloqueo optimista por ETag. |
| 415 | Unsupported Media Type | Content-Type no soportado. El error «misterioso» más frecuente. |
| 422 | Unprocessable Entity | Sintaxis correcta, semántica inválida. Muchas APIs lo usan para validación de negocio. |
| 429 | Too Many Requests | Rate limiting. Añade Retry-After. |
| 500 | Internal Server Error | Tu bug. Nunca por una entrada inválida del cliente. |
| 502 / 504 | Bad Gateway / Timeout | Un servicio del que dependes falló o no respondió. |
| 503 | Service Unavailable | Sobrecarga o mantenimiento. Añade Retry-After. |
6.3 El controlador: anotaciones y firma
@RestController // = @Controller + @ResponseBody
@RequestMapping("/api/v1/pedidos") // prefijo común, sin barra final
@Validated // habilita validación de @RequestParam/@PathVariable
class PedidoController {
private final CrearPedido crearPedido; // casos de uso, no repositorios
private final ConsultarPedidos consultarPedidos;
PedidoController(CrearPedido crearPedido, ConsultarPedidos consultarPedidos) {
this.crearPedido = crearPedido;
this.consultarPedidos = consultarPedidos;
}
// ── GET colección: filtros + paginación ─────────────────────────────────
@GetMapping
PaginaResponse<PedidoResumen> listar(
@RequestParam(required = false) EstadoPedido estado,
@RequestParam(required = false) @DateTimeFormat(iso = ISO.DATE) LocalDate desde,
@RequestParam(defaultValue = "false") boolean incluirCancelados,
@PageableDefault(size = 20, sort = "fechaCreacion",
direction = Sort.Direction.DESC) Pageable pageable) {
return consultarPedidos.buscar(new Filtro(estado, desde, incluirCancelados), pageable);
}
// ── GET elemento: ResponseEntity para controlar cabeceras y caché ────────
@GetMapping("/{id}")
ResponseEntity<PedidoResponse> obtener(
@PathVariable @Pattern(regexp = "[A-Z]{3}-\\d{6}") String id,
@RequestHeader(value = HttpHeaders.IF_NONE_MATCH, required = false) String etagCliente) {
Pedido pedido = consultarPedidos.porId(id); // lanza PedidoNoEncontrado → 404
String etag = "\"" + pedido.version() + "\"";
if (etag.equals(etagCliente)) {
return ResponseEntity.status(HttpStatus.NOT_MODIFIED).eTag(etag).build(); // 304
}
return ResponseEntity.ok()
.eTag(etag)
.cacheControl(CacheControl.maxAge(Duration.ofMinutes(1)).cachePrivate())
.body(PedidoResponse.de(pedido));
}
// ── POST: 201 + Location (obligatorio) ──────────────────────────────────
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
ResponseEntity<PedidoResponse> crear(@Valid @RequestBody CrearPedidoRequest peticion,
@RequestHeader(value = "Idempotency-Key",
required = false) String claveIdempotencia,
UriComponentsBuilder uriBuilder) {
Pedido creado = crearPedido.ejecutar(peticion.aComando(), claveIdempotencia);
URI ubicacion = uriBuilder.path("/api/v1/pedidos/{id}")
.buildAndExpand(creado.id().valor()).toUri();
return ResponseEntity.created(ubicacion).body(PedidoResponse.de(creado));
}
// ── PATCH: modificación parcial ─────────────────────────────────────────
@PatchMapping("/{id}")
PedidoResponse actualizar(@PathVariable String id,
@Valid @RequestBody ActualizarPedidoRequest peticion) {
return PedidoResponse.de(consultarPedidos.actualizar(id, peticion.aComando()));
}
// ── Acción sobre un sub-recurso ─────────────────────────────────────────
@PostMapping("/{id}/confirmacion")
@ResponseStatus(HttpStatus.ACCEPTED) // 202: se procesa de forma asíncrona
void confirmar(@PathVariable String id) { crearPedido.confirmar(id); }
// ── DELETE: 204 sin cuerpo ──────────────────────────────────────────────
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
void borrar(@PathVariable String id) { crearPedido.cancelar(id); }
}
| Anotación de parámetro | De dónde saca el valor | Nota práctica |
|---|---|---|
@PathVariable | Segmento de la ruta | Compila con -parameters o pon el nombre: @PathVariable("id"). |
@RequestParam | Query string o formulario | Usa Optional o defaultValue; required=true es el defecto. |
@RequestBody | Cuerpo, vía HttpMessageConverter | Si lo olvidas, todos los campos llegan null. |
@RequestHeader | Cabecera HTTP | Marca required=false para cabeceras opcionales. |
@CookieValue | Cookie | — |
@RequestPart | Parte de un multipart | Para JSON + fichero en la misma petición. |
@ModelAttribute | Varios query params a un objeto | Muy cómodo para agrupar filtros. |
@AuthenticationPrincipal | Usuario autenticado | Spring Security (módulo 10). |
HttpServletRequest | La petición cruda | Último recurso: acopla el controlador al servlet. |
6.4 Jackson, DTOs y por qué nunca se expone una entidad
// ── DTO DE ENTRADA: solo lo que el cliente puede enviar ─────────────────────
public record CrearPedidoRequest(
@NotBlank(message = "el cliente es obligatorio")
@Size(max = 36) String clienteId,
@NotEmpty(message = "el pedido debe tener al menos una línea")
@Size(max = 100, message = "máximo 100 líneas por pedido")
@Valid List<LineaRequest> lineas, // @Valid: validación EN CASCADA
@Email(message = "email con formato inválido") String emailContacto,
@JsonProperty("direccion_envio") // el JSON usa snake_case, Java camelCase
@NotNull @Valid DireccionRequest direccionEnvio,
@FutureOrPresent LocalDate fechaEntregaDeseada,
@JsonInclude(JsonInclude.Include.NON_NULL) String comentario
) {
public record LineaRequest(@NotBlank @Pattern(regexp = "[A-Z0-9-]{4,20}") String sku,
@Positive @Max(999) int unidades) { }
public CrearPedidoComando aComando() { // el mapeo vive en el DTO, no en el servicio
return new CrearPedidoComando(new ClienteId(clienteId),
lineas.stream().map(LineaRequest::aLinea).toList(),
direccionEnvio.aDireccion());
}
}
// ── DTO DE SALIDA: solo lo que el cliente debe ver ──────────────────────────
public record PedidoResponse(String id,
String estado,
BigDecimal total,
String moneda,
Instant fechaCreacion, // ISO-8601 en UTC
List<LineaResponse> lineas) {
public record LineaResponse(String sku, String descripcion,
int unidades, BigDecimal precioUnitario) { }
public static PedidoResponse de(Pedido pedido) { // factoría estática: un solo sitio
return new PedidoResponse(
pedido.id().valor(),
pedido.estado().name(),
pedido.total().cantidad(),
pedido.total().moneda().getCurrencyCode(),
pedido.fechaCreacion(),
pedido.lineas().stream()
.map(l -> new LineaResponse(l.sku(), l.descripcion(),
l.unidades(), l.precioUnitario()))
.toList());
}
}
- Fuga de datos: cualquier columna nueva se publica sin que nadie lo decida. Costes internos, márgenes, hashes de contraseña, notas del comercial…
- Acoplamiento del contrato al esquema: renombrar una columna rompe a todos los clientes. Ya no puedes refactorizar la base de datos.
LazyInitializationException: Jackson recorre las relaciones perezosas después de que se haya cerrado la sesión de Hibernate.- Recursión infinita en relaciones bidireccionales (
Pedido → Linea → Pedido → …), que se «arregla» con@JsonIgnorey ensucia el modelo. - Consultas N+1 disparadas por la propia serialización: 1 + 500
SELECTpara pintar una lista.
# Configuración de Jackson: lo que yo pongo en todos los proyectos
spring:
jackson:
default-property-inclusion: non_null # no serialices los null: menos bytes, menos ruido
serialization:
write-dates-as-timestamps: false # ISO-8601 "2026-07-31T10:15:30Z", no 1785...
write-durations-as-timestamps: false
fail-on-empty-beans: false
indent-output: false # true solo en local, para depurar
deserialization:
fail-on-unknown-properties: false # tolerante: el cliente puede enviar campos extra
accept-empty-string-as-null-object: true
read-unknown-enum-values-as-null: false # mejor fallar con 400 que guardar un null silencioso
time-zone: UTC # SIEMPRE UTC en la API; el formato es del cliente
# property-naming-strategy: SNAKE_CASE # si tu contrato es snake_case, aquí y no con 40 @JsonProperty
// Serialización a medida cuando el tipo lo requiere (aquí, un value object de dominio)
@JsonComponent // atajo de Boot: registra el módulo automáticamente
public class DineroJson {
public static class Serializador extends JsonSerializer<Dinero> {
@Override public void serialize(Dinero d, JsonGenerator gen, SerializerProvider sp)
throws IOException {
gen.writeStartObject();
gen.writeStringField("cantidad", d.cantidad().toPlainString()); // String, no double
gen.writeStringField("moneda", d.moneda().getCurrencyCode());
gen.writeEndObject();
}
}
public static class Deserializador extends JsonDeserializer<Dinero> {
@Override public Dinero deserialize(JsonParser p, DeserializationContext ctx)
throws IOException {
JsonNode nodo = p.readValueAsTree();
return new Dinero(new BigDecimal(nodo.get("cantidad").asText()),
Currency.getInstance(nodo.get("moneda").asText()));
}
}
}
// Anotaciones de Jackson que conviene tener a mano
record EjemploJackson(
@JsonProperty("id_externo") String idExterno, // renombrar
@JsonIgnore String secretoInterno, // nunca sale ni entra
@JsonProperty(access = Access.WRITE_ONLY) String password, // entra, no sale
@JsonProperty(access = Access.READ_ONLY) Instant creadoEn, // sale, no entra
@JsonFormat(shape = Shape.STRING, pattern = "yyyy-MM-dd") LocalDate fecha,
@JsonAlias({ "correo", "mail" }) String email, // acepta varios nombres al leer
@JsonInclude(Include.NON_EMPTY) List<String> etiquetas
) { }
record se
deserializan sin necesidad de @JsonCreator, pero necesitas los nombres de parámetro
(el spring-boot-starter-parent ya compila con -parameters, así que funciona;
fuera de él, añádelo); (2) un record no puede tener un valor por defecto: si el
cliente omite un campo, llega null (o 0 en primitivos). Por eso los campos
obligatorios necesitan @NotNull/@NotBlank explícito: no hay ninguna red de seguridad.
6.5 Validación en el borde
// ── 1. Validación estándar con Bean Validation ──────────────────────────────
public record RegistroRequest(
@NotBlank @Size(min = 2, max = 60) String nombre,
@NotBlank @Email String email,
@NotNull @Past LocalDate fechaNacimiento,
@Positive BigDecimal limiteCredito,
@Pattern(regexp = "\\+?[0-9]{9,15}", message = "teléfono inválido") String telefono,
@NotNull @Nif String nif, // ← validador propio, más abajo
@AssertTrue(message = "hay que aceptar las condiciones") boolean aceptaCondiciones
) { }
// ── 2. Validador PROPIO: la anotación… ──────────────────────────────────────
@Documented
@Constraint(validatedBy = NifValidator.class)
@Target({ ElementType.FIELD, ElementType.PARAMETER, ElementType.RECORD_COMPONENT })
@Retention(RetentionPolicy.RUNTIME)
public @interface Nif {
String message() default "{tienda.validacion.nif}"; // clave de i18n, no texto fijo
Class<?>[] groups() default { };
Class<? extends Payload>[] payload() default { };
}
// ── …y la implementación ────────────────────────────────────────────────────
public class NifValidator implements ConstraintValidator<Nif, String> {
private static final String LETRAS = "TRWAGMYFPDXBNJZSQVHLCKE";
private static final Pattern FORMATO = Pattern.compile("^[0-9]{8}[A-Z]$");
@Override
public boolean isValid(String valor, ConstraintValidatorContext ctx) {
if (valor == null) return true; // null lo controla @NotNull, no nosotros
if (!FORMATO.matcher(valor).matches()) return false;
int numero = Integer.parseInt(valor.substring(0, 8));
char esperada = LETRAS.charAt(numero % 23);
boolean ok = esperada == valor.charAt(8);
if (!ok) { // mensaje dinámico y útil
ctx.disableDefaultConstraintViolation();
ctx.buildConstraintViolationWithTemplate(
"la letra del NIF debería ser " + esperada).addConstraintViolation();
}
return ok;
}
}
// ── 3. GRUPOS de validación: la misma clase, reglas distintas según la operación ──
public interface Creacion { }
public interface Actualizacion { }
public record ProductoRequest(
@Null(groups = Creacion.class, message = "el id lo asigna el servidor")
@NotNull(groups = Actualizacion.class) String id,
@NotBlank(groups = { Creacion.class, Actualizacion.class }) String nombre,
@NotNull(groups = Creacion.class) BigDecimal precio
) { }
@RestController
@RequestMapping("/api/v1/productos")
@Validated // necesario para grupos y para params sueltos
class ProductoController {
@PostMapping
ProductoResponse crear(@Validated(Creacion.class) @RequestBody ProductoRequest p) { … }
@PutMapping("/{id}")
ProductoResponse actualizar(@PathVariable String id,
@Validated(Actualizacion.class) @RequestBody ProductoRequest p) { … }
// Validación de parámetros sueltos: requiere @Validated EN LA CLASE
@GetMapping
List<ProductoResponse> buscar(@RequestParam @Size(min = 3, max = 50) String texto,
@RequestParam @Min(1) @Max(100) int limite) { … }
}
// ── 4. Validación de reglas de NEGOCIO: no es trabajo de Bean Validation ────
// @NotBlank comprueba el FORMATO. "Este SKU existe y tiene stock" es una regla de negocio
// que necesita la base de datos: va en el servicio o en el dominio, y lanza una excepción
// de dominio que el advice traduce a 409 o 422. No lo metas en un ConstraintValidator
// (acabarías inyectando repositorios en validadores y perdiendo el control transaccional).
| Anotación | Válida en | Trampa |
|---|---|---|
@NotNull | Cualquier tipo | "" y " " la pasan. |
@NotEmpty | String, colecciones, mapas, arrays | " " la pasa. |
@NotBlank | Solo String | La que quieres el 90% de las veces. |
@Size | String, colecciones | No valida null: combínala con @NotNull. |
@Email | String | Muy permisiva; a@b es válido. Para verificar de verdad, envía un correo. |
@Positive / @PositiveOrZero | Números | @Min(1) es equivalente y más explícito para enteros. |
@Digits | BigDecimal | Imprescindible para importes: @Digits(integer=10, fraction=2). |
@Past / @Future | java.time | Depende del reloj: inyecta un Clock en los tests. |
@Valid | Campos y parámetros | Sin ella, los objetos anidados no se validan. Es el olvido número uno. |
6.6 Errores homogéneos: ProblemDetail y RFC 9457
Si cada endpoint inventa su formato de error, cada cliente escribe un parser distinto. El estándar
RFC 9457 (sucesor del 7807) define un formato común, y Spring 6 lo implementa con la clase
ProblemDetail. Un solo @RestControllerAdvice centraliza toda la traducción de
excepciones a HTTP.
spring:
mvc:
problemdetails:
enabled: true # las excepciones estándar de MVC ya responden application/problem+json
@RestControllerAdvice
class ManejadorGlobalDeErrores {
private static final Logger log = LoggerFactory.getLogger(ManejadorGlobalDeErrores.class);
private static final URI BASE = URI.create("https://api.tienda.example/errores/");
// ── 400: cuerpo inválido (@Valid en @RequestBody) ───────────────────────
@ExceptionHandler(MethodArgumentNotValidException.class)
ProblemDetail cuerpoInvalido(MethodArgumentNotValidException e) {
var problema = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
problema.setType(BASE.resolve("validacion"));
problema.setTitle("Datos de entrada inválidos");
problema.setDetail("La petición contiene %d error(es) de validación"
.formatted(e.getBindingResult().getErrorCount()));
// Extensión propia: la lista exacta de campos. Esto es lo que agradece el frontend.
List<Map<String, String>> errores = e.getBindingResult().getFieldErrors().stream()
.map(fe -> Map.of("campo", fe.getField(),
"mensaje", Objects.requireNonNullElse(fe.getDefaultMessage(), "inválido"),
"rechazado", String.valueOf(fe.getRejectedValue())))
.toList();
problema.setProperty("errores", errores);
problema.setProperty("traceId", traceIdActual());
return problema;
}
// ── 400: validación de @RequestParam/@PathVariable ──────────────────────
// Spring 6.1+ lanza HandlerMethodValidationException; antes, ConstraintViolationException.
// Se manejan las dos para no depender de la versión.
@ExceptionHandler({ HandlerMethodValidationException.class, ConstraintViolationException.class })
ProblemDetail parametrosInvalidos(Exception e) {
var problema = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
problema.setType(BASE.resolve("parametros"));
problema.setTitle("Parámetros inválidos");
problema.setProperty("traceId", traceIdActual());
return problema;
}
// ── 400: JSON malformado o tipo incorrecto ──────────────────────────────
@ExceptionHandler(HttpMessageNotReadableException.class)
ProblemDetail jsonIlegible(HttpMessageNotReadableException e) {
var problema = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
problema.setType(BASE.resolve("json-malformado"));
problema.setTitle("Cuerpo de la petición ilegible");
// Detalle útil SIN filtrar internos: la ruta del campo que ha fallado
if (e.getCause() instanceof InvalidFormatException ife) {
String ruta = ife.getPath().stream().map(JsonMappingException.Reference::getFieldName)
.filter(Objects::nonNull).collect(Collectors.joining("."));
problema.setDetail("El campo '%s' no admite el valor recibido".formatted(ruta));
problema.setProperty("campo", ruta);
} else {
problema.setDetail("El JSON enviado no se puede interpretar");
}
return problema;
}
// ── 404: excepción de dominio ───────────────────────────────────────────
@ExceptionHandler(RecursoNoEncontrado.class)
ProblemDetail noEncontrado(RecursoNoEncontrado e) {
var problema = ProblemDetail.forStatus(HttpStatus.NOT_FOUND);
problema.setType(BASE.resolve("no-encontrado"));
problema.setTitle("Recurso no encontrado");
problema.setDetail(e.getMessage());
problema.setProperty("recurso", e.tipo());
problema.setProperty("id", e.id());
return problema;
}
// ── 409: conflicto de estado del dominio ────────────────────────────────
@ExceptionHandler(TransicionInvalida.class)
ProblemDetail conflicto(TransicionInvalida e) {
var problema = ProblemDetail.forStatus(HttpStatus.CONFLICT);
problema.setType(BASE.resolve("transicion-invalida"));
problema.setTitle("Operación no permitida en el estado actual");
problema.setDetail(e.getMessage());
problema.setProperty("estadoActual", e.estadoActual().name());
problema.setProperty("transicionesPosibles", e.posibles());
return problema;
}
// ── 409: choque de edición concurrente (bloqueo optimista) ──────────────
@ExceptionHandler(OptimisticLockingFailureException.class)
ProblemDetail edicionConcurrente(OptimisticLockingFailureException e) {
var problema = ProblemDetail.forStatus(HttpStatus.CONFLICT);
problema.setType(BASE.resolve("conflicto-version"));
problema.setTitle("El recurso ha sido modificado por otro usuario");
problema.setDetail("Recarga los datos y vuelve a intentarlo");
return problema;
}
// ── 422: regla de negocio incumplida ────────────────────────────────────
@ExceptionHandler(ReglaDeNegocioIncumplida.class)
ProblemDetail reglaNegocio(ReglaDeNegocioIncumplida e) {
var problema = ProblemDetail.forStatus(HttpStatus.UNPROCESSABLE_ENTITY);
problema.setType(BASE.resolve("regla-" + e.codigo()));
problema.setTitle("Regla de negocio incumplida");
problema.setDetail(e.getMessage());
problema.setProperty("codigo", e.codigo());
return problema;
}
// ── 503: una dependencia externa ha fallado ─────────────────────────────
@ExceptionHandler(ServicioExternoNoDisponible.class)
ResponseEntity<ProblemDetail> dependenciaCaida(ServicioExternoNoDisponible e) {
log.error("Dependencia '{}' no disponible", e.servicio(), e);
var problema = ProblemDetail.forStatus(HttpStatus.SERVICE_UNAVAILABLE);
problema.setType(BASE.resolve("dependencia-no-disponible"));
problema.setTitle("Servicio temporalmente no disponible");
problema.setDetail("Inténtalo de nuevo en unos segundos"); // sin decir CUÁL falló
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.header(HttpHeaders.RETRY_AFTER, "10")
.body(problema);
}
// ── 500: el cajón de último recurso ─────────────────────────────────────
@ExceptionHandler(Exception.class)
ProblemDetail inesperado(Exception e) {
String traceId = traceIdActual();
// El detalle técnico va al LOG con el traceId; al cliente, nada.
log.error("Error inesperado [traceId={}]", traceId, e);
var problema = ProblemDetail.forStatus(HttpStatus.INTERNAL_SERVER_ERROR);
problema.setType(BASE.resolve("interno"));
problema.setTitle("Error interno");
problema.setDetail("Se ha producido un error inesperado. "
+ "Facilita esta referencia al soporte: " + traceId);
problema.setProperty("traceId", traceId);
return problema;
}
private String traceIdActual() {
return Objects.requireNonNullElseGet(MDC.get("traceId"),
() -> UUID.randomUUID().toString());
}
}
// Alternativa: excepciones de dominio que YA saben su respuesta HTTP.
// ErrorResponseException implementa ErrorResponse, así que Spring la traduce sin advice.
public class PedidoNoEncontrado extends ErrorResponseException {
public PedidoNoEncontrado(String id) {
super(HttpStatus.NOT_FOUND,
ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND,
"No existe el pedido " + id),
null);
getBody().setType(URI.create("https://api.tienda.example/errores/no-encontrado"));
getBody().setTitle("Pedido no encontrado");
getBody().setProperty("pedidoId", id);
}
}
// ⚠️ Compromiso a valorar: es cómodo y muy conciso, pero mete detalles de HTTP en el
// dominio. Si tu dominio debe ser independiente del transporte (hexagonal), lanza
// excepciones puras y traduce en el advice.
// Otra opción muy usada, la más simple de todas:
@ResponseStatus(HttpStatus.NOT_FOUND) // Spring devuelve 404 automáticamente
public class ClienteNoEncontrado extends RuntimeException { }
// Inconveniente: no controlas el cuerpo, así que no hay ProblemDetail enriquecido.
Respuesta que ve el cliente (Content-Type: application/problem+json):
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json
{
"type": "https://api.tienda.example/errores/validacion",
"title": "Datos de entrada inválidos",
"status": 400,
"detail": "La petición contiene 2 error(es) de validación",
"instance": "/api/v1/pedidos",
"traceId": "8f3a1c9e2b7d4f60",
"errores": [
{ "campo": "clienteId", "mensaje": "el cliente es obligatorio", "rechazado": "null" },
{ "campo": "lineas[0].unidades", "mensaje": "must be greater than 0", "rechazado": "-3" }
]
}
server.error.include-stacktrace=never y
server.error.include-message=never (valores por defecto en Boot 3). La información técnica va al
log con un traceId; al cliente solo le das ese identificador para que lo cite al soporte.
6.7 Paginación y ordenación
// ── Paginación por offset: la que trae Spring Data de serie ─────────────────
@GetMapping
PaginaResponse<PedidoResumen> listar(
@PageableDefault(size = 20, sort = "fechaCreacion",
direction = Sort.Direction.DESC) Pageable pageable) {
Page<Pedido> pagina = repositorio.findAll(pageable);
return PaginaResponse.de(pagina.map(PedidoResumen::de));
}
// Un DTO propio para la página: NO devuelvas org.springframework.data.domain.Page.
// Su JSON no es estable entre versiones y expone estructura interna (por eso Boot avisa
// desde 3.3 y existe spring.data.web.pageable.serialization-mode).
public record PaginaResponse<T>(List<T> contenido, Metadatos metadatos) {
public record Metadatos(int pagina, int tamano, long totalElementos,
int totalPaginas, boolean primera, boolean ultima) { }
public static <T> PaginaResponse<T> de(Page<T> p) {
return new PaginaResponse<>(p.getContent(),
new Metadatos(p.getNumber(), p.getSize(), p.getTotalElements(),
p.getTotalPages(), p.isFirst(), p.isLast()));
}
}
spring:
data:
web:
pageable:
default-page-size: 20
max-page-size: 100 # ⚠️ IMPRESCINDIBLE: sin esto, ?size=1000000 tumba el servicio
one-indexed-parameters: false
sort:
sort-parameter: sort
// ── Paginación por CURSOR: la que hay que usar en listados grandes ──────────
// Problema del offset: "OFFSET 500000 LIMIT 20" obliga a la base de datos a leer y
// descartar medio millón de filas (cada vez), y si alguien inserta mientras paginas,
// verás elementos repetidos o te saltarás otros.
// Solución: en lugar de "salta N", di "dame lo que viene después de ESTE".
public record PaginaCursor<T>(List<T> contenido, String siguienteCursor, boolean hayMas) { }
@GetMapping("/feed")
PaginaCursor<PedidoResumen> feed(@RequestParam(required = false) String cursor,
@RequestParam(defaultValue = "20") @Max(100) int limite) {
Cursor decodificado = Cursor.decodificar(cursor); // Base64 de (fecha, id)
// Pedimos uno más para saber si hay página siguiente sin hacer un COUNT
List<Pedido> pedidos = repositorio.siguientesDespuesDe(
decodificado.fecha(), decodificado.id(), limite + 1);
// SELECT * FROM pedidos
// WHERE (fecha_creacion, id) < (:fecha, :id) ← comparación de tuplas
// ORDER BY fecha_creacion DESC, id DESC
// LIMIT :limite ← siempre rápido con índice
boolean hayMas = pedidos.size() > limite;
List<Pedido> pagina = hayMas ? pedidos.subList(0, limite) : pedidos;
String siguiente = hayMas ? Cursor.de(pagina.getLast()).codificar() : null;
return new PaginaCursor<>(pagina.stream().map(PedidoResumen::de).toList(), siguiente, hayMas);
}
Offset (page/size) | Cursor (keyset) | |
|---|---|---|
| Rendimiento en página 10.000 | Malo: la BD lee y descarta todo lo anterior | Constante: siempre un index seek |
| Saltar a una página concreta | Sí | No (solo siguiente/anterior) |
| Total de elementos | Sí (a costa de un COUNT) | Normalmente no |
| Consistencia con inserciones | Mala: duplicados y saltos | Buena |
| Úsalo para | Tablas de administración con navegador de páginas | Feeds, scroll infinito, exportaciones, APIs públicas |
6.8 Versionado de la API (y HATEOAS)
| Estrategia | Ejemplo | Ventajas | Inconvenientes |
|---|---|---|---|
| URI (recomendada por defecto) | /api/v1/pedidos |
Visible en logs y trazas; trivial de enrutar en el gateway; cacheable sin Vary; fácil de probar con un navegador o curl. |
Los puristas alegan que la URI debería identificar el recurso, no su representación; duplica rutas. |
| Cabecera propia | X-API-Version: 2 |
URIs limpias y estables. | Invisible en el navegador; obliga a Vary: X-API-Version en cachés y CDNs; difícil de depurar. |
| Media type (la más «correcta») | Accept: application/vnd.tienda.pedido.v2+json |
Versiona la representación, que es lo que realmente cambia; usa HTTP como estaba pensado. | Verbosa; peor soporte en herramientas y generadores de SDK; a los clientes les cuesta. |
| Query param | ?version=2 |
Fácil de probar. | Se mezcla con filtros; se pierde en redirecciones; complica el caché. |
// Convivencia de dos versiones: cada una con su DTO, compartiendo el caso de uso
@RestController
@RequestMapping("/api/v1/pedidos")
class PedidoControllerV1 {
private final ConsultarPedidos consultar;
PedidoControllerV1(ConsultarPedidos consultar) { this.consultar = consultar; }
@GetMapping("/{id}")
@Deprecated(since = "2026-06-01", forRemoval = true)
ResponseEntity<PedidoResponseV1> obtener(@PathVariable String id) {
return ResponseEntity.ok()
// Cabeceras estándar para avisar de la retirada (RFC 8594 y draft Deprecation)
.header("Deprecation", "Sun, 01 Jun 2026 00:00:00 GMT")
.header("Sunset", "Wed, 31 Dec 2026 23:59:59 GMT")
.header("Link", "<https://api.tienda.example/api/v2/pedidos/" + id
+ ">; rel=\"successor-version\"")
.body(PedidoResponseV1.de(consultar.porId(id)));
}
}
@RestController
@RequestMapping("/api/v2/pedidos")
class PedidoControllerV2 { /* mismo caso de uso, DTO nuevo */ }
// Versionado por media type, si te decides por él
@GetMapping(value = "/{id}", produces = "application/vnd.tienda.pedido.v2+json")
PedidoResponseV2 obtenerV2(@PathVariable String id) { … }
// Nota de actualidad: Spring Framework 7 / Boot 4 incorporan soporte declarativo de
// versión en @RequestMapping (atributo "version" + ApiVersionConfigurer). Ver módulo 11.
HATEOAS (que la respuesta incluya los enlaces a las acciones posibles) es el nivel más alto del
modelo de madurez de Richardson, y Spring lo soporta con spring-boot-starter-hateoas
(EntityModel, WebMvcLinkBuilder). En la práctica se usa poco: casi ningún cliente
navega los enlaces, y añade peso y complejidad. Merece la pena en APIs donde las transiciones de estado
disponibles son la información valiosa (un flujo de aprobación, un checkout) y en APIs públicas de larga
vida. Conócelo, sabe que existe, y no lo impongas por dogma.
6.9 Documentación con OpenAPI (springdoc)
// springdoc genera la especificación a partir de tu código: los tipos, los @Valid y los
// códigos de estado ya salen solos. Las anotaciones solo añaden lo que el código no dice.
@Configuration
class OpenApiConfig {
@Bean
OpenAPI api(BuildProperties build) {
return new OpenAPI()
.info(new Info()
.title("API de la Tienda")
.version(build.getVersion())
.description("Gestión de pedidos, catálogo y pagos")
.contact(new Contact().name("Equipo Plataforma").email("api@tienda.example"))
.license(new License().name("Uso interno")))
.servers(List.of(new Server().url("https://api.tienda.example").description("Producción"),
new Server().url("http://localhost:8080").description("Local")))
.components(new Components().addSecuritySchemes("bearer",
new SecurityScheme().type(SecurityScheme.Type.HTTP)
.scheme("bearer").bearerFormat("JWT")))
.addSecurityItem(new SecurityRequirement().addList("bearer"));
}
}
@Tag(name = "Pedidos", description = "Creación y consulta de pedidos")
@RestController
@RequestMapping("/api/v1/pedidos")
class PedidoControllerDocumentado {
@Operation(summary = "Crea un pedido",
description = "Valida el catálogo y reserva stock. Idempotente si se envía Idempotency-Key.")
@ApiResponses({
@ApiResponse(responseCode = "201", description = "Creado"),
@ApiResponse(responseCode = "400", description = "Datos inválidos",
content = @Content(mediaType = "application/problem+json",
schema = @Schema(implementation = ProblemDetail.class))),
@ApiResponse(responseCode = "409", description = "Stock insuficiente",
content = @Content(mediaType = "application/problem+json"))
})
@PostMapping
ResponseEntity<PedidoResponse> crear(@Valid @RequestBody CrearPedidoRequest peticion) { … }
}
springdoc:
api-docs:
path: /v3/api-docs
enabled: true
swagger-ui:
path: /swagger-ui.html
tags-sorter: alpha
operations-sorter: method
try-it-out-enabled: true
# En producción es habitual servir la especificación pero NO la interfaz gráfica
---
spring:
config:
activate:
on-profile: prod
springdoc:
swagger-ui:
enabled: false # la UI no se expone en producción
openapi-generator-maven-plugin: obliga a diseñar la API antes de programarla y permite que el
cliente y el servidor se desarrollen en paralelo. Para APIs públicas o entre equipos, contract-first gana; para
un servicio interno pequeño, springdoc es suficiente.
6.10 CORS sin sufrir
CORS es una protección del navegador, no del servidor: si el origen de la página no coincide con
el de la API, el navegador exige que la respuesta lo autorice. De ahí la frase que se oye en todas las oficinas:
«con curl funciona, en el navegador no».
// ── Global (lo habitual) ────────────────────────────────────────────────────
@Configuration
class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registro) {
registro.addMapping("/api/**")
// ⚠️ allowedOrigins("*") es INCOMPATIBLE con allowCredentials(true).
// Usa patrones concretos, nunca el comodín con credenciales.
.allowedOriginPatterns("https://*.tienda.example", "http://localhost:[*]")
.allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
.allowedHeaders("Authorization", "Content-Type", "Idempotency-Key")
.exposedHeaders("Location", "X-Total-Count", "Deprecation")
.allowCredentials(true)
.maxAge(3600); // cachea el preflight una hora: menos OPTIONS
}
}
// ── Por controlador (para excepciones puntuales) ────────────────────────────
@CrossOrigin(origins = "https://socio.example", maxAge = 1800)
@RestController
@RequestMapping("/api/v1/publico")
class ApiPublicaController { … }
// ⚠️ CON SPRING SECURITY hay que decírselo explícitamente, o los filtros de seguridad
// responderán al preflight OPTIONS antes de que se aplique la configuración de CORS.
@Bean
SecurityFilterChain cadena(HttpSecurity http) throws Exception {
return http
.cors(cors -> cors.configurationSource(fuenteCors())) // ← la línea que falta siempre
.csrf(csrf -> csrf.disable()) // API sin cookies de sesión
.authorizeHttpRequests(a -> a
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
.requestMatchers("/actuator/health/**").permitAll()
.anyRequest().authenticated())
.build();
}
@Bean
CorsConfigurationSource fuenteCors() {
var config = new CorsConfiguration();
config.setAllowedOriginPatterns(List.of("https://*.tienda.example"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE"));
config.setAllowedHeaders(List.of("Authorization", "Content-Type"));
config.setAllowCredentials(true);
var fuente = new UrlBasedCorsConfigurationSource();
fuente.registerCorsConfiguration("/api/**", config);
return fuente;
}
6.11 Filtro, interceptor o aspecto
| Filtro (Servlet) | Interceptor (MVC) | Aspecto (AOP) | |
|---|---|---|---|
| Nivel | Contenedor de servlets | DispatcherServlet | Invocación de métodos de beans |
| Alcance | Todas las peticiones (estáticos, actuator, errores) | Solo las que gestiona MVC | Cualquier bean, con o sin HTTP |
| ¿Sabe qué controlador atenderá? | No | Sí (HandlerMethod y sus anotaciones) | Sí: método, argumentos y anotaciones |
| ¿Puede modificar cuerpos? | Sí (con wrappers) | No fácilmente | Sí, argumentos y retorno |
| Se ejecuta ante un 404 | Sí | No | No |
| Úsalo para | traceId/MDC, seguridad, CORS, compresión, límite de tamaño, rate limiting | Auditoría por endpoint, métricas por operación, cabeceras según el handler | Transacciones, caché, reintentos, cronómetros de servicios |
// ── FILTRO: traceId en el MDC. El caso de uso canónico. ─────────────────────
@Component
@Order(Ordered.HIGHEST_PRECEDENCE) // el primero: todo lo demás ya tiene traceId
public class TraceIdFilter extends OncePerRequestFilter { // "OncePer...": evita repetirse
// en forwards e includes
private static final String CABECERA = "X-Trace-Id";
@Override
protected void doFilterInternal(HttpServletRequest peticion,
HttpServletResponse respuesta,
FilterChain cadena) throws ServletException, IOException {
String traceId = Optional.ofNullable(peticion.getHeader(CABECERA))
.filter(s -> !s.isBlank())
.orElseGet(() -> UUID.randomUUID().toString().replace("-", ""));
try {
MDC.put("traceId", traceId); // lo verán TODOS los logs
respuesta.setHeader(CABECERA, traceId); // y también el cliente
cadena.doFilter(peticion, respuesta);
} finally {
MDC.clear(); // ¡OBLIGATORIO! Los hilos se reutilizan del pool:
// sin esto, el traceId se filtra a la siguiente petición
// y además tienes una fuga de memoria en el ThreadLocal.
}
}
@Override
protected boolean shouldNotFilter(HttpServletRequest peticion) {
return peticion.getRequestURI().startsWith("/actuator/health"); // ruido innecesario
}
}
// Registro con orden explícito cuando no usas @Component
@Bean
FilterRegistrationBean<TraceIdFilter> registroTraceId() {
var registro = new FilterRegistrationBean<>(new TraceIdFilter());
registro.addUrlPatterns("/api/*");
registro.setOrder(1);
return registro;
}
// ── INTERCEPTOR: auditoría que necesita saber QUÉ endpoint se ha ejecutado ──
@Component
public class AuditoriaInterceptor implements HandlerInterceptor {
private final MeterRegistry registro;
AuditoriaInterceptor(MeterRegistry registro) { this.registro = registro; }
@Override
public boolean preHandle(HttpServletRequest peticion, HttpServletResponse respuesta,
Object handler) {
peticion.setAttribute("inicio", System.nanoTime());
// Esto es lo que un filtro NO puede hacer: inspeccionar el método destino
if (handler instanceof HandlerMethod metodo) {
Auditado anotacion = metodo.getMethodAnnotation(Auditado.class);
if (anotacion != null) peticion.setAttribute("operacion", anotacion.value());
}
return true; // false cortaría la petición aquí mismo
}
@Override
public void afterCompletion(HttpServletRequest peticion, HttpServletResponse respuesta,
Object handler, Exception ex) {
long inicio = (long) peticion.getAttribute("inicio");
String operacion = (String) peticion.getAttributeOrDefault("operacion", "sin-nombre");
registro.timer("api.operacion",
"operacion", operacion, // baja cardinalidad
"resultado", ex == null ? "ok" : "error",
"status", String.valueOf(respuesta.getStatus()))
.record(System.nanoTime() - inicio, TimeUnit.NANOSECONDS);
}
}
@Configuration
class WebConfig implements WebMvcConfigurer {
private final AuditoriaInterceptor auditoria;
WebConfig(AuditoriaInterceptor auditoria) { this.auditoria = auditoria; }
@Override public void addInterceptors(InterceptorRegistry registro) {
registro.addInterceptor(auditoria)
.addPathPatterns("/api/**")
.excludePathPatterns("/api/v1/salud", "/actuator/**");
}
}
6.12 Multipart, streaming y compresión
// ── Subida de ficheros ──────────────────────────────────────────────────────
@PostMapping(path = "/{id}/adjuntos", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
ResponseEntity<AdjuntoResponse> subir(@PathVariable String id,
@RequestPart("fichero") MultipartFile fichero,
@RequestPart("metadatos") @Valid MetadatosRequest metadatos) {
// Validaciones que NO puedes delegar en el framework
if (fichero.isEmpty()) throw new PeticionInvalida("el fichero está vacío");
String tipo = fichero.getContentType();
if (!Set.of("application/pdf", "image/png", "image/jpeg").contains(tipo)) {
throw new PeticionInvalida("tipo no permitido: " + tipo);
}
// ⚠️ El Content-Type lo envía el cliente y se puede falsificar: comprueba los
// "magic bytes" reales (Apache Tika) si el fichero se va a servir después.
// ⚠️ NUNCA uses el nombre original para escribir en disco (path traversal:
// "../../etc/passwd"). Genera un nombre propio.
String nombreSeguro = UUID.randomUUID() + extensionSegura(tipo);
try (InputStream in = fichero.getInputStream()) { // streaming, no getBytes()
almacen.guardar(nombreSeguro, in, fichero.getSize());
} catch (IOException e) {
throw new UncheckedIOException(e);
}
return ResponseEntity.status(HttpStatus.CREATED)
.body(new AdjuntoResponse(nombreSeguro, fichero.getSize()));
}
spring:
servlet:
multipart:
max-file-size: 10MB # por fichero
max-request-size: 25MB # por petición completa
file-size-threshold: 2KB # a partir de aquí, a disco en lugar de a memoria
location: /tmp/uploads
server:
tomcat:
max-swallow-size: 25MB # si no, Tomcat corta la conexión antes de tu 413
compression:
enabled: true
mime-types: application/json,application/problem+json,text/html,text/css,application/javascript
min-response-size: 2KB # comprimir 200 bytes cuesta más de lo que ahorra
// ── Respuestas grandes: StreamingResponseBody ───────────────────────────────
// ❌ MAL: cargar 500.000 pedidos en una List y devolverla → OutOfMemoryError
// ✅ BIEN: escribir en el OutputStream a medida que se leen de la base de datos
@GetMapping(value = "/exportacion", produces = "text/csv")
ResponseEntity<StreamingResponseBody> exportar(@RequestParam LocalDate desde) {
StreamingResponseBody cuerpo = salida -> {
try (var escritor = new BufferedWriter(new OutputStreamWriter(salida, UTF_8));
Stream<Pedido> flujo = repositorio.streamDesde(desde)) { // cursor de BD
escritor.write("id,fecha,cliente,total\n");
Iterator<Pedido> it = flujo.iterator();
int n = 0;
while (it.hasNext()) {
Pedido p = it.next();
escritor.write("%s,%s,%s,%s%n".formatted(p.id(), p.fechaCreacion(),
p.clienteId(), p.total()));
if (++n % 1000 == 0) escritor.flush(); // vaciar cada 1.000 filas
}
}
};
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"pedidos.csv\"")
.header(HttpHeaders.CACHE_CONTROL, "no-store")
.body(cuerpo);
}
// ⚠️ Requisitos para que esto funcione de verdad:
// · el método del repositorio debe usar un CURSOR (Stream + @Transactional(readOnly=true)),
// no cargar todo en memoria antes de devolver;
// · sube spring.mvc.async.request-timeout o desactívalo para esta ruta;
// · desactiva la compresión si el cliente necesita ver datos en tiempo real.
6.13 Llamar a otros servicios
| Cliente | Estado | Modelo | Cuándo |
|---|---|---|---|
RestTemplate | En mantenimiento (no deprecado, pero sin evolución) | Bloqueante | Código existente. No lo elijas para algo nuevo. |
RestClient (Spring 6.1+) | Recomendado | Bloqueante, API fluida | La opción por defecto en MVC. |
WebClient | Recomendado en reactivo | No bloqueante | WebFlux, o streaming y alta concurrencia. |
HTTP interfaces (@HttpExchange) | Recomendado | Declarativo sobre los anteriores | Cuando quieres un contrato tipado, estilo Feign, sin dependencias extra. |
// ── RestClient: configuración central con TIMEOUTS (no negociables) ─────────
@Configuration(proxyBeanMethods = false)
class ClientesHttpConfig {
@Bean
RestClient clienteAlmacen(RestClient.Builder builder,
AlmacenProperties props,
ObservationRegistry observaciones) {
var settings = ClientHttpRequestFactorySettings.DEFAULTS
.withConnectTimeout(props.conexion()) // 2 s: abrir el socket
.withReadTimeout(props.lectura()); // 5 s: esperar la respuesta
return builder
.baseUrl(props.url())
.requestFactory(ClientHttpRequestFactories.get(settings))
.defaultHeader("X-Origen", "tienda-api")
.observationRegistry(observaciones) // métricas y trazas automáticas
.requestInterceptor((peticion, cuerpo, ejecucion) -> {
peticion.getHeaders().add("X-Trace-Id", MDC.get("traceId")); // propaga
return ejecucion.execute(peticion, cuerpo);
})
.defaultStatusHandler(HttpStatusCode::is5xxServerError, (pet, resp) -> {
throw new ServicioExternoNoDisponible("almacen", resp.getStatusCode());
})
.build();
}
}
// ── Uso: fíjate en el manejo explícito de errores ───────────────────────────
@Component
class ClienteAlmacen {
private final RestClient cliente;
ClienteAlmacen(RestClient cliente) { this.cliente = cliente; }
Optional<Stock> consultarStock(String sku) {
return Optional.ofNullable(cliente.get()
.uri("/stock/{sku}", sku) // parámetros escapados
.accept(MediaType.APPLICATION_JSON)
.exchange((peticion, respuesta) -> switch (respuesta.getStatusCode().value()) {
case 200 -> respuesta.bodyTo(Stock.class);
case 404 -> null; // "no hay" no es un error
case 429 -> throw new DemasiadasPeticiones(
respuesta.getHeaders().getFirst("Retry-After"));
default -> throw new ServicioExternoNoDisponible("almacen",
respuesta.getStatusCode());
}));
}
void reservar(ReservaRequest peticion) {
cliente.post()
.uri("/reservas")
.contentType(MediaType.APPLICATION_JSON)
.body(peticion)
.retrieve()
.toBodilessEntity();
}
// Genéricos: hace falta ParameterizedTypeReference (por el type erasure, módulo 01)
List<Stock> consultarTodos(List<String> skus) {
return cliente.post().uri("/stock/lote").body(skus).retrieve()
.body(new ParameterizedTypeReference<List<Stock>>() { });
}
}
// ── HTTP interfaces declarativas: el contrato como interfaz ─────────────────
@HttpExchange(url = "/api/v1", accept = "application/json", contentType = "application/json")
public interface AlmacenApi {
@GetExchange("/stock/{sku}")
Stock stock(@PathVariable String sku);
@GetExchange("/stock")
List<Stock> stockDe(@RequestParam List<String> skus,
@RequestHeader("X-Almacen") String almacen);
@PostExchange("/reservas")
ResponseEntity<ReservaResponse> reservar(@RequestBody ReservaRequest peticion);
@DeleteExchange("/reservas/{id}")
void cancelar(@PathVariable String id);
}
@Configuration(proxyBeanMethods = false)
class ApisDeclarativasConfig {
@Bean
AlmacenApi almacenApi(RestClient clienteAlmacen) {
// La implementación la genera Spring con un proxy dinámico
return HttpServiceProxyFactory
.builderFor(RestClientAdapter.create(clienteAlmacen))
.build()
.createClient(AlmacenApi.class);
}
}
// ── Reintentos y circuit breaker (Resilience4j; detalle en el módulo 08) ────
@Component
class ClienteAlmacenResiliente {
private final AlmacenApi api;
ClienteAlmacenResiliente(AlmacenApi api) { this.api = api; }
@Retry(name = "almacen") // 3 intentos, backoff exponencial + jitter
@CircuitBreaker(name = "almacen", fallbackMethod = "sinDatos")
@Bulkhead(name = "almacen") // limita llamadas concurrentes
Optional<Stock> stock(String sku) { return Optional.of(api.stock(sku)); }
// La firma del fallback = la original + el Throwable al final
Optional<Stock> sinDatos(String sku, Throwable causa) {
log.warn("Almacén no disponible para {}: {}", sku, causa.getMessage());
return Optional.empty(); // degradación elegante, no un 500
}
}
connectTimeout (1–3 s) y readTimeout (menor que el
timeout de tu propio cliente), añade reintentos con backoff solo para operaciones idempotentes, y un
circuit breaker para dejar de insistir cuando el otro extremo está caído.
7 · AOP: la maquinaria detrás de las anotaciones
7.1 Vocabulario mínimo
La programación orientada a aspectos resuelve un problema concreto: hay preocupaciones (cross-cutting concerns) que atraviesan muchas clases —transacciones, seguridad, caché, métricas, auditoría— y que, si se escriben a mano, aparecen copiadas en cien métodos. AOP permite escribirlas una vez y declarar dónde se aplican.
| Término | Qué es | En el código |
|---|---|---|
| Aspecto | El módulo que agrupa la preocupación transversal | Una clase @Aspect |
| Join point | Un punto del programa donde se puede intervenir | En Spring AOP, siempre la ejecución de un método |
| Advice | El código que se ejecuta en ese punto | @Before, @After, @AfterReturning, @AfterThrowing, @Around |
| Pointcut | La expresión que selecciona los join points | execution(* com.tienda..*Service.*(..)) |
| Weaving | El momento en que se une el aspecto al código | Spring AOP: en tiempo de ejecución, con proxies. AspectJ: en compilación o carga de clases. |
| Target | El objeto real envuelto | Tu bean |
7.2 Proxies JDK vs CGLIB, y las dos limitaciones que hay que memorizar
┌────────────────────── PROXY DINÁMICO DE JDK ──────────────────────┐
│ Requiere que el bean implemente al menos una INTERFAZ │
│ Se genera una clase que implementa esas interfaces y delega │
│ │
│ Cliente ──► $Proxy17 (implements PedidoService) ──► PedidoServiceImpl
│ │ │
│ └─ aspecto (transacción, caché, métrica…) │
│ │
│ ⚠️ Solo intercepta los métodos DECLARADOS EN LA INTERFAZ │
└───────────────────────────────────────────────────────────────────┘
┌──────────────────────── PROXY CGLIB ──────────────────────────────┐
│ Genera una SUBCLASE en tiempo de ejecución. No necesita interfaz │
│ (Boot lo usa por defecto: proxyTargetClass = true) │
│ │
│ Cliente ──► PedidoService$$SpringCGLIB$$0 extends PedidoService │
│ │ │
│ └─ sobrescribe cada método y llama a super │
│ │
│ ⚠️ NO puede interceptar métodos final, private ni static │
│ ⚠️ La clase no puede ser final │
└───────────────────────────────────────────────────────────────────┘
// ── ⚠️ LIMITACIÓN 1: LA AUTOINVOCACIÓN. La causa nº 1 de "no funciona". ─────
@Service
public class ServicioPedidos {
@Transactional
public void procesarLote(List<String> ids) {
for (String id : ids) {
this.procesarUno(id); // ❌ this = el objeto REAL, no el proxy.
// @Transactional(REQUIRES_NEW) NO se aplica:
// todo cae en la transacción de procesarLote.
}
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void procesarUno(String id) { … }
}
// Diagrama de lo que ocurre:
// otroBean.procesarLote() ──► PROXY ──► [aspecto: abre TX] ──► objeto.procesarLote()
// │
// this.procesarUno() ────┘
// (llamada Java normal:
// el proxy NO está en medio)
// ✅ SOLUCIÓN CORRECTA: separar en otro bean → la llamada vuelve a ser externa
@Service
public class ServicioLotes {
private final ProcesadorPedido procesador; // otro bean = otro proxy
ServicioLotes(ProcesadorPedido procesador) { this.procesador = procesador; }
public void procesarLote(List<String> ids) {
for (String id : ids) {
try { procesador.procesarUno(id); } // ✅ pasa por el proxy
catch (Exception e) { log.error("Falló {}, sigo con el resto", id, e); }
}
}
}
@Service
class ProcesadorPedido {
@Transactional(propagation = Propagation.REQUIRES_NEW) // ahora sí: una TX por pedido
public void procesarUno(String id) { … }
}
// ⚠️ Alternativas peores, por si las ves en código heredado:
// · auto-inyección: private @Lazy ServicioPedidos self; → funciona, pero confunde
// · ((ServicioPedidos) AopContext.currentProxy()).procesarUno(id);
// requiere @EnableAspectJAutoProxy(exposeProxy = true) y ata el código al framework
// · TransactionTemplate: para transacciones es una alternativa LEGÍTIMA y explícita
// ── ⚠️ LIMITACIÓN 2: métodos final, private y static NO se interceptan ──────
@Service
public class ServicioConProblemas {
@Transactional public final void a() { } // ❌ CGLIB no puede sobrescribirla → sin TX
@Transactional private void b() { } // ❌ tampoco
@Transactional static void c() { } // ❌ tampoco
@Transactional public void d() { } // ✅ esta sí
}
// Y peor aún: NO HAY NINGÚN AVISO. El código compila, arranca y silenciosamente
// no hace lo que la anotación promete. Regla: los métodos anotados son public y no final.
7.3 Un aspecto real y útil
// La anotación pública que marca qué se audita
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Auditado {
String value(); // nombre de la operación de negocio
boolean incluirArgumentos() default false;
}
@Aspect
@Component
public class AuditoriaAspecto {
private static final Logger log = LoggerFactory.getLogger("AUDITORIA");
private static final Set<String> SENSIBLES = Set.of("password", "tarjeta", "cvv", "token", "nif");
private final MeterRegistry registro;
private final AuditoriaProperties props;
public AuditoriaAspecto(MeterRegistry registro, AuditoriaProperties props) {
this.registro = registro;
this.props = props;
}
// Los pointcuts con nombre se reutilizan y se leen mucho mejor
@Pointcut("@annotation(com.tienda.auditoria.Auditado)")
void metodosAuditados() { }
@Pointcut("within(@org.springframework.stereotype.Service *)")
void dentroDeServicios() { }
@Around("metodosAuditados() && dentroDeServicios()")
public Object auditar(ProceedingJoinPoint punto) throws Throwable {
MethodSignature firma = (MethodSignature) punto.getSignature();
Auditado anotacion = firma.getMethod().getAnnotation(Auditado.class);
String operacion = anotacion.value();
String usuario = usuarioActual();
long t0 = System.nanoTime();
try {
Object resultado = punto.proceed(); // ← ejecuta el método real
long ms = (System.nanoTime() - t0) / 1_000_000;
log.info("op={} usuario={} resultado=OK ms={} args={}",
operacion, usuario, ms,
anotacion.incluirArgumentos() ? sanear(firma, punto.getArgs()) : "-");
if (ms > props.umbralLento().toMillis()) {
log.warn("op={} LENTA: {} ms (umbral {} ms)", operacion, ms,
props.umbralLento().toMillis());
}
registro.timer("negocio.operacion", "operacion", operacion, "resultado", "ok")
.record(ms, TimeUnit.MILLISECONDS);
return resultado;
} catch (Throwable e) {
long ms = (System.nanoTime() - t0) / 1_000_000;
log.error("op={} usuario={} resultado=ERROR excepcion={} ms={}",
operacion, usuario, e.getClass().getSimpleName(), ms);
registro.timer("negocio.operacion", "operacion", operacion, "resultado", "error")
.record(ms, TimeUnit.MILLISECONDS);
throw e; // ⚠️ SIEMPRE relanzar: un aspecto no decide el flujo
}
}
// Nunca registres datos sensibles: enmascáralos por nombre de parámetro
private String sanear(MethodSignature firma, Object[] args) {
String[] nombres = firma.getParameterNames();
return IntStream.range(0, args.length)
.mapToObj(i -> nombres[i] + "=" +
(SENSIBLES.contains(nombres[i].toLowerCase()) ? "***" : args[i]))
.collect(Collectors.joining(", ", "[", "]"));
}
private String usuarioActual() {
return Optional.ofNullable(SecurityContextHolder.getContext().getAuthentication())
.map(Authentication::getName).orElse("anonimo");
}
}
// Los cinco tipos de advice, con lo que hay que saber de cada uno
@Aspect @Component
class TiposDeAdvice {
@Before("execution(* com.tienda..*Service.*(..))")
void antes(JoinPoint punto) { } // no puede evitar la ejecución
@AfterReturning(pointcut = "metodosAuditados()", returning = "resultado")
void alDevolver(JoinPoint punto, Object resultado) { } // solo si NO hubo excepción
@AfterThrowing(pointcut = "metodosAuditados()", throwing = "error")
void alFallar(JoinPoint punto, Throwable error) { } // solo si hubo excepción
@After("metodosAuditados()")
void siempre(JoinPoint punto) { } // como un finally
@Around("metodosAuditados()") // el más potente
Object alrededor(ProceedingJoinPoint punto) throws Throwable {
// Puede: medir, modificar argumentos, cambiar el retorno, reintentar,
// cachear e incluso NO llamar a proceed() (cortocircuito).
return punto.proceed();
}
}
// SINTAXIS DE POINTCUT que realmente se usa:
// execution(* com.tienda.pedidos..*.*(..)) por paquete (incluye subpaquetes con "..")
// execution(public * *..*Service.*(..)) por convención de nombre
// @annotation(com.tienda.Auditado) por anotación en el MÉTODO
// @within(org.springframework.stereotype.Service) por anotación en la CLASE
// within(com.tienda.pedidos..*) por ubicación
// args(String, ..) por tipos de argumento
// bean(*Repository) por nombre de bean (solo Spring AOP)
// this(com.tienda.Auditable) / target(...) por tipo del proxy / del objeto real
7.4 Orden de los aspectos (y por qué importa)
// Cuando varios aspectos se aplican al mismo método, el orden CAMBIA el comportamiento.
// Menor valor de @Order = más externo (se ejecuta antes al entrar, después al salir).
@Aspect @Component @Order(1) class TrazaAspecto { } // el más externo
@Aspect @Component @Order(2) class MetricaAspecto { }
@Aspect @Component @Order(3) class AuditoriaAspecto2 { }
// Órdenes de los aspectos de Spring (se pueden cambiar por propiedad):
// @Transactional → Ordered.LOWEST_PRECEDENCE (muy interno)
// @Cacheable → por defecto también muy bajo
// @Async → Ordered.LOWEST_PRECEDENCE
// @PreAuthorize → configurable; conviene que sea EXTERNO a la transacción
// ¿Por qué importa? Dos ejemplos concretos:
//
// (a) CACHÉ y TRANSACCIÓN
// Caché FUERA de transacción → un acierto de caché no abre conexión a la BD. ✅
// Caché DENTRO de transacción → cada lectura cacheada consume una conexión. ❌
//
// (b) MÉTRICA y REINTENTO
// Métrica fuera del reintento → mides la duración total percibida por el cliente
// Métrica dentro → mides cada intento por separado
// Ambas son válidas: lo importante es saber QUÉ estás midiendo.
// Puedes ajustar el orden de la caché y de las transacciones:
@EnableCaching(order = 0) // caché por fuera
@EnableTransactionManagement(order = 100) // transacción por dentro
@Configuration class OrdenAspectos { }
CalcularTotal entenderá por qué el
resultado no coincide, y el test unitario del servicio pasará mientras producción falla. Usa AOP para
infraestructura observable y desechable (métricas, trazas, auditoría, reintentos, caché,
transacciones) y para nada más. Si al borrar el aspecto cambia un resultado de negocio, estaba mal puesto.
8 · Caching
8.1 La abstracción de caché de Spring
@SpringBootApplication
@EnableCaching // sin esto, las anotaciones de caché NO hacen nada
public class TiendaApplication { }
@Service
public class CatalogoService {
// ── @Cacheable: si está en caché, devuelve; si no, ejecuta y guarda ─────
@Cacheable(cacheNames = "productos", key = "#sku")
public Producto porSku(String sku) {
log.info("CACHE MISS para {}", sku); // solo aparece la primera vez
return repositorio.buscarPorSku(sku).orElseThrow(() -> new ProductoNoEncontrado(sku));
}
// Clave compuesta y condiciones
@Cacheable(cacheNames = "busquedas",
key = "#texto + ':' + #pagina",
condition = "#texto.length() >= 3", // ANTES de ejecutar
unless = "#result.isEmpty()", // DESPUÉS: no cachear vacíos
sync = true) // ← evita el "cache stampede"
public List<Producto> buscar(String texto, int pagina) { … }
// ── @CachePut: SIEMPRE ejecuta y actualiza la entrada ───────────────────
@CachePut(cacheNames = "productos", key = "#producto.sku()")
public Producto guardar(Producto producto) { return repositorio.guardar(producto); }
// ── @CacheEvict: invalida ───────────────────────────────────────────────
@CacheEvict(cacheNames = "productos", key = "#sku")
public void borrar(String sku) { repositorio.borrar(sku); }
@CacheEvict(cacheNames = { "productos", "busquedas" }, allEntries = true)
public void recargarCatalogo() { … } // vacía las dos cachés completas
// ── @Caching: varias operaciones en una sola llamada ────────────────────
@Caching(
put = { @CachePut(cacheNames = "productos", key = "#p.sku()") },
evict = { @CacheEvict(cacheNames = "busquedas", allEntries = true),
@CacheEvict(cacheNames = "catalogoPorCategoria", key = "#p.categoria()") }
)
public Producto actualizar(Producto p) { return repositorio.guardar(p); }
}
// Generador de claves propio: si no lo defines, SimpleKeyGenerator usa TODOS los parámetros
@Component("clavePorTenant")
public class ClavePorTenantGenerator implements KeyGenerator {
@Override
public Object generate(Object destino, Method metodo, Object... params) {
// En una aplicación multi-tenant, olvidar el tenant en la clave es una
// FUGA DE DATOS ENTRE CLIENTES: el usuario A ve datos cacheados del B.
return TenantContext.actual() + ":" + metodo.getName() + ":" + Arrays.deepHashCode(params);
}
}
@Cacheable(cacheNames = "porTenant", keyGenerator = "clavePorTenant")
public Configuracion configuracion() { … }
this.porSku(sku) desde otro método de la misma clase no consulta la caché, y un
método private o final nunca se cachea. Si tu hit ratio es 0%, esto es lo
primero que hay que comprobar (métrica cache.gets con result=miss).
8.2 Caffeine: caché en memoria
@Configuration
@EnableCaching
public class CacheConfig {
// Configuración por caché: cada una con su TTL y su tamaño. Nunca una talla única.
@Bean
CacheManager cacheManager(MeterRegistry registro) {
var gestor = new SimpleCacheManager();
gestor.setCaches(List.of(
caffeine("productos", 10_000, Duration.ofMinutes(10), registro),
caffeine("busquedas", 1_000, Duration.ofMinutes(1), registro),
caffeine("configuracion", 50, Duration.ofHours(1), registro)));
return gestor;
}
private CaffeineCache caffeine(String nombre, long maximo, Duration ttl, MeterRegistry reg) {
Cache<Object, Object> nativa = Caffeine.newBuilder()
.maximumSize(maximo) // ⚠️ SIEMPRE un límite: sin él, OOM
.expireAfterWrite(ttl) // TTL absoluto desde la escritura
.refreshAfterWrite(ttl.dividedBy(2)) // refresco en segundo plano
.recordStats() // necesario para las métricas
.build();
// Expone hit ratio, tamaño y desalojos en /actuator/metrics/cache.*
CaffeineCacheMetrics.monitor(reg, nativa, nombre);
return new CaffeineCache(nombre, nativa);
}
}
# Alternativa sencilla, solo por configuración (misma política para todas)
spring:
cache:
type: caffeine
cache-names: productos,busquedas,configuracion
caffeine:
spec: maximumSize=10000,expireAfterWrite=10m,recordStats
8.3 Redis: caché distribuida
@Configuration
@EnableCaching
@Profile("prod")
public class RedisCacheConfig {
@Bean
RedisCacheManager cacheManager(RedisConnectionFactory conexion, ObjectMapper mapper) {
// ⚠️ NO uses el serializador JDK por defecto: obliga a Serializable, genera
// binarios ilegibles y rompe en cuanto cambias una clase (InvalidClassException).
var json = new GenericJackson2JsonRedisSerializer(mapper.copy()
.activateDefaultTyping(mapper.getPolymorphicTypeValidator(),
ObjectMapper.DefaultTyping.NON_FINAL));
var porDefecto = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(10))
.disableCachingNullValues() // no guardes null
.prefixCacheNameWith("tienda:v1:") // ⭐ versiona el prefijo:
// al desplegar v2 con otro formato,
// las claves viejas se ignoran solas
.serializeKeysWith(SerializationPair.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(SerializationPair.fromSerializer(json));
return RedisCacheManager.builder(conexion)
.cacheDefaults(porDefecto)
.withCacheConfiguration("productos", porDefecto.entryTtl(Duration.ofHours(1)))
.withCacheConfiguration("busquedas", porDefecto.entryTtl(Duration.ofMinutes(1)))
.transactionAware() // solo escribe en la caché tras el COMMIT
.build();
}
}
spring:
data:
redis:
host: ${REDIS_HOST}
port: 6379
password: ${REDIS_PASSWORD}
timeout: 2s # ⚠️ obligatorio: sin timeout, Redis caído = servicio caído
connect-timeout: 1s
lettuce:
pool:
enabled: true
max-active: 16
max-idle: 8
min-idle: 2
| Caffeine (local) | Redis (distribuida) | |
|---|---|---|
| Latencia | Nanosegundos (misma JVM) | 0,5–2 ms (red) |
| Coherencia entre réplicas | Ninguna: cada pod tiene su versión | Total: una sola fuente |
| Se pierde al reiniciar | Sí | No |
| Nueva dependencia que puede caer | No | Sí (¡ponle timeout!) |
| Úsala para | Datos casi estáticos, alta frecuencia, tolerancia a desfase | Sesiones, datos que deben ser coherentes, cachés grandes |
En la práctica lo mejor suele ser una caché en dos niveles: Caffeine con TTL muy corto (segundos) delante de Redis con TTL largo. El nivel local absorbe las ráfagas y Redis mantiene la coherencia. El coste es que puedes servir datos hasta N segundos desfasados: decide ese número explícitamente y documéntalo.
8.4 Invalidación, stampede y cuándo NO cachear
EL "CACHE STAMPEDE" (o thundering herd)
t=0 La entrada "producto:ABC" expira
t=0 500 peticiones concurrentes preguntan por ABC
t=0 Las 500 fallan en caché → 500 consultas idénticas a la base de datos
t=0 La base de datos se satura, los timeouts empiezan, los reintentos empeoran todo
t=… Caída en cascada por algo que "estaba cacheado"
SOLUCIONES, de la más simple a la más completa:
1. sync = true en @Cacheable → solo un hilo calcula; los demás esperan. Barato y eficaz.
2. refreshAfterWrite (Caffeine) → sirve el valor viejo y refresca en segundo plano
3. TTL con jitter aleatorio → evita que 10.000 claves expiren en el mismo instante
4. Precarga programada → un @Scheduled refresca antes de que expire
5. Bloqueo distribuido (Redis) → un solo pod recalcula, en lugar de uno por pod
// TTL con jitter: la clave está en no darle a todas las entradas la misma fecha de caducidad
private Duration ttlConJitter(Duration base) {
long ms = base.toMillis();
long jitter = (long) (ms * 0.2 * ThreadLocalRandom.current().nextDouble()); // ±20%
return Duration.ofMillis(ms - (long) (ms * 0.1) + jitter);
}
// Invalidación por evento: la forma correcta de mantener coherencia entre servicios
@Component
class InvalidadorDeCache {
private final CacheManager cacheManager;
InvalidadorDeCache(CacheManager cacheManager) { this.cacheManager = cacheManager; }
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) // tras el commit
void al(ProductoActualizado evento) {
Optional.ofNullable(cacheManager.getCache("productos"))
.ifPresent(c -> c.evict(evento.sku()));
}
// Si hay varias réplicas y la caché es local, hace falta propagar la invalidación:
// un canal de Redis pub/sub o un topic de Kafka al que todas las réplicas escuchen.
}
9 · Tareas asíncronas y programadas
9.1 @Async bien hecho
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
// ⚠️ El executor POR DEFECTO de Spring Boot es un SimpleAsyncTaskExecutor que
// CREA UN HILO NUEVO POR TAREA y no tiene límite: con 10.000 tareas tienes
// 10.000 hilos y un OutOfMemoryError ("unable to create native thread").
// Define SIEMPRE tu propio executor, y uno por tipo de trabajo.
@Bean("correoExecutor")
ThreadPoolTaskExecutor correoExecutor(MeterRegistry registro) {
var executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4); // hilos siempre vivos
executor.setMaxPoolSize(8); // techo
executor.setQueueCapacity(500); // ⚠️ ACOTADA. Una cola infinita
// convierte la sobrecarga en OOM.
executor.setThreadNamePrefix("correo-"); // para leer los thread dumps
executor.setKeepAliveSeconds(60);
// Qué hacer cuando la cola está llena: CallerRunsPolicy aplica contrapresión
// (el hilo que envía ejecuta la tarea y así deja de producir más).
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
// Apagado ordenado: no perder tareas en curso al desplegar
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(30);
// Propagar el contexto: sin esto, el hilo asíncrono pierde el traceId y el usuario
executor.setTaskDecorator(new DecoradorDeContexto());
executor.initialize();
// Métricas del pool: cola, activos, rechazos. Imprescindible en producción.
ExecutorServiceMetrics.monitor(registro, executor.getThreadPoolExecutor(), "correo");
return executor;
}
// Executor por defecto para los @Async sin nombre
@Override public Executor getAsyncExecutor() { return correoExecutor(null); }
// ⚠️ Las excepciones de un @Async con retorno void SE PIERDEN si no pones esto:
// nadie hace get() sobre el resultado, así que nadie ve el fallo.
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return (excepcion, metodo, params) -> {
log.error("Fallo en @Async {}.{} con args {}",
metodo.getDeclaringClass().getSimpleName(), metodo.getName(),
Arrays.toString(params), excepcion);
// Y una métrica, para poder alertar
Metrics.counter("async.errores", "metodo", metodo.getName()).increment();
};
}
}
// El decorador que propaga MDC y contexto de seguridad al hilo destino
class DecoradorDeContexto implements TaskDecorator {
@Override
public Runnable decorate(Runnable tarea) {
Map<String, String> mdc = MDC.getCopyOfContextMap(); // se copia en el hilo ORIGEN
var auth = SecurityContextHolder.getContext().getAuthentication();
return () -> { // se aplica en el hilo DESTINO
try {
if (mdc != null) MDC.setContextMap(mdc);
if (auth != null) SecurityContextHolder.getContext().setAuthentication(auth);
tarea.run();
} finally {
MDC.clear();
SecurityContextHolder.clearContext(); // ¡el hilo se reutiliza!
}
};
}
}
@Service
public class ServicioNotificaciones {
// ── ✅ void: dispara y olvida. Las excepciones van al handler global. ────
@Async("correoExecutor")
public void enviarBienvenida(String email) { clienteSmtp.enviar(email, plantilla()); }
// ── ✅ CompletableFuture: cuando necesitas el resultado o encadenar ──────
@Async("correoExecutor")
public CompletableFuture<Recibo> enviarFactura(String pedidoId) {
return CompletableFuture.completedFuture(generarYEnviar(pedidoId));
}
// (Future<T> y ListenableFuture<T> son la forma antigua; usa CompletableFuture.)
// ── ❌ NO FUNCIONA: autoinvocación (mismo problema que en 7.2) ───────────
public void procesarTodos(List<String> emails) {
emails.forEach(this::enviarBienvenida); // se ejecuta SÍNCRONO, sin ningún aviso
}
// ── ❌ NO FUNCIONA: llamada desde @PostConstruct ─────────────────────────
@PostConstruct
void alArrancar() {
enviarBienvenida("admin@tienda.example"); // el proxy aún no existe (paso [8] del
// ciclo de vida). Usa ApplicationReadyEvent.
}
}
// Paralelizar varias llamadas independientes: el uso más rentable de @Async
@Service
class ServicioFichaProducto {
private final ClienteAlmacen almacen;
private final ClienteValoraciones valoraciones;
private final ClienteRecomendador recomendador;
public FichaCompleta ficha(String sku) {
// Las tres llamadas salen a la vez: el tiempo total es el de la más lenta,
// no la suma. Con 3 servicios de 200 ms: 200 ms en lugar de 600 ms.
var f1 = almacen.stockAsync(sku);
var f2 = valoraciones.resumenAsync(sku);
var f3 = recomendador.similaresAsync(sku);
try {
CompletableFuture.allOf(f1, f2, f3).orTimeout(2, TimeUnit.SECONDS).join();
return new FichaCompleta(f1.join(), f2.join(), f3.join());
} catch (CompletionException e) {
log.warn("Ficha incompleta para {}", sku, e);
return FichaCompleta.parcial(sku); // degradación elegante
}
}
}
9.2 @Scheduled y el problema de las N réplicas
@Configuration
@EnableScheduling
class SchedulingConfig implements SchedulingConfigurer {
// Por defecto el planificador tiene UN SOLO HILO: una tarea lenta retrasa
// a todas las demás. Con varias tareas, define un pool.
@Override
public void configureTasks(ScheduledTaskRegistrar registrar) {
var planificador = new ThreadPoolTaskScheduler();
planificador.setPoolSize(4);
planificador.setThreadNamePrefix("programada-");
planificador.setErrorHandler(t -> log.error("Fallo en tarea programada", t));
planificador.initialize();
registrar.setTaskScheduler(planificador);
}
}
@Component
public class TareasProgramadas {
// fixedDelay: espera N ms DESPUÉS de que termine la ejecución anterior.
// Nunca se solapan. Es lo que quieres el 90% de las veces.
@Scheduled(fixedDelay = 60_000, initialDelay = 10_000)
void refrescarCatalogo() { … }
// fixedRate: intenta arrancar cada N ms sin importar cuánto tardó la anterior.
// ⚠️ Si la tarea dura más que el intervalo, las ejecuciones se acumulan.
@Scheduled(fixedRate = 30_000)
void latido() { … }
// Con unidades explícitas: mucho más legible (Boot 2.2+)
@Scheduled(fixedDelayString = "PT5M") // ISO-8601
void cadaCincoMinutos() { … }
// Configurable desde application.yml: siempre preferible a un número fijo
@Scheduled(fixedDelayString = "${tienda.tareas.limpieza-delay:300000}")
void limpieza() { … }
// CRON con ZONA HORARIA EXPLÍCITA. Sin ella se usa la del sistema, que en un
// contenedor suele ser UTC: tu informe "de las 2 de la madrugada" se ejecuta
// a las 3 o a las 4 según el horario de verano, y nadie sabe por qué.
@Scheduled(cron = "0 0 2 * * MON-FRI", zone = "Europe/Madrid")
void informeDiario() { … }
@Scheduled(cron = "${tienda.tareas.cierre-cron}", zone = "Europe/Madrid")
void cierreMensual() { … }
}
SINTAXIS CRON DE SPRING: 6 campos (¡el primero son SEGUNDOS, no minutos!)
┌───────────── segundo (0-59)
│ ┌─────────── minuto (0-59)
│ │ ┌───────── hora (0-23)
│ │ │ ┌─────── día del mes (1-31)
│ │ │ │ ┌───── mes (1-12 o JAN-DEC)
│ │ │ │ │ ┌─── día semana (0-7 o SUN-SAT; 0 y 7 = domingo)
│ │ │ │ │ │
* * * * * *
0 0 2 * * * todos los días a las 02:00:00
0 */15 * * * * cada 15 minutos
0 0 9-18 * * MON-FRI cada hora en punto, de 9 a 18, de lunes a viernes
0 0 0 1 * * el día 1 de cada mes a medianoche
0 30 3 L * * el ÚLTIMO día del mes a las 03:30
0 0 12 ? * FRI#3 el tercer viernes de cada mes (# = enésimo)
Macros legibles que Spring acepta: @hourly @daily @weekly @monthly @yearly
⚠️ Un cron de 5 campos (el de Unix) NO es válido en Spring: falta el de segundos.
// ── EL PROBLEMA: en Kubernetes hay 3 réplicas → el informe se envía 3 veces ──
// Soluciones, de peor a mejor:
// 1. Un perfil "planificador" y una única réplica con él → punto único de fallo
// 2. Un CronJob de Kubernetes que llame a un endpoint → sale del proceso, muy limpio
// 3. ShedLock: bloqueo distribuido sobre la base de datos → 4 líneas y funciona
@Configuration
@EnableSchedulerLock(defaultLockAtMostFor = "PT10M") // seguro anticaída
class ShedLockConfig {
@Bean
LockProvider lockProvider(DataSource dataSource) {
return new JdbcTemplateLockProvider(JdbcTemplateLockProvider.Configuration.builder()
.withJdbcTemplate(new JdbcTemplate(dataSource))
.usingDbTime() // ⭐ usa el reloj de la BD: evita problemas de
.build()); // desincronización entre los relojes de los pods
}
}
@Component
class TareasConBloqueo {
@Scheduled(cron = "0 0 2 * * *", zone = "Europe/Madrid")
@SchedulerLock(name = "informeDiario", // nombre ÚNICO del bloqueo
lockAtMostFor = "PT30M", // si el pod muere, se libera a los 30 min
lockAtLeastFor = "PT1M") // evita dobles arranques por desfase
void informeDiario() { … }
}
-- La tabla que necesita ShedLock con JDBC (créala con Flyway; ver módulo 05)
CREATE TABLE shedlock (
name VARCHAR(64) NOT NULL PRIMARY KEY,
lock_until TIMESTAMP NOT NULL,
locked_at TIMESTAMP NOT NULL,
locked_by VARCHAR(255) NOT NULL
);
9.3 Virtual threads en Boot 3.2+
spring:
threads:
virtual:
enabled: true # Requiere Java 21+. Una línea, y cambia el modelo de concurrencia.
Con esa propiedad, Spring Boot sustituye los hilos de plataforma por virtuales en:
- El executor de Tomcat: un virtual thread por petición, en lugar de un pool de 200.
- El
@Asyncpor defecto (SimpleAsyncTaskExecutorcon virtuales). - El planificador de
@Scheduled. - Los listeners de RabbitMQ y Kafka, y los clientes que usan el executor de la aplicación.
MODELO CLÁSICO (thread-per-request con hilos de plataforma)
200 hilos de Tomcat · cada uno ≈ 1 MB de pila · bloquearse en E/S malgasta un hilo del SO
Petición ──► hilo #37 ──► [JDBC 50 ms: el hilo DUERME] ──► respuesta
Concurrencia máxima ≈ 200. Con más, las peticiones se encolan.
MODELO CON VIRTUAL THREADS (Java 21+)
1 virtual thread por petición · unos cientos de bytes · al bloquearse, se DESMONTA
del hilo portador y este atiende a otro
Petición ──► vthread ──► [JDBC 50 ms: se desmonta, el carrier trabaja] ──► respuesta
Concurrencia máxima ≈ decenas de miles, con código bloqueante NORMAL.
⚠️ EL CUELLO DE BOTELLA SE MUEVE, NO DESAPARECE:
Con 10.000 peticiones concurrentes y un pool de 10 conexiones JDBC, ahora tienes
10.000 hilos peleándose por 10 conexiones. Hay que redimensionar los pools
(y proteger las dependencias con bulkheads y timeouts), o cambias un límite
controlado por una cola invisible.
⚠️ synchronized puede FIJAR (pin) el virtual thread a su portador y anular la ventaja.
Desde Java 24 esto está resuelto en la mayoría de los casos, pero en Java 21
conviene sustituir synchronized por ReentrantLock en los caminos con E/S.
Y ojo con los ThreadLocal: con millones de hilos virtuales, un ThreadLocal
pesado multiplica la memoria (usa ScopedValue cuando esté disponible).
10 · Actuator y observabilidad
Un servicio que no se puede observar no se puede operar. Actuator convierte tu aplicación en algo diagnosticable desde fuera, y es la razón por la que Spring Boot se adoptó tan rápido en entornos con Kubernetes.
10.1 Endpoints y exposición segura
management:
# ⭐ PUERTO SEPARADO: el 9090 no se publica en el Ingress, así que los endpoints
# de gestión son inaccesibles desde internet aunque te equivoques al configurarlos.
server:
port: 9090
endpoints:
web:
base-path: /actuator
exposure:
include: health,info,metrics,prometheus,loggers,env,configprops,threaddump,httpexchanges
# ❌ NUNCA include: "*" en producción: expone heapdump (todo el contenido de la
# memoria: tokens, contraseñas en claro) y shutdown (apagar el servicio por HTTP).
endpoint:
health:
show-details: when-authorized # o "never" si el puerto es público
show-components: when-authorized
probes:
enabled: true # habilita /health/liveness y /health/readiness
group:
liveness:
include: ping,diskSpace # ⚠️ SOLO comprobaciones internas
readiness:
include: db,redis,pasarelaPago # dependencias necesarias para atender tráfico
env:
show-values: when-authorized # por defecto los valores salen ofuscados
configprops:
show-values: when-authorized
info:
env:
enabled: true
git:
mode: full # commit exacto que está en producción
build:
enabled: true
java:
enabled: true
metrics:
tags:
application: ${spring.application.name}
entorno: ${ENTORNO:desconocido}
observations:
key-values:
servicio: ${spring.application.name}
prometheus:
metrics:
export:
enabled: true
tracing:
sampling:
probability: 0.1 # 10% de las trazas: en producción, 1.0 es carísimo
| Endpoint | Qué da | ¿En producción? |
|---|---|---|
/health | Estado agregado y por componente | Sí, con detalles restringidos |
/health/liveness | ¿El proceso está vivo? | Sí (sonda de Kubernetes) |
/health/readiness | ¿Puede atender tráfico? | Sí (sonda de Kubernetes) |
/info | Versión, commit, fecha de build | Sí: saber qué hay desplegado no es opcional |
/metrics | Métricas navegables una a una | Sí (interno) |
/prometheus | Todas las métricas para scraping | Sí (solo red interna) |
/loggers | Consultar y cambiar niveles en caliente | Sí, autenticado: vale oro en un incidente |
/env, /configprops | De dónde viene cada propiedad | Autenticado y con valores ofuscados |
/conditions | Informe de autoconfiguración | Autenticado |
/threaddump | Volcado de hilos | Autenticado: la herramienta para «se ha colgado» |
/heapdump | Descarga el heap completo | NO exponerlo. Contiene todos los secretos en memoria |
/httpexchanges | Últimas peticiones HTTP | Con cuidado: puede contener datos personales |
/startup | Qué tardó en el arranque | Sí, para diagnosticar arranques lentos |
/shutdown | Apaga la aplicación | JAMÁS. Deshabilitado por defecto: déjalo así |
# Cambiar el nivel de log de un paquete SIN reiniciar. Imprescindible en un incidente.
curl -X POST localhost:9090/actuator/loggers/com.tienda.pagos \
-H 'Content-Type: application/json' \
-d '{"configuredLevel":"DEBUG"}'
# …y devolverlo a su sitio cuando termines (o te comerás el disco de logs)
curl -X POST localhost:9090/actuator/loggers/com.tienda.pagos \
-H 'Content-Type: application/json' -d '{"configuredLevel":null}'
# ¿Se ha colgado? Volcado de hilos y búsqueda de bloqueos
curl -s localhost:9090/actuator/threaddump > hilos.json
jq '[.threads[] | select(.threadState=="BLOCKED")] | length' hilos.json
# Métricas concretas
curl -s localhost:9090/actuator/metrics/http.server.requests | jq
curl -s 'localhost:9090/actuator/metrics/hikaricp.connections.pending' | jq
curl -s localhost:9090/actuator/metrics/jvm.memory.used | jq '.measurements'
# Diagnóstico de arranque lento
curl -s localhost:9090/actuator/startup | jq '.timeline.events
| sort_by(-.duration) | .[0:15] | .[] | {nombre: .startupStep.name, ms: .duration}'
10.2 Health indicators propios y sondas de Kubernetes
@Component("pasarelaPago") // el nombre del bean = nombre del componente
public class PasarelaPagoHealthIndicator implements HealthIndicator {
private final PasarelaApi api;
private final Duration umbralDegradado = Duration.ofMillis(500);
PasarelaPagoHealthIndicator(PasarelaApi api) { this.api = api; }
@Override
public Health health() {
long t0 = System.nanoTime();
try {
EstadoPasarela estado = api.ping(); // ⚠️ con timeout corto (1 s)
Duration latencia = Duration.ofNanos(System.nanoTime() - t0);
var builder = latencia.compareTo(umbralDegradado) > 0
? Health.status("DEGRADADO") // estado propio: ni UP ni DOWN
: Health.up();
return builder
.withDetail("latenciaMs", latencia.toMillis())
.withDetail("version", estado.version())
.build();
} catch (Exception e) {
return Health.down()
.withDetail("error", e.getClass().getSimpleName())
.withDetail("mensaje", e.getMessage()) // sin stack trace
.build();
}
}
}
// Health indicator reactivo o con timeout garantizado
@Component("almacen")
class AlmacenHealthIndicator extends AbstractHealthIndicator {
@Override protected void doHealthCheck(Health.Builder builder) throws Exception {
builder.up().withDetail("modo", "lectura-escritura");
}
}
// Controlar manualmente la disponibilidad: útil para vaciar tráfico antes de un mantenimiento
@Component
class ControlDeDisponibilidad {
private final ApplicationEventPublisher eventos;
ControlDeDisponibilidad(ApplicationEventPublisher eventos) { this.eventos = eventos; }
void dejarDeRecibirTrafico() {
// readiness pasa a DOWN → Kubernetes lo saca del Service, pero NO lo reinicia
AvailabilityChangeEvent.publish(eventos, this, ReadinessState.REFUSING_TRAFFIC);
}
void volverAlServicio() {
AvailabilityChangeEvent.publish(eventos, this, ReadinessState.ACCEPTING_TRAFFIC);
}
}
# Kubernetes: las tres sondas y por qué son distintas
apiVersion: apps/v1
kind: Deployment
spec:
template:
spec:
containers:
- name: api
# startupProbe: da tiempo al arranque sin relajar las otras dos.
# 30 × 5 s = hasta 150 s para arrancar; después entran liveness y readiness.
startupProbe:
httpGet: { path: /actuator/health/readiness, port: 9090 }
failureThreshold: 30
periodSeconds: 5
# liveness: "¿hay que REINICIAR el pod?" → solo comprobaciones INTERNAS.
# ⚠️ Si incluyes la base de datos aquí y la BD se cae, Kubernetes reiniciará
# TODAS tus réplicas en bucle, convirtiendo una incidencia en una caída total.
livenessProbe:
httpGet: { path: /actuator/health/liveness, port: 9090 }
periodSeconds: 10
failureThreshold: 3
# readiness: "¿le mando TRÁFICO?" → aquí sí van las dependencias.
# Si falla, el pod sale del balanceador pero NO se reinicia.
readinessProbe:
httpGet: { path: /actuator/health/readiness, port: 9090 }
periodSeconds: 5
failureThreshold: 2
10.3 Micrometer: métricas que sirven para algo
@Service
public class ServicioPedidosMedido {
private final Counter pedidosCreados;
private final Counter pedidosRechazados;
private final Timer tiempoConfirmacion;
private final DistributionSummary importePedido;
private final AtomicInteger enCurso = new AtomicInteger();
public ServicioPedidosMedido(MeterRegistry registro) {
// CONTADOR: solo sube. Para "cuántas veces ha pasado X".
this.pedidosCreados = Counter.builder("pedidos.creados")
.description("Pedidos creados correctamente")
.baseUnit("pedidos")
.tag("canal", "web")
.register(registro);
this.pedidosRechazados = Counter.builder("pedidos.rechazados")
.tag("motivo", "sin-stock")
.register(registro);
// TIMER: duración + número de eventos. Con histograma para percentiles reales.
this.tiempoConfirmacion = Timer.builder("pedidos.confirmacion")
.description("Tiempo de confirmación de un pedido")
.publishPercentileHistogram() // ⭐ permite calcular p95/p99 AGREGADOS
// entre réplicas en Prometheus
.serviceLevelObjectives(Duration.ofMillis(200), Duration.ofSeconds(1))
.register(registro);
// SUMMARY: distribución de un valor que no es tiempo (importes, tamaños)
this.importePedido = DistributionSummary.builder("pedidos.importe")
.baseUnit("EUR")
.publishPercentiles(0.5, 0.95, 0.99)
.register(registro);
// GAUGE: un valor instantáneo que sube y baja. Se MUESTREA, no se acumula.
Gauge.builder("pedidos.en.curso", enCurso, AtomicInteger::get)
.description("Pedidos en proceso ahora mismo")
.register(registro);
}
public Pedido crear(CrearPedidoComando comando) {
enCurso.incrementAndGet();
try {
Pedido pedido = tiempoConfirmacion.recordCallable(() -> hacerElTrabajo(comando));
pedidosCreados.increment();
importePedido.record(pedido.total().cantidad().doubleValue());
return pedido;
} catch (SinStock e) {
pedidosRechazados.increment();
throw e;
} finally {
enCurso.decrementAndGet();
}
}
}
// @Timed: la versión declarativa, para casos sencillos
@Service
class ServicioInformesMedido {
@Timed(value = "informes.generacion",
description = "Tiempo de generación de informes",
extraTags = { "tipo", "mensual" },
histogram = true)
public Informe generar(Mes mes) { … } // requiere el bean TimedAspect
}
@Configuration
class MetricasConfig {
@Bean TimedAspect timedAspect(MeterRegistry registro) { return new TimedAspect(registro); }
@Bean CountedAspect countedAspect(MeterRegistry registro) { return new CountedAspect(registro); }
// Tags comunes a TODAS las métricas del servicio
@Bean
MeterRegistryCustomizer<MeterRegistry> tagsComunes(
@Value("${spring.application.name}") String servicio,
@Value("${ENTORNO:local}") String entorno) {
return registro -> registro.config().commonTags(
"servicio", servicio, "entorno", entorno,
"instancia", System.getenv().getOrDefault("HOSTNAME", "local"));
}
}
10.4 Trazas distribuidas y logs estructurados
<!-- Micrometer Tracing con OpenTelemetry (el estándar en 2026) -->
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-tracing-bridge-otel</artifactId>
</dependency>
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-exporter-otlp</artifactId>
</dependency>
<!-- Alternativa si tu backend es Zipkin: micrometer-tracing-bridge-brave -->
management:
tracing:
enabled: true
sampling:
probability: 0.1 # 10%. Con 1.0 en un servicio con tráfico, el coste se dispara
otlp:
tracing:
endpoint: http://otel-collector:4318/v1/traces
logging:
# traceId y spanId en cada línea: así puedes ir del log a la traza completa
pattern:
level: "%5p [${spring.application.name:},%X{traceId:-},%X{spanId:-}]"
# Boot 3.4+: JSON estructurado nativo, sin dependencias extra
structured:
format:
console: ecs # ecs (Elastic) | logstash | gelf
// Observación propia: un span de negocio con sus atributos
@Service
class ServicioPagosObservado {
private final ObservationRegistry registro;
ServicioPagosObservado(ObservationRegistry registro) { this.registro = registro; }
Recibo cobrar(Pago pago) {
return Observation.createNotStarted("pago.cobro", registro)
.lowCardinalityKeyValue("pasarela", pago.pasarela()) // → métrica Y traza
.lowCardinalityKeyValue("moneda", pago.moneda())
.highCardinalityKeyValue("pedidoId", pago.pedidoId()) // → solo traza
.observe(() -> pasarela.cobrar(pago));
// Con una sola llamada obtienes: un timer en Micrometer, un span en la traza
// y los logs correlacionados por traceId. Este es el modelo de observabilidad
// unificada de Spring 6.
}
// La versión declarativa
@Observed(name = "pago.reembolso", contextualName = "reembolso-pago")
Recibo reembolsar(String pagoId) { … }
}
@Bean
ObservedAspect observedAspect(ObservationRegistry registro) { return new ObservedAspect(registro); }
LOS TRES PILARES, Y QUÉ PREGUNTA RESPONDE CADA UNO
MÉTRICAS → "¿va bien el sistema?" Agregadas, baratas, para alertas y paneles
p95 de latencia, tasa de error, throughput, saturación de pools
TRAZAS → "¿POR QUÉ esta petición tardó 3 s?" Muestreadas, con el árbol de llamadas
[API 3,0 s]
├─[auth 0,05 s]
├─[BD: SELECT pedidos 0,08 s]
└─[almacén 2,80 s] ◄── AQUÍ está el problema
└─[BD del almacén 2,75 s]
LOGS → "¿QUÉ pasó exactamente?" Detalle completo, con traceId para correlacionar
{"ts":"…","level":"ERROR","traceId":"8f3a…","msg":"timeout almacén","sku":"ABC"}
El traceId es el hilo que une los tres. Sin él, en un sistema con 15 servicios,
diagnosticar un incidente es adivinar. Más detalle en 08-microservicios.html#observabilidad
y en 09-devops-cloud.html.
/actuator/prometheus), los paneles (Grafana), las alertas
(Alertmanager) y las trazas (Tempo, Jaeger o el OTel Collector) se montan en el
módulo 09. La correlación entre servicios y los patrones de resiliencia
asociados, en el módulo 08.
11 · Spring MVC vs WebFlux (y el resto de la familia web)
11.1 Modelos de hilos comparados
┌── SPRING MVC: THREAD-PER-REQUEST ─────────────────────────────────────────────┐
│ │
│ Pool de Tomcat (200 hilos por defecto) │
│ ┌───────┐ │
│ │hilo 1 │──► controlador ──► servicio ──► JDBC ──[BLOQUEADO 50 ms]──► resp. │
│ │hilo 2 │──► … │
│ │ … │ Petición 201 con los 200 hilos ocupados → ESPERA en la cola │
│ │hilo200│ │
│ └───────┘ │
│ ✅ Sencillo: pila de llamadas legible, depuración normal, JPA, ThreadLocal │
│ ❌ Un hilo del SO (≈1 MB) por petición en curso; techo bajo con E/S lenta │
└───────────────────────────────────────────────────────────────────────────────┘
┌── SPRING WEBFLUX: EVENT LOOP ─────────────────────────────────────────────────┐
│ │
│ Netty: tantos event loops como núcleos (p. ej. 8) │
│ ┌───────┐ │
│ │ loop 1│──► pipeline reactivo ──► [E/S no bloqueante: SUELTA el hilo] │
│ │ loop 2│ y cuando llega el dato, otro loop continúa el pipeline │
│ └───────┘ │
│ ✅ Miles de conexiones con 8 hilos; contrapresión de extremo a extremo │
│ ❌ Cero llamadas bloqueantes permitidas (una sola PARA TODO el loop) │
│ ❌ Stack traces inútiles, depuración difícil, ThreadLocal/MDC no funcionan │
│ directamente, hay que aprender Reactor (map/flatMap/zip/switchIfEmpty…) │
└───────────────────────────────────────────────────────────────────────────────┘
┌── MVC + VIRTUAL THREADS (Java 21+): lo mejor de los dos ──────────────────────┐
│ │
│ 1 virtual thread por petición · el bloqueo DESMONTA el hilo del portador │
│ Petición ──► vthread ──► JDBC ──[se desmonta; el carrier atiende a otro]──► │
│ │
│ ✅ Concurrencia de WebFlux con código bloqueante NORMAL: JPA, MDC, try/catch │
│ ✅ Una línea de configuración; nada que reaprender │
│ ❌ No hay contrapresión: hay que limitar con pools y bulkheads │
│ ❌ synchronized puede fijar el hilo (mejor ReentrantLock en Java 21) │
└───────────────────────────────────────────────────────────────────────────────┘
| Criterio | Elige MVC (+ virtual threads) | Elige WebFlux |
|---|---|---|
| CRUD con base de datos relacional | Sí | No: R2DBC es menos maduro y pierdes JPA |
| Miles de conexiones abiertas (SSE, WebSocket, chat) | Posible | Sí |
| Contrapresión de extremo a extremo | No | Sí (solo WebFlux la ofrece) |
| Gateway / proxy con poca lógica | No | Sí (Spring Cloud Gateway es reactivo) |
| Streaming de flujos infinitos | Limitado | Sí |
| Equipo sin experiencia en Reactor | Sí | No: el coste de aprendizaje y de depuración es real |
| Facilidad de depuración y de perfilado | Sí | No |
RestTemplate, Thread.sleep, un .block()). Bloqueas un
event loop que atiende a miles de conexiones y el rendimiento se desploma por debajo del de MVC, con
el añadido de que ya no sabes depurarlo. Si migras a WebFlux, tiene que ser toda la pila
(WebClient, R2DBC, Redis reactivo) o aislar lo bloqueante en un Schedulers.boundedElastic() —lo que
en gran medida anula la ventaja—. Regla honesta: si no tienes un motivo escrito para usar WebFlux, usa MVC.
11.2 WebFlux, SSE y RSocket en la práctica
// ── WebFlux con anotaciones: casi igual que MVC, pero con Mono y Flux ───────
@RestController
@RequestMapping("/api/v1/reactivo/pedidos")
class PedidoReactivoController {
private final PedidoReactiveRepository repositorio; // R2DBC, no JPA
PedidoReactivoController(PedidoReactiveRepository repositorio) { this.repositorio = repositorio; }
@GetMapping("/{id}")
Mono<PedidoResponse> obtener(@PathVariable String id) { // 0 o 1 elemento
return repositorio.findById(id)
.map(PedidoResponse::de)
.switchIfEmpty(Mono.error(new PedidoNoEncontrado(id)));
}
@GetMapping
Flux<PedidoResponse> listar() { // 0..N elementos
return repositorio.findAll().map(PedidoResponse::de);
}
// ── SSE: eventos del servidor al cliente sobre HTTP normal ──────────────
@GetMapping(value = "/eventos", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
Flux<ServerSentEvent<PedidoEvento>> eventos() {
return flujoDeEventos()
.map(e -> ServerSentEvent.<PedidoEvento>builder()
.id(e.id())
.event("pedido-actualizado")
.data(e)
.build())
// Latido: mantiene la conexión viva a través de proxies y balanceadores
.mergeWith(Flux.interval(Duration.ofSeconds(15))
.map(t -> ServerSentEvent.<PedidoEvento>builder().comment("ping").build()));
}
}
// ── Router functions: el estilo funcional de WebFlux (sin anotaciones) ──────
@Configuration
class RutasReactivas {
@Bean
RouterFunction<ServerResponse> rutas(PedidoHandler handler) {
return RouterFunctions.route()
.GET("/fn/pedidos/{id}", handler::obtener)
.POST("/fn/pedidos", accept(MediaType.APPLICATION_JSON), handler::crear)
.onError(PedidoNoEncontrado.class,
(e, req) -> ServerResponse.notFound().build())
.build();
}
}
// ── SSE en MVC (sin WebFlux): también se puede, y suele ser suficiente ──────
@GetMapping(value = "/notificaciones", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
SseEmitter notificaciones() {
SseEmitter emisor = new SseEmitter(Duration.ofMinutes(30).toMillis());
emisores.registrar(emisor);
emisor.onCompletion(() -> emisores.eliminar(emisor)); // ⚠️ limpia SIEMPRE: si no,
emisor.onTimeout(() -> emisores.eliminar(emisor)); // tienes una fuga de memoria
emisor.onError(e -> emisores.eliminar(emisor));
return emisor;
}
// ── RSocket: protocolo binario bidireccional con contrapresión ──────────────
@Controller
class PrecioRSocketController {
@MessageMapping("precio.actual") // request-response
Mono<Precio> actual(String sku) { return servicio.precio(sku); }
@MessageMapping("precio.stream") // request-stream
Flux<Precio> stream(String sku) {
return servicio.flujoDePrecios(sku); // el cliente controla el ritmo
}
@MessageMapping("precio.canal") // channel (bidireccional)
Flux<Precio> canal(Flux<String> skus) { return skus.flatMap(servicio::precio); }
}
// Cuándo tiene sentido: comunicación entre servicios internos con streaming en ambos
// sentidos y necesidad real de contrapresión (cotizaciones, telemetría, IoT).
// Para una API pública, HTTP sigue siendo la respuesta correcta.
11.3 WebSockets con Spring
// ── OPCIÓN A: WebSocket "a pelo", sin STOMP. Control total, más código. ─────
@Configuration
@EnableWebSocket
class WebSocketConfig implements WebSocketConfigurer {
@Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registro) {
registro.addHandler(new ChatHandler(), "/ws/chat")
.setAllowedOrigins("https://tienda.example"); // sin comodines
}
}
class ChatHandler extends TextWebSocketHandler {
private final Map<String, WebSocketSession> sesiones = new ConcurrentHashMap<>();
@Override public void afterConnectionEstablished(WebSocketSession sesion) {
sesiones.put(sesion.getId(), sesion);
}
@Override protected void handleTextMessage(WebSocketSession sesion, TextMessage mensaje)
throws IOException {
for (WebSocketSession s : sesiones.values()) {
if (s.isOpen()) s.sendMessage(new TextMessage(mensaje.getPayload()));
}
}
@Override public void afterConnectionClosed(WebSocketSession sesion, CloseStatus estado) {
sesiones.remove(sesion.getId()); // ⚠️ o tienes una fuga de memoria segura
}
}
// ── OPCIÓN B: STOMP. Da suscripciones, destinos y mensajes a usuario. ───────
@Configuration
@EnableWebSocketMessageBroker
class StompConfig implements WebSocketMessageBrokerConfigurer {
@Override public void registerStompEndpoints(StompEndpointRegistry registro) {
registro.addEndpoint("/ws")
.setAllowedOriginPatterns("https://*.tienda.example")
.withSockJS(); // respaldo para navegadores antiguos
}
@Override public void configureMessageBroker(MessageBrokerRegistry registro) {
registro.setApplicationDestinationPrefixes("/app"); // cliente → servidor
registro.enableSimpleBroker("/topic", "/queue"); // broker EN MEMORIA
// ⚠️ ESCALADO: el broker simple vive en un solo proceso. Con 3 réplicas, un
// mensaje publicado en el pod A no llega a los suscriptores del pod B.
// Para escalar horizontalmente hace falta un broker externo:
// registro.enableStompBrokerRelay("/topic", "/queue")
// .setRelayHost("rabbitmq").setRelayPort(61613);
}
}
@Controller
class ChatStompController {
@MessageMapping("/sala/{id}") // el cliente envía a /app/sala/42
@SendTo("/topic/sala/{id}") // se difunde a los suscriptores
MensajeSalida mensaje(@DestinationVariable String id, MensajeEntrada entrada,
Principal usuario) {
return new MensajeSalida(usuario.getName(), entrada.texto(), Instant.now());
}
// Mensaje a UN usuario concreto: llega a /user/queue/avisos
@Autowired SimpMessagingTemplate plantilla;
void avisar(String usuario, String texto) {
plantilla.convertAndSendToUser(usuario, "/queue/avisos", texto);
}
}
| Tecnología | Dirección | Úsala para | Coste |
|---|---|---|---|
| Polling | Cliente pregunta | Actualizaciones poco frecuentes; lo más simple | Latencia y peticiones desperdiciadas |
| SSE | Servidor → cliente | Notificaciones, progreso, precios: la opción por defecto | Una conexión por cliente; solo texto |
| WebSocket | Bidireccional | Chat, colaboración en vivo, juegos | Estado por conexión; escalado con broker; proxies quisquillosos |
| RSocket | Bidireccional + contrapresión | Servicio a servicio con streaming | Poco extendido fuera de Spring |
Last-Event-ID), funciona con
autenticación por cabecera y se depura con curl. Solo necesitas WebSocket si el
cliente también tiene que enviar mensajes con frecuencia.
11.4 GraphQL con Spring for GraphQL
# src/main/resources/graphql/schema.graphqls
type Query {
pedido(id: ID!): Pedido
pedidos(estado: EstadoPedido, primeros: Int = 20, despuesDe: String): ConexionPedidos!
}
type Mutation {
crearPedido(entrada: CrearPedidoEntrada!): Pedido!
confirmarPedido(id: ID!): Pedido!
}
type Subscription {
pedidoActualizado(id: ID!): Pedido!
}
type Pedido {
id: ID!
estado: EstadoPedido!
total: String!
cliente: Cliente! # ← ¡OJO! Aquí nace el problema N+1
lineas: [Linea!]!
}
type Cliente { id: ID!, nombre: String!, email: String! }
enum EstadoPedido { BORRADOR, CONFIRMADO, ENVIADO, CANCELADO }
@Controller
class PedidoGraphQlController {
private final ConsultarPedidos consultar;
private final ClienteService clientes;
@QueryMapping
Pedido pedido(@Argument String id) { return consultar.porId(id); }
@QueryMapping
ConexionPedidos pedidos(@Argument EstadoPedido estado,
@Argument int primeros,
@Argument String despuesDe) {
return consultar.porCursor(estado, despuesDe, primeros);
}
@MutationMapping
Pedido crearPedido(@Argument @Valid CrearPedidoEntrada entrada) { … }
// ── ❌ EL PROBLEMA N+1 DE GRAPHQL ───────────────────────────────────────
// @SchemaMapping se ejecuta UNA VEZ POR PEDIDO: una consulta de 100 pedidos
// dispara 1 + 100 consultas de clientes.
@SchemaMapping(typeName = "Pedido", field = "cliente")
Cliente clienteMal(Pedido pedido) { return clientes.porId(pedido.clienteId()); }
// ── ✅ LA SOLUCIÓN: @BatchMapping (DataLoader por debajo) ───────────────
// Recibe TODOS los pedidos del nivel de golpe y hace UNA sola consulta.
@BatchMapping(typeName = "Pedido", field = "cliente")
Map<Pedido, Cliente> clientes(List<Pedido> pedidos) {
Set<String> ids = pedidos.stream().map(Pedido::clienteId).collect(toSet());
Map<String, Cliente> porId = clientes.porIds(ids); // 1 consulta
return pedidos.stream().collect(toMap(p -> p, p -> porId.get(p.clienteId())));
}
// Suscripción (necesita WebFlux o WebSocket)
@SubscriptionMapping
Flux<Pedido> pedidoActualizado(@Argument String id) { return flujoDeCambios(id); }
}
spring:
graphql:
graphiql:
enabled: true # ⚠️ interfaz de pruebas: SOLO en local
path: /graphiql
schema:
printer:
enabled: true
# Protecciones OBLIGATORIAS en una API GraphQL pública:
# · límite de PROFUNDIDAD de consulta (MaxQueryDepthInstrumentation)
# · límite de COMPLEJIDAD (MaxQueryComplexityInstrumentation)
# · lista de consultas permitidas o consultas persistidas
# Sin ellas, un cliente puede pedir cliente→pedidos→cliente→pedidos… 20 niveles
# y tumbar la base de datos con una sola petición: es un DoS trivial.
12 · Perfil de producción
12.1 Lo que hay que configurar antes de desplegar
Esta lista es la diferencia entre «funciona en mi máquina» y «funciona a las tres de la mañana un viernes». Marca cada punto: todos corresponden a incidentes reales provocados por omitirlos.
12.2 El application-prod.yml que yo escribiría
spring:
config:
activate:
on-profile: prod
main:
banner-mode: off
lazy-initialization: false # nunca lazy en producción: esconde fallos hasta la 1ª petición
datasource:
url: ${DB_URL}
username: ${DB_USER}
password: ${DB_PASSWORD}
hikari:
maximum-pool-size: 10 # regla: núcleos×2 + husos… y CUENTA LAS RÉPLICAS
minimum-idle: 5
connection-timeout: 3000 # esperar una conexión libre: falla rápido
validation-timeout: 2000
idle-timeout: 600000
max-lifetime: 1200000 # menor que el wait_timeout del servidor de base de datos
leak-detection-threshold: 20000 # avisa de conexiones que nadie devuelve
jpa:
hibernate:
ddl-auto: validate
open-in-view: false # ⚠️ el valor por defecto (true) es una trampa: ver módulo 05
properties:
hibernate:
jdbc:
batch_size: 50
time_zone: UTC
order_inserts: true
query:
fail_on_pagination_over_collection_fetch: true
flyway:
enabled: true
validate-on-migrate: true
threads:
virtual:
enabled: true
jackson:
default-property-inclusion: non_null
server:
port: 8080
shutdown: graceful # deja de aceptar y termina lo que hay en curso
compression:
enabled: true
min-response-size: 2KB
error:
include-message: never
include-stacktrace: never
include-binding-errors: never
whitelabel:
enabled: false
tomcat:
threads:
max: 200 # casi irrelevante con virtual threads activados
max-connections: 8192
accept-count: 100
max-http-form-post-size: 2MB
connection-timeout: 20s
management:
server:
port: 9090 # puerto de gestión separado y NO publicado
endpoints:
web:
exposure:
include: health,info,metrics,prometheus,loggers
endpoint:
health:
show-details: when-authorized
probes:
enabled: true
tracing:
sampling:
probability: 0.05
logging:
level:
root: INFO
com.tienda: INFO
org.hibernate.SQL: WARN # ⚠️ DEBUG aquí llena el disco en horas
structured:
format:
console: ecs
springdoc:
swagger-ui:
enabled: false
# Arranque en producción: los flags que sí importan
java \
-XX:MaxRAMPercentage=75 \
-XX:+UseG1GC -XX:MaxGCPauseMillis=200 \
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/heap.hprof \
-XX:+ExitOnOutOfMemoryError \
-Dfile.encoding=UTF-8 -Duser.timezone=UTC \
-jar /app/app.jar
# ⚠️ ExitOnOutOfMemoryError es clave en Kubernetes: tras un OOM la JVM queda en un estado
# inconsistente. Es mejor que muera y el orquestador arranque una instancia nueva.
12.3 Tiempo de arranque: lazy init, CDS, AOT y nativo
// Instrumentar el arranque para saber DÓNDE se va el tiempo (en lugar de adivinar)
public static void main(String[] args) {
var app = new SpringApplication(TiendaApplication.class);
app.setApplicationStartup(new BufferingApplicationStartup(4096)); // guarda cada paso
app.run(args);
}
// Después: GET /actuator/startup (se consume una sola vez y devuelve el timeline completo)
| Técnica | Mejora típica | Coste / riesgo |
|---|---|---|
| Quitar dependencias que no usas | 10–30% | Ninguno. Empieza siempre por aquí. |
Acotar el @ComponentScan | 5–20% | Ninguno. |
Mover trabajo a ApplicationReadyEvent | Variable, a veces enorme | Ninguno; además mejora el comportamiento de las sondas. |
spring.main.lazy-initialization=true | 30–50% | Solo en desarrollo: los fallos de configuración se descubren con la primera petición. |
| CDS (Class Data Sharing, Boot 3.3+) | 20–40% | Bajo: un paso más en el build. La mejor relación coste/beneficio. |
| Procesamiento AOT | 10–20% sobre la JVM | Medio: menos flexibilidad en tiempo de ejecución. |
| Imagen nativa (GraalVM) | Arranque de ~50 ms y mucha menos memoria | Alto: build muy lento, reflexión que hay que declarar, sin JIT (menor pico de rendimiento) y depuración distinta. |
# CDS con Spring Boot 3.3+: tres comandos y un 25-35% menos de arranque
java -Djarmode=tools -jar app.jar extract --destination app-extraido
cd app-extraido
java -XX:ArchiveClassesAtExit=app.jsa -Dspring.context.exit=onRefresh -jar app.jar
java -XX:SharedArchiveFile=app.jsa -jar app.jar # arranque acelerado
# Imagen nativa (solo si de verdad necesitas arranque instantáneo: serverless, CLI)
./mvnw -Pnative native:compile # tarda MINUTOS y consume mucha RAM
./target/tienda-api # arranca en ~50 ms con una fracción de la memoria
# Los detalles de AOT, CDS, GraalVM y @ImportRuntimeHints están en el módulo 11.
13 · Testing en Spring (resumen)
Este apartado es un mapa; el terreno completo está en el módulo 07. Lo que hay que interiorizar aquí es una sola idea: arrancar el contexto de Spring es caro, así que la pregunta correcta en cada test es «¿cuánto contexto necesito de verdad?».
| Anotación | Qué arranca | Velocidad | Para qué |
|---|---|---|---|
| Sin anotaciones | Nada: new MiServicio(mock, mock) | < 10 ms | El 80% de tus tests. Lógica de negocio pura. |
@WebMvcTest | Controladores, advices, converters y filtros de MVC | 1–2 s | Rutas, status, serialización, validación y mapeo de errores. |
@DataJpaTest | JPA, repositorios y base de datos (embebida o Testcontainers) | 2–4 s | Consultas, mapeos y migraciones. |
@JsonTest | Solo Jackson | < 1 s | Contratos de serialización de los DTOs. |
@RestClientTest | Clientes HTTP con servidor simulado | < 1 s | Clientes salientes, timeouts y traducción de errores. |
@SpringBootTest | Toda la aplicación | 5–20 s | Unos pocos flujos críticos de extremo a extremo. |
13.1 Slices: probar una capa sin arrancar el mundo
@WebMvcTest(PedidoController.class)
@Import(ManejadorGlobalDeErrores.class) // el advice no entra solo si está en otro paquete
class PedidoControllerTest {
@Autowired MockMvc mockMvc; // NO abre puerto: invoca el DispatcherServlet
@Autowired ObjectMapper mapper;
// @MockitoBean desde Boot 3.4; @MockBean en versiones anteriores (deprecado en 3.4+).
// Si dudas de la versión del proyecto, comprueba cuál de las dos importa el IDE.
@MockitoBean CrearPedido crearPedido;
@MockitoBean ConsultarPedidos consultarPedidos;
@Test
void crea_un_pedido_y_devuelve_201_con_location() throws Exception {
given(crearPedido.ejecutar(any(), any())).willReturn(unPedido("ABC-000042"));
mockMvc.perform(post("/api/v1/pedidos")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"clienteId": "cli-1",
"lineas": [ { "sku": "SKU-0001", "unidades": 2 } ],
"direccion_envio": { "calle": "Gran Vía 1", "cp": "28013" }
}
"""))
.andExpect(status().isCreated())
.andExpect(header().string("Location", endsWith("/api/v1/pedidos/ABC-000042")))
.andExpect(jsonPath("$.id").value("ABC-000042"))
.andExpect(jsonPath("$.estado").value("BORRADOR"));
}
@Test
void devuelve_400_con_problem_detail_si_faltan_lineas() throws Exception {
mockMvc.perform(post("/api/v1/pedidos")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{ "clienteId": "cli-1", "lineas": [] }
"""))
.andExpect(status().isBadRequest())
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
.andExpect(jsonPath("$.type").value(endsWith("/errores/validacion")))
.andExpect(jsonPath("$.errores[*].campo").value(hasItem("lineas")));
}
@Test
void devuelve_404_cuando_el_pedido_no_existe() throws Exception {
given(consultarPedidos.porId("NO-EXISTE")).willThrow(new PedidoNoEncontrado("NO-EXISTE"));
mockMvc.perform(get("/api/v1/pedidos/{id}", "NO-EXISTE"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.title").value("Pedido no encontrado"));
}
}
13.2 Integración con Testcontainers y configuración de test
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@Testcontainers
@ActiveProfiles("test")
class PedidoFlujoCompletoIT {
@Container
@ServiceConnection // ⭐ Boot 3.1+: configura el DataSource sin @DynamicPropertySource
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
@Container
@ServiceConnection
static GenericContainer<?> redis =
new GenericContainer<>("redis:7-alpine").withExposedPorts(6379);
@Autowired TestRestTemplate rest; // cliente HTTP real contra el puerto aleatorio
@LocalServerPort int puerto;
@Test
void crea_confirma_y_consulta_un_pedido() {
var creado = rest.postForEntity("/api/v1/pedidos", unaPeticion(), PedidoResponse.class);
assertThat(creado.getStatusCode()).isEqualTo(HttpStatus.CREATED);
assertThat(creado.getHeaders().getLocation()).isNotNull();
String id = creado.getBody().id();
assertThat(rest.postForEntity("/api/v1/pedidos/{id}/confirmacion", null, Void.class, id)
.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
await().atMost(Duration.ofSeconds(5)).untilAsserted(() ->
assertThat(rest.getForObject("/api/v1/pedidos/{id}", PedidoResponse.class, id)
.estado()).isEqualTo("CONFIRMADO"));
}
}
// @TestConfiguration: sustituir un bean SOLO en los tests. No se descubre por escaneo:
// hay que importarla explícitamente con @Import.
@TestConfiguration
class RelojFijoConfig {
@Bean @Primary
Clock relojFijo() { // tests deterministas con fechas
return Clock.fixed(Instant.parse("2026-07-31T10:00:00Z"), ZoneOffset.UTC);
}
}
# src/test/resources/application-test.yml
spring:
jpa:
hibernate:
ddl-auto: validate # el esquema lo crea Flyway, igual que en producción
show-sql: false
flyway:
clean-disabled: false # solo en test
main:
banner-mode: off
logging:
level:
root: WARN
com.tienda: DEBUG
tienda:
pasarela:
url: http://localhost:${wiremock.server.port:9999}
static (se comparten entre clases) y
evita @DirtiesContext salvo que sea imprescindible.
14 · Buenas y malas prácticas de arquitectura
14.1 Capas frente a hexagonal
┌── ARQUITECTURA EN CAPAS (la clásica) ────────────────────────────────────────┐
│ │
│ Controller ──► Service ──► Repository ──► Base de datos │
│ (HTTP) (negocio) (persistencia) │
│ │
│ ✅ Todo el mundo la entiende; suficiente para un CRUD │
│ ❌ Las dependencias apuntan HACIA LA BASE DE DATOS: el negocio conoce JPA │
│ ❌ Con lógica compleja, el "Service" se convierte en un cajón de 2.000 │
│ líneas y el modelo queda anémico (getters y setters sin comportamiento) │
└─────────────────────────────────────────────────────────────────────────────┘
┌── HEXAGONAL / PUERTOS Y ADAPTADORES ────────────────────────────────────────┐
│ │
│ ┌─────────────────────────────┐ │
│ REST ──┐ │ DOMINIO │ ┌── JPA │
│ gRPC ──┼─►[puerto │ Pedido, Dinero, reglas │ puerto]─┼── Kafka │
│ CLI ──┘ entrada]│ Java PURO: cero Spring, │ salida └── SMTP │
│ │ cero JPA, cero HTTP │ │
│ └─────────────────────────────┘ │
│ │
│ Las dependencias apuntan HACIA DENTRO: el dominio no sabe que existe una │
│ base de datos ni HTTP. Los adaptadores implementan SUS interfaces. │
│ │
│ ✅ El dominio se prueba sin Spring y sin base de datos, en milisegundos │
│ ✅ Cambiar de infraestructura no toca la lógica de negocio │
│ ❌ Más clases y más mapeo: sobreingeniería en un CRUD de tres tablas │
└─────────────────────────────────────────────────────────────────────────────┘
// ── EL DOMINIO: Java puro. Ni una anotación de Spring ni de JPA. ────────────
package com.tienda.pedidos.domain;
public class Pedido { // entidad de dominio, no de persistencia
private final PedidoId id;
private final ClienteId clienteId;
private final List<Linea> lineas;
private EstadoPedido estado;
private Pedido(PedidoId id, ClienteId clienteId, List<Linea> lineas) {
if (lineas.isEmpty()) throw new PedidoSinLineas(); // invariante garantizado
this.id = id;
this.clienteId = clienteId;
this.lineas = List.copyOf(lineas);
this.estado = EstadoPedido.BORRADOR;
}
public static Pedido nuevo(ClienteId cliente, List<Linea> lineas) {
return new Pedido(PedidoId.nuevo(), cliente, lineas);
}
// ⭐ El COMPORTAMIENTO vive con los datos: lo contrario de un modelo anémico
public void confirmar() {
if (estado != EstadoPedido.BORRADOR) {
throw new TransicionInvalida(estado, EstadoPedido.CONFIRMADO);
}
this.estado = EstadoPedido.CONFIRMADO;
}
public Dinero total() {
return lineas.stream().map(Linea::subtotal).reduce(Dinero.CERO, Dinero::mas);
}
}
// ── EL PUERTO DE SALIDA: una interfaz que define el DOMINIO ─────────────────
package com.tienda.pedidos.application.port;
public interface RepositorioPedidos { // el dominio dice QUÉ necesita
Optional<Pedido> buscar(PedidoId id);
Pedido guardar(Pedido pedido);
List<Pedido> deCliente(ClienteId cliente);
}
// ── EL ADAPTADOR: la infraestructura implementa el puerto ───────────────────
package com.tienda.pedidos.infrastructure.persistence;
@Repository // aquí SÍ van las anotaciones
class RepositorioPedidosJpa implements RepositorioPedidos {
private final PedidoJpaRepository jpa; // Spring Data
private final PedidoMapper mapper; // traduce dominio ↔ entidad JPA
RepositorioPedidosJpa(PedidoJpaRepository jpa, PedidoMapper mapper) {
this.jpa = jpa;
this.mapper = mapper;
}
@Override public Optional<Pedido> buscar(PedidoId id) {
return jpa.findById(id.valor()).map(mapper::aDominio);
}
@Override public Pedido guardar(Pedido pedido) {
return mapper.aDominio(jpa.save(mapper.aEntidad(pedido)));
}
@Override public List<Pedido> deCliente(ClienteId cliente) {
return jpa.findByClienteId(cliente.valor()).stream().map(mapper::aDominio).toList();
}
}
pagos hexagonal y el de catalogo en capas, en el mismo proyecto. El error
grave es aplicar hexagonal por moda y acabar con seis carpetas por cada findById.
14.2 Las reglas que revisaría en tu código
| Regla | ❌ Mal | ✅ Bien | Por qué |
|---|---|---|---|
| El controlador no lleva lógica | Cálculos, if de negocio y acceso al repositorio dentro del @RestController. |
Valida el formato, delega en un caso de uso y traduce el resultado a HTTP. | Si la lógica está en el controlador, no se puede reutilizar desde un consumidor de Kafka ni desde un job, y solo se puede probar con MockMvc. |
| Nunca la entidad en la respuesta | return repositorio.findById(id) |
return PedidoResponse.de(...) |
Fugas de datos, LazyInitializationException y acoplamiento del contrato al esquema (sección 6.4). |
| Servicios sin estado | private int contador; o un SimpleDateFormat como campo de un @Service. |
Solo colaboradores final e inmutables; el estado, en parámetros y variables locales. |
Los singletons se comparten entre todos los hilos: estado mutable equivale a condición de carrera. |
| Transacciones en el servicio | @Transactional en el controlador, o en el repositorio método a método. |
@Transactional en el método del servicio que representa la operación completa. |
La frontera de la transacción es la de la unidad de trabajo. En el controlador abarcaría también la serialización JSON, alargando el uso de la conexión. |
Constructor con final |
@Autowired en campos. |
Inyección por constructor (o @RequiredArgsConstructor). |
Los seis motivos de la sección 2.5. |
| El contexto no es un localizador | applicationContext.getBean(X.class) en la lógica. |
Inyecta lo que necesitas; si es dinámico, un Map<String, Estrategia> o un ObjectProvider. |
Dependencias invisibles, imposibles de analizar estáticamente y de sustituir en un test. |
| Evita el «God service» | PedidoService de 1.500 líneas con 40 métodos y 12 dependencias. |
Un caso de uso por clase (ConfirmarPedido, CancelarPedido) o servicios por subdominio. |
El número de parámetros del constructor es tu métrica gratuita de cohesión: más de cinco o seis es un olor claro. |
| Paquetes por feature | controller/, service/, util/ con 60 clases cada uno. |
pedidos/, catalogo/, pagos/. |
Cohesión, encapsulación real con package-private y posibilidad de extraer módulos (sección 4.2). |
| Excepciones de dominio tipadas | throw new RuntimeException("error") |
throw new CreditoInsuficiente(cliente, disponible, solicitado) |
El advice puede mapearlas a un status y a un ProblemDetail con datos útiles. |
| Nada de Spring en el dominio puro | Value objects y entidades anotados con estereotipos. | El dominio es Java puro; los adaptadores llevan las anotaciones. | El dominio debe compilar y probarse sin Spring en el classpath. |
| Configuración agrupada y validada | @Value repartidos por treinta clases. |
Records @ConfigurationProperties por área. |
Sección 5.3. |
| Idempotencia en las escrituras | POST /pagos sin clave: un reintento del cliente cobra dos veces. |
Idempotency-Key persistida con el resultado y unicidad garantizada en base de datos. |
En una red, un timeout no significa «no se hizo». Ver módulo 08. |
// ── ❌ ANTIPATRÓN COMPLETO: todo lo que no hay que hacer, en veinte líneas ───
@RestController
public class PedidoControllerMalo {
@Autowired private PedidoRepository repositorio; // inyección por campo
@Autowired private ApplicationContext contexto; // service locator
@Autowired private EntityManager em; // el controlador toca JPA
private int pedidosProcesados; // estado mutable en un singleton
private final SimpleDateFormat formato = new SimpleDateFormat("dd/MM/yyyy"); // no thread-safe
@Transactional // transacción en el controlador
@PostMapping("/crearPedido") // verbo en la URL
public Pedido crear(@RequestBody Pedido pedido) { // ¡la ENTIDAD como DTO de entrada!
BigDecimal total = BigDecimal.ZERO;
for (LineaPedido l : pedido.getLineas()) {
Producto p = repositorio.buscarProducto(l.getSku()); // N+1 garantizado
total = total.add(p.getPrecio().multiply(new BigDecimal(l.getUnidades())));
if (p.getStock() < l.getUnidades()) {
throw new RuntimeException("sin stock"); // excepción genérica → 500
}
}
pedido.setTotal(total); // lógica en el controlador
pedidosProcesados++; // condición de carrera
var notificador = contexto.getBean(Notificador.class); // localizador de servicios
notificador.enviar(pedido.getEmail()); // efecto externo DENTRO de la TX
return repositorio.save(pedido); // devuelve la entidad
}
}
// ── ✅ LA MISMA FUNCIONALIDAD, BIEN ────────────────────────────────────────
@RestController
@RequestMapping("/api/v1/pedidos")
class PedidoControllerBueno {
private final CrearPedido crearPedido; // caso de uso, por constructor
PedidoControllerBueno(CrearPedido crearPedido) { this.crearPedido = crearPedido; }
@PostMapping
ResponseEntity<PedidoResponse> crear(@Valid @RequestBody CrearPedidoRequest peticion,
UriComponentsBuilder uri) {
Pedido creado = crearPedido.ejecutar(peticion.aComando()); // solo delega
return ResponseEntity
.created(uri.path("/api/v1/pedidos/{id}")
.buildAndExpand(creado.id().valor()).toUri())
.body(PedidoResponse.de(creado)); // DTO de salida
}
}
@Service
class CrearPedido { // un caso de uso, una clase
private final RepositorioPedidos pedidos; // puertos, no implementaciones
private final CatalogoProductos catalogo;
private final ApplicationEventPublisher eventos;
CrearPedido(RepositorioPedidos pedidos, CatalogoProductos catalogo,
ApplicationEventPublisher eventos) {
this.pedidos = pedidos;
this.catalogo = catalogo;
this.eventos = eventos;
}
@Transactional // la unidad de trabajo, aquí
Pedido ejecutar(CrearPedidoComando comando) {
// Una sola consulta para todos los SKU: no hay N+1
Map<Sku, Producto> productos = catalogo.porSkus(comando.skus());
// La entidad valida sus invariantes y calcula el total: el negocio vive en el dominio
Pedido pedido = Pedido.nuevo(comando.clienteId(), comando.lineas(), productos);
Pedido guardado = pedidos.guardar(pedido);
// El correo se enviará tras el COMMIT, no dentro de la transacción (sección 2.11)
eventos.publishEvent(new PedidoCreado(guardado.id(), guardado.total()));
return guardado;
}
}
15 · Errores comunes y cómo solucionarlos
| Error / síntoma | Causa habitual | Solución |
|---|---|---|
NoSuchBeanDefinitionException: No qualifying bean of type 'X' |
La clase no está en un paquete escaneado; falta el estereotipo; una condición @Conditional no se cumple; el perfil que la define no está activo. |
Comprueba el paquete respecto a la clase @SpringBootApplication; añade @Component o un @Bean; arranca con --debug y busca en Negative matches; revisa /actuator/beans. |
NoUniqueBeanDefinitionException: expected single matching bean but found 2 |
Dos implementaciones del mismo tipo sin desambiguar. | @Primary en la habitual o @Qualifier (mejor tipado) en el punto de inyección; o inyecta List/Map si de verdad quieres todas. |
The dependencies of some of the beans form a cycle |
Dependencia circular por constructor (prohibida desde Boot 2.6). | Extrae la responsabilidad compartida, invierte con un evento o introduce una interfaz. @Lazy solo como parche temporal (sección 2.10). |
@Transactional «no hace nada»: no hay rollback |
(a) autoinvocación this.metodo(); (b) el método es private o final; (c) se lanzó una excepción checked (no provoca rollback por defecto); (d) se capturó la excepción dentro del método; (e) el motor de la base de datos no soporta transacciones (MyISAM). |
Separar en otro bean; método public y no final; @Transactional(rollbackFor = Exception.class); no tragarse la excepción; o usar TransactionTemplate. |
@Async se ejecuta en el mismo hilo |
Autoinvocación, falta @EnableAsync, o se llama desde @PostConstruct. |
Llamar desde otro bean, añadir @EnableAsync y mover la llamada a ApplicationReadyEvent. |
@Cacheable nunca acierta |
Autoinvocación; la clave incluye un objeto sin equals/hashCode; TTL demasiado corto; unless descarta siempre. |
Verifica con /actuator/metrics/cache.gets; define una key explícita con SpEL; separa el bean. |
LazyInitializationException: could not initialize proxy - no Session |
Se serializa una entidad con relaciones LAZY fuera de la transacción (típico al devolver la entidad desde el controlador). |
Usa DTOs y mapea dentro de la transacción; JOIN FETCH o @EntityGraph para lo que necesites; spring.jpa.open-in-view=false (que enmascara el problema). Ver módulo 05. |
415 Unsupported Media Type |
El cliente no envía Content-Type: application/json, o el endpoint declara otro consumes. |
Ajustar la cabecera del cliente o el consumes; en multipart, usar @RequestPart. |
400 con HttpMessageNotReadableException |
JSON malformado, un campo con tipo incorrecto, una fecha con formato inesperado o un enum con valor desconocido. | Mapear la excepción a un ProblemDetail que indique la ruta del campo (sección 6.6) y documentar los formatos en OpenAPI. |
Todos los campos del DTO llegan null |
Falta @RequestBody; los nombres del record no coinciden con el JSON; el cliente envía form-urlencoded; naming strategy distinta (snake vs camel). |
Añadir @RequestBody; alinear @JsonProperty o la estrategia de nombres; comprobar el Content-Type real con curl -v. |
Un campo obligatorio de un record llega null y nadie se queja |
Un record no admite valores por defecto: lo que el cliente omite es null (o 0 en primitivos). |
@NotNull/@NotBlank explícitos y @Valid en el parámetro. No hay red de seguridad implícita. |
IllegalArgumentException: Could not resolve placeholder 'x.y' |
@Value sin valor por defecto y propiedad ausente; nombre mal escrito (@Value no tiene relaxed binding); el fichero de perfil no se carga. |
@Value("${x.y:porDefecto}"); verificar con /actuator/env; pasar a @ConfigurationProperties. |
| El perfil no se aplica | spring.profiles.active definido dentro de application-prod.yml (un perfil no puede activarse a sí mismo); nombre de fichero incorrecto; un .properties tapando el .yml. |
Activarlo por variable de entorno o argumento y comprobar el log de arranque («The following 1 profile is active»). |
Web server failed to start. Port 8080 was already in use |
Otra instancia viva, o un test que no cerró su contexto. | lsof -i :8080 y matar el proceso; en tests, server.port=0 con @LocalServerPort. |
| Whitelabel Error Page en lugar de tu JSON | No hay @ExceptionHandler para esa excepción; el @ControllerAdvice está fuera de un paquete escaneado; el Accept es text/html. |
Añadir el handler; server.error.whitelabel.enabled=false; comprobar que el advice se registra (/actuator/beans). |
CORS bloqueado en el navegador (pero curl funciona) |
Falta la configuración de CORS, o Spring Security responde al OPTIONS antes de aplicarla; allowCredentials(true) junto a allowedOrigins("*"). |
http.cors(...) en el SecurityFilterChain más un CorsConfigurationSource; usar allowedOriginPatterns (sección 6.10). |
Todo devuelve 401 o 403 tras añadir Spring Security |
Su autoconfiguración protege todos los endpoints por defecto; falta CSRF en peticiones no-GET desde formularios; el usuario generado aparece en el log. | Definir un SecurityFilterChain explícito; deshabilitar CSRF solo en APIs sin cookies; ver módulo 10. |
404 en /api/pedidos/ pero funciona sin la barra final |
Spring 6 eliminó el trailing slash match por defecto (generaba URLs duplicadas y problemas de caché y SEO). | Corregir el cliente (lo correcto); como parche, configurar PathPatternParser con setUseTrailingSlashMatch(true). |
Doble barra o rutas duplicadas: /api//pedidos |
Concatenación manual de un prefijo terminado en / con un @RequestMapping que también empieza por /. |
Un solo criterio: prefijo en la clase sin barra final y métodos siempre con barra inicial. Usa spring.mvc.servlet.path para un prefijo global. |
ClassCastException: com.sun.proxy.$Proxy123 cannot be cast to MiServicio |
Proxy JDK (basado en interfaz) inyectado donde se espera la clase concreta. | Inyectar la interfaz, o forzar CGLIB con proxyTargetClass = true (que ya es el valor por defecto en Boot). |
BeanCurrentlyInCreationException o el aviso «is not eligible for getting processed by all BeanPostProcessors» |
Un BeanPostProcessor o una @Configuration depende de beans de negocio y fuerza su creación demasiado pronto. |
Usar ObjectProvider o @Lazy en los post-processors; no inyectar beans de aplicación en infraestructura de arranque (sección 2.9). |
| Arranque de cuarenta segundos | @ComponentScan demasiado amplio; muchas autoconfiguraciones activas; trabajo pesado en @PostConstruct; conexiones abiertas al arrancar. |
/actuator/startup ordenado por duración; limitar el escaneo; mover el trabajo a ApplicationReadyEvent; CDS (sección 12.3). |
| La aplicación se «cuelga» bajo carga con la CPU baja | Pool de conexiones o de hilos agotado; llamada HTTP sin timeout; synchronized en el camino caliente. |
/actuator/threaddump y métricas de Hikari (hikaricp.connections.pending); poner timeouts; revisar bloqueos (módulo 03). |
La tarea @Scheduled se ejecuta N veces |
N réplicas del servicio. | ShedLock o un CronJob de Kubernetes; y hacer la tarea idempotente de todas formas (sección 9.2). |
OutOfMemoryError tras semanas funcionando |
Caché sin límite; colección estática que crece; ThreadLocal/MDC sin limpiar en un pool; sesiones WebSocket que no se eliminan. |
Límite y TTL en todas las cachés; finally { MDC.clear() }; heap dump y Eclipse MAT (módulo 01). |
| Las variables de entorno no se aplican | Traducción incorrecta del nombre; @Value no admite relaxed binding; la variable está en tu shell pero no en el contenedor. |
SPRING_DATASOURCE_URL (mayúsculas, puntos a _, guiones eliminados); usar @ConfigurationProperties; comprobar con /actuator/env. |
| Un correo se envía aunque la transacción falle | El efecto externo está dentro del método transaccional, o el oyente usa @EventListener en lugar de la fase posterior al commit. |
@TransactionalEventListener(phase = AFTER_COMMIT) (sección 2.11). |
16 · Preguntas de entrevista
¿Qué es la inversión de control y en qué se diferencia de la inyección de dependencias?
IoC es el principio: el control del ciclo de vida y del ensamblado de los objetos pasa de mi
código al contenedor. DI es la técnica concreta con la que Spring lo implementa: las
dependencias se entregan al objeto en lugar de que él las busque o las construya. El beneficio real no
es escribir menos new, sino que mis clases dependan de abstracciones sustituibles en tests y en
producción: es el principio de inversión de dependencias (la D de SOLID) aplicado a toda la aplicación. Hay IoC
que no es DI: el patrón template method (JdbcTemplate controla el flujo y tú aportas el
RowMapper) o los callbacks del ciclo de vida.
¿Por qué inyección por constructor y no por campo?
Seis razones: (1) las dependencias son explícitas en la firma, y un constructor de nueve
parámetros delata una clase que hace demasiado; (2) permite campos final, es decir
inmutabilidad y visibilidad garantizada entre hilos; (3) la clase se puede
instanciar sin Spring, así que los tests son de milisegundos; (4) las
dependencias circulares fallan en el arranque en lugar de esconderse; (5) se puede
validar en construcción; (6) evita la tentación de inyectar el ApplicationContext
y convertir el contenedor en un localizador de servicios. Con @RequiredArgsConstructor de Lombok no
hay ni penalización de verbosidad.
¿Los beans singleton de Spring son thread-safe?
No por sí mismos. «Singleton» en Spring significa una instancia por contenedor, y esa instancia se
comparte entre todos los hilos de petición. Es segura solo si no tiene estado
mutable: campos final apuntando a colaboradores que también son sin estado. En el momento
en que añades un private int contador, un SimpleDateFormat, un
StringBuilder como campo o una lista que se rellena, tienes una condición de carrera. Y un matiz
que gusta en entrevistas: el singleton de Spring no es el patrón Singleton clásico —no hay
getInstance() estático, y puedes tener varias instancias de la misma clase con nombres distintos, o
una por contexto—.
¿Cómo funciona exactamente la autoconfiguración de Spring Boot?
En cuatro pasos: (1) @EnableAutoConfiguration, dentro de
@SpringBootApplication, activa el AutoConfigurationImportSelector; (2) este lee de
todos los jars del classpath el fichero
META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports (en Boot 2.x era
spring.factories), con una clase de configuración por línea; (3) cada candidata se filtra por sus
anotaciones @Conditional*: @ConditionalOnClass (¿está la librería?),
@ConditionalOnMissingBean (¿el usuario no ha definido ya el bean?),
@ConditionalOnProperty, @ConditionalOnWebApplication…; (4) las supervivientes se
aplican al final, después de tus clases @Configuration, y por eso definir tu propio
bean siempre gana.
Para depurarlo: arranca con --debug y lee el conditions evaluation report, o consulta
/actuator/conditions.
¿Diferencia entre @Component y @Bean?
@Component (y sus especializaciones @Service, @Repository,
@Controller) se pone sobre la clase y requiere que Spring la descubra por escaneo:
sirve para tu código y Spring decide cómo construirla. @Bean se pone sobre un
método de una clase @Configuration y tú controlas la construcción: es la única
opción para clases de terceros que no puedes anotar, para creación condicional o cuando hace falta lógica
(elegir implementación, configurar timeouts, usar un builder). Un método @Bean también
admite initMethod y destroyMethod.
¿Cómo funciona @Transactional por dentro?
Es un aspecto implementado con un proxy. Al arrancar, un BeanPostProcessor detecta la anotación y
envuelve tu bean en un proxy (CGLIB por defecto en Boot). Cuando llamas al método, el
TransactionInterceptor: (1) consulta si ya hay una transacción en el hilo, en un
ThreadLocal del TransactionSynchronizationManager; (2) según la propagación
decide unirse, crear una nueva o suspender; (3) obtiene una conexión del pool y hace
setAutoCommit(false) aplicando aislamiento y timeout; (4) ejecuta tu método; (5) hace
commit si termina bien y rollback si se lanza una RuntimeException o un
Error —las excepciones checked hacen commit salvo que declares
rollbackFor—; (6) devuelve la conexión y limpia el ThreadLocal.
Consecuencia de que sea un proxy: no funciona en autoinvocaciones (this.metodo())
ni en métodos private o final.
¿Filtro, interceptor o aspecto?
Filtro (nivel Servlet): ve todas las peticiones, incluidas las de recursos
estáticos, /actuator y las de error; puede envolver y modificar cuerpos; es el sitio correcto para
traceId y MDC, seguridad, CORS, compresión y límites de tamaño. Interceptor (nivel Spring MVC):
solo ve lo que gestiona el DispatcherServlet, pero sabe qué controlador va a atender
(HandlerMethod y sus anotaciones), así que es ideal para auditoría por endpoint y métricas por
operación. Aspecto (AOP): intercepta llamadas a métodos de cualquier bean, también sin HTTP; es
lo adecuado para transacciones, caché, reintentos y cronómetros de servicios. Regla mnemotécnica: filtro =
protocolo, interceptor = endpoint, aspecto = método.
¿Cómo versionarías una API REST?
Por defecto, versión en la URI (/api/v1/pedidos): es visible en logs y trazas,
cacheable, trivial de enrutar en el gateway y fácil de documentar. La alternativa más «correcta» según HTTP es el
media type (Accept: application/vnd.tienda.pedido.v2+json), que versiona la
representación y no el recurso, pero es más difícil de probar y las herramientas lo soportan peor. La cabecera
propia queda a medio camino y obliga a usar Vary en las cachés. Lo importante es el resto de la
respuesta: versiona lo mínimo —la mayoría de los cambios son retrocompatibles si solo añades
campos opcionales—, pon fecha de retirada a la versión anterior con cabeceras
Deprecation y Sunset, y mide el uso por versión con una métrica para
poder apagarla algún día.
¿DTO o entidad en la respuesta de un controlador?
DTO, siempre. Devolver la entidad JPA tiene cinco problemas concretos: (1) fuga de datos,
porque cualquier columna nueva se publica automáticamente (costes, márgenes, hashes); (2)
acopla el contrato público al esquema, así que renombrar una columna rompe a los clientes; (3)
LazyInitializationException cuando Jackson recorre relaciones perezosas fuera de la sesión; (4)
recursión infinita en relaciones bidireccionales; (5) consultas N+1 disparadas
por la propia serialización. Con records y un método factoría estático el coste de escribir DTOs es mínimo, y hay
un único sitio que decide qué se expone.
¿Qué es ProblemDetail y por qué usarlo?
Es la implementación en Spring 6 del estándar RFC 9457 (que reemplaza al RFC 7807),
«Problem Details for HTTP APIs»: un formato común de error con type (una URI que identifica el tipo
de problema), title, status, detail e instance, servido como
application/problem+json y extensible con propiedades propias (traceId, lista de
errores de validación, datos de negocio). Se usa para que cada equipo no invente su formato: los clientes y los
generadores de SDK pueden programar contra type en lugar de parsear textos. En Spring se activa con
spring.mvc.problemdetails.enabled=true para las excepciones estándar de MVC, y se personaliza
devolviendo ProblemDetail desde un @ExceptionHandler o usando
ErrorResponseException.
¿Cuándo elegirías WebFlux en 2026?
Rara vez, y con motivos concretos: (a) necesito contrapresión real de extremo a extremo; (b)
hago streaming de flujos largos o infinitos (SSE, WebSockets con mucho tráfico, respuestas
incrementales); (c) construyo un gateway o un proxy con miles de conexiones y muy poca lógica;
(d) el equipo ya domina Reactor y toda la pila es reactiva (R2DBC, drivers reactivos). Para el resto,
Spring MVC con virtual threads (spring.threads.virtual.enabled=true, Java 21+) da
una concurrencia comparable con código bloqueante normal, JPA, depuración sencilla y sin curva de aprendizaje. Lo
peor de todo es WebFlux con una llamada JDBC bloqueante dentro: paraliza el event loop y obtienes lo
malo de los dos modelos.
¿Cómo depurarías un arranque lento de cuarenta segundos?
Con datos, no por intuición: (1) registro un BufferingApplicationStartup y consulto
/actuator/startup ordenado por duración, que me dice exactamente qué paso tarda; (2) arranco con
--debug y reviso cuántas autoconfiguraciones se aplican y si hay dependencias que no uso; (3)
compruebo el alcance del @ComponentScan, que es la causa número uno; (4) busco trabajo pesado en
constructores y en @PostConstruct (llamadas de red, precarga de cachés) y lo muevo a
ApplicationReadyEvent; (5) mido la conexión inicial a la base de datos y el
minimum-idle de Hikari; (6) si aún es lento, aplico CDS (Boot 3.3+), que da un
20–40% con muy poco esfuerzo, y solo en último caso AOT o imagen nativa. En desarrollo,
spring.main.lazy-initialization=true alivia mucho, pero nunca en producción.
¿Cuál es el orden de precedencia de la configuración?
De mayor a menor: propiedades de test (@TestPropertySource) → argumentos de línea de
comandos → SPRING_APPLICATION_JSON → propiedades del sistema (-D) →
variables de entorno → application-{perfil}.yml fuera del jar →
application-{perfil}.yml dentro → application.yml fuera →
application.yml dentro → @PropertySource → valores por defecto. Dos consecuencias
prácticas: una variable de entorno siempre puede sobrescribir el yml empaquetado (por eso funciona la
configuración en Docker y Kubernetes), y los ficheros de perfil se superponen propiedad a propiedad
sobre application.yml, no lo reemplazan. Y un detalle que sorprende: si coexisten
application.properties y application.yml, gana el .properties.
¿Qué hace exactamente @SpringBootApplication?
Es una anotación compuesta de tres: @SpringBootConfiguration (que es
@Configuration más la marca de «configuración principal», usada por los tests para localizarla),
@EnableAutoConfiguration (activa la autoconfiguración) y @ComponentScan (escanea el
paquete de la clase anotada y sus subpaquetes, con filtros para excluir las propias
autoconfiguraciones y aplicar los TypeExcludeFilter de test). De ahí la regla práctica: la clase
principal va en la raíz del paquete base; si la esconden en un subpaquete, media aplicación no
se escanea y aparecen NoSuchBeanDefinitionException desconcertantes.
¿Cómo pruebas solo la capa web?
Con @WebMvcTest, que arranca un contexto reducido: solo controladores,
@ControllerAdvice, converters de Jackson, resolvers de argumentos y filtros de MVC; ni repositorios,
ni @Service, ni base de datos. Las dependencias del controlador se sustituyen con
@MockitoBean (Boot 3.4+; @MockBean en versiones anteriores, hoy deprecado) y se
ejercita con MockMvc, que no abre un puerto: invoca el
DispatcherServlet directamente, así que arranca en uno o dos segundos. Se comprueban rutas, códigos
de estado, cabeceras, serialización, validación y el mapeo de excepciones a ProblemDetail. Para el
flujo completo con base de datos real se usa @SpringBootTest con Testcontainers, pero solo en unos
pocos casos críticos.
¿Qué diferencia hay entre @Valid y @Validated?
@Valid es de Jakarta Bean Validation y se usa en parámetros (típicamente
@Valid @RequestBody) y en campos para validación en cascada de objetos anidados.
@Validated es de Spring y aporta dos cosas que @Valid no puede: (1) soporta
grupos de validación (@Validated(Creacion.class)); (2) puesta a nivel de
clase, activa un proxy que valida @RequestParam, @PathVariable, parámetros de
métodos de servicio y @ConfigurationProperties. En un controlador es habitual usar ambas. Ojo con el
tipo de excepción resultante: @Valid en el cuerpo produce
MethodArgumentNotValidException, mientras que la validación de parámetros produce
HandlerMethodValidationException (Spring 6.1+) o ConstraintViolationException, y hay que
mapear ambas a 400.
¿Por qué mi @Transactional, @Async o @Cacheable no funciona si lo llamo desde el mismo objeto?
Porque todas esas anotaciones se implementan con un proxy que envuelve tu bean. Cuando otro
bean te llama, la llamada pasa por el proxy y el aspecto se ejecuta. Pero cuando haces this.metodo()
la llamada es una invocación Java normal dentro del objeto original: el proxy no está en medio y el
aspecto no se aplica. No hay ningún aviso; el código simplemente no hace lo que la anotación promete. La solución
correcta es separar el método en otro bean, para que la llamada vuelva a ser externa.
Alternativas peores: auto-inyectarse con @Lazy, usar AopContext.currentProxy() (requiere
exposeProxy = true) o, para transacciones, gestionar el flujo con TransactionTemplate. Y
por el mismo motivo, los métodos private, static y final nunca se
interceptan.
¿Qué es un scoped proxy y cuándo lo necesito?
Es un proxy que se inyecta en lugar del bean real cuando este tiene un scope más corto que quien lo
recibe: por ejemplo un bean de scope request inyectado en un singleton. Sin él,
el singleton capturaría una única instancia al arrancar —cuando no hay ninguna petición— y fallaría. Con
@Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS), el singleton recibe un proxy que
en cada llamada resuelve la instancia de la petición actual. Costes: una indirección por llamada y una
IllegalStateException («No thread-bound request found») si se usa fuera de una petición HTTP, por
ejemplo desde un @Scheduled o un hilo @Async. Cuando se puede, es mejor
pasar el dato como parámetro que inyectar contexto de petición.
¿Cómo compartes configuración y componentes entre quince microservicios sin copiar y pegar?
Dos mecanismos complementarios. Para código y beans: un starter propio (sección 3.6),
con autoconfiguración condicional, @ConfigurationProperties validadas y metadatos; se publica en el
repositorio interno de artefactos y cada servicio solo añade la dependencia. Nunca un @ComponentScan
compartido. Para valores de configuración: un parent POM corporativo que fije versiones,
más Spring Cloud Config (o Consul, o simplemente ConfigMaps generados por el pipeline) para lo
que cambia por entorno; con @RefreshScope y /actuator/refresh se recargan sin
reiniciar. La contrapartida honesta de un Config Server es que se convierte en una dependencia de arranque: hay
que hacerlo altamente disponible o tolerar su caída con valores en caché (ver
módulo 08).
¿Qué pasa si dos autoconfiguraciones definen el mismo bean?
Normalmente no ocurre, porque todas usan @ConditionalOnMissingBean: la primera que se evalúa
registra el bean y la segunda se aparta. Ahí es donde importa el orden
(@AutoConfiguration(before/after)): si tu autoconfiguración se evalúa antes de que exista el bean del
que depende, la condición dará falso y tu bean no se creará sin ningún mensaje de error, que es el bug
más frustrante al escribir un starter. Si dos configuraciones registran el mismo nombre de bean sin condiciones,
la segunda sobrescribe la definición solo con
spring.main.allow-bean-definition-overriding=true; con el valor por defecto (false desde
Boot 2.1) el arranque falla con BeanDefinitionOverrideException, que es lo correcto: prefiere el
fallo explícito.
¿Qué diferencia hay entre BeanFactoryPostProcessor y BeanPostProcessor?
El BeanFactoryPostProcessor actúa sobre las definiciones (los metadatos) antes de
que se cree ningún objeto: puede cambiar una clase, marcar un bean como lazy o añadir propiedades. Es lo
que hace PropertySourcesPlaceholderConfigurer al resolver los ${...}. El
BeanPostProcessor actúa sobre las instancias ya creadas, antes y después de la
inicialización, y es el mecanismo con el que Spring crea los proxies (paso 8 del ciclo de vida) y
procesa @Autowired, @PostConstruct o @ConfigurationProperties. Detalle
práctico: los BeanPostProcessor se instancian muy pronto, así que no conviene inyectarles beans de
negocio por constructor; usa ObjectProvider.
¿Cuándo usarías eventos de Spring y cuándo una cola de mensajes?
Los eventos de ApplicationEventPublisher son un observer en memoria y en un solo proceso:
excelentes para desacoplar módulos dentro de un servicio y para separar «lo que pasó» de «lo que
hay que hacer», sobre todo con @TransactionalEventListener(AFTER_COMMIT). Pero no hay persistencia,
ni reintentos, ni garantía de entrega: si la JVM muere entre el commit y el oyente, el evento se pierde. Para
integrar servicios, o cuando la entrega tiene que estar garantizada, hacen falta Kafka o RabbitMQ y el patrón
transactional outbox (módulo 08).
17 · Ejercicios y retos
17.1 Ejercicios guiados (haz los ocho)
17.2 Retos (nivel entrevista senior)
17.3 Checklist de repaso (sin mirar apuntes)
18 · Resumen y recursos
Las quince ideas que debes llevarte de este módulo
- Spring es, antes que nada, un contenedor de objetos: declaras piezas y él las construye, las conecta, las decora y las destruye. Todo lo demás se apoya en eso.
- Inyección por constructor con campos
final, siempre. Es la diferencia entre una clase testeable y una que necesita Spring para respirar. - Los beans singleton se comparten entre todos los hilos: son seguros solo si no tienen estado mutable.
- El ciclo de vida importa: en
postProcessAfterInitializationnacen los proxies, y eso explica@Transactional,@Cacheable,@Asyncy todos sus límites. - La autoinvocación no pasa por el proxy. Es la causa número uno de anotaciones que «no funcionan», y la solución es separar el método en otro bean.
- Las dependencias circulares fallan por diseño desde Boot 2.6: son una señal de arquitectura,
no un obstáculo que sortear con
@Lazy. - La autoconfiguración no es magia:
AutoConfiguration.importsmás@Conditional*más evaluación al final. Se depura con--debugy/actuator/conditions. - Para sobrescribir un bean autoconfigurado, basta con definir el tuyo:
@ConditionalOnMissingBeanse aparta solo. - Precedencia de configuración: argumentos, variables de entorno, perfil, base, defaults. Y
@ConfigurationPropertiescon records validados en lugar de@Valuedispersos. - Nunca expongas entidades JPA en la API: DTOs de entrada y de salida separados, con validación en el borde.
- Los errores se devuelven con
ProblemDetail(RFC 9457), untraceIdy cero stack traces: el detalle técnico va al log. - Todo lo que sale de tu proceso lleva timeout: HTTP, base de datos, Redis, colas. Una llamada sin timeout es un fallo en cascada esperando su turno.
- Filtro para el protocolo, interceptor para el endpoint, aspecto para el método. Y el AOP jamás para lógica de negocio.
- Actuator y Micrometer desde el primer día, con puerto separado, sondas liveness y readiness bien distinguidas, histogramas y tags de baja cardinalidad.
- En 2026, Spring MVC con virtual threads es la opción por defecto; WebFlux solo con una razón concreta: contrapresión, streaming o gateway.
Documentación oficial
- Spring Boot Reference Documentation — la referencia. Léete completos los apartados de Externalized Configuration y Actuator: son los que más rentabilidad dan.
- Spring Framework Reference — el capítulo Core Technologies (IoC y AOP) convierte la «magia» en ingeniería.
- Common Application Properties — el índice completo de propiedades. Tenlo a mano.
- Guías oficiales (spring.io/guides) — tutoriales cortos y correctos para empezar con cada tecnología.
- Wiki de Spring Boot: release notes y guías de migración — imprescindible antes de subir de versión.
- Blog de Spring — anuncios y explicaciones de los propios mantenedores.
- Canal Spring Developer (YouTube) — charlas de SpringOne y los Spring Tips de Josh Long.
Libros y herramientas
- Spring in Action (Craig Walls, 6.ª ed.) — la mejor introducción amplia y ordenada al ecosistema.
- Spring Boot: Up & Running (Mark Heckler) — enfoque práctico y moderno, con muy buena parte de Actuator y despliegue.
- Spring Start Here (Laurentiu Spilca) — si los fundamentos de IoC y AOP no te han quedado claros, este es el libro.
- Spring Security in Action (Laurentiu Spilca) — continúa en el módulo 10.
- Herramientas: start.spring.io, Actuator,
ApplicationContextRunner, ArchUnit, Testcontainers, OpenRewrite (migraciones automáticas), springdoc-openapi y k6 o Gatling para carga. - Siguiente lectura del plan: 05 · Spring Data JPA, 07 · Testing y 12 · Entrevistas.