Plan de estudio Java 2026
Módulo 04 crítico Días 8–9 ≈ 8 h de estudio

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.

Progreso del módulo0 / 0
Requisitos previos: el módulo 01 (interfaces, inmutabilidad, excepciones, genéricos) y una idea general de hilos del módulo 03. La persistencia se ve a fondo en el módulo 05, el testing en el 07 y la seguridad en el 10: aquí solo tocaremos lo imprescindible de cada uno para no duplicar contenido.

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.
Lo que hay que recordar de esta cronología: Spring Framework aportó el contenedor y la abstracción; Spring Boot aportó las decisiones por defecto. Antes de Boot, empezar un proyecto costaba dos días de XML, versiones incompatibles y configuración de Tomcat. Con Boot cuesta dos minutos, y el precio a pagar es que hay mucho comportamiento implícito. Este módulo se dedica en gran parte a hacer explícito ese implícito.

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.

ProyectoQué aportaEjemplos concretosAnalogí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.
Respuesta de entrevista en una frase: «Spring Framework es el contenedor de inversión de control y sus abstracciones; Spring Boot es una capa de convenciones sobre Framework que autoconfigura la aplicación, empaqueta un servidor y estandariza la configuración y la monitorización; Spring Cloud añade los patrones que hacen falta cuando esa aplicación es una de veinte. Boot no sustituye a Framework: lo usa».

1.3 Versiones, baseline de Java y el salto javaxjakarta

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;
Consecuencia práctica al migrar: no basta con cambiar tus 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ónJava mínimoNamespaceEstado en 2026Nota
Boot 2.78javaxFin de soporte comercialMigrar ya; es deuda técnica con fecha de caducidad.
Boot 3.0–3.117jakartaSin soporte OSS3.1 trae RestClient y Docker Compose en dev.
Boot 3.217jakartaLegadoVirtual threads con spring.threads.virtual.enabled.
Boot 3.3–3.517 (21 recomendado)jakartaObjetivo razonable hoyCDS, @MockitoBean, mejoras de observabilidad.
Boot 4 / Spring 717+ (21/25 recomendado)jakartaActualAPI 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:

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.

BeanFactoryApplicationContext
RolContenedor mínimo: registrar y obtener beans, DI y ciclo de vida básico.Superinterfaz de BeanFactory con todo lo «empresarial».
InstanciaciónPerezosa (bajo demanda).Anticipada: crea los singletons al arrancar y falla rápido si algo está mal configurado.
ExtrasNinguno.Publicación de eventos, internacionalización (MessageSource), acceso a recursos (Resource), jerarquía de contextos, detección automática de BeanPostProcessor.
Cuándo lo usasCasi 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);
    }
}
Nunca uses el contexto como localizador de servicios. 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ónDónde vaSemántica añadidaCuándo usarla
@ComponentClaseNinguna: bean genérico.Componentes técnicos: mappers, utilidades con estado inyectado, adaptadores.
@ServiceClaseDocumental: lógica de negocio / caso de uso.Servicios de aplicación. Es un @Component con intención.
@RepositoryClaseSí 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.
@ControllerClaseSí: la detecta RequestMappingHandlerMapping.MVC con vistas. Para APIs, @RestController (= @Controller + @ResponseBody).
@ConfigurationClaseSí: la clase se proxifica para que los métodos @Bean respeten el scope.Definir beans a mano y agrupar configuración.
@BeanMétodo de una @ConfigurationEl 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);
    }
}
Detalle fino de @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 { }
Nunca escanees un paquete raíz corporativo como 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:

  1. Oculta el coste de la clase. Un constructor con nueve parámetros grita «esta clase hace demasiado»; nueve @Autowired pasan desapercibidos. La firma del constructor es tu métrica de cohesión gratuita.
  2. Impide final. Sin final no hay inmutabilidad ni garantías de visibilidad entre hilos, y cualquiera puede reasignar el campo por reflexión o por error.
  3. 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.
  4. Esconde las dependencias circulares hasta que explotan en tiempo de ejecución, en lugar de fallar en el arranque.
  5. 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.
  6. Invita al service locator: cuando inyectar es «gratis», acaba entrando el ApplicationContext.
Regla de equipo: prohíbe @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
    }
}
Diferencia con 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

ScopeUna instancia por…Uso realCuidado con
singleton (por defecto)ContenedorEl 99% de tus beans: servicios, repositorios, controladores.Se comparte entre todos los hilos: cero estado mutable.
prototypeCada petición al contenedorObjetos con estado de corta vida creados por el contenedor.Spring no gestiona su destrucción: @PreDestroy no se ejecuta.
requestPetición HTTPDatos del usuario de la petición actual.Necesita scoped proxy; falla fuera de una petición.
sessionSesión HTTPCarrito de la compra en apps con estado.Consume memoria y rompe la escalabilidad horizontal sin sesión distribuida.
applicationServletContextCasi nunca; comparte entre varios contextos de Spring.Difícil de razonar. Mejor un singleton.
websocketSesión WebSocketEstado 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.
El error de concurrencia número uno con Spring: estado mutable en un singleton. 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()"); }
}
Consecuencia práctica del paso [8]: el objeto que el contenedor entrega a los demás beans no es tu objeto, es un proxy que lo envuelve. Todo el comportamiento de @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ónActúa sobreCuándoEjemplo en Spring
BeanFactoryPostProcessorLas definiciones (metadatos)Paso [2], antes de crear nadaPropertySourcesPlaceholderConfigurer, ConfigurationClassPostProcessor
BeanPostProcessorLas instancias ya creadasPasos [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 vidaCuándo se publicaPara qué sirve
ApplicationStartingEventAntes de casi todoConfigurar logging o banderas del sistema.
ApplicationEnvironmentPreparedEventEnvironment listo, contexto noDescifrar propiedades, añadir PropertySource.
ContextRefreshedEventBeans creadosValidaciones que necesiten el contexto completo.
ApplicationStartedEventContexto refrescado, antes de los runnersPoco habitual.
ApplicationReadyEventTodo listo y aceptando tráficoEl sitio correcto para precargar cachés, avisar a un registro de servicios o lanzar trabajo inicial.
ApplicationFailedEventEl arranque fallóNotificar el fallo antes de morir.
ContextClosedEventCierre ordenadoVaciar buffers, desregistrarse.
Los eventos de Spring no son una cola de mensajes. Viven en memoria, en un solo proceso: si la JVM muere entre el commit y el oyente, el evento se pierde para siempre. No hay reintentos, ni orden garantizado entre distintos publicadores, ni persistencia. Para eso están Kafka o RabbitMQ, y el patrón transactional outbox (módulo 08). Los eventos internos son excelentes para desacoplar módulos dentro de un servicio, no para integrar servicios.

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

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
Aquí está la clave de todo: las autoconfiguraciones se registran después de las tuyas y casi todas sus definiciones llevan @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ónSe cumple si…Ejemplo real en Boot
@ConditionalOnClassLa clase está en el classpathDataSourceAutoConfiguration requiere DataSource y EmbeddedDatabaseType.
@ConditionalOnMissingClassLa clase no estáElegir una alternativa cuando falta una librería.
@ConditionalOnBeanYa existe un bean de ese tipo/nombreJpaRepositoriesAutoConfiguration necesita un DataSource.
@ConditionalOnMissingBeanNo existe ese beanEl mecanismo que te deja sobrescribir cualquier valor por defecto.
@ConditionalOnPropertyUna propiedad tiene cierto valormanagement.endpoints.web.exposure..., spring.cache.type.
@ConditionalOnWebApplicationEs web (SERVLET o REACTIVE)WebMvcAutoConfiguration solo si type = SERVLET.
@ConditionalOnNotWebApplicationEs una app de consolaBatch, CLI.
@ConditionalOnResourceExiste un recurso@ConditionalOnResource(resources = "classpath:banner.txt").
@ConditionalOnExpressionUna expresión SpEL es ciertaCondiciones compuestas.
@ConditionalOnJavaVersión de la JVMActivar virtual threads solo en Java 21+.
@ConditionalOnThreadingPlataforma o virtualElegir executor según spring.threads.virtual.enabled.
@ConditionalOnCloudPlatformSe detecta la plataformaKUBERNETES 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
Cómo leer el informe cuando algo no funciona: ve directamente a Negative matches y busca la autoconfiguración que esperabas. La línea «Did not match» te dice exactamente qué falta: una clase (te falta una dependencia), un bean (existe algo que la desplaza) o una propiedad (no la has puesto). Es literalmente el framework diciéndote por qué ha tomado cada decisión, y ahorra horas de conjeturas.

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");
    }
}
Jerarquía de preferencia al «pelearse» con Boot, de mejor a peor: (1) una propiedad en 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"));
    }
}
Cinco reglas de oro para un starter que no odien tus compañeros: (1) todo condicional, incluido un interruptor general; (2) @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>
Desde Spring Boot 3.4 no necesitas Logstash para tener logs en JSON: 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

MavenGradle
ConfiguraciónXML declarativo, muy predecibleKotlin/Groovy DSL, muy flexible
VelocidadSuficiente; con -T 1C paraleliza módulosMás rápido en repos grandes: caché de build e incremental
CurvaBaja: todos los proyectos se parecenMás alta: un build puede ser un programa
Cuándo elegirloServicios estándar, equipos grandes, CI simpleMonorepos, 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
DevTools nunca en producción. Añade endpoints de reinicio, desactiva cachés de plantillas y consume memoria. Está marcado 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)

#FuenteEjemploUso típico
1@TestPropertySource y properties de @SpringBootTest@SpringBootTest(properties = "tienda.iva=0")Solo tests.
2Devtools en ~/.config/spring-bootspring-boot-devtools.propertiesAjustes personales del desarrollador.
3Argumentos de línea de comandosjava -jar app.jar --server.port=9090Sobrescribir algo puntual al lanzar.
4SPRING_APPLICATION_JSONSPRING_APPLICATION_JSON='{"tienda":{"iva":0.10}}'Inyectar un bloque entero en la nube.
5ServletConfig / ServletContextDespliegues WAR (raro hoy).
6JNDIjava:comp/envServidores de aplicaciones legados.
7Propiedades del sistema Java-Dserver.port=9090Scripts de arranque.
8Variables de entornoSERVER_PORT=9090El estándar en Docker y Kubernetes.
9random.*${random.uuid}Puertos y secretos de test.
10application-{perfil}.yml fuera del jar./config/application-prod.ymlConfiguración montada por el operador.
11application-{perfil}.yml dentro del jarEmpaquetadoValores por entorno versionados.
12application.yml fuera del jar./config/application.ymlBase sobrescrita en el despliegue.
13application.yml dentro del jarEmpaquetadoTu configuración base.
14@PropertySourceEn una @ConfigurationFicheros heredados.
15Valores por defectoSpringApplication.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.
Trampa que cuesta una tarde: si en el mismo directorio coexisten 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());
    }
}
No abuses de @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ónPropiedad a propiedadUn objeto por área funcional
ValidaciónNo (a mano)Sí, con @Validated + Bean Validation
Relaxed bindingNo
Tipos ricosConversión básicaDuration, DataSize, URI, enums, listas, mapas, anidados
Metadatos en el IDENoSí (con el configuration-processor)
SpELSí (#{...})No
InmutabilidadCampos mutablesRecords o constructor binding
VeredictoSolo para un valor suelto y aisladoPor 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
Ningún secreto en el repositorio. Nunca. Ni en 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.

Mención para imagen nativa: si compilas a GraalVM, la reflexión y los recursos deben declararse en tiempo de compilación. Spring genera casi todo automáticamente con AOT, pero para casos propios existe @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.
VerboIdempotenteSeguroÉxitoUso
GET200, 204 si vacíoLeer. Jamás modificar estado.
POSTNoNo201 + LocationCrear o ejecutar una acción.
PUTNo200 / 204Reemplazar el recurso completo.
PATCHDependeNo200 / 204Modificación parcial.
DELETENo204Borrar. Repetirlo devuelve 204 o 404, elige y documenta.
HEAD200Como GET sin cuerpo (comprobar existencia).
OPTIONS200Preflight de CORS.
CódigoNombreCuándo usarlo exactamente
200OKLectura o actualización con cuerpo de respuesta.
201CreatedRecurso creado. Obligatorio devolver cabecera Location.
202AcceptedAceptado para procesar de forma asíncrona; devuelve una URL de seguimiento.
204No ContentÉxito sin cuerpo: DELETE, o PUT sin representación.
206Partial ContentDescargas por rangos.
301 / 308Moved PermanentlyEl recurso cambió de sitio para siempre.
304Not ModifiedRespuesta a If-None-Match/If-Modified-Since: ahorra ancho de banda.
400Bad RequestSintaxis o validación incorrecta. JSON malformado, campo obligatorio ausente.
401UnauthorizedNo autenticado (mal nombrado: significa «no sé quién eres»).
403ForbiddenAutenticado pero sin permiso. «Sé quién eres y no puedes».
404Not FoundEl recurso no existe. También para ocultar existencia a quien no tiene permiso.
405Method Not AllowedLa ruta existe pero no con ese verbo. Spring lo devuelve solo.
406Not AcceptableNo puedes producir el Accept solicitado.
409ConflictConflicto de estado: duplicado, edición concurrente, transición inválida.
410GoneExistió y se eliminó permanentemente (útil al retirar una versión de API).
412Precondition FailedIf-Match no coincide: bloqueo optimista por ETag.
415Unsupported Media TypeContent-Type no soportado. El error «misterioso» más frecuente.
422Unprocessable EntitySintaxis correcta, semántica inválida. Muchas APIs lo usan para validación de negocio.
429Too Many RequestsRate limiting. Añade Retry-After.
500Internal Server ErrorTu bug. Nunca por una entrada inválida del cliente.
502 / 504Bad Gateway / TimeoutUn servicio del que dependes falló o no respondió.
503Service UnavailableSobrecarga o mantenimiento. Añade Retry-After.
Regla mnemotécnica: 4xx = culpa del cliente (no reintentes sin cambiar la petición), 5xx = culpa del servidor (reintentar puede tener sentido). El error más común en producción es devolver 500 cuando el cliente ha enviado basura: contamina tus alertas, dispara reintentos inútiles y esconde los fallos reales. Si tu tasa de 5xx no es prácticamente cero, no puedes usarla como alarma.

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ámetroDe dónde saca el valorNota práctica
@PathVariableSegmento de la rutaCompila con -parameters o pon el nombre: @PathVariable("id").
@RequestParamQuery string o formularioUsa Optional o defaultValue; required=true es el defecto.
@RequestBodyCuerpo, vía HttpMessageConverterSi lo olvidas, todos los campos llegan null.
@RequestHeaderCabecera HTTPMarca required=false para cabeceras opcionales.
@CookieValueCookie
@RequestPartParte de un multipartPara JSON + fichero en la misma petición.
@ModelAttributeVarios query params a un objetoMuy cómodo para agrupar filtros.
@AuthenticationPrincipalUsuario autenticadoSpring Security (módulo 10).
HttpServletRequestLa 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());
        }
}
Los cinco problemas de devolver la entidad JPA directamente (y por eso es una regla, no una preferencia):
  1. Fuga de datos: cualquier columna nueva se publica sin que nadie lo decida. Costes internos, márgenes, hashes de contraseña, notas del comercial…
  2. Acoplamiento del contrato al esquema: renombrar una columna rompe a todos los clientes. Ya no puedes refactorizar la base de datos.
  3. LazyInitializationException: Jackson recorre las relaciones perezosas después de que se haya cerrado la sesión de Hibernate.
  4. Recursión infinita en relaciones bidireccionales (Pedido → Linea → Pedido → …), que se «arregla» con @JsonIgnore y ensucia el modelo.
  5. Consultas N+1 disparadas por la propia serialización: 1 + 500 SELECT para 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
) { }
Records y Jackson, dos detalles que confunden: (1) desde Jackson 2.12 los 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ónVálida enTrampa
@NotNullCualquier tipo"" y " " la pasan.
@NotEmptyString, colecciones, mapas, arrays" " la pasa.
@NotBlankSolo StringLa que quieres el 90% de las veces.
@SizeString, coleccionesNo valida null: combínala con @NotNull.
@EmailStringMuy permisiva; a@b es válido. Para verificar de verdad, envía un correo.
@Positive / @PositiveOrZeroNúmeros@Min(1) es equivalente y más explícito para enteros.
@DigitsBigDecimalImprescindible para importes: @Digits(integer=10, fraction=2).
@Past / @Futurejava.timeDepende del reloj: inyecta un Clock en los tests.
@ValidCampos y parámetrosSin 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" }
  ]
}
Nunca devuelvas la stack trace al cliente. Revela versiones de librerías, rutas del sistema de ficheros, nombres de clases internas y, con frecuencia, fragmentos de SQL: es reconocimiento gratuito para un atacante. La configuración correcta es 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.000Malo: la BD lee y descarta todo lo anteriorConstante: siempre un index seek
Saltar a una página concretaNo (solo siguiente/anterior)
Total de elementosSí (a costa de un COUNT)Normalmente no
Consistencia con insercionesMala: duplicados y saltosBuena
Úsalo paraTablas de administración con navegador de páginasFeeds, scroll infinito, exportaciones, APIs públicas

6.8 Versionado de la API (y HATEOAS)

EstrategiaEjemploVentajasInconvenientes
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.
La mejor estrategia de versionado es no necesitarla. Añadir campos opcionales, añadir endpoints y añadir valores de enum tolerados son cambios retrocompatibles. Rompen: eliminar o renombrar un campo, cambiar su tipo, hacer obligatorio algo que era opcional, cambiar el significado de un valor. Y cuando publiques una versión mayor: pon fecha de retirada desde el día uno, avisa con cabeceras y mide el uso por versión con un contador de Micrometer etiquetado, o nunca podrás apagar la vieja.

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
Contrato primero o código primero. Generar el OpenAPI desde el código (springdoc) es cómodo y siempre está sincronizado, pero el contrato es una consecuencia de la implementación. La alternativa, contract-first, escribe el YAML de OpenAPI y genera interfaces con 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)
NivelContenedor de servletsDispatcherServletInvocación de métodos de beans
AlcanceTodas las peticiones (estáticos, actuator, errores)Solo las que gestiona MVCCualquier bean, con o sin HTTP
¿Sabe qué controlador atenderá?No (HandlerMethod y sus anotaciones)Sí: método, argumentos y anotaciones
¿Puede modificar cuerpos?Sí (con wrappers)No fácilmenteSí, argumentos y retorno
Se ejecuta ante un 404NoNo
Úsalo paratraceId/MDC, seguridad, CORS, compresión, límite de tamaño, rate limitingAuditoría por endpoint, métricas por operación, cabeceras según el handlerTransacciones, 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

ClienteEstadoModeloCuándo
RestTemplateEn mantenimiento (no deprecado, pero sin evolución)BloqueanteCódigo existente. No lo elijas para algo nuevo.
RestClient (Spring 6.1+)RecomendadoBloqueante, API fluidaLa opción por defecto en MVC.
WebClientRecomendado en reactivoNo bloqueanteWebFlux, o streaming y alta concurrencia.
HTTP interfaces (@HttpExchange)RecomendadoDeclarativo sobre los anterioresCuando 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
    }
}
Todo cliente HTTP sin timeout es una bomba de relojería. Los valores por defecto de la JDK son infinito: si el servicio remoto acepta la conexión y no contesta, tu hilo se queda esperando para siempre. Con 200 hilos de Tomcat, bastan 200 peticiones para que tu servicio deje de responder del todo, incluidas las sondas de salud, y Kubernetes lo reinicie. Es el mecanismo exacto de la mayoría de los fallos en cascada. Pon siempre 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érminoQué esEn el código
AspectoEl módulo que agrupa la preocupación transversalUna clase @Aspect
Join pointUn punto del programa donde se puede intervenirEn Spring AOP, siempre la ejecución de un método
AdviceEl código que se ejecuta en ese punto@Before, @After, @AfterReturning, @AfterThrowing, @Around
PointcutLa expresión que selecciona los join pointsexecution(* com.tienda..*Service.*(..))
WeavingEl momento en que se une el aspecto al códigoSpring AOP: en tiempo de ejecución, con proxies. AspectJ: en compilación o carga de clases.
TargetEl objeto real envueltoTu 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 { }
Cuándo NO usar AOP: jamás para lógica de negocio. Un aspecto es invisible en el código que lo sufre: si el cálculo del descuento vive en un aspecto, nadie que lea 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() { … }
Las anotaciones de caché son aspectos: les aplican exactamente las mismas limitaciones de la sección 7.2. 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)
LatenciaNanosegundos (misma JVM)0,5–2 ms (red)
Coherencia entre réplicasNinguna: cada pod tiene su versiónTotal: una sola fuente
Se pierde al reiniciarNo
Nueva dependencia que puede caerNoSí (¡ponle timeout!)
Úsala paraDatos casi estáticos, alta frecuencia, tolerancia a desfaseSesiones, 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.
}
Cuándo NO cachear: (1) datos que cambian más a menudo de lo que se leen (la caché añade complejidad y no aporta aciertos); (2) datos personales por usuario con muchos usuarios (la clave incluye el usuario y el hit ratio es ínfimo, pero la memoria se llena); (3) cuando no puedes tolerar datos desfasados —saldos, stock reservado, permisos—; (4) consultas que ya duran 2 ms (no hay nada que optimizar); (5) antes de medir: cachear para arreglar una consulta N+1 es esconder el problema. Arregla primero la consulta (módulo 05). Y recuerda la frase de Phil Karlton: los dos problemas difíciles de la informática son la invalidación de cachés y poner nombres.

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
);
Haz las tareas programadas idempotentes de todas formas. Aunque uses ShedLock, un despliegue puede solaparse, un reloj puede ir mal o alguien puede lanzar la tarea a mano. Si «enviar el informe diario» dos veces genera dos correos, el problema es la tarea, no el planificador. Marca en base de datos lo ya procesado («informe del 2026-07-31 enviado») y comprueba antes de actuar.

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:

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).
Recomendación práctica para 2026: actívalos. Para una aplicación MVC con JDBC son casi gratis y mejoran mucho el comportamiento bajo picos de carga. Pero mide antes y después (throughput, p95, p99, número de hilos, memoria) y revisa el dimensionado de los pools de conexiones. Y no esperes milagros si tu cuello de botella era la CPU o la base de datos: los virtual threads mejoran la concurrencia de E/S, no la capacidad de cómputo. Más detalle en el módulo 03.

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
EndpointQué da¿En producción?
/healthEstado agregado y por componente, con detalles restringidos
/health/liveness¿El proceso está vivo? (sonda de Kubernetes)
/health/readiness¿Puede atender tráfico? (sonda de Kubernetes)
/infoVersión, commit, fecha de buildSí: saber qué hay desplegado no es opcional
/metricsMétricas navegables una a unaSí (interno)
/prometheusTodas las métricas para scrapingSí (solo red interna)
/loggersConsultar y cambiar niveles en calienteSí, autenticado: vale oro en un incidente
/env, /configpropsDe dónde viene cada propiedadAutenticado y con valores ofuscados
/conditionsInforme de autoconfiguraciónAutenticado
/threaddumpVolcado de hilosAutenticado: la herramienta para «se ha colgado»
/heapdumpDescarga el heap completoNO exponerlo. Contiene todos los secretos en memoria
/httpexchangesÚltimas peticiones HTTPCon cuidado: puede contener datos personales
/startupQué tardó en el arranqueSí, para diagnosticar arranques lentos
/shutdownApaga la aplicaciónJAMÁ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"));
    }
}
La regla de oro de las etiquetas: baja cardinalidad. Cada combinación distinta de valores de etiqueta crea una serie temporal nueva en Prometheus. Un tag con el ID del pedido, el email del usuario, la URL completa con parámetros o el mensaje de una excepción genera millones de series y tumba el sistema de métricas (y la factura). Los tags valen para dimensiones acotadas: método HTTP, código de estado, nombre de operación, tipo de error. Los identificadores van a los logs y a las trazas, nunca a las métricas.

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.
Enlaces del ecosistema: la recogida (Prometheus haciendo scraping de /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)          │
└───────────────────────────────────────────────────────────────────────────────┘
CriterioElige MVC (+ virtual threads)Elige WebFlux
CRUD con base de datos relacionalNo: R2DBC es menos maduro y pierdes JPA
Miles de conexiones abiertas (SSE, WebSocket, chat)Posible
Contrapresión de extremo a extremoNo (solo WebFlux la ofrece)
Gateway / proxy con poca lógicaNo (Spring Cloud Gateway es reactivo)
Streaming de flujos infinitosLimitado
Equipo sin experiencia en ReactorNo: el coste de aprendizaje y de depuración es real
Facilidad de depuración y de perfiladoNo
El peor de todos los mundos: WebFlux con una llamada bloqueante dentro (JDBC, un 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íaDirecciónÚsala paraCoste
PollingCliente preguntaActualizaciones poco frecuentes; lo más simpleLatencia y peticiones desperdiciadas
SSEServidor → clienteNotificaciones, progreso, precios: la opción por defectoUna conexión por cliente; solo texto
WebSocketBidireccionalChat, colaboración en vivo, juegosEstado por conexión; escalado con broker; proxies quisquillosos
RSocketBidireccional + contrapresiónServicio a servicio con streamingPoco extendido fuera de Spring
Antes de montar WebSockets, pregúntate si te basta con SSE. SSE es HTTP normal: atraviesa proxies y balanceadores sin configuración especial, se reconecta solo (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.
Cuándo GraphQL merece la pena: muchos clientes distintos (web, móvil, socios) con necesidades de datos muy diferentes; agregación de varios servicios en una sola respuesta; evolución del esquema sin versionar. Cuándo no: un CRUD interno consumido por un solo frontend —REST es más simple, se cachea con HTTP estándar y no requiere protecciones especiales—. Y cuenta con el coste real: N+1 en todas partes, caché mucho más difícil, autorización campo a campo, y observabilidad que ya no puede basarse en el método y la ruta HTTP.

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écnicaMejora típicaCoste / riesgo
Quitar dependencias que no usas10–30%Ninguno. Empieza siempre por aquí.
Acotar el @ComponentScan5–20%Ninguno.
Mover trabajo a ApplicationReadyEventVariable, a veces enormeNinguno; además mejora el comportamiento de las sondas.
spring.main.lazy-initialization=true30–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 AOT10–20% sobre la JVMMedio: menos flexibilidad en tiempo de ejecución.
Imagen nativa (GraalVM)Arranque de ~50 ms y mucha menos memoriaAlto: 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.
¿Merece la pena optimizar el arranque? Depende del contexto. Con réplicas de larga vida y despliegues rolling, pasar de 8 s a 5 s no cambia nada. Importa mucho cuando escalas automáticamente ante picos (cada segundo de arranque es tráfico mal atendido), cuando el escalado a cero es parte del diseño o en funciones serverless. Mide el impacto real en tu operación antes de invertir en una imagen nativa.

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ónQué arrancaVelocidadPara qué
Sin anotacionesNada: new MiServicio(mock, mock)< 10 msEl 80% de tus tests. Lógica de negocio pura.
@WebMvcTestControladores, advices, converters y filtros de MVC1–2 sRutas, status, serialización, validación y mapeo de errores.
@DataJpaTestJPA, repositorios y base de datos (embebida o Testcontainers)2–4 sConsultas, mapeos y migraciones.
@JsonTestSolo Jackson< 1 sContratos de serialización de los DTOs.
@RestClientTestClientes HTTP con servidor simulado< 1 sClientes salientes, timeouts y traducción de errores.
@SpringBootTestToda la aplicación5–20 sUnos 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}
El truco que más tiempo ahorra en una suite de tests: reutilizar el contexto. Spring lo cachea por configuración, así que si cada clase de test usa propiedades o dobles distintos, arrancas treinta contextos y la suite tarda diez minutos. Usa la misma combinación de anotaciones y perfiles en todos los tests de integración, declara los contenedores como 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();
    }
}
Cómo decidir sin dogmatismos. Cuenta las reglas de negocio: si tu «lógica» consiste en validar campos y guardar, hexagonal solo añade clases de mapeo y frustración, y las tres capas son la respuesta honesta. Si tienes máquinas de estados, cálculos con reglas cambiantes, invariantes que hay que proteger y varios canales de entrada, hexagonal se paga en la primera refactorización grande. Y no es una decisión de todo o nada: puedes tener el módulo de 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✅ BienPor 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íntomaCausa habitualSolució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 Errorlas 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 comandosSPRING_APPLICATION_JSON → propiedades del sistema (-D) → variables de entornoapplication-{perfil}.yml fuera del jar → application-{perfil}.yml dentroapplication.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

  1. 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.
  2. Inyección por constructor con campos final, siempre. Es la diferencia entre una clase testeable y una que necesita Spring para respirar.
  3. Los beans singleton se comparten entre todos los hilos: son seguros solo si no tienen estado mutable.
  4. El ciclo de vida importa: en postProcessAfterInitialization nacen los proxies, y eso explica @Transactional, @Cacheable, @Async y todos sus límites.
  5. 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.
  6. 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.
  7. La autoconfiguración no es magia: AutoConfiguration.imports más @Conditional* más evaluación al final. Se depura con --debug y /actuator/conditions.
  8. Para sobrescribir un bean autoconfigurado, basta con definir el tuyo: @ConditionalOnMissingBean se aparta solo.
  9. Precedencia de configuración: argumentos, variables de entorno, perfil, base, defaults. Y @ConfigurationProperties con records validados en lugar de @Value dispersos.
  10. Nunca expongas entidades JPA en la API: DTOs de entrada y de salida separados, con validación en el borde.
  11. Los errores se devuelven con ProblemDetail (RFC 9457), un traceId y cero stack traces: el detalle técnico va al log.
  12. 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.
  13. Filtro para el protocolo, interceptor para el endpoint, aspecto para el método. Y el AOP jamás para lógica de negocio.
  14. Actuator y Micrometer desde el primer día, con puerto separado, sondas liveness y readiness bien distinguidas, histogramas y tags de baja cardinalidad.
  15. 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

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.
Siguiente paso lógico: este módulo ha construido la aplicación y su API. El módulo 05 baja a la persistencia (JPA, Hibernate, N+1, transacciones en detalle, Flyway), que es donde está el 80% de los problemas de rendimiento reales. Si vienes de la concurrencia del módulo 03, ya tienes todo lo necesario para entender por qué los virtual threads cambian el dimensionado de los pools. Y si preparas entrevistas, las respuestas de la sección 16 son literalmente las preguntas de la primera criba técnica: consulta también el módulo 12.