Plan de estudio Java 2026
Módulo 12 crítico Días 26–27 ≈ 6 h de estudio

Preparación de entrevistas: el módulo que convierte el estudio en oferta

Los once módulos anteriores te han dado conocimiento. Este te da rendimiento en entrevista, que es una habilidad distinta y también se entrena. Aquí tienes cómo es un proceso real en España y en remoto europeo en 2026, qué evalúa cada etapa, un CV que pasa filtros, un método para responder cualquier pregunta técnica, un banco amplio de preguntas con respuesta oral modelo, katas resueltas y comentadas, plantilla de system design con casos prácticos, cómo entregar un take-home que impresione, la entrevista de comportamiento con historias reutilizables, la negociación del salario y un plan de simulacros de dos días. No es un módulo para leer: es un módulo para practicar en voz alta.

Progreso de este módulo0 / 0
Cómo usar este módulo: las secciones 1 a 3 son el marco (proceso, CV/GitHub y método de respuesta): léelas enteras una vez. Las secciones 4 a 10 son el banco de preguntas: úsalas como tarjetas, respondiendo en voz alta con el desplegable cerrado. Las 11 a 13 (STAR, katas y system design) se practican con cronómetro. Las 14 a 16 cubren take-home, negociación y errores frecuentes. La 17 es la autoevaluación y la agenda de los días 26–27; la 18, el resumen.
La regla que más te va a ayudar: una respuesta que no has dicho nunca en voz alta no está preparada. Leer «entiendo @Transactional» y explicarlo hablando durante noventa segundos sin muletillas son dos cosas diferentes, y la entrevista mide la segunda. Reserva la mitad del tiempo de este módulo para hablar, grabarte y volver a escucharte. Es incómodo los primeros diez minutos y después es la práctica con mejor relación entre esfuerzo y resultado de todo el plan.
Sobre las respuestas modelo: están escritas para que las adaptes, no para que las recites. Un entrevistador con experiencia detecta a un kilómetro una respuesta memorizada, porque no resiste la primera repregunta. Usa la estructura y las ideas, pero mete tus propios ejemplos: tu proyecto, tu incidente, tu decisión. Donde pone «en mi último proyecto» tienes que poder contar algo real, aunque sea el proyecto del módulo 13.

1 · Cómo es un proceso de selección real en 2026

Antes de estudiar respuestas conviene entender el juego. Un proceso de selección no es un examen con una nota: es una secuencia de filtros, cada uno con un evaluador distinto, un criterio distinto y un coste distinto para la empresa. Saber en qué filtro estás te dice qué debes demostrar en los próximos cuarenta minutos y, sobre todo, qué no hace falta demostrar todavía.

1.1 El embudo completo, etapa por etapa

Este es el proceso típico de una empresa de producto de tamaño medio en España. Las consultoras suelen comprimirlo y las big tech lo alargan, pero el esqueleto se repite. Fíjate en la columna de conversión: es la que te dice dónde merece la pena invertir tu preparación.

EtapaQuién la conduceDuraciónQué evalúa de verdadPasan
1. Criba de CV ATS + recruiter (o el propio tech lead en empresas pequeñas) 20–60 segundos por CV Encaje mínimo: stack, años, ubicación y expectativa salarial. No se lee, se escanea. 10–25 %
2. Llamada con recruiter Recruiter o talent partner 20–30 min Que existes, que hablas con soltura, que la banda salarial cuadra, disponibilidad y motivo del cambio. 50–70 %
3. Entrevista técnica Uno o dos ingenieros del equipo 45–75 min Profundidad real de tu stack, criterio en trade-offs y capacidad de explicar. Es el filtro principal. 30–50 %
4a. Take-home El equipo lo propone, uno lo revisa 3–6 h tuyas, 20 min de revisión Cómo estructuras código sin supervisión, si escribes tests y si sabes documentar decisiones. 50–70 %
4b. Live coding Un ingeniero, a veces dos 45–60 min Cómo piensas mientras programas: preguntas, casos borde, comunicación, no la solución perfecta. 40–60 %
5. Diseño de sistemas Ingeniero senior, staff o arquitecto 45–60 min Si sabes convertir un enunciado vago en decisiones justificadas, y si piensas en fallos y en coste. 40–60 %
6. Entrevista con manager Engineering manager 30–45 min Cómo trabajas con otros, cómo priorizas, si eres autónomo y si vas a estar a gusto en ese equipo. 70–85 %
7. Cultural o values fit Alguien de otro equipo, RRHH o el fundador 30–45 min Valores, honestidad, cómo hablas de tu pasado. Rara vez te selecciona; con frecuencia te descarta. 80–90 %
8. Oferta y negociación Recruiter, con el manager detrás 2–10 días Nada técnico: aquí solo se decide cuánto y en qué condiciones. Es donde más se pierde por no preparar.

Consecuencia práctica del embudo: el punto de mayor pérdida es la criba de CV y la entrevista técnica. Si envías cincuenta candidaturas y no te llaman, el problema es el CV o el encaje, no tu conocimiento de HashMap. Si llegas a técnicas y caes ahí, el problema es profundidad o comunicación. Diagnostica antes de estudiar: mucha gente estudia más de lo que necesita y arregla menos de lo que debería.

Etapa cero, la que no aparece en el embudo: la referencia interna. Un candidato presentado por alguien de dentro se salta la criba y llega con crédito. Por eso escribir en LinkedIn, ir a un meetup de la comunidad Java local, o comentar de forma útil en un repositorio, tiene un retorno desproporcionado. No es networking de tarjeta de visita: es que alguien pueda decir «a este lo he visto explicar algo bien».

1.2 Qué se mide en cada etapa (y qué es ruido)

Cada evaluador tiene una rúbrica, explícita o mental. Conocerla evita el error más común: dar la respuesta correcta para otra etapa. Contarle al recruiter los detalles del particionado de Kafka no suma; contarle al ingeniero que «eres muy proactivo y trabajas bien en equipo» tampoco.

Lo que busca el ingeniero técnico

  • Profundidad frente a superficie: ¿aguantas dos repreguntas seguidas sobre lo mismo?
  • Criterio: ¿sabes cuándo no usar lo que acabas de proponer?
  • Honestidad: ¿reconoces los límites de lo que sabes sin bloquearte?
  • Mentalidad de producción: ¿mencionas errores, latencia, datos, coste y despliegue por iniciativa propia?
  • Comunicación: ¿te seguiría alguien que no conoce ese sistema?
  • «¿Querría revisarle un PR?» Es la pregunta que se hace de verdad al terminar.

Lo que busca el manager

  • Autonomía: ¿avanzas con un enunciado incompleto o te quedas parado?
  • Colaboración: ¿cómo cuentas un desacuerdo? ¿aparece alguien culpable en tu relato?
  • Priorización: ¿sabes decir que no y explicar el coste de oportunidad?
  • Relación con producto: ¿entiendes por qué se construye lo que construyes?
  • Riesgo de rotación: ¿por qué te fuiste de los tres últimos sitios y en cuánto tiempo?
  • Nivel real: te está calibrando para decidir tu banda y tu oferta.

Hay una asimetría que conviene interiorizar: las etapas técnicas te seleccionan y las etapas humanas te descartan. Nadie consigue una oferta por caer simpático en la cultural, pero mucha gente pierde una oferta ya ganada por hablar mal de su empresa anterior, por regatear de mala manera o por contradecirse respecto a lo que dijo en la primera llamada. Trata las etapas «fáciles» con el mismo respeto que las difíciles.

1.3 Consultora, producto, startup, banca y multinacional

El mismo perfil vale muchísimo más en unos contextos que en otros, y la entrevista cambia de forma. Esta tabla es un mapa del mercado español y del remoto europeo tal y como está en 2026; los rangos son de bruto anual para un perfil mid a senior con Java y Spring, y varían con la ciudad.

TipoCómo es la entrevistaQué valoranRiesgo para tiRango típico
Consultora / cárnica Una técnica corta, a veces test de opción múltiple. Rápida y con poca profundidad. Que encajes en el perfil que le han pedido al cliente y que estés disponible ya. Acabar de «recurso» en un proyecto que no aporta nada a tu CV y sin decisión técnica ninguna. 26–42 k€
Consultora buena / boutique Técnica seria, a veces pair programming real. Te preguntan por criterio, no por sintaxis. Versatilidad, capacidad de aprender un dominio nuevo rápido, trato con cliente. Rotación de proyecto cada pocos meses; profundidad limitada por el ciclo del contrato. 38–60 k€
Producto (SaaS, escalada) El proceso completo del embudo: técnica, prueba, diseño, manager. Es el más exigente y el más justo. Profundidad, autonomía, criterio de producto, capacidad de mantener un sistema vivo. Nivel de exigencia alto y guardias; si el producto no encuentra mercado, hay recortes. 45–75 k€
Startup temprana Informal, con el CTO. Mucho «cuéntame qué has construido» y a veces un take-home muy práctico. Que arranques cosas de cero, que toques todo el stack y que soportes ambigüedad. Salario por debajo de mercado con equity de valor incierto; procesos inmaduros. 35–55 k€ + equity
Banca y seguros Varias rondas, muy protocolizadas. Preguntas de manual, mucha normativa y bastante entrevista humana. Rigor, trazabilidad, seguridad, capacidad de trabajar con sistemas antiguos y con muchos equipos. Ritmo lento, mucho proceso, stack heterogéneo con partes muy antiguas. 40–65 k€
Multinacional / big tech Screening, dos o tres rondas de algoritmos, diseño de sistemas y varias de comportamiento con rúbrica. Algoritmos y estructuras de datos, diseño a escala, señal medible en cada competencia. Proceso largo (6–10 semanas), muy dependiente de práctica específica de algoritmos. 60–110 k€ + acciones
Remoto europeo Similar a producto, en inglés, con más peso del take-home por la distancia y el uso horario. Comunicación escrita, autonomía total, solapamiento de horario, inglés funcional. Contrato como contratista sin las coberturas de la nómina española; revisa la fiscalidad. 55–95 k€
Cómo usar esta tabla sin cinismo: ningún tipo de empresa es «malo». Una consultora puede ser el mejor sitio para un primer empleo porque te expone a muchos dominios en poco tiempo; una startup es el mejor sitio para aprender a decidir sin red; la banca es donde vas a ver de verdad qué significa un sistema con veinte años de historia y auditorías. Lo importante es elegir a conciencia y con fecha de caducidad: saber qué vas a sacar de ahí y en cuánto tiempo.

1.4 Señales de que la empresa merece la pena y banderas rojas

La entrevista es bidireccional, aunque no lo parezca cuando estás nervioso. Estas señales se recogen sin preguntar nada raro: solo hay que fijarse.

Buenas señales

  • El proceso está descrito por escrito, con etapas y plazos, desde el primer contacto.
  • Te entrevistan ingenieros del equipo en el que vas a trabajar, y saben de lo que hablan.
  • Las preguntas técnicas son sobre problemas suyos, reales, no de un banco de preguntas genérico.
  • Te responden con concreción cuando preguntas por despliegues al día, cobertura o guardias.
  • Admiten deuda técnica sin ponerse a la defensiva, y saben cuál duele más.
  • Hay tests, revisión de código y CI, y te lo cuentan sin que lo preguntes.
  • El rango salarial aparece pronto y coincide con la oferta final.
  • Te dan feedback aunque te descarten.

Banderas rojas

  • No te dicen el rango salarial ni el nombre del cliente. «Depende de la valía del candidato.»
  • Take-home de más de seis horas, o que resulta ser un trozo de su producto real sin pagar.
  • Cinco rondas para un puesto de mid, o rondas que se inventan a mitad del proceso.
  • El entrevistador no ha abierto tu CV, llega tarde o está claramente haciendo otra cosa.
  • Preguntas de trivia de memoria («¿cuántos métodos tiene Collectors?») en lugar de criterio.
  • «Aquí somos como una familia», «hay que tener pasión», «a veces se echan horas y ya está».
  • Hablan mal de la persona que se va del puesto que estás cubriendo.
  • No hay ningún test automático y lo justifican con «vamos muy rápido».
  • Guardias sin compensación, ni económica ni en tiempo.
  • Presión para firmar en veinticuatro horas, o retirada de la oferta si pides tiempo.
La bandera roja que más gente se come: el desajuste entre lo que te cuentan en la entrevista y lo que se ve en el propio proceso. Si te dicen que tienen «cultura de excelencia técnica» pero la entrevista técnica es un cuestionario de definiciones sacado de internet, ya sabes lo que hay. Si te dicen que «documentan todo» y no han sido capaces de mandarte por escrito las etapas del proceso, tampoco. El proceso de selección es la demo pública de sus procesos internos: es la muestra gratuita que te dan de cómo trabajan.

1.5 Cuánto dura y cómo llevar varios procesos a la vez

Un proceso completo tarda entre dos y seis semanas en una empresa de producto española, de una a dos semanas en consultora y de seis a diez en una multinacional grande. La búsqueda entera, desde el primer envío hasta firmar, suele llevar de uno a tres meses si estás trabajando, con una tasa de respuesta entre el 10 % y el 25 % de las candidaturas. Ten expectativas realistas: el silencio es la respuesta más frecuente y no es una valoración de tu talento.

Llevar varios procesos en paralelo no es codicia, es gestión de riesgo y de calendario. Tres ventajas concretas: reduces la desesperación (que es lo que arruina las negociaciones), sincronizas las ofertas para poder compararlas, y te entrenas gratis, porque las primeras entrevistas siempre son peores que las cuartas.

Cómo organizarlo sin liarte

  1. Una hoja de cálculo, no la bandeja de entrada. Columnas: empresa, puesto, fuente, fecha de contacto, etapa actual, siguiente paso y fecha, persona de contacto, rango comunicado, notas de cada ronda, veredicto.
  2. Ordena las candidaturas por interés real. Manda primero a las de interés medio: te sirven de calentamiento. Guarda tus tres favoritas para cuando lleves dos o tres procesos de rodaje.
  3. Agrupa las entrevistas. Mejor tres en una semana que una cada quince días: estás calentito y la comparación es más justa.
  4. Sincroniza los finales. Si una empresa va muy rápido y otra que te gusta más va lenta, se puede decir sin dramatismo: «tengo un proceso avanzado con otra compañía, ¿sería posible adelantar las siguientes etapas?». Funciona más veces de las que crees.
  5. Nunca menciones nombres de las otras empresas. Basta con «otro proceso en fase final».
  6. Notas frescas. Escribe cinco líneas justo después de cada entrevista: qué preguntaron, qué respondiste mal, qué te contaron de la empresa. En la siguiente ronda con esa gente vale oro.
  7. Reserva energía. Más de tres entrevistas técnicas en una semana rinde peor: llegas quemado y respondes en automático.
Sobre el orden de las candidaturas: hay una tentación clara de lanzarse a por la empresa soñada el primer día, con el CV recién hecho y sin haber dicho una respuesta en voz alta. Es la peor forma de gastar la mejor oportunidad. Las candidaturas se pueden reintentar en seis o doce meses, pero mejor que la primera versión de ti mismo la vea alguien que te importe menos.

1.6 Niveles: junior, mid, senior y staff

Los títulos varían de empresa a empresa, pero las expectativas de fondo son bastante estables en todo el sector. Esta tabla te sirve para dos cosas: saber a qué nivel te estás presentando (y no infra ni sobrevenderte) y entender por qué te hacen determinadas preguntas.

NivelÁmbito de su trabajoQué se espera técnicamenteImpacto y colaboraciónCómo lo comprueban
Junior
0–2 años
Una tarea bien definida, con revisión frecuente. Fundamentos de Java y POO, colecciones, Git, un test unitario, saber leer un stack trace. Pide ayuda pronto y aprende del feedback. Entrega lo que se le pide. Preguntas de fundamentos, un ejercicio pequeño, muchas ganas de ver cómo razona.
Mid
2–5 años
Una funcionalidad completa de principio a fin, con poca supervisión. Spring Boot con soltura, JPA y SQL sin sorpresas, tests de integración, depurar en producción con logs y métricas. Trabaja solo dentro de su ámbito, revisa PR de otros, negocia detalles con producto. Profundidad en el stack, un take-home real, un caso de depuración, N+1 y transacciones.
Senior
5–9 años
Un sistema o un servicio entero, incluido su ciclo de vida y sus incidentes. Trade-offs conscientes, diseño de APIs y de datos, resiliencia, rendimiento, seguridad, coste. Descompone trabajo para otros, mentoriza, dice no con argumentos, escribe ADR y lidera decisiones. Diseño de sistemas, «cuéntame una decisión que salió mal», profundidad y honestidad bajo repregunta.
Staff / principal
9+ años
Varios equipos o una capa transversal de la plataforma. Estrategia técnica, migraciones grandes, estándares, dónde no invertir. Alinea a gente que no depende de él, convierte problemas de negocio en planes técnicos de varios trimestres. Diseño abierto y ambiguo, casos de influencia sin autoridad, cómo llevaste una migración de un año.
El salto que casi nadie explica bien: de mid a senior no se pasa sabiendo más tecnologías, se pasa asumiendo el resultado y no solo la tarea. Un mid entrega el endpoint; un senior entrega el endpoint con su plan de despliegue, su métrica, su comportamiento cuando el servicio de al lado se cae, y la conversación con producto sobre lo que no hacía falta construir. Si te presentas a senior, tus respuestas tienen que sonar a eso, y para sonar a eso hace falta haber vivido al menos un par de incidentes y contarlos con detalle.
¿A qué nivel me presento si tengo cuatro años pero he tocado mucho?

Preséntate a mid alto o senior bajo y deja que el proceso te calibre; no te autoexcluyas de una oferta que dice «5+ años», porque ese número casi siempre es un deseo del hiring manager y no un requisito. Lo que sí tienes que hacer es traer pruebas del comportamiento senior, no de la antigüedad: una decisión de diseño que tomaste y defendiste, un incidente que gestionaste, alguien a quien mentorizaste, una migración que planificaste en fases.

La conversación honesta que funciona en la primera llamada es: «tengo cuatro años, pero he sido el responsable técnico de dos servicios en producción con guardias. Me presento a senior; si al conocerme encajo mejor en mid, prefiero que me lo digáis y hablamos de qué me falta.» Eso demuestra criterio y no cierra puertas.

Trampa: si te vendes como senior y no sabes decir qué pasa cuando tu servicio recibe el doble de tráfico, o no has visto nunca un despliegue romperse, la repregunta te va a dejar sin argumentos. Es mejor entrar como mid con una revisión a seis meses que entrar como senior y no llegar a la expectativa.

¿Debo aplicar si no cumplo todos los requisitos de la oferta?

Sí, si cumples el núcleo. Las ofertas se escriben sumando los deseos de tres personas y casi nadie las cumple al cien por cien; el filtro real son dos o tres puntos, y el resto es lista de la compra. Mi regla: si cumples el lenguaje principal, el framework principal y el nivel de seniority, aplica aunque falten Kafka, Kubernetes o el sector.

Lo que no funciona es ignorar el hueco. Nómbralo tú, con un plan: «no he trabajado con Kafka en producción, he montado un consumidor con Spring Kafka en un proyecto propio y entiendo particiones, grupos y entrega al menos una vez; me pondría al día en semanas, no en meses». Eso convierte una carencia en una demostración de honestidad y de capacidad de aprendizaje.

Trampa: no digas «aprendo rápido» sin evidencia. Es la frase más vacía del mercado. Da un ejemplo concreto de algo que aprendiste en poco tiempo, con qué material y qué entregaste con ello.

¿Cuántas candidaturas hacen falta para tener una oferta?

Con un CV decente y un stack demandado como Java y Spring, la aritmética típica en España es: de 40 a 60 candidaturas dan 8 a 15 primeras llamadas, 4 a 8 procesos técnicos y 1 a 3 ofertas. Si estás muy por debajo de esos números, el diagnóstico se hace por etapas: si no te llaman, el problema está en el CV, en el encaje o en el canal; si te llaman y caes en la técnica, es profundidad o comunicación; si llegas al final y no cierras, suele ser expectativa salarial o la parte humana.

Dos ajustes que cambian mucho la tasa: candidaturas dirigidas (una línea que demuestre que has mirado su producto) en lugar de masivas, y usar la puerta lateral —alguien de dentro, la comunidad, el propio equipo en LinkedIn— en las diez empresas que más te importan.

Llevo tres meses buscando y no avanzo. ¿Qué reviso primero?

Por este orden, porque es el orden de coste creciente y de impacto decreciente: (1) el CV —una página, logros con números, palabras clave del puesto—; (2) el canal —si solo aplicas por portales masivos, cambia a ofertas directas de la empresa y a contacto humano—; (3) el nivel y el rango al que te presentas, porque presentarse a senior con perfil de mid produce silencio; (4) la primera llamada, grabándote para ver si tu respuesta a «háblame de ti» es un relato o un paseo por tu CV; (5) la profundidad técnica, que es lo que la mayoría revisa primero y casi nunca es el cuello de botella real.

Y algo poco intuitivo: pide feedback explícitamente a los que te descartan, aunque solo responda uno de cada cinco. Una sola frase honesta de un entrevistador ahorra semanas de suposiciones.

2 · Antes de la entrevista: CV, LinkedIn, GitHub y logística

Esta sección es la de mayor retorno por hora invertida de todo el módulo, y la que casi nadie hace bien. Cuatro horas arreglando el CV pueden multiplicar por tres las llamadas; cuatro horas más de estudio de HashMap no cambian nada si nadie te llama. Ordena el trabajo así: primero el material que abre puertas (CV, LinkedIn, GitHub), después la preparación específica de cada empresa, y por último la logística y los nervios, que son lo que arruina entrevistas ya ganadas.

2.1 El CV de una página que pasa filtros

Un CV no cuenta tu vida: es un argumento comercial de una página orientado a un puesto concreto. Se lee en cuarenta segundos, a menudo en un móvil, a veces por alguien que no es técnico y con un ATS por medio. Todo lo que no ayude a decidir «lo llamo» es ruido que hace perder de vista lo que sí ayuda.

Estructura que funciona, en este orden

  1. Cabecera (2 líneas): nombre, puesto al que aspiras («Desarrollador Backend Java/Spring»), ciudad y modalidad, teléfono, email, LinkedIn y GitHub como texto plano y como enlace.
  2. Resumen (3 líneas, opcional pero recomendable): años, dominio, stack principal y el resultado más grande que puedas demostrar. Nada de adjetivos.
  3. Experiencia (60 % del espacio): orden cronológico inverso. Por puesto: empresa, cargo, fechas (mes/año) y de tres a cinco viñetas de logro, no de tarea.
  4. Stack técnico: agrupado y honesto. Lenguajes, frameworks, datos, infraestructura, herramientas. Si lo pones, tienes que poder defenderlo.
  5. Proyectos (solo si aportan): uno o dos, con enlace y una línea de qué resuelve y con qué. Imprescindible si tienes poca experiencia.
  6. Formación y certificaciones: al final y breve. Los estudios pesan mucho en el primer empleo y casi nada a partir del tercero.
  7. Idiomas: con nivel real. «Inglés B2, reuniones técnicas sin problema» es información; «inglés alto» no.

Reglas de forma

  • Una página hasta unos diez años de experiencia. Dos como máximo absoluto.
  • PDF con texto seleccionable, nunca imagen ni Word con macros. Nómbralo Nombre-Apellido-Java-Backend.pdf.
  • Una sola columna. Los diseños a dos columnas los mezclan muchos ATS.
  • Sin foto en España es perfectamente aceptable, y en el remoto europeo es lo recomendable.
  • Sin barras de nivel («Java ▮▮▮▮▯»). No significan nada y ocupan sitio.
  • Fuente legible de 10 a 11 pt, márgenes generosos. Si tienes que bajar de 9 pt, te sobra contenido.
  • Fechas con mes y año siempre. Ocultarlas levanta sospechas de huecos.
  • Verbos en pasado y en primera persona implícita: «reduje», «migré», «diseñé».

Palabras clave y ATS

  • El ATS busca coincidencia literal: si la oferta dice «Spring Boot», escribe «Spring Boot», no «Boot».
  • Pon las siglas y el desarrollo: «CI/CD (GitHub Actions)», «IaC (Terraform)», «JPA/Hibernate».
  • Las palabras clave van dentro de las viñetas de experiencia, donde son creíbles, no solo en una lista final.
  • Adapta tres o cuatro términos por candidatura. No hace falta reescribir el CV entero.
  • Nada de trucos de texto blanco o de palabras invisibles: los ATS modernos los detectan y algunos reclutadores los penalizan.
  • Menciona la versión: «Java 21», «Spring Boot 3.5», «PostgreSQL 16». Da señal de actualización.

2.2 Tres viñetas malas reescritas bien

Aquí está el 80 % de la diferencia entre un CV que llama y uno que no. La fórmula es acción + cómo + resultado medible, y el resultado no siempre es dinero: puede ser latencia, errores, tiempo de despliegue, incidencias, tiempo de onboarding o coste de infraestructura.

❌ Mal

  • «Desarrollo de aplicaciones con Java, Spring Boot, Hibernate, Maven, Git, Jenkins y Docker.»

Es una lista de tecnologías, no de trabajo. Podría haberla escrito cualquiera de los 40.000 desarrolladores Java de España, y no dice qué hiciste ni qué salió de ahí.

✅ Bien

  • «Diseñé y llevé a producción la API de facturación (Spring Boot 3, PostgreSQL): 40 endpoints, 1,2 M de peticiones/día, p95 por debajo de 180 ms.»

Mismo stack, pero ahora hay ámbito, volumen y calidad. Y regala tres repreguntas que quieres que te hagan: cómo llegaste a ese p95, cómo modelaste, cómo lo despliegas.

❌ Mal

  • «Optimización de consultas y mejora del rendimiento de la aplicación.»

Genérico y sin verificar. «Mejora del rendimiento» puede significar cambiar un índice o rediseñar el modelo de datos; el lector no puede saber cuál de las dos.

✅ Bien

  • «Reduje el tiempo del listado de pedidos de 4,5 s a 220 ms eliminando un N+1 con @EntityGraph y añadiendo paginación por keyset; verificado con un test que cuenta sentencias SQL.»

Cifra antes y después, técnica concreta y —la parte que casi nadie pone— cómo garantizaste que no volviera a pasar. Eso último es lo que suena a senior.

❌ Mal

  • «Participación en un equipo ágil siguiendo metodología Scrum, con dailies y retrospectivas.»

Describe una asistencia a reuniones. Nadie contrata a alguien por haber ido a dailies; se da por supuesto.

✅ Bien

  • «Introduje tests de integración con Testcontainers en un proyecto sin tests: de 0 % a 65 % de cobertura en dominio y de 3 a 0,4 incidencias de regresión por release.»

El logro no es la herramienta, es el cambio en el equipo. Y el número final habla el idioma del manager, no solo el del ingeniero.

Si no tienes métricas (porque nadie medía): no te las inventes, estímalas y dilo. «Aproximadamente unas 300 peticiones por minuto en hora punta», «el despliegue pasó de manual de una hora a diez minutos automatizados», «éramos cuatro y el módulo lo mantenía yo». Un orden de magnitud honesto es infinitamente mejor que un vacío, y muchísimo mejor que un número falso que se derrumba en la primera repregunta.

Checklist del CV (marca cuando esté hecho)

Los cinco errores que descartan un CV en veinte segundos: (1) faltas de ortografía o el nombre de otra empresa copiado de la candidatura anterior; (2) enlaces que no funcionan o un GitHub vacío puesto solo para rellenar; (3) tres páginas con todas las tareas de todos los proyectos desde 2014; (4) tecnologías infladas que no aguantan una pregunta —poner «Kafka» por haber leído un tutorial es una bomba de relojería—; y (5) huecos temporales sin explicar en el CV que luego se explican mal en la entrevista. El hueco no es el problema; la incoherencia sí.

2.3 LinkedIn: el sitio donde te encuentran

En España, una porción muy grande de los procesos de backend empieza con un recruiter buscando en LinkedIn. Eso significa que tu perfil no es un CV en web: es un documento optimizado para búsqueda interna. Los recruiters filtran por titular, por ubicación y por palabras clave del texto completo.

CampoError habitualQué poner
Titular «Desarrollador en Acme S.L.» «Backend Java · Spring Boot · PostgreSQL · Kubernetes | 5 años en sistemas de pagos». Es el campo con más peso en las búsquedas: mete el stack.
Extracto (about) Vacío, o un párrafo en tercera persona lleno de adjetivos. Cinco o seis líneas en primera persona: qué construyes, con qué, un logro con cifra y qué buscas. Termina con las palabras clave que quieres que indexen.
Experiencia Copia literal del CV, con las mismas viñetas telegráficas. Aquí sí puedes ser más largo y contextual: dos líneas de qué era el sistema y luego los logros. Aprovecha que no hay límite de página.
Aptitudes Cincuenta aptitudes incluyendo «Microsoft Word». De diez a quince, las tres primeras las que quieres que se vean fijadas. El ATS de LinkedIn las usa.
Proyectos y destacados Sin usar. Fija el proyecto propio con su repositorio y una captura. Es lo primero que ve quien entra al perfil.
«Abierto a oportunidades» Activado en público con el empleo actual y un jefe que lo ve. Usa el modo visible solo para recruiters si estás trabajando. Con puestos y ubicación configurados.
Foto y portada Foto de boda recortada o ninguna. Foto sencilla, con luz, cara visible y fondo neutro. No hace falta traje.
URL /in/juan-perez-8a7f3b21 Personalízala: /in/juanperez-java. Queda mejor en el CV y es más fácil de dictar.

Publicar ayuda más de lo que parece, y no hace falta convertirse en creador de contenido. Un par de publicaciones al mes explicando algo técnico que resolviste —con código, con el antes y el después— hace que los recruiters te encuentren y, más importante, que los ingenieros te reconozcan. Escribir sobre algo también es la mejor manera de comprobar si de verdad lo entiendes, así que el tiempo no está perdido aunque nadie lo lea.

2.4 Un GitHub que ayuda (y uno que resta)

Un GitHub vacío no penaliza. Un GitHub con doce repositorios abandonados llamados curso-spring, prueba2 y tutorial-final sí penaliza, porque comunica que no terminas nada. La estrategia correcta es un repositorio bueno y visible, y el resto archivado o privado.

Qué mira quien te va a entrevistar (en este orden, en unos dos minutos)

  1. El README. ¿Entiendo en treinta segundos qué hace esto y por qué existe? ¿Hay una captura o un diagrama?
  2. Cómo se arranca. ¿Hay un docker compose up o un ./mvnw test que funcione sin instalar nada raro?
  3. La estructura de paquetes. ¿Se ve el dominio o es controller/service/repository con toda la lógica en el servicio?
  4. Los tests. ¿Existen? ¿Se entiende qué comprueban? ¿Hay alguno de integración?
  5. Un par de commits. ¿Los mensajes dicen algo o son «fix», «cambios», «asdf»?
  6. Un par de clases. Nombres, tamaño de los métodos, si hay validación, si hay comentarios que narran lo obvio.

Un README que abre puertas

  • Una frase con qué es y para quién.
  • Cómo se arranca en tres comandos, copiables.
  • Diagrama aunque sea ASCII, con los componentes.
  • Decisiones técnicas: tres, con su alternativa descartada y por qué.
  • Qué falta y qué harías con dos semanas más. Esto genera muchísima confianza.
  • Cómo se prueba: un comando y qué cubre.

Señales de calidad barata de conseguir

  • Un docker-compose.yml que levanta la base de datos y la app.
  • Un workflow de GitHub Actions con mvn verify y la insignia verde en el README.
  • Migraciones con Flyway en lugar de ddl-auto=update.
  • Un @RestControllerAdvice con ProblemDetail.
  • OpenAPI publicado y un .http o una colección de ejemplos.
  • Commits pequeños con mensajes en imperativo y un historial que se puede leer.
Sobre el proyecto que enseñas: mejor uno pequeño y terminado que uno ambicioso a medias. Un CRUD con autenticación, tests de integración con Testcontainers, migraciones, observabilidad y un README impecable impresiona más que un «clon de Twitter con microservicios» que no arranca. Y te da munición para media hora de entrevista, porque cada decisión que documentaste es una respuesta preparada. El proyecto del módulo 13 está diseñado exactamente para eso.

2.5 Carta de presentación: cuándo sirve de verdad

En la mayoría de las candidaturas por portal, la carta no se lee. Merece la pena escribirla en tres situaciones concretas: cuando cambias de sector o de rol y hay que explicar el salto; cuando te falta un requisito llamativo y quieres controlar el relato; y cuando escribes directamente a una persona (el manager, el CTO) en una empresa que te importa mucho. En esos casos no es una carta formal, son cuatro párrafos cortos:

Asunto: Backend Java/Spring — candidatura a Senior Backend Engineer (ref. #482)

Hola [nombre]:

[1 · Por qué vosotros, concreto] Uso [producto] desde hace un año y me llamó la
atención el artículo de vuestro blog sobre cómo migrasteis el catálogo a eventos:
es exactamente el tipo de problema que llevo tres años resolviendo.

[2 · Qué traigo, con una prueba] Soy backend Java/Spring con 5 años. En [empresa]
diseñé la API de facturación (1,2 M peticiones/día, p95 180 ms) y lideré la
migración de un monolito a tres servicios con el patrón outbox, sin caídas.

[3 · El hueco, nombrado por mí] No he trabajado con Kafka en producción; sí con
RabbitMQ y con Spring Kafka en un proyecto propio. Los conceptos de particiones,
grupos y entrega al menos una vez los tengo, y espero cerrar la brecha en semanas.

[4 · Cierre corto] Te dejo el CV y un proyecto con el que se ve cómo escribo:
github.com/usuario/pedidos. Encantado de contarte más cuando te encaje.

Gracias por tu tiempo,
[Nombre] · [teléfono] · [linkedin]

Menos de doscientas palabras, ninguna frase hecha, una prueba verificable y un hueco reconocido antes de que lo detecten. Ese es todo el truco. Y si no puedes escribir el párrafo 1 con algo específico de esa empresa, no mandes carta: se nota inmediatamente que es una plantilla.

2.6 Preparar una empresa concreta en 45 minutos

Llegar sabiendo cosas de la empresa no es peloteo, es la única forma de que tus respuestas sean relevantes y tus preguntas no sean genéricas. Con tres cuartos de hora bien invertidos vas mejor preparado que el 90 % de los candidatos.

MinutosQué mirarQué buscas exactamente
0–10 El producto: web, precios, prueba gratuita si la hay. Quién paga, por qué, y cuál es el flujo crítico que no puede fallar. Ese flujo es el que te van a hacer diseñar.
10–18 El blog de ingeniería y las charlas de sus ingenieros. Su stack real, sus dolores confesados y su vocabulario. Citar un artículo suyo con criterio vale más que cualquier halago.
18–25 Ofertas anteriores y actuales de la empresa. Qué tecnologías repiten (eso es lo que usan), qué buscan ahora (eso es lo que les duele) y cómo describen los niveles.
25–32 Las personas: quién te entrevista, su LinkedIn, su GitHub, sus charlas. Su especialidad, para anticipar por dónde va a ir. Un entrevistador que da charlas de JVM va a preguntar de JVM.
32–40 Situación de la empresa: financiación, plantilla, noticias, opiniones de empleados. Si crecen o recortan, si el equipo de ingeniería es de 5 o de 200, y si hay un patrón de quejas repetido.
40–45 Tus notas: una página con lo anterior. Tres cosas que te gustan, dos dudas reales y una historia tuya que encaje con su problema principal.

Preguntas que debes tener escritas antes de entrar

Ten seis y usa tres o cuatro, eligiendo según con quién hables. Las tres primeras revelan la cultura de ingeniería real mejor que ninguna otra:

  • ¿Cuántas veces desplegáis a producción a la semana y cuánto tarda un cambio desde el merge? La respuesta te dice si tienen CI/CD de verdad o un ritual de release nocturno.
  • ¿Qué pasa cuando algo se rompe en producción a las 11 de la noche? Guardias, compensación, si hay postmortem y si sale sin culpables.
  • ¿Qué proporción del sprint va a producto, a mantenimiento y a deuda técnica? Si la respuesta es «todo a producto», sabes lo que te espera en un año.
  • ¿Cómo se decide qué se construye, y puede un ingeniero cambiar de opinión a producto con datos?
  • ¿Cómo es la revisión de código aquí? ¿Cuánto tarda en aprobarse un PR normal?
  • ¿Qué habría que arreglar del sistema y nadie ha tenido tiempo de tocar?
  • ¿Qué haría que dijeras, a los tres meses, que mi incorporación ha ido bien?
  • ¿Cómo es el proceso de promoción y quién decide?
Una pregunta con superpoderes: «¿qué es lo que más frustra hoy al equipo?». Casi nadie la hace, casi todo el mundo la contesta con sinceridad, y te da a la vez información valiosísima para decidir y una oportunidad perfecta para conectar («eso mismo lo vivimos con el proceso de facturación; lo abordamos así»). Guárdala para el ingeniero o el manager, no para RRHH.

2.7 Logística: la parte aburrida que arruina entrevistas

Los primeros noventa segundos de una videollamada fijan la impresión, y se juegan con cosas que no tienen nada que ver con Java: si se te oye, si se te ve, si tienes que buscar el enlace con prisa. Todo eso se resuelve el día antes en veinte minutos.

Checklist de logística (el día antes, no una hora antes)

Si eres tú quien comparte pantalla: comparte una ventana concreta, nunca el escritorio completo. Cierra el correo, Slack y el navegador con las otras candidaturas. Sube el tamaño de la fuente del IDE a 16-18 pt antes de empezar: el entrevistador está mirando en una ventana pequeña y si no lee tu código no puede evaluarlo. Y desactiva la finalización automática agresiva por IA si el proceso pide resolverlo sin ayuda; que se vea que la desactivas es una buena señal de honestidad.

2.8 Nervios y calentamiento previo

Los nervios no son un defecto de carácter, son activación fisiológica: te suben las pulsaciones, se te seca la boca y la memoria de trabajo se estrecha. Ese estrechamiento es el problema real: es por lo que te quedas en blanco en algo que sabes perfectamente. No se combate con fuerza de voluntad, se combate reduciendo la carga y calentando antes.

Qué funciona

  • Hablar en voz alta 15 minutos antes. Responde tres preguntas fáciles del banco. Llegar con la voz caliente cambia los dos primeros minutos por completo.
  • Respiración larga: cuatro segundos dentro, seis fuera, durante dos minutos. Baja las pulsaciones de verdad, no es esoterismo.
  • Reinterpretar la activación: decirte «estoy activado» en lugar de «estoy nervioso» mejora de forma medible el rendimiento en pruebas.
  • Guion de los primeros 90 segundos memorizado (tu «háblame de ti»). El arranque es lo que más miedo da, así que quítale la improvisación.
  • Notas a la vista: una página con tus seis historias y tus preguntas. Saber que están ahí reduce la ansiedad aunque no las mires.
  • Bajar la apuesta: tener otros procesos abiertos es el mejor ansiolítico que existe.

Qué no funciona

  • Estudiar material nuevo la hora antes. Solo consigue que sientas que no sabes nada.
  • Café triple. Añade temblor y habla acelerada a algo que ya va acelerado.
  • Intentar «no estar nervioso». Suprimir consume la atención que necesitas para pensar.
  • Encadenar tres entrevistas el mismo día. La tercera la haces en piloto automático.
  • Memorizar respuestas palabra por palabra. Si te sacan del guion, te quedas sin nada.
  • Fingir seguridad absoluta. Es peor que la duda honesta y se detecta.

Calentamiento de 15 minutos, exacto

  1. 3 min · Di en voz alta tu presentación de 90 segundos, una vez. No la corrijas, solo dila.
  2. 4 min · Responde tres preguntas cortas del banco en voz alta, sin mirar la respuesta: por ejemplo equals/hashCode, por qué inyección por constructor y qué es un N+1.
  3. 3 min · Repasa tu página de notas de la empresa y las tres preguntas que vas a hacer.
  4. 2 min · Respiración 4-6 y estirar hombros y cuello. Bebe agua.
  5. 3 min · Comprueba audio, cámara, pantalla compartida y entra a la sala dos minutos antes.
Me quedo en blanco con preguntas que sé responder. ¿Cómo lo arreglo?

Es un problema de memoria de trabajo, no de conocimiento, y se ataca de tres formas. La primera: gana tiempo en voz alta con una frase de arranque que sea siempre la misma —«vale, déjame ordenarlo: hay dos partes en esto»—; suena reflexivo y te da cinco segundos reales para recuperar el hilo. La segunda: empieza por el ejemplo concreto en lugar de por la definición. Recordar «el bug del HashSet que tuvimos por hacer mutable la clave» es mucho más fácil que recitar el contrato de hashCode, y la definición sale sola tirando del ejemplo.

La tercera es preventiva: práctica de recuperación, no de relectura. Leer las respuestas de este módulo diez veces no entrena la recuperación; responder en voz alta con el desplegable cerrado, sí. Es el mismo principio que hace que las tarjetas de repaso funcionen y el subrayado no.

Trampa: si te quedas de verdad en blanco, dilo: «se me ha ido, dame un segundo». Es infinitamente mejor que un silencio largo o que empezar a divagar. Nadie descarta a un candidato por un blanco de cinco segundos; muchos descartan por dos minutos de relleno sin contenido.

¿Puedo tener notas delante en una entrevista remota?

Sí, y es una buena práctica siempre que las uses como índice y no como guion. Lo aceptable: una página con tus seis historias en una línea cada una, tus preguntas para ellos, el nombre de quien te entrevista y los números clave de tu experiencia. Lo que se detecta y queda fatal: leer respuestas técnicas de la pantalla —cambia el ritmo del habla, la mirada se va y las frases se vuelven demasiado perfectas—.

Si consultas algo, dilo con naturalidad: «déjame mirar el número exacto, lo tengo apuntado». Eso no resta nada; al contrario, transmite rigor. Y en un live coding, preguntar «¿puedo consultar la documentación?» casi siempre recibe un sí, porque es lo que harías en el trabajo real.

Es una entrevista en inglés y mi nivel es intermedio. ¿Qué hago?

Prepara el vocabulario técnico y las cinco respuestas de arranque, no la gramática. En la práctica, el 80 % de una entrevista técnica en inglés se juega con doscientas palabras: trade-off, bottleneck, throughput, latency, rollback, staging, deadlock, thread-safe, eventually consistent, idempotent… Escribe tu presentación en inglés y dila veinte veces en voz alta hasta que salga sin pensar. El arranque fluido compra mucha paciencia para el resto.

Durante la entrevista: habla más despacio de lo natural, usa frases cortas y no traduzcas del español (las subordinadas largas es donde te vas a atascar). Si no entiendes algo, pide que lo repitan sin disculparte cinco veces: «sorry, could you rephrase that?». Y avisa al principio si te ayuda: «my English is good enough for technical discussions, but tell me if I go too fast». Nadie penaliza el acento; sí penaliza que asientas a una pregunta que no has entendido y respondas otra cosa.

¿Cómo explico un hueco de un año en el CV?

En una frase, sin dramatismo y con lo que hiciste. «Estuve un año fuera del mercado por [motivo breve]; en ese tiempo hice [algo concreto: cuidar de un familiar, un proyecto propio, formación, recuperarme de una baja] y desde [mes] estoy buscando activamente.» Punto. El error no es el hueco, es tratarlo como un secreto culpable: si tú lo tratas como algo normal, el entrevistador lo trata como algo normal.

Si el hueco fue formativo, ten la prueba: el repositorio, el curso terminado, la certificación. Si fue personal, no tienes ninguna obligación de dar detalles médicos o familiares, y una respuesta corta y tranquila cierra el tema. Lo que sí conviene es que el CV lo muestre con fechas claras en lugar de esconderlo cambiando los meses, porque una incoherencia detectada pesa mucho más que el hueco en sí.

Trampa: «he estado haciendo proyectos personales» sin poder enseñar ninguno es la peor versión de esta respuesta. Si lo dices, ten el enlace preparado.

3 · Cómo responder a una pregunta técnica

Dos candidatos con el mismo conocimiento pueden sacar notas opuestas. La diferencia está en la forma de la respuesta: si empieza por lo importante, si trae un ejemplo, si reconoce un límite y si termina en algún momento. Esta sección es un método, y como todos los métodos hay que practicarlo hasta que deje de parecer un método.

3.1 La estructura de cuatro movimientos

Respuesta directa → el por qué → un ejemplo tuyo → el matiz. En ese orden, y con esa proporción de tiempo: 10 % / 25 % / 45 % / 20 %.

  1. Respuesta directa (una frase). Contesta exactamente lo que te han preguntado, sin preámbulo. Si la pregunta es «¿por qué inyección por constructor?», la primera frase tiene que contener «porque…».
  2. El por qué (dos o tres frases). Qué problema resuelve, contra qué alternativa. Aquí se ve si entiendes o memorizas.
  3. Un ejemplo concreto (el bloque central). Preferiblemente de algo que hiciste. Con nombres de dominio, con un número, con lo que salió mal. Esto es lo que el entrevistador va a recordar.
  4. El matiz o el trade-off. Cuándo no lo harías así, o qué coste tiene. Es el movimiento que separa a un mid de un senior, y el que casi nadie hace.

Duración objetivo: entre 45 y 120 segundos. Menos de 30 segundos suena a que no tienes nada más; más de dos minutos y medio sin que te repregunten es una señal de que te has ido.

❌ Respuesta de 3/10

«¿Qué es @Transactional? Pues es una anotación de Spring que sirve para las transacciones. La pones en el método o en la clase y hace que se haga commit o rollback. Se usa mucho en los servicios.»

Es correcta y es inútil: no explica cómo lo consigue, no hay ejemplo, no hay ningún límite. La siguiente pregunta del entrevistador va a ser incómoda porque acaba de descubrir que no sabe si entiendes proxies.

✅ Respuesta de 9/10

«Es programación declarativa de transacciones mediante un proxy (1). Spring envuelve el bean y el proxy abre la transacción antes del método y confirma o revierte al salir, así que no hay begin ni commit en mi código (2). Nos mordió en un caso concreto: teníamos un método público que llamaba a otro @Transactional de la misma clase, y no abría transacción nunca, porque la llamada interna no pasa por el proxy; lo detectamos porque un fallo a mitad no revertía y quedaban pedidos huérfanos (3). Por eso hoy la pongo siempre en el servicio de aplicación, con readOnly en las lecturas, y evito llamadas HTTP dentro de la transacción para no tener la conexión bloqueada esperando a un tercero (4).»

Truco del ejemplo: ten preparados de seis a ocho ejemplos técnicos reutilizables de tu experiencia y engánchalos a muchas preguntas distintas. Un N+1 que arreglaste sirve para preguntas de JPA, de rendimiento, de observabilidad, de testing («escribí un test que cuenta consultas») y de comportamiento («cuéntame un problema difícil»). Es mucho más eficiente que preparar una historia por pregunta.

3.2 Qué hacer cuando no lo sabes: protocolo de cuatro pasos

Esta es la habilidad más rentable de una entrevista, porque va a pasar. Un entrevistador competente busca tu límite a propósito: quiere ver qué haces cuando llegas a él. La respuesta que puntúa no es «no lo sé» seco ni un invento, es un razonamiento honesto y acotado.

  1. Reconócelo con precisión. Delimita qué no sabes y qué sí: «no he usado @Async con un executor personalizado en producción, pero sé cómo funciona la anotación por proxy».
  2. Razona en voz alta desde lo que sí sabes. «Por analogía con @Transactional, esperaría que… porque los dos van por proxy.» Aquí es donde ganas la mayor parte de los puntos.
  3. Di cómo lo averiguarías en el trabajo real. «Mirar la documentación de Spring, escribir un test que imprima el nombre del hilo, y comprobarlo antes de dar una respuesta a un compañero.» Esto es exactamente lo que quieren oír.
  4. Devuelve la pelota. «¿Es algo con lo que os habéis encontrado?» A veces te lo explican y aprendes; siempre convierte un momento incómodo en conversación.

Y si al terminar la entrevista te acuerdas de la respuesta, mándala en el correo de agradecimiento en dos líneas. Causa una impresión excelente.

Nunca inventes. Es el error irrecuperable. El entrevistador conoce la respuesta, así que un invento no solo falla: contamina retroactivamente todo lo que dijiste bien antes, porque ya no puede fiarse de nada. Hay tres formas de inventar y las tres se detectan: (1) usar una palabra de moda sin contenido —«lo resolveríamos con event sourcing»—; (2) afirmar con seguridad algo que solo has leído; y (3) inflar tu papel en un proyecto ajeno. La versión honesta de las tres puntúa más, no menos.

3.3 Pedir contexto sin parecer que esquivas

Muchas preguntas se hacen a propósito incompletas para ver si preguntas antes de lanzarte. La forma correcta de pedir contexto es corta, concreta y explicando por qué cambia tu respuesta. Lo que no funciona es un interrogatorio de cinco minutos ni pedir contexto para ganar tiempo cuando no lo necesitas.

Pregunta abiertaLo que debes preguntarPor qué cambia la respuesta
«¿Cómo cachearías esto?» ¿Cuántas instancias hay y cuánta desactualización tolera el negocio? Con una instancia y datos tolerantes, Caffeine en memoria; con varias y datos compartidos, Redis. Son diseños distintos.
«¿Usarías microservicios?» ¿Cuántos equipos hay y cómo despliegan hoy? Con un equipo y despliegue manual, la respuesta correcta es monolito modular, y decirlo es una señal de madurez.
«¿Cómo harías esta consulta más rápida?» ¿Cuántas filas tiene la tabla y qué dice el plan de ejecución? Sin volumen ni plan, cualquier respuesta es adivinar. Decir «lo primero es medir» ya puntúa.
«¿SQL o NoSQL?» ¿Qué consultas necesita el producto y hay transacciones de negocio? La elección la determinan los patrones de acceso, no la moda ni el volumen bruto.
«Diseña un sistema de notificaciones.» ¿Qué canales, qué volumen en pico y qué pasa si se envía dos veces? Duplicar un email de marketing es molesto; duplicar un SMS con un código de pago es un incidente.
«¿Cómo testearías esto?» ¿Qué es lo que más miedo da que se rompa? Orienta el esfuerzo al riesgo real en lugar de a la cobertura por cobertura.

Fórmula de una línea que siempre funciona: «Depende de X; si X es A haría esto, si es B haría lo otro. ¿Cuál es vuestro caso?». Con eso demuestras que conoces las dos soluciones y el criterio para elegir, que es más de lo que se pedía, y además abres conversación en lugar de cerrar con un monólogo.

3.4 Detectar la pregunta trampa

Hay cinco familias de preguntas que parecen inocentes y no lo son. Reconocerlas por su forma te da una ventaja enorme, porque casi siempre lo que se evalúa es el matiz, no el hecho.

FamiliaEjemplo típicoQué buscan de verdadCómo responder
Falsa dicotomía «¿Qué es mejor, ArrayList o LinkedList Que no aceptes el marco y que sepas que la intuición del libro está desfasada. «Depende, pero en la práctica casi siempre ArrayList, y te explico por qué la teoría engaña aquí.»
Premisa falsa «Como los virtual threads hacen que todo vaya más rápido…» Si detectas el error y lo corriges con educación, o si tragas por miedo. «Matizo una cosa: no aceleran la CPU, mejoran la concurrencia con I/O bloqueante. Si el cuello está en la base de datos, no cambia nada.»
Pregunta con anzuelo de moda «¿Migrarías esto a event sourcing?» Si te lanzas a lo brillante o si evalúas coste y beneficio. Nombra el coste real (proyecciones, versionado de eventos, depuración) y pon la condición que lo justificaría.
«¿Estás seguro?» Después de una respuesta correcta. Si te sostienes con argumentos o cambias de opinión por presión social. «Sí, y el motivo es este. Si estoy pasando algo por alto, dímelo, porque me interesa.»
El caso extremo «¿Y si tuvieras diez mil peticiones por segundo?» Si sabes que la solución cambia con la escala y en qué punto cambia. Di qué se rompe primero, con qué número aproximado, y cuál es el siguiente paso de arquitectura.

3.5 Señales de que te estás alargando (y cómo cerrar)

Alargarse es el problema de comunicación más frecuente en candidatos que saben. Consume el tiempo de otras preguntas donde podrías brillar y transmite falta de criterio para separar lo importante de lo accesorio. Las señales son visibles si las buscas:

Señales de alarma

  • El entrevistador deja de asentir y mira la hora o sus notas.
  • Dice «vale», «perfecto», «entendido» varias veces: te está intentando cerrar.
  • Empieza a escribir mientras hablas y ya no levanta la vista.
  • Llevas más de dos minutos sin que nadie te haya interrumpido.
  • Te has ido a un tercer nivel de detalle que nadie ha pedido.
  • Estás explicando algo que ya explicaste hace cinco minutos.

Frases para cerrar con elegancia

  • «Y eso es lo esencial. ¿Quieres que entre en la parte de X?»
  • «Resumo: la clave es esto. Hay más detalle si te interesa.»
  • «Ahí lo dejo por no alargarme, pero puedo profundizar en cualquier punto.»
  • «¿Voy por donde querías o preferías que enfocara otra parte?»
  • «En una frase: [conclusión]. ¿Siguiente?»
El patrón «titular y luego cuerpo»: di la conclusión en la primera frase y desarrolla después. Así, si te interrumpen a los veinte segundos —y en una buena entrevista te van a interrumpir—, ya has dicho lo importante. Es la misma técnica que se usa al escribir un incidente o un mensaje en Slack: nadie debería tener que leer hasta el final para saber qué le estás contando.

3.6 Usar tu experiencia real como munición

La diferencia entre una respuesta de manual y una respuesta contratable es que la segunda tiene cicatrices. Prepara una tabla con tus propias historias técnicas y ténla presente; en la sección 15 haremos lo mismo con las de comportamiento.

Tipo de historiaQué demuestraPreguntas a las que sirve
Un problema de rendimiento que diagnosticaste Método: medir, aislar, corregir, verificar. N+1, índices, caché, observabilidad, «la API va lenta», problema difícil.
Un incidente en producción Sangre fría, prioridades, aprendizaje sin culpables. Fugas de memoria, 100 % de CPU, rollback, postmortem, fallo que causaste.
Un bug de concurrencia o de datos Profundidad real: casi nadie ha visto uno de verdad. Condición de carrera, bloqueo optimista, idempotencia, duplicados.
Una decisión de arquitectura que tomaste Criterio, alternativas descartadas, coste asumido. Monolito o microservicios, síncrono o asíncrono, elección de almacenamiento.
Una migración o un cambio grande Planificación por fases, compatibilidad, gestión de riesgo. Migración sin caída, Java 8 → 21, partir un monolito, versionar una API.
Algo que hiciste mal y corregiste Honestidad y capacidad de aprender. Vale más de lo que crees. Decisión equivocada, sobreingeniería, fallo que causaste, feedback recibido.
Si tu experiencia es corta: el proyecto propio cuenta, siempre que hables de él con la misma seriedad. «En mi proyecto de pedidos tuve un N+1 en el listado, lo detecté con show-sql y un contador de sentencias en un test, y lo arreglé con @EntityGraph» es una historia perfectamente válida. Lo que no vale es fingir que era un sistema en producción con clientes: di lo que es y gana credibilidad en lugar de perderla.

3.7 Hablar de código de otros y de decisiones que no tomaste tú

Buena parte de tu carrera es código heredado y decisiones ajenas. Hay una forma de contarlo que suma y otra que resta mucho.

❌ Lo que resta

  • «El código era un desastre, lo había hecho un becario.»
  • «La arquitectura no tenía ningún sentido.»
  • «Nadie sabía lo que hacía, yo fui el único que se enteró.»
  • Atribuirte trabajo de equipo en primera persona del singular todo el rato.
  • Defender una decisión mala solo porque era «la que había».

El entrevistador piensa: «así vas a hablar de nuestro código dentro de seis meses, y de mí».

✅ Lo que suma

  • «Era código con muchos años y decisiones tomadas en un contexto que no conocí; con la información y los plazos de entonces se entiende.»
  • «No comparto la decisión de X. Habría hecho Y por estos dos motivos, y así lo planteé; se decidió mantener X por el coste de migración, lo cual también es un argumento.»
  • «Yo me encargué de la parte de pagos; la de catálogo la llevaba una compañera.»
  • «Lo que hice fue acotar el daño: puse tests alrededor antes de tocar nada.»
  • «Documenté el porqué en un ADR para que el siguiente no tenga que adivinarlo.»

Criterio propio sin desprecio, límites claros de tu aportación y respeto por el contexto que no viviste.

Y si la decisión ajena era objetivamente mala, se puede decir con precisión técnica en lugar de con juicio moral: «guardábamos importes en double, lo que provocaba descuadres de céntimos en las agregaciones; propuse migrar a BigDecimal con una migración en dos fases y lo hicimos en el trimestre siguiente». Eso es una crítica dura y perfectamente profesional.

3.8 Vocabulario que transmite seniority

En lugar de…Di…Por qué
«es mejor», «es lo correcto»«el trade-off es…», «a cambio de…»Casi nada es mejor en abstracto; todo tiene coste.
«va rápido», «es lento»«el p95 está en 180 ms», «pasó de 4 s a 220 ms»Los números son verificables y las impresiones no.
«nunca falla»«el modo de fallo es este y lo tratamos así»Todo falla. Pensar en el modo de fallo es señal de experiencia.
«hay que refactorizar todo»«acotaría con tests y refactorizaría el módulo X primero, porque es el que más cambia»Prioridad y gestión de riesgo frente a idealismo.
«lo hice yo»«me encargué de X; Y lo llevó un compañero»Precisión sobre tu aportación: se comprueba en las referencias.
«no s黫no lo he usado; por analogía con X esperaría Y, y lo confirmaría así»Convierte un hueco en una demostración de método.
«usamos Kafka»«usamos Kafka con clave por cliente para garantizar orden por entidad»El detalle demuestra que lo usaste tú y no que estaba en el proyecto.
«implementé la seguridad»«configuré la cadena de filtros con JWT validando iss y aud»Concreción: la palabra «seguridad» sola no dice nada.

Muletillas que te restan

  • «O sea…», «en plan…», «tipo…» encadenados.
  • «Básicamente» al principio de cada frase.
  • «Como tal», «a nivel de», «de cara a».
  • «¿Sabes?» y «¿no?» buscando aprobación cada diez segundos.
  • «Es súper fácil» (si lo es, ¿por qué te lo preguntan?).
  • «Obviamente», «evidentemente»: hacen sentir tonto al que pregunta.

Cómo quitarlas

  • Grábate una respuesta de dos minutos y cuéntalas. Es desagradable y funciona a la primera.
  • Sustituye la muletilla por una pausa de un segundo. El silencio suena a seguridad.
  • Habla más despacio: las muletillas aparecen cuando la boca va por delante de la cabeza.
  • Prepara la primera frase de cada respuesta: la mayoría de muletillas están en el arranque.

3.9 Cuando el entrevistador se equivoca

Va a pasar, y es un momento delicado: te la juegas igual si tragas que si le corriges por encima. El objetivo no es ganar la discusión, es mostrar criterio sin humillar a nadie. La fórmula tiene tres partes: reconoce el punto de partida, aporta el dato, deja la puerta abierta.

Ejemplo real y frecuente. El entrevistador dice: «claro, con @Transactional(readOnly = true) el rendimiento mejora porque no escribe en la base de datos».

Respuesta que funciona: «Sí, ayuda, aunque el mecanismo principal en Hibernate es otro y me parece interesante: con readOnly el flush mode pasa a MANUAL, así que se salta la detección de cambios (dirty checking) al final de la transacción, y además puede enrutar a una réplica de lectura si lo tienes configurado. La parte de "no escribe" la garantiza la base de datos si la transacción va marcada como de solo lectura. Al final el efecto que tú dices se cumple, pero el motivo cambia lo que puedes esperar de la mejora. ¿Lo habéis medido en vuestro caso?»

Frases que abren

  • «Sí, y matizo una cosa que me parece interesante…»
  • «Puede que dependa de la versión; en Spring Boot 3 yo lo veo así…»
  • «Yo lo tenía entendido de otra forma, déjame contrastarlo contigo…»
  • «Puedo estar equivocado, pero mi experiencia fue esta…»
  • «¿Te refieres a X o a Y? Porque en el segundo caso sí, y en el primero cambia.»

Frases que cierran puertas

  • «No, eso está mal.»
  • «Eso es un mito.» (aunque lo sea)
  • «Se ve que no has trabajado con esto.»
  • «En la documentación dice claramente que…» dicho con tono de corrección.
  • Insistir tres veces cuando ya has dicho tu argumento una vez.
Y si insiste en algo que sabes que es falso: di tu argumento una vez, con claridad, ofrece la comprobación («si quieres lo montamos en dos líneas y lo vemos») y sigue adelante. Si aun así el entrevistador se enroca y te penaliza por tener razón, tienes información valiosísima sobre esa empresa: acabas de ver cómo se resuelven allí los desacuerdos técnicos. Hay procesos que es mejor no ganar.
¿Cuánto debe durar una respuesta técnica?

Entre 45 y 120 segundos para una pregunta conceptual, y hasta tres minutos si te piden contar un caso completo. La referencia práctica: si estás hablando más de dos minutos y el entrevistador no ha intervenido ni una vez, para y pregunta «¿quieres que profundice o paso a lo siguiente?».

El otro extremo también es un problema. Una respuesta de diez segundos —«volatile garantiza visibilidad»— es correcta y no da ninguna señal; el entrevistador tiene que trabajar para sacarte lo que sabes, y eso puntúa mal. Añade siempre el por qué y un ejemplo, aunque no te lo pidan.

¿Está bien preguntar «¿he respondido lo que querías?»

Sí, con moderación: una o dos veces en la entrevista, y en las preguntas grandes. Es una señal de orientación al interlocutor y evita el peor escenario, que es responder brillantemente a una pregunta distinta de la que te hicieron. Formulaciones que funcionan: «¿voy por donde te interesa?», «¿quieres que entre en la parte de rendimiento o en la de consistencia?».

Lo que no debes hacer es preguntarlo después de cada respuesta, porque entonces suena a inseguridad y a buscar aprobación. Y no lo uses como sustituto de pensar: si no entendiste la pregunta, pregunta antes de responder, no después.

Me han pedido que piense en voz alta y me siento raro. ¿Cómo se hace?

Pensar en voz alta no es narrar cada tecla, es hacer visibles tus decisiones. Tres cosas y ya está: (1) di qué vas a hacer antes de hacerlo —«voy a empezar por la firma del método y un test con el caso simple»—; (2) nombra las alternativas que descartas y por qué —«podría usar un Set, pero necesito el índice, así que un Map»—; (3) señala los riesgos que ves aunque no los vayas a tratar todavía —«esto explota con entrada nula; lo dejo anotado y lo cubro al final».

Lo que suena mal es el silencio de dos minutos seguido de código perfecto: el entrevistador no puede evaluar lo que no ve, y si no habla contigo no sabe si has entendido el problema o has recordado la solución. Si te bloqueas, verbaliza el bloqueo: «estoy dudando entre dos formas de tratar los duplicados, te cuento las dos».

Trampa: hablar tanto que no escribes código. Alterna: treinta segundos de explicación, un bloque de código, otra vez explicación.

¿Debo corregirme si me doy cuenta de un error diez minutos después?

Sí, siempre, y es una de las cosas que mejor puntúan de toda la entrevista. «Antes te he dicho que volatile hacía atómico el incremento y no es así: garantiza visibilidad y ordenación, pero contador++ sigue siendo una carrera; para atomicidad, AtomicInteger.» Eso demuestra tres cosas a la vez: que sigues pensando, que te importa la corrección más que tu imagen, y que eres alguien en cuyas afirmaciones se puede confiar.

La única condición es que sea breve y sin dramatismo. Una frase de corrección y sigues; no un párrafo de disculpas ni una autoflagelación que cambie el tono de la conversación.

El entrevistador es cortante y no da ninguna señal. ¿Qué hago?

Asume que es su estilo o que está teniendo un día malo, y no lo interpretes como una valoración negativa, porque muchas veces no lo es: hay gente que entrevista con cara de póquer a propósito para no sesgar. Lo que sí puedes hacer es tomar tú el control del ritmo: haz respuestas más cortas, pregunta más a menudo «¿quieres que profundice?», y busca su terreno («¿esto os pasa en vuestro sistema?»).

Si de verdad es hostil —interrumpe, ridiculiza, no deja terminar—, mantén el tono profesional hasta el final y apúntalo como dato. Una entrevista es la mejor muestra que vas a tener de cómo se trata a la gente dentro. Nadie te obliga a aceptar una oferta de un sitio donde te trataron mal siendo aún un invitado.

4 · Banco de preguntas · Java fundamentos

Cómo trabajar este banco: lee la pregunta, responde en voz alta con el desplegable cerrado y cronometra. Luego ábrelo y compara: no busques coincidencia literal, busca si dijiste la idea principal, si diste un ejemplo y si mencionaste el límite. Marca con un punto las que fallaste y vuelve a ellas al día siguiente, no en la misma sesión. Si respondes 25 de estas 30 con soltura, los fundamentos de Java están cubiertos para cualquier entrevista de mid o senior.
1. ¿Diferencia entre == y equals()?

== compara identidad —si las dos referencias apuntan al mismo objeto en memoria— y equals() compara equivalencia lógica según la definición de la clase. En primitivos == compara valores, porque no hay referencias.

Dónde muerde en la práctica: con envoltorios y con cadenas, porque hay caché por medio y el resultado parece aleatorio.

Integer a = 127, b = 127;      // caché de Integer: -128..127
System.out.println(a == b);     // true  ← engañoso
Integer c = 128, d = 128;
System.out.println(c == d);     // false ← el mismo código, otro resultado
System.out.println(c.equals(d));// true  ← lo correcto

String s1 = "hola", s2 = "hola";
System.out.println(s1 == s2);              // true (literales, mismo objeto del pool)
System.out.println(s1 == new String("hola")); // false
System.out.println(s1.equals(new String("hola"))); // true

Regla operativa: para objetos, siempre equals(); el único == legítimo con objetos es contra null, con enum (donde es preferible porque es null-safe y no se puede romper) y cuando de verdad quieres comparar identidad.

Trampa: la repregunta suele ser «¿y con Long en un switch o en un if de un id?». Comparar Long id1 == id2 con identificadores por encima de 127 es un bug clásico que pasa los tests en local (ids pequeños) y falla en producción.

2. Contrato de equals y hashCode: ¿qué pasa si lo rompes?

El contrato tiene cinco propiedades para equals —reflexivo, simétrico, transitivo, consistente y false frente a null— y una regla de enlace: si dos objetos son equals, sus hashCode deben ser iguales. La inversa no se exige: dos objetos distintos pueden compartir hash (colisión).

Si rompes la regla de enlace, los objetos se pierden en las colecciones basadas en hash: lo metes en un HashSet, lo buscas y no está, porque se busca en el bucket equivocado.

// El bug: hashCode por defecto (identidad) + equals sobrescrito
class Cliente {
    final String nif;
    Cliente(String nif) { this.nif = nif; }
    @Override public boolean equals(Object o) {
        return o instanceof Cliente c && nif.equals(c.nif);
    }
    // ¡falta hashCode!
}
var set = new HashSet<Cliente>();
set.add(new Cliente("12345678Z"));
set.contains(new Cliente("12345678Z"));  // false → el objeto "desaparece"

La solución en 2026 casi siempre es un record, que los genera correctamente a partir de todos los componentes. Si los escribes a mano, Objects.equals y Objects.hash, y usa solo campos inmutables y significativos del dominio.

Trampa: la repregunta es «¿qué pasa si la clave de un HashMap es mutable y la mutas después de insertarla?». Respuesta: el hash cambia, la entrada se queda en el bucket antiguo y se vuelve inaccesible —ni se encuentra ni se puede borrar— aunque siga ocupando memoria. Es una fuga de memoria silenciosa.

3. ¿Qué es la inmutabilidad y por qué te la piden tanto?

Un objeto inmutable es uno cuyo estado observable no cambia después de construirse. La razón por la que se insiste tanto: es la forma más barata de seguridad entre hilos. Si nada cambia, no hay carreras, no hace falta sincronizar y no hay que razonar sobre visibilidad de memoria.

Receta completa para hacer una clase inmutable:

public final class Dinero {                    // final: nadie hereda y rompe invariantes
    private final BigDecimal importe;           // campos private final
    private final Currency moneda;
    private final List<String> etiquetas;       // ¡cuidado con las colecciones!

    public Dinero(BigDecimal importe, Currency moneda, List<String> etiquetas) {
        if (importe == null || moneda == null) throw new IllegalArgumentException();
        this.importe = importe.setScale(2, RoundingMode.HALF_UP);
        this.moneda = moneda;
        this.etiquetas = List.copyOf(etiquetas);   // copia defensiva al entrar
    }
    public List<String> etiquetas() { return etiquetas; }  // ya es inmutable, no hace falta copiar
    public Dinero mas(Dinero otro) {                        // "mutar" devuelve otro objeto
        exigirMismaMoneda(otro);
        return new Dinero(importe.add(otro.importe), moneda, etiquetas);
    }
}

Los cuatro puntos que hay que nombrar: final en la clase, campos final y privados, copias defensivas de lo mutable (entrada y salida) y ningún setter. Un record te da los tres primeros gratis, pero no hace copia defensiva de una lista mutable que le pases: eso lo tienes que hacer tú en el constructor compacto.

Trampa: «un record es inmutable, ¿no?». Es superficialmente inmutable: sus componentes no se reasignan, pero si un componente es un ArrayList o una entidad JPA, su contenido sí puede cambiar. La inmutabilidad profunda hay que construirla.

4. ¿Qué es el String pool y cuándo importa?

Es una zona de la memoria (dentro del heap desde Java 7) donde la JVM guarda una única instancia de cada literal de cadena. Existe porque las cadenas son el objeto más repetido de cualquier programa y son inmutables, así que compartirlas es seguro y ahorra muchísima memoria.

String a = "pedido";                 // va al pool
String b = "pedido";                 // misma instancia del pool → a == b
String c = new String("pedido");     // objeto nuevo en el heap → a != c
String d = c.intern();               // fuerza la búsqueda en el pool → a == d
String e = "pe" + "dido";            // el compilador la resuelve → a == e (constante)
String f = leerDeBd() + "dido";      // en tiempo de ejecución → objeto nuevo

Importa en tres sitios: (1) explica los resultados aparentemente contradictorios de ==; (2) concatenar en un bucle crea un objeto por iteración, y por eso se usa StringBuilder —aunque el compilador ya optimiza la concatenación en una sola expresión—; (3) intern() tiene sentido para deduplicar cadenas muy repetidas leídas de fuera (por ejemplo, códigos de país de un fichero de millones de líneas), pero es fácil de usar mal.

Trampa: nunca uses intern() «por rendimiento» sin medir, y nunca sincronices sobre una cadena internada (synchronized("clave")): el pool es global, así que estarías compartiendo un cerrojo con cualquier otro código de la JVM que use ese literal.

5. ¿Java pasa los parámetros por valor o por referencia?

Siempre por valor. Lo que ocurre es que, cuando el argumento es un objeto, el valor que se copia es la referencia. Consecuencia: dentro del método puedes mutar el objeto apuntado, pero no puedes reasignar la variable del llamante.

void mutar(List<String> l) { l.add("x"); }        // el llamante SÍ ve el cambio
void reasignar(List<String> l) { l = new ArrayList<>(); l.add("y"); } // no ve nada

var lista = new ArrayList<String>();
mutar(lista);      // lista = [x]
reasignar(lista);  // lista = [x]  ← sigue igual

void incrementar(int n) { n++; }        // primitivo: copia del valor, no cambia nada
void cambiar(String s) { s = "otro"; }  // String es inmutable: imposible mutarla

La forma de decirlo que suena bien en entrevista: «Java es pass-by-value de referencias; el objeto no se copia, la referencia sí».

Trampa: mucha gente responde «por referencia para objetos, por valor para primitivos». Es la respuesta que el entrevistador está esperando para repreguntar, porque implica que reasignar el parámetro afectaría al llamante, y no es así.

6. Sobrecarga (overloading) frente a sobrescritura (overriding)

La sobrecarga es polimorfismo estático: varios métodos con el mismo nombre y firma distinta en la misma clase; el compilador elige cuál llamar según los tipos declarados. La sobrescritura es polimorfismo dinámico: una subclase redefine un método heredado con la misma firma, y la JVM elige la implementación en tiempo de ejecución según el tipo real del objeto.

SobrecargaSobrescritura
Se resuelveEn compilación, por tipo declaradoEn ejecución, por tipo real
FirmaDebe cambiar (parámetros)Debe coincidir
RetornoPuede cambiarIgual o covariante (subtipo)
VisibilidadLibreNo se puede reducir
Excepciones checkedLibresNo se pueden ampliar
static/private/finalSe pueden sobrecargarNo se pueden sobrescribir
// El clásico que sorprende: se elige por tipo DECLARADO
static void log(Object o) { System.out.println("Object"); }
static void log(String s) { System.out.println("String"); }

Object x = "hola";
log(x);        // imprime "Object", no "String"
log((String) x); // imprime "String"

// Los métodos static se OCULTAN, no se sobrescriben
class Padre { static String quien() { return "padre"; } }
class Hijo extends Padre { static String quien() { return "hijo"; } }
Padre p = new Hijo();
// p.quien() → "padre" (se resuelve por el tipo declarado)

Trampa: te van a preguntar por qué usar @Override. No cambia el comportamiento, pero hace que el compilador falle si crees que estás sobrescribiendo y en realidad estás sobrecargando —el caso típico es equals(Cliente c) en vez de equals(Object o)—, que es uno de los bugs más difíciles de ver a ojo.

7. ¿Interfaz o clase abstracta? Con Java 21 en la mano.

Por defecto, interfaz. Una interfaz define un contrato o una capacidad y permite herencia múltiple de tipos; una clase abstracta define una implementación parcial con estado compartido, y consume la única bala de la herencia simple. Desde Java 8 las interfaces tienen métodos default y static, y desde Java 9 private, así que la vieja distinción «las interfaces no tienen código» ya no aplica.

Necesito…ElijoPor qué
Un contrato que puedan cumplir clases sin relaciónInterfazHerencia múltiple de tipos
Compartir estado mutable entre implementacionesClase abstractaLas interfaces no tienen campos de instancia
Un template method con pasos protegidosClase abstractaLos default no pueden ser protected
Un constructor que valide invariantes comunesClase abstractaLas interfaces no tienen constructor
Cerrar la jerarquía a un conjunto conocidoInterfaz sealedPermite switch exhaustivo con patrones
Añadir un método a un contrato ya publicadoInterfaz con defaultNo rompe a los implementadores existentes

En Spring, además, hay un motivo pragmático: los puertos de la arquitectura hexagonal son interfaces porque permiten cambiar el adaptador y probar con dobles sin tocar el dominio. Pero ojo, la interfaz «con una sola implementación y el mismo nombre más Impl» es ceremonia sin valor: úsala cuando exista un motivo (varias implementaciones, un límite de arquitectura, o un test que la necesita).

Trampa: «¿y el problema del diamante con métodos default?» Si dos interfaces aportan el mismo default, el compilador te obliga a resolverlo explícitamente con Interfaz.super.metodo(). No hay ambigüedad silenciosa.

8. ¿Qué significan exactamente static y final?

static significa «pertenece a la clase, no a la instancia»; final significa «no se puede reasignar / heredar / sobrescribir», según dónde se aplique.

Aplicado a…staticfinal
CampoUno por clase, compartido por todos y por todos los hilosSe asigna una vez (en la declaración o en el constructor)
MétodoSin this, se resuelve en compilación, no se sobrescribeNo se puede sobrescribir
ClaseClase anidada sin referencia a la externaNo se puede heredar
Variable localNo aplicaNo se reasigna (necesario en lambdas antes de Java 8, hoy basta con que sea effectively final)
BloqueInicializador estático, se ejecuta al cargar la claseNo aplica

Lo importante en entrevista es lo que final no garantiza: que el objeto sea inmutable. final List<String> l = new ArrayList<>() impide reasignar l, pero no impide l.add(...).

Y hay un detalle de concurrencia que da muchos puntos: un campo final tiene una garantía especial del modelo de memoria. Si se inicializa en el constructor y no se filtra this, cualquier hilo que vea la referencia al objeto ve el valor correcto del campo sin sincronizar. Es la base de por qué los objetos inmutables son seguros de publicar.

Trampa: un static mutable es la fuente número uno de fugas de memoria y de bugs de concurrencia en aplicaciones Spring: una static Map que se usa de caché crece sin límite y nunca se libera, porque la clase vive mientras viva el classloader.

9. ¿Qué es un record y cuándo NO usarlo?

Un portador de datos inmutable y transparente: declaras los componentes y el compilador genera constructor canónico, accesores, equals, hashCode y toString. Existe para eliminar el ruido de las clases de datos y, sobre todo, para que equals/hashCode dejen de escribirse mal a mano.

public record CrearPedido(
        @NotBlank String clienteId,
        @NotEmpty List<Linea> lineas,
        @Positive BigDecimal total) {

    // Constructor compacto: validación e invariantes
    public CrearPedido {
        Objects.requireNonNull(clienteId);
        lineas = List.copyOf(lineas);              // copia defensiva: importante
        if (total.signum() <= 0) throw new IllegalArgumentException("total > 0");
    }
    // Métodos derivados sí, estado extra no
    public int numeroLineas() { return lineas.size(); }
    public record Linea(String sku, int cantidad, BigDecimal precio) { }
}

Dónde encaja perfecto: DTO de entrada y salida, objetos de valor del dominio (Dinero, Email, Rango), resultados de consulta (proyecciones), eventos, claves compuestas de mapas y, junto a sealed, tipos algebraicos para modelar «o esto o aquello».

Dónde no: entidades JPA (necesitan constructor sin argumentos, campos no finales y proxies para lazy), objetos que deben poder evolucionar ocultando su representación interna, y jerarquías con herencia de implementación —un record no puede extender una clase—.

Trampa: «¿es seguro entre hilos?». Sí, si todos sus componentes lo son. Un record Carrito(List<Linea> lineas) al que le pasas un ArrayList sin copiar es tan mutable y tan insegura como cualquier clase.

10. ¿Para qué sirven las clases sealed?

Para cerrar una jerarquía a un conjunto conocido y declarado de subtipos. Con eso el compilador puede comprobar que un switch cubre todos los casos, y tú puedes modelar «el resultado es A, B o C» sin excepciones, sin nulos y sin instanceof en cadena.

sealed interface Resultado<T> permits Ok, ErrorValidacion, NoEncontrado { }
record Ok<T>(T valor) implements Resultado<T> { }
record ErrorValidacion<T>(List<String> errores) implements Resultado<T> { }
record NoEncontrado<T>(String id) implements Resultado<T> { }

// El switch es una EXPRESIÓN y es exhaustivo: no hace falta default
ResponseEntity<?> aRespuesta(Resultado<PedidoDto> r) {
    return switch (r) {
        case Ok(var dto)              -> ResponseEntity.ok(dto);
        case ErrorValidacion(var errs)-> ResponseEntity.badRequest().body(errs);
        case NoEncontrado(var id)     -> ResponseEntity.notFound().build();
    };
}

El valor real está en el mantenimiento: el día que alguien añada record Conflicto(...) a la interfaz sellada, todos los switch del proyecto dejarán de compilar y te obligarán a decidir qué hacer en ese caso. Con una jerarquía abierta y un default, ese caso se habría colado en producción en silencio.

Trampa: si pones default en el switch, pierdes la exhaustividad y con ella la mitad del beneficio. Resiste la tentación de añadirlo «por seguridad».

11. ¿Qué es realmente un enum en Java?

Una clase con un número fijo de instancias creadas por la JVM, garantizadas únicas y seguras entre hilos. No es «una lista de constantes»: puede tener campos, constructor, métodos, implementar interfaces e incluso sobrescribir comportamiento por constante.

public enum EstadoPedido {
    NUEVO(true), PAGADO(true), ENVIADO(false), CANCELADO(false);

    private final boolean cancelable;
    EstadoPedido(boolean cancelable) { this.cancelable = cancelable; }
    public boolean esCancelable() { return cancelable; }

    // Máquina de estados dentro del propio enum: las reglas viven con el dato
    private static final Map<EstadoPedido, Set<EstadoPedido>> TRANSICIONES = Map.of(
        NUEVO,  EnumSet.of(PAGADO, CANCELADO),
        PAGADO, EnumSet.of(ENVIADO, CANCELADO),
        ENVIADO, EnumSet.noneOf(EstadoPedido.class),
        CANCELADO, EnumSet.noneOf(EstadoPedido.class));

    public boolean puedeIrA(EstadoPedido destino) {
        return TRANSICIONES.get(this).contains(destino);
    }
}

Tres cosas que dan puntos: el singleton más simple y seguro de Java es un enum de un solo elemento (resiste reflexión y serialización); EnumMap y EnumSet son implementaciones especializadas mucho más rápidas y compactas que HashMap/HashSet (un array indexado por ordinal y un mapa de bits, respectivamente); y con enum se puede usar == con total seguridad.

Trampa: nunca persistas ni serialices el ordinal(). Alguien insertará una constante nueva en medio de la lista y todos los datos históricos pasarán a significar otra cosa. En JPA, @Enumerated(EnumType.STRING) siempre; en JSON, el nombre.

12. Genéricos y borrado de tipos: ¿qué se pierde en ejecución?

Los genéricos son una comprobación del compilador. En el bytecode desaparecen: el tipo se reemplaza por su límite (normalmente Object) y el compilador inserta las conversiones necesarias. Se hizo así para mantener la compatibilidad binaria con el código anterior a Java 5.

List<String> a = new ArrayList<>();
List<Integer> b = new ArrayList<>();
a.getClass() == b.getClass();   // true: en ejecución las dos son ArrayList

// Lo que el borrado te impide hacer:
// new T[10]                     ← no se puede crear array de un genérico
// x instanceof List<String>     ← no compila; solo List<?>
// void f(List<String> l) {} y void f(List<Integer> l) {}  ← misma firma tras borrado
// catch (MiExcepcion<String> e) ← prohibido

// Y por eso, para deserializar un tipo parametrizado, hay que "colar" el tipo:
var lista = mapper.readValue(json, new TypeReference<List<PedidoDto>>() { });

El truco del TypeReference (o ParameterizedTypeReference en Spring) funciona porque el tipo genérico de una superclase sí se conserva en los metadatos de la clase: se crea una subclase anónima solo para guardar esa información.

Trampa: la repregunta es «¿qué es un tipo reified y qué lenguajes lo tienen?». Java no reifica los genéricos (C# y Kotlin con inline reified sí). Y ojo con la advertencia de unchecked cast: cuando la silencias con @SuppressWarnings("unchecked"), estás asumiendo tú una garantía que el compilador ya no puede darte, y el error saldrá como ClassCastException en un sitio lejano.

13. ¿Qué es PECS y cuándo uso ? extends o ? super?

Producer Extends, Consumer Super. Si la estructura te da valores (productor), usa ? extends T; si recibe valores (consumidor), usa ? super T. Existe porque los genéricos en Java son invariantes: List<Perro> no es un List<Animal>, y los comodines son la forma de recuperar la flexibilidad sin perder la seguridad de tipos.

// Productor: solo leo de origen → extends
static <T> void copiar(List<? extends T> origen, List<? super T> destino) {
    for (T t : origen) destino.add(t);      // leer de origen: OK; escribir en destino: OK
}
copiar(List.of(new Perro()), new ArrayList<Animal>());   // compila

List<? extends Number> nums = List.of(1, 2, 3);
Number n = nums.get(0);   // ✅ leer sí
// nums.add(4);           // ❌ escribir no: el compilador no sabe si es List<Integer> o <Double>

List<? super Integer> sink = new ArrayList<Number>();
sink.add(42);             // ✅ escribir sí
Object o = sink.get(0);   // solo puedes leer como Object

Lo verás por todas partes en la API estándar sin haberte fijado: Collections.copy(List<? super T> dest, List<? extends T> src), Stream.map(Function<? super T, ? extends R>), Comparator.comparing(Function<? super T, ? extends U>).

Trampa: «¿y si la estructura hace las dos cosas?». Entonces no uses comodín: usa T exacto. Un tipo que produce y consume no puede ser ni covariante ni contravariante.

14. Excepciones checked frente a unchecked: ¿cuál uso?

Checked para condiciones excepcionales pero previsibles y recuperables que el llamante debería tratar (el fichero no existe, el servicio remoto ha devuelto 503). Unchecked para errores de programación y violaciones de invariantes (argumento nulo, estado imposible, bug).

La realidad del mundo Spring es que la mayoría del código moderno usa unchecked: Spring convierte las SQLException en la jerarquía no comprobada de DataAccessException, y las lambdas de los streams no pueden lanzar checked, lo que obliga a envolver y ensucia el código. Una respuesta madura reconoce las dos posturas.

// Excepción de dominio propia: unchecked, con contexto útil y causa preservada
public class PedidoNoEncontrado extends RuntimeException {
    private final String pedidoId;
    public PedidoNoEncontrado(String pedidoId) {
        super("Pedido no encontrado: " + pedidoId);   // el mensaje incluye el dato
        this.pedidoId = pedidoId;                      // y el dato es accesible
    }
    public String pedidoId() { return pedidoId; }
}

// ❌ Lo que nunca se hace
try { hacerAlgo(); }
catch (Exception e) { }                        // tragar en silencio
catch (Exception e) { log.error("error"); }    // sin la causa ni contexto
catch (Exception e) { throw new RuntimeException(e.getMessage()); } // pierdes el stack

// ✅ Lo correcto
catch (IOException e) {
    throw new ImportacionFallida("fichero " + ruta + ", línea " + n, e);  // causa incluida
}

Tres reglas que valen más que la clasificación: no capturar sin actuar, no perder la causa, y añadir el contexto que el que lea el log necesita para reproducir (identificadores, no solo «error al procesar»).

Trampa: «¿capturarías Exception?». Solo en el límite del sistema: un @RestControllerAdvice, el bucle principal de un consumidor de mensajes o un scheduler, donde tragarte todo es preferible a que el hilo muera. Y ahí siempre se registra con stack trace completo. Capturar Throwable es otra cosa: te comerías OutOfMemoryError, y eso no se debe manejar.

15. ¿Qué hace exactamente try-with-resources?

Cierra automáticamente todo recurso que implemente AutoCloseable declarado en la cabecera, en orden inverso a su apertura, tanto en el camino normal como si hay excepción. Y hace algo que a mano casi nadie hacía bien: si el cierre falla, la excepción de cierre se añade como suppressed en lugar de reemplazar a la original.

// El patrón antiguo tenía un bug sutil: si close() lanzaba, perdías la excepción real
try (var conn = ds.getConnection();
     var ps = conn.prepareStatement("select * from pedido where id = ?")) {
    ps.setLong(1, id);
    try (var rs = ps.executeQuery()) {
        return mapear(rs);
    }
}   // se cierran rs, ps y conn en ese orden, pase lo que pase

// Java 9+: si la variable ya es final o effectively final, se puede usar directamente
var stream = Files.lines(ruta);
try (stream) { ... }

// Recuperar las excepciones suprimidas
catch (Exception e) {
    for (Throwable s : e.getSuppressed()) log.warn("fallo al cerrar", s);
}

Trampa: Files.lines(), Files.walk() y Files.newDirectoryStream() devuelven streams que abren un descriptor de fichero. Si no los pones en un try-with-resources, filtras descriptores hasta que el proceso muere con «too many open files». Es uno de los bugs más difíciles de detectar en desarrollo y uno de los más frecuentes en producción.

16. ¿Qué pasa si hay un return en el finally?

El finally gana: descarta el return anterior y, peor aún, se come cualquier excepción en curso. Es un antipatrón que oculta errores y que algunos analizadores estáticos marcan directamente como error.

int malo() {
    try { throw new IllegalStateException("boom"); }
    finally { return 1; }          // devuelve 1 y la excepción DESAPARECE
}

int confuso() {
    int x = 0;
    try { return x; }              // se evalúa x → 0 y se guarda ese valor
    finally { x = 99; }            // modificar x ya no cambia lo devuelto
}                                   // devuelve 0

// Con objeto mutable sí se ve el cambio, porque se devolvió la referencia:
List<String> tambienConfuso() {
    var l = new ArrayList<String>();
    try { return l; }
    finally { l.add("x"); }        // el llamante recibe [x]
}

La regla: en el finally solo va liberación de recursos, y a ser posible ni eso, porque para liberar recursos existe try-with-resources. Nunca return, nunca throw, nunca break ni continue.

Trampa: la repregunta es «¿se ejecuta siempre el finally?». Casi siempre, pero no si se llama a System.exit(), si la JVM se muere de forma anormal o si el hilo se detiene abruptamente. Es un buen detalle para demostrar precisión.

17. Optional: uso correcto y abuso

Optional existe para expresar en el tipo de retorno que un valor puede no estar, obligando al llamante a decidir qué hace. No es un sustituto universal de null ni un contenedor de propósito general.

// ✅ Uso correcto: retorno de una búsqueda que puede no encontrar nada
Optional<Cliente> buscarPorNif(String nif);

var nombre = buscarPorNif(nif)
        .map(Cliente::nombre)
        .map(String::toUpperCase)
        .orElse("DESCONOCIDO");

// orElseThrow con excepción de dominio: la forma más habitual en un servicio
var cliente = repo.findByNif(nif)
        .orElseThrow(() -> new ClienteNoEncontrado(nif));

// ❌ Abusos que se ven en entrevistas y en código real
if (opt.isPresent()) { usar(opt.get()); }        // es un if con más letras
opt.get();                                       // NoSuchElementException esperando a pasar
void f(Optional<String> p)                       // parámetro: usa sobrecarga o @Nullable
class Cliente { private Optional<String> tel; }  // campo: no es Serializable, ocupa más
List<Optional<Pedido>> lista;                    // una lista vacía ya expresa "nada"
return Optional.ofNullable(x).orElse(null);      // ida y vuelta absurda
opt.orElse(calcularCaro());                      // orElse SIEMPRE evalúa: usa orElseGet

La diferencia entre orElse y orElseGet es una pregunta muy frecuente: orElse evalúa su argumento aunque el Optional tenga valor, así que si el valor por defecto es una llamada costosa o con efectos secundarios, tienes un bug de rendimiento silencioso.

Trampa: «¿devolverías Optional<List<T>>?». No: para colecciones, la ausencia se representa con la colección vacía. Un Optional vacío y una lista vacía significan lo mismo y obligan al llamante a comprobar dos cosas.

18. Lambdas y closures: ¿qué capturan y por qué effectively final?

Una lambda es una implementación concisa de una interfaz funcional (un solo método abstracto). Captura las variables locales por valor —de ahí la exigencia de que sean effectively final— y los campos de instancia por referencia a través de this.

int base = 10;
Function<Integer, Integer> f = x -> x + base;   // captura el VALOR 10
// base = 20;   ← si descomentas, la lambda deja de compilar

// Los campos sí se capturan por referencia (y ahí está el riesgo de concurrencia)
class Contador {
    private int n;                                  // campo mutable
    Runnable incrementar() { return () -> n++; }    // captura this: carrera si hay hilos
}

// Diferencia clave con las clases anónimas: this
class A {
    void m() {
        Runnable lambda   = () -> System.out.println(this);       // this = la instancia de A
        Runnable anonimo  = new Runnable() {
            public void run() { System.out.println(this); }        // this = el Runnable anónimo
        };
    }
}

El motivo real del effectively final es que la variable local vive en la pila del método, que puede haber terminado cuando la lambda se ejecute. Capturar por valor evita tener que decidir qué significaría escribir en una variable que ya no existe.

Trampa: «¿cómo acumulo un valor en un forEach?». Truco frecuente y mala idea: un array de un elemento o un AtomicInteger para saltarse la restricción. Si necesitas acumular, el forEach es la herramienta equivocada: usa reduce, collect o un bucle normal.

19. Referencias a métodos: los cuatro tipos y cuándo se ven raras

Son azúcar sintáctico para lambdas que solo llaman a un método existente. Hay cuatro formas, y conviene reconocer la tercera porque es la que confunde a todo el mundo:

// 1. Método estático                   Integer::parseInt      ≡ s -> Integer.parseInt(s)
// 2. Método de una instancia concreta   log::info              ≡ m -> log.info(m)
// 3. Método de instancia de un tipo     String::toUpperCase    ≡ s -> s.toUpperCase()
//    (el primer parámetro pasa a ser el receptor: ahí está el truco)
// 4. Constructor                        ArrayList::new         ≡ () -> new ArrayList<>()

var mayus = nombres.stream().map(String::toUpperCase).toList();
var mapa  = pedidos.stream().collect(toMap(Pedido::id, Function.identity()));
var set   = nombres.stream().collect(Collectors.toCollection(TreeSet::new));

// La forma 3 con dos argumentos: comparadores
lista.sort(String::compareToIgnoreCase);   // ≡ (a, b) -> a.compareToIgnoreCase(b)

Regla práctica de legibilidad: usa la referencia cuando el nombre del método ya explica lo que pasa, y una lambda cuando haga falta un nombre de parámetro que aporte significado. .filter(p -> p.esCancelable()) se lee peor que .filter(Pedido::esCancelable), pero .map(c -> c.getDireccion().getCiudad()) se lee mejor que cualquier acrobacia con referencias.

Trampa: this::metodo dentro de un bean de Spring captura this, así que si ese método está anotado con @Transactional o @Cacheable no pasa por el proxy y la anotación no se aplica. Es la misma trampa que la autoinvocación, pero disfrazada.

20. ¿Por qué se dice que los streams son perezosos?

Porque las operaciones intermedias (map, filter, sorted…) no hacen nada al invocarlas: solo construyen la canalización. Nada se ejecuta hasta la operación terminal, y entonces cada elemento recorre toda la cadena antes de pasar al siguiente (fusión de bucles), lo que evita crear colecciones intermedias.

var s = List.of(1, 2, 3, 4, 5).stream()
        .peek(n -> System.out.println("map  " + n))
        .filter(n -> n % 2 == 0);
// Aquí no se ha impreso NADA todavía

var primero = s.findFirst();   // ahora sí: imprime "map 1", "map 2" y para

// Cortocircuito: no procesa el millón de elementos
var alguno = millon.stream().filter(caro).anyMatch(p -> p.total() > 1000);

// Ojo: sorted y distinct son "con estado" y necesitan ver todo (o casi)
// Stream.iterate infinito + limit funciona; + sorted no termina nunca

Consecuencias prácticas que dan puntos: un stream es de un solo uso (reutilizarlo lanza IllegalStateException); las operaciones de cortocircuito (findFirst, anyMatch, limit) pueden ahorrar casi todo el trabajo; y el orden de las operaciones importa: filtrar antes de mapear procesa menos elementos.

Trampa: peek es para depurar, no para efectos secundarios: con algunas fuentes y terminales la JVM puede omitirlo. Si necesitas un efecto por elemento, usa forEach.

21. map frente a flatMap

map transforma un elemento en otro elemento (uno a uno); flatMap transforma un elemento en un stream de elementos y aplana el resultado (uno a muchos). En cuanto tu función de transformación devuelve una colección u otro Optional, lo que necesitas es flatMap.

record Pedido(String id, List<Linea> lineas) { }
record Linea(String sku, int cantidad) { }

// map → Stream<List<Linea>>: casi nunca es lo que quieres
Stream<List<Linea>> mal = pedidos.stream().map(Pedido::lineas);

// flatMap → Stream<Linea>: todas las líneas de todos los pedidos
var unidadesPorSku = pedidos.stream()
        .flatMap(p -> p.lineas().stream())
        .collect(Collectors.groupingBy(Linea::sku,
                 Collectors.summingInt(Linea::cantidad)));

// También con Optional, para evitar Optional<Optional<T>>
Optional<Ciudad> ciudad = buscarCliente(id)
        .flatMap(Cliente::direccion)    // direccion() devuelve Optional<Direccion>
        .map(Direccion::ciudad);

// Y mapMulti (Java 16+) evita crear un stream intermedio por elemento
var todas = pedidos.stream()
        .<Linea>mapMulti((p, consumidor) -> p.lineas().forEach(consumidor))
        .toList();

Trampa: el mismo patrón aparece en CompletableFuture (thenApply frente a thenCompose) y en Reactor (map frente a flatMap). Si dices que es el mismo concepto —aplanar un contenedor anidado, la operación bind de una mónada— demuestras que has entendido el patrón y no solo la API.

22. reduce: sus tres formas y cuándo se rompe

reduce combina los elementos de un stream en un solo valor con una función asociativa. Tiene tres sobrecargas, y la tercera es la que casi nadie sabe explicar.

// 1. Solo acumulador → Optional (el stream puede estar vacío)
Optional<BigDecimal> total = importes.stream().reduce(BigDecimal::add);

// 2. Con identidad → nunca vacío
BigDecimal total2 = importes.stream().reduce(BigDecimal.ZERO, BigDecimal::add);

// 3. Con identidad, acumulador y COMBINADOR: necesario cuando el tipo de
//    acumulación es distinto del tipo del elemento (y obligatorio en paralelo)
int caracteres = nombres.stream()
        .reduce(0,
                (acc, s) -> acc + s.length(),   // acumulador: (U, T) -> U
                Integer::sum);                   // combinador: (U, U) -> U

// ❌ El error clásico: reduce con un acumulador MUTABLE
var lista = palabras.stream()
        .reduce(new ArrayList<String>(), (l, s) -> { l.add(s); return l; }, (a, b) -> a);
// funciona en secuencial por accidente y corrompe datos en paralelo → usa collect()

Los requisitos que hay que nombrar: la función debe ser asociativa, la identidad debe ser una identidad de verdad (f(identidad, x) == x) y no debe haber estado mutable compartido. Si necesitas acumular en un contenedor mutable, la operación correcta es collect, que está diseñada para eso (con su supplier, acumulador y combinador).

Trampa: restar no es asociativo, así que reduce(0, (a,b) -> a - b) da resultados distintos en secuencial y en paralelo. Es un ejemplo perfecto para demostrar que entiendes el requisito.

23. Los Collectors que de verdad se usan

Con seis u ocho colectores cubres el 95 % del código real. Merece la pena tenerlos en los dedos porque son petición habitual en un live coding.

record Venta(String vendedor, String region, BigDecimal importe, LocalDate fecha) { }

// Agrupar y contar
Map<String, Long> ventasPorRegion = ventas.stream()
        .collect(groupingBy(Venta::region, counting()));

// Agrupar y sumar dinero (reducing, porque summingDouble no vale para BigDecimal)
Map<String, BigDecimal> totalPorRegion = ventas.stream()
        .collect(groupingBy(Venta::region, TreeMap::new,
                 reducing(BigDecimal.ZERO, Venta::importe, BigDecimal::add)));

// Particionar (siempre devuelve las dos claves, true y false)
Map<Boolean, List<Venta>> grandes = ventas.stream()
        .collect(partitioningBy(v -> v.importe().compareTo(new BigDecimal("1000")) > 0));

// A mapa, con función de fusión OBLIGATORIA si puede haber claves repetidas
Map<String, Venta> ultimaPorVendedor = ventas.stream()
        .collect(toMap(Venta::vendedor, v -> v,
                 (a, b) -> a.fecha().isAfter(b.fecha()) ? a : b));

// Agrupar en dos niveles
Map<String, Map<Month, Long>> porRegionYMes = ventas.stream()
        .collect(groupingBy(Venta::region,
                 groupingBy(v -> v.fecha().getMonth(), counting())));

// Unir cadenas
String informe = ventas.stream().map(Venta::vendedor).distinct().sorted()
        .collect(joining(", ", "[", "]"));

// teeing (Java 12+): dos colectores a la vez en una sola pasada
record Resumen(long n, BigDecimal total) { }
var resumen = ventas.stream().collect(teeing(
        counting(),
        reducing(BigDecimal.ZERO, Venta::importe, BigDecimal::add),
        Resumen::new));

Trampa: toMap sin función de fusión lanza IllegalStateException con claves duplicadas, y además no admite valores null (a diferencia de groupingBy, que sí puede acabar con listas vacías). Los dos son fallos que aparecen solo con datos reales, nunca con los tres elementos del test.

24. ¿Cuándo es mejor un bucle que un stream?

Cuando el bucle sea más legible o cuando el stream te obligue a acrobacias. La respuesta madura no es «streams para todo», es reconocer que son una herramienta de expresión de transformaciones de datos y que fuera de ese caso estorban.

SituaciónMejor con…Por qué
Filtrar, mapear, agrupar, agregarStreamDeclarativo, se lee como la intención
Necesitas el índiceBucle forCon streams hay que usar IntStream.range y pierde claridad
Hay que salir a mitad con lógica complejaBucle con breaktakeWhile/findFirst no cubren todos los casos
Excepciones checked dentroBucleLas lambdas obligan a envolver y el código se hace ilegible
Dos colecciones en paralelo (zip)BucleJava no tiene zip en la API estándar
Acumular en estructuras mutables complejasBucleMás claro que un collect con tres funciones
Bucle muy caliente y medido como cuello de botellaBucleMenos indirección y menos megamorfismo; pero solo si lo has medido
// Stream retorcido para conseguir el índice: señal de que quieres un bucle
IntStream.range(0, lista.size())
         .filter(i -> i % 2 == 0)
         .mapToObj(i -> i + ": " + lista.get(i))
         .forEach(System.out::println);

// El bucle equivalente se entiende sin pensar
for (int i = 0; i < lista.size(); i += 2) {
    System.out.println(i + ": " + lista.get(i));
}

Trampa: si dices «los streams son más lentos», te van a pedir un número. La verdad es que la diferencia es despreciable en la inmensa mayoría del código (y en bucles muy calientes con primitivos puede ser de un 10-30 % a favor del bucle). La legibilidad manda salvo que un perfil diga lo contrario.

25. ¿Usas var? ¿Dónde no lo usarías?

Sí, es inferencia de tipo en variables locales, resuelta en compilación: Java sigue siendo de tipado estático y var no tiene nada que ver con el var de JavaScript. Sirve para eliminar la repetición del tipo cuando ya es obvio por el lado derecho.

// ✅ Donde mejora: el tipo está a la vista
var pedidos = new ArrayList<Pedido>();
var entrada = Map.entry("clave", List.of(1, 2, 3));
for (var e : mapa.entrySet()) { ... }
try (var conn = ds.getConnection()) { ... }

// ❌ Donde empeora: el tipo se pierde
var x = servicio.procesar(id);          // ¿qué devuelve? hay que ir a mirar
var lista = new ArrayList<>();          // ArrayList<Object>: sorpresa desagradable
var r = 0;                               // int, no long; cuidado con desbordar

// Donde NO se puede usar
// var campo = 1;              ← campos de clase
// void f(var p)               ← parámetros
// var f() { return 1; }       ← tipos de retorno
// var x;                      ← sin inicializador
// var x = null;               ← no hay tipo que inferir

Norma de equipo razonable: var cuando el tipo aparezca en la misma línea (un new, un literal, un cast), y tipo explícito cuando el valor venga de una llamada cuyo retorno no sea evidente. Es una decisión de legibilidad, y conviene que esté acordada en el proyecto.

Trampa: var con tipos anónimos o intersecciones infiere un tipo que no puedes escribir, y eso puede propagarse de formas raras. Y en un for con var i = 0 sobre un contador que llega a miles de millones, acabas con un int desbordado donde querías un long.

26. Text blocks: qué resuelven y qué reglas tienen

Cadenas multilínea sin escapes ni concatenación, con la indentación incidental eliminada de forma automática. Resuelven el problema real de tener SQL, JSON o HTML dentro de código Java, que antes era ilegible y propenso a errores.

String sql = """
        SELECT p.id, p.total, c.nombre
          FROM pedido p
          JOIN cliente c ON c.id = p.cliente_id
         WHERE p.creado_en >= ?
         ORDER BY p.creado_en DESC
        """;

String json = """
        { "clienteId": "%s", "total": %.2f }
        """.formatted(clienteId, total);   // formatted() está pensado para esto

// Reglas que preguntan:
// · La comilla triple de apertura debe ir seguida de salto de línea.
// · La indentación se calcula con la línea menos indentada, INCLUIDA la de cierre:
//   mover la comilla de cierre a la izquierda cambia la indentación de todo el bloque.
// · \ al final de línea une líneas sin meter el salto.
// · \s conserva un espacio final que si no se eliminaría.
// · No hay interpolación de variables: usa formatted() o concatena.

Trampa: un text block no valida nada. Meter SQL con concatenación de variables dentro de un bloque de texto sigue siendo inyección SQL; los parámetros van con ? o con nombres, igual que antes. La legibilidad no es seguridad.

27. ¿Qué mejoró java.time respecto a Date y Calendar?

Cuatro cosas: los tipos son inmutables y seguros entre hilos, el modelo distingue conceptos que antes se mezclaban, la aritmética es explícita, y el formateo se hace con DateTimeFormatter, que sí es seguro entre hilos —SimpleDateFormat no lo era y causaba corrupción silenciosa cuando se guardaba en un campo estático—.

TipoQué representaCuándo usarlo
InstantUn punto en la línea temporal, en UTCMarcas de tiempo, auditoría, created_at. Es el que va a la base de datos.
LocalDateUna fecha civil sin hora ni zonaFecha de nacimiento, fecha de factura, vencimiento.
LocalDateTimeFecha y hora sin zona: no identifica un instanteCasi nunca en persistencia. Es el error más común.
ZonedDateTimeInstante + zona con reglas de horario de veranoMostrar al usuario, programar «todos los martes a las 9:00 en Madrid».
OffsetDateTimeInstante + desplazamiento fijoIntercambio en APIs (ISO-8601), columnas timestamptz.
Duration / PeriodCantidad de tiempo / de fechasDuration para segundos y nanos; Period para años, meses y días.
// Inyecta un Clock: hace los tests deterministas sin librerías de magia
@Service class ServicioPedidos {
    private final Clock clock;
    ServicioPedidos(Clock clock) { this.clock = clock; }
    Pedido crear(...) { return new Pedido(..., Instant.now(clock)); }
}
// En el test: Clock.fixed(Instant.parse("2026-03-30T02:30:00Z"), ZoneOffset.UTC)

// Aritmética con horario de verano: solo ZonedDateTime lo hace bien
var madrid = ZoneId.of("Europe/Madrid");
var antes = ZonedDateTime.of(2026, 3, 28, 23, 0, 0, 0, madrid);
antes.plusDays(1);        // respeta el cambio de hora del 29 de marzo
antes.plusHours(24);      // NO es lo mismo: suma 24 horas exactas

Trampa: guardar LocalDateTime en la base de datos para un sistema con usuarios en varias zonas. Funciona hasta que hay que comparar dos registros creados en zonas distintas, o hasta el último domingo de octubre, cuando la misma hora local existe dos veces. La regla es: almacena en UTC (Instant / timestamptz) y convierte a zona solo al mostrar.

28. ¿Por qué BigDecimal y no double para dinero?

Porque double es coma flotante binaria (IEEE 754) y no puede representar exactamente la mayoría de los decimales, así que los errores se acumulan y aparecen descuadres de céntimos que en contabilidad son inaceptables.

System.out.println(0.1 + 0.2);                    // 0.30000000000000004
System.out.println(1.03 - 0.42);                  // 0.6100000000000001

// ❌ El error dentro del error: construir BigDecimal desde double
new BigDecimal(0.1);      // 0.1000000000000000055511151231257827021181583404541015625
// ✅ Siempre desde String o con valueOf
new BigDecimal("0.1");    // exacto
BigDecimal.valueOf(0.1);  // usa Double.toString: aceptable, pero String es mejor

// Escala y redondeo explícitos, y comparación con compareTo
var precio = new BigDecimal("19.99");
var total  = precio.multiply(BigDecimal.valueOf(3))
                   .setScale(2, RoundingMode.HALF_UP);      // 59.97

new BigDecimal("1.0").equals(new BigDecimal("1.00"));       // false (escala distinta)
new BigDecimal("1.0").compareTo(new BigDecimal("1.00")) == 0; // true ← usa este

// División: SIEMPRE con escala y redondeo, o ArithmeticException con 1/3
var unitario = total.divide(BigDecimal.valueOf(3), 2, RoundingMode.HALF_UP);

Alternativa válida y a veces mejor: guardar los importes en céntimos como long (lo que hacen muchas pasarelas de pago, incluida Stripe). Es más rápido y no tiene sorpresas de escala, a costa de tener que recordar el factor y de complicarse con divisiones y con monedas de tres decimales.

Trampa: la repregunta es «¿y en la base de datos?». numeric(19,4) o decimal, nunca float/double precision. Y en JPA, @Column(precision = 19, scale = 4) con BigDecimal. Si en algún punto de la cadena hay un double, el resto de las precauciones no sirven de nada.

29. Serialización en Java: ¿por qué se considera peligrosa?

Porque deserializar un flujo binario con ObjectInputStream es, en la práctica, ejecutar código elegido por quien envió los datos. La deserialización construye objetos arbitrarios sin pasar por los constructores y ejecuta readObject; encadenando clases presentes en el classpath (gadget chains) se puede conseguir ejecución remota. Es el origen de vulnerabilidades muy conocidas y el motivo de que la propia especificación de Java la considere un error de diseño histórico.

// ❌ Nunca deserialices datos que vengan de fuera
var obj = new ObjectInputStream(peticionHttp.getInputStream()).readObject();

// ✅ Usa un formato de datos, no de objetos: JSON con un DTO explícito
record PedidoDto(String id, BigDecimal total) { }
var dto = mapper.readValue(cuerpo, PedidoDto.class);

// Si NO puedes evitarla, filtra las clases permitidas (Java 9+, JEP 290)
var in = new ObjectInputStream(entrada);
in.setObjectInputFilter(ObjectInputFilter.Config.createFilter(
        "com.miempresa.dominio.*;java.util.*;!*"));   // lista blanca y deniega el resto

Otros motivos para evitarla aunque no haya atacante: acopla tu formato de almacenamiento a la estructura interna de tus clases, cualquier refactor rompe la compatibilidad, y serialVersionUID es una fuente inagotable de errores. Para persistir o transportar, siempre un formato explícito (JSON, Avro, Protobuf) con su esquema versionado.

Trampa: esto también aplica a lo que parece inocente: sesiones HTTP serializadas en Redis, cachés distribuidas y colas que transportan objetos Java. Si un atacante puede escribir en ese almacén, tiene ejecución de código. Configura serializadores JSON explícitos en Spring Session y en Spring Data Redis, y desactiva el default typing de Jackson.

30. ¿Qué patrones de diseño usas de verdad en un proyecto Spring?

La respuesta que puntúa no es una lista de los 23 del GoF: es reconocerlos donde ya están y usar tres o cuatro con criterio. Los que aparecen todos los días:

PatrónDónde lo usas o lo ves
StrategyVarias implementaciones de una interfaz inyectadas como Map<String, Calculadora> y elegidas por clave. Sustituye a un switch gigante que crece con cada requisito.
Proxy / DecoratorEs literalmente cómo funcionan @Transactional, @Cacheable, @Async y @PreAuthorize. Explicarlo demuestra que entiendes Spring por dentro.
Template methodJdbcTemplate, RestClient, TransactionTemplate: el framework controla el flujo y tú rellenas el hueco.
BuilderObjetos con muchos campos opcionales; y lo generan Lombok o los records con métodos with.
FactoryCreación de objetos de valor validados (Email.of("...")) y BeanFactory de Spring.
ObserverEventos de dominio con ApplicationEventPublisher y @TransactionalEventListener.
Adapter / Ports and adaptersLa arquitectura hexagonal entera: el puerto es la interfaz del dominio, el adaptador la implementación con la tecnología.
RepositorySpring Data. Y merece la pena saber que el patrón original de Fowler es una colección de agregados, no un DAO con 40 métodos.
// Strategy con inyección de un Map: Spring rellena las claves con el nombre del bean
@Service
class CalculadoraImpuestos {
    private final Map<String, EstrategiaImpuesto> estrategias;   // "es", "pt", "fr"…

    CalculadoraImpuestos(Map<String, EstrategiaImpuesto> estrategias) {
        this.estrategias = estrategias;
    }
    BigDecimal calcular(String pais, BigDecimal base) {
        return estrategias.getOrDefault(pais, estrategias.get("default")).aplicar(base);
    }
}
// Añadir un país = añadir una clase con @Component("pt"). Cero cambios aquí.

Trampa: si nombras Singleton, te van a preguntar cómo lo implementas de forma segura y por qué en Spring no hace falta (el contenedor gestiona una instancia por ApplicationContext, que no es lo mismo que el patrón Singleton clásico con un static). Y si nombras un patrón, tienes que poder decir qué problema resuelve y cuál es su coste; nombrarlo sin eso resta.

5 · Banco de preguntas · colecciones y rendimiento

Este bloque separa muy rápido a quien ha leído la documentación de quien ha depurado un problema real. Casi todas las preguntas admiten una respuesta de libro y una respuesta con criterio; la segunda es la que buscan.

1. ¿Cómo eliges la estructura de datos para un caso concreto?

Con tres preguntas, en este orden: ¿qué operación se hace más veces?, ¿necesito orden? y ¿hay concurrencia?. La complejidad asintótica es el último criterio, no el primero, porque con pocos miles de elementos la localidad de memoria pesa más que la notación O grande.

Necesito…UsoPor qué
Lista con acceso por índice e iteraciónArrayListArray contiguo: acceso O(1) y la mejor localidad de caché.
Cola o pilaArrayDequeMás rápido que LinkedList y que Stack (sincronizado y obsoleto).
Buscar por claveHashMapO(1) medio. Es la estructura por defecto del 80 % del código.
Claves ordenadas o consultas por rangoTreeMapÁrbol rojo-negro: O(log n) y navegación (headMap, floorKey).
Recordar el orden de inserción o de accesoLinkedHashMapHash + lista doblemente enlazada. Base de una LRU.
Conjunto sin duplicadosHashSetEs un HashMap con valores ficticios.
Claves de tipo enumEnumMap/EnumSetArray por ordinal y mapa de bits: mucho más rápido y compacto.
Prioridades («el siguiente más urgente»)PriorityQueueMontículo binario: O(log n) para insertar y extraer el mínimo.
Cola entre productor y consumidorArrayBlockingQueueAcotada y bloqueante: aplica contrapresión de forma natural.
Mapa compartido entre hilosConcurrentHashMapConcurrencia real por segmentos, con operaciones compuestas atómicas.
Lista de listeners que se lee mucho y cambia pocoCopyOnWriteArrayListLectura sin cerrojos; escritura copia todo el array.

Trampa: la repregunta habitual es «¿y si no sabes cuántos elementos habrá?». Respuesta: ArrayList o HashMap y, si tienes una estimación, pásala al constructor para evitar redimensionados —new HashMap<>(esperados / 0.75f + 1)—. Ese detalle demuestra que sabes lo que cuesta un resize.

2. ArrayList frente a LinkedList: por qué la intuición falla

La respuesta correcta es casi siempre ArrayList, incluso en los casos que el libro de texto asigna a LinkedList. La tabla teórica dice que insertar en medio es O(1) en la lista enlazada y O(n) en el array, pero esa tabla ignora dos cosas decisivas.

OperaciónArrayListLinkedListQué pasa de verdad
get(i)O(1)O(n)Ventaja aplastante del array.
add al finalO(1) amortizadoO(1)Empate; el array gana por localidad.
add(i, e) en medioO(n) copia de memoriaO(1) si ya tienes el nodoPero para llegar al nodo necesitas un O(n) recorriendo punteros.
IterarMuy rápidoLentoCada nodo está en una zona distinta del heap: fallo de caché por elemento.
Memoria por elemento~4-8 bytes de referencia~40 bytes (nodo + 2 punteros + cabecera)5-10× más memoria y más presión sobre el GC.

Las dos razones por las que la intuición falla: (1) System.arraycopy está implementado en código nativo vectorizado y mueve megabytes por milisegundo, así que «desplazar n elementos» es ridículamente rápido; y (2) la localidad de caché: un array contiguo se lee de la caché L1/L2, mientras que recorrer nodos dispersos provoca un acceso a memoria principal por elemento, que cuesta unas cien veces más.

Cuándo LinkedList gana de verdad: prácticamente nunca en código de aplicación. Si necesitas una cola por los dos extremos, ArrayDeque es mejor que las dos. Si insertas y borras mucho en medio manteniendo posición, probablemente necesites otra estructura entera (un TreeMap, una lista enlazada propia con acceso a los nodos, o un índice).

Trampa: te pueden preguntar «¿y para implementar una LRU?». Ahí sí hace falta una lista doblemente enlazada, pero con acceso directo a los nodos desde un mapa; eso es exactamente lo que hace LinkedHashMap por dentro y por eso no se implementa a mano.

3. ¿Cómo funciona HashMap por dentro?

Un array de buckets cuyo índice se obtiene del hash de la clave. Los pasos exactos, que es lo que quieren oír:

1. h = clave.hashCode()
2. Mezcla:  h ^= (h >>> 16)      ← propaga los bits altos a los bajos
3. Índice:  i = h & (n - 1)      ← n es potencia de 2, así que es un AND en vez de un módulo
4. En el bucket i:
   · vacío            → se crea la entrada
   · lista enlazada   → se compara hash y luego equals() con cada nodo
   · árbol rojo-negro → búsqueda O(log k)
5. Si el bucket supera TREEIFY_THRESHOLD (8) y la tabla tiene ≥ 64 buckets,
   la lista se convierte en árbol. Si baja de UNTREEIFY_THRESHOLD (6), vuelve a lista.
6. Si size > capacidad × loadFactor (0.75), la tabla DUPLICA su tamaño y se
   redistribuyen todas las entradas (rehash).

Por qué cada decisión: la mezcla del paso 2 existe porque el índice solo usa los bits bajos, y muchos hashCode reales solo varían en los altos (por ejemplo, objetos con el mismo prefijo). La potencia de dos permite sustituir un módulo por un AND, que es una instrucción en lugar de una división. El árbol se añadió en Java 8 para que un ataque de colisiones (mandar miles de claves con el mismo hash) degradara a O(log n) en lugar de a O(n), que era una vulnerabilidad de denegación de servicio real.

Rendimiento: O(1) medio, O(log n) en el peor caso desde Java 8. Y un detalle práctico: el resize es la operación caliente escondida. Si vas a meter un millón de entradas, dimensiona el mapa al crearlo o pagarás unos veinte redimensionados con rehash completo.

Trampa: «¿está garantizado el orden de iteración?» No, y además puede cambiar entre versiones de Java y entre ejecuciones. Si tu código depende del orden de un HashMap, tienes un bug esperando: usa LinkedHashMap o TreeMap.

4. ¿Qué pasa si uso una clave mutable en un HashMap?

Que la entrada se vuelve inalcanzable. El bucket se eligió con el hash antiguo; al mutar el campo que participa en hashCode, el nuevo hash apunta a otro bucket, así que get busca donde no está y remove tampoco lo encuentra. La entrada sigue ocupando memoria y aparece al iterar, lo que produce la escena desconcertante de un mapa que «contiene» algo que no puedes recuperar.

class ClaveMutable {
    String valor;
    ClaveMutable(String v) { valor = v; }
    @Override public int hashCode() { return valor.hashCode(); }
    @Override public boolean equals(Object o) {
        return o instanceof ClaveMutable c && valor.equals(c.valor);
    }
}

var k = new ClaveMutable("a");
var mapa = new HashMap<ClaveMutable, String>();
mapa.put(k, "valor");
k.valor = "b";                       // mutamos la clave YA insertada

mapa.get(k);            // null   ← busca en el bucket de "b"
mapa.get(new ClaveMutable("a"));  // null ← busca en el de "a", pero equals falla
mapa.size();            // 1      ← sigue ahí, pero es basura inaccesible
mapa.remove(k);         // no borra nada

La regla: las claves de un mapa y los elementos de un Set deben ser inmutables, o al menos deben ser inmutables los campos que participan en equals/hashCode. Por eso String, los envoltorios numéricos, los enum y los record con componentes inmutables son las claves ideales.

Trampa: el caso real más frecuente es una entidad JPA usada como clave con equals basado en el id. Metes la entidad nueva en un Set cuando su id es null, Hibernate hace flush, asigna el id y el hash cambia. Solución: equals/hashCode por clave de negocio, o por id con un hash constante por tipo (que es lo que recomienda la propia documentación de Hibernate).

5. ¿Qué es un hashCode malo y cómo se nota?

Uno que distribuye mal: muchas claves distintas caen en el mismo bucket. El extremo absurdo es return 1, que es formalmente correcto (cumple el contrato) y convierte el mapa en una lista enlazada: todas las operaciones pasan de O(1) a O(n) —o a O(log n) desde Java 8, gracias a la treeificación—.

// ❌ Correcto pero catastrófico
@Override public int hashCode() { return 1; }

// ❌ Casi tan malo: campos con poca variabilidad
@Override public int hashCode() { return activo ? 1 : 0; }   // 2 buckets para 1 M de objetos

// ❌ Malo de otra forma: caro de calcular y se llama muchísimo
@Override public int hashCode() { return Objects.hash(listaDeMilElementos); }

// ✅ Bien: campos discriminantes e inmutables
@Override public int hashCode() { return Objects.hash(nif, tipo); }

// ✅ Si el objeto es inmutable y el hash es caro, cachéalo (lo hace String)
private int hash;   // 0 = no calculado
@Override public int hashCode() {
    int h = hash;
    if (h == 0) hash = h = Objects.hash(campoA, campoB, campoC);
    return h;
}

Cómo se detecta en producción: un método que parece trivial aparece caliente en el perfilador (HashMap.getNode o tu propio equals con un número absurdo de llamadas), o el tiempo de una operación crece de forma lineal con el número de elementos cuando debería ser plano. Se confirma contando, en un test, cuántas claves distintas producen el mismo hash sobre datos reales.

Trampa: «¿usarías el hashCode para nada más?». Nunca para persistir, ni para deduplicar entre ejecuciones, ni como identificador: no es estable entre JVM ni entre versiones, y para Object depende de la dirección de memoria. Si necesitas un hash estable, usa SHA-256 sobre una representación canónica.

6. ¿Cuándo usarías TreeMap en lugar de HashMap?

Cuando necesites orden o consultas por rango. TreeMap es un árbol rojo-negro: mantiene las claves ordenadas por su orden natural o por un Comparator, con O(log n) en las operaciones básicas, y ofrece una API de navegación que HashMap no puede dar.

// Tarifas por tramo: "¿qué tarifa aplica a 1.750 unidades?"
var tarifas = new TreeMap<Integer, BigDecimal>();
tarifas.put(0,    new BigDecimal("1.00"));
tarifas.put(1000, new BigDecimal("0.80"));
tarifas.put(5000, new BigDecimal("0.60"));

var tarifa = tarifas.floorEntry(1750).getValue();   // 0.80 ← el tramo que empieza en 1000

// Rango de fechas sin filtrar toda la colección
var eventos = new TreeMap<Instant, Evento>();
var deLaSemana = eventos.subMap(inicioSemana, true, finSemana, false);

// Otras operaciones útiles: firstKey, lastKey, higherKey, ceilingKey, headMap,
// tailMap, descendingMap, pollFirstEntry (útil para colas de vencimientos).

Detalle importante: TreeMap usa compareTo/compare, no equals, para decidir si dos claves son la misma. Si tu Comparator es inconsistente con equals, tendrás resultados sorprendentes (dos claves «iguales» para el comparador se sobrescriben aunque no sean equals). Y no admite claves null.

Trampa: si solo necesitas «los datos ordenados al mostrar», no uses TreeMap: usa HashMap y ordena al final, o mejor, ordena en la base de datos con un ORDER BY que use un índice. Pagar O(log n) en cada escritura para un orden que solo necesitas una vez es un mal negocio.

7. ¿Cómo se implementa una caché LRU en Java?

Con LinkedHashMap en modo orden de acceso y sobrescribiendo removeEldestEntry. Es la respuesta corta y correcta; la respuesta completa añade por qué funciona y qué usarías en producción.

public class CacheLru<K, V> extends LinkedHashMap<K, V> {
    private final int capacidad;

    public CacheLru(int capacidad) {
        super(16, 0.75f, true);        // true = orden de ACCESO, no de inserción
        this.capacidad = capacidad;
    }
    @Override protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
        return size() > capacidad;      // se llama después de cada put
    }
}

var cache = new CacheLru<String, Cliente>(3);
cache.put("a", a); cache.put("b", b); cache.put("c", c);
cache.get("a");                 // "a" pasa a ser el más reciente
cache.put("d", d);              // expulsa "b", que es el menos usado recientemente

Funciona porque LinkedHashMap mantiene, además de la tabla hash, una lista doblemente enlazada con todas las entradas: en modo acceso, cada get mueve el nodo al final en O(1). Eso da lo mejor de las dos estructuras: búsqueda O(1) y orden de uso mantenido sin recorrer nada.

Lo que hay que decir después, y es lo que distingue la respuesta: en producción usaría Caffeine. Esta implementación no es segura entre hilos (envolverla con Collections.synchronizedMap serializa todos los accesos), no tiene expiración por tiempo, no tiene carga asíncrona, no da métricas y usa LRU puro, que se comporta mal con barridos secuenciales. Caffeine resuelve todo eso con el algoritmo W-TinyLFU y es la implementación por defecto de Spring Cache.

Trampa: «¿y si son varias instancias del servicio?». Entonces una caché en memoria significa que cada instancia tiene datos distintos, y hay que decidir si eso es tolerable. Si no lo es, caché distribuida (Redis) y asumir la latencia de red y la invalidación.

8. ¿Para qué sirve EnumMap y por qué no un HashMap?

EnumMap es un array indexado por el ordinal() de la constante. No calcula hash, no tiene colisiones, no redimensiona y su iteración sigue el orden de declaración del enum. Es entre dos y cinco veces más rápido que HashMap y ocupa mucho menos.

// Contadores por estado: la forma idiomática
var porEstado = new EnumMap<EstadoPedido, Integer>(EstadoPedido.class);
for (var p : pedidos) porEstado.merge(p.estado(), 1, Integer::sum);
// La iteración sale en orden: NUEVO, PAGADO, ENVIADO, CANCELADO

// EnumSet es un mapa de bits: un long cubre hasta 64 constantes
var cancelables = EnumSet.of(EstadoPedido.NUEVO, EstadoPedido.PAGADO);
if (cancelables.contains(pedido.estado())) { ... }     // una operación de bits

var todosMenosEnviado = EnumSet.complementOf(EnumSet.of(EstadoPedido.ENVIADO));
var rango = EnumSet.range(EstadoPedido.NUEVO, EstadoPedido.ENVIADO);

La regla es sencilla: si la clave es un enum, usa EnumMap; si es un conjunto de enum, usa EnumSet. Es una de esas mejoras que no cuestan nada y que demuestran que conoces la biblioteca estándar más allá de lo básico.

Trampa: aunque EnumMap usa internamente el ordinal, eso es un detalle de implementación y no te obliga a persistirlo. Sigue guardando el nombre en base de datos y en JSON.

9. Colecciones inmutables: List.of, copyOf y unmodifiableList

Tres cosas distintas que se confunden constantemente:

// 1. List.of / Map.of / Set.of (Java 9+): verdaderamente inmutables
var a = List.of("x", "y");
a.add("z");                       // UnsupportedOperationException
// Ojo: NO admiten null (ni claves ni valores) y Map.of falla con claves duplicadas

// 2. List.copyOf: copia inmutable de otra colección
var origen = new ArrayList<>(List.of("x"));
var copia = List.copyOf(origen);  // snapshot: si origen cambia, copia NO cambia
origen.add("y");                  // copia sigue siendo [x]

// 3. Collections.unmodifiableList: VISTA de solo lectura, no una copia
var vista = Collections.unmodifiableList(origen);
vista.add("z");                   // UnsupportedOperationException
origen.add("z");                  // ¡vista ahora contiene z! ← la trampa

// 4. Y el más traicionero: Arrays.asList
var fija = Arrays.asList("a", "b");
fija.set(0, "c");                 // ✅ funciona (es una vista del array)
fija.add("d");                    // ❌ UnsupportedOperationException (tamaño fijo)

Cuándo usar cada uno: List.of para constantes y literales; List.copyOf para copias defensivas en constructores y en getters, que es lo que hace segura una clase inmutable; unmodifiableList solo cuando quieras exponer una vista viva a propósito y el llamante entienda que puede cambiar bajo sus pies.

Trampa: «¿son inmutables los elementos?». No: son colecciones inmutables de referencias. List.of(pedido) no impide llamar a pedido.setTotal(...). La inmutabilidad profunda exige que los elementos también lo sean.

10. ¿Por qué aparece ConcurrentModificationException y cómo se evita?

Porque los iteradores de las colecciones no concurrentes son fail-fast: guardan un contador de modificaciones (modCount) y si detectan que la colección cambió por otra vía durante la iteración, abortan. Y ojo, el nombre engaña: el caso más frecuente es un solo hilo, no una carrera.

// ❌ Un hilo, excepción garantizada
for (var p : pedidos) {
    if (p.estaCancelado()) pedidos.remove(p);      // ConcurrentModificationException
}

// ✅ Opción 1: iterator.remove()
var it = pedidos.iterator();
while (it.hasNext()) { if (it.next().estaCancelado()) it.remove(); }

// ✅ Opción 2 (la idiomática): removeIf
pedidos.removeIf(Pedido::estaCancelado);

// ✅ Opción 3: construir una colección nueva (mejor si además transformas)
var vivos = pedidos.stream().filter(p -> !p.estaCancelado()).toList();

// ✅ Opción 4, con concurrencia real: colección concurrente
var compartida = new CopyOnWriteArrayList<Pedido>();   // iterar es sobre un snapshot
var mapa = new ConcurrentHashMap<String, Pedido>();     // iteración débilmente consistente

Matiz que da puntos: la excepción es best-effort, no una garantía. Con varios hilos puede no lanzarse y dejar que leas datos inconsistentes, así que no la uses como mecanismo de detección de errores de concurrencia. Y las colecciones concurrentes no la lanzan porque sus iteradores son weakly consistent: reflejan algunos cambios y otros no, pero nunca fallan.

Trampa: el caso oculto es borrar el penúltimo elemento de un ArrayList en un for-each: por cómo está implementado hasNext(), el bucle termina antes de comprobar el modCount y no lanza excepción, simplemente se salta un elemento en silencio. Ese es un bug mucho peor que la excepción.

11. ¿Cómo funciona ConcurrentHashMap por dentro?

Desde Java 8 no usa segmentos con cerrojos: usa CAS para el caso sin contención y sincronización por nodo (cabeza del bucket) cuando hay colisión. Eso significa que las lecturas son completamente libres de cerrojos y que dos escrituras en buckets distintos no se estorban.

Estructura: la misma tabla de buckets que HashMap (con treeificación),
pero con nodos volátiles.

· Lectura (get): sin cerrojos. Los campos val y next son volatile, así que se
  ve un valor consistente sin sincronizar.
· Escritura en bucket vacío: CAS sobre la posición de la tabla. Si falla,
  se reintenta (otro hilo llegó antes).
· Escritura en bucket ocupado: synchronized sobre el primer nodo del bucket.
  Solo bloquea ese bucket, no el mapa.
· Tamaño: no hay un contador único (sería un punto de contención). Usa un
  array de celdas al estilo LongAdder → size() es una aproximación coherente,
  no una foto exacta bajo escritura concurrente.
· Resize: colaborativo. Varios hilos ayudan a transferir buckets a la tabla nueva.

La parte que importa en la práctica: cada operación individual es atómica, pero una secuencia de operaciones no lo es. Por eso existen los métodos compuestos, que son los que hay que usar:

// ❌ Carrera: entre containsKey y put puede colarse otro hilo
if (!mapa.containsKey(k)) mapa.put(k, cargar(k));

// ✅ Atómico
mapa.computeIfAbsent(k, this::cargar);
mapa.merge(clave, 1L, Long::sum);              // contador concurrente
mapa.putIfAbsent(k, v);
mapa.compute(k, (key, viejo) -> viejo == null ? 1 : viejo + 1);

Dos avisos sobre computeIfAbsent: la función se ejecuta con el bucket bloqueado, así que debe ser rápida y no debe tocar el mismo mapa (si lo hace, puedes provocar un bloqueo o corromper la estructura). Si la carga es lenta (una llamada remota), guarda un CompletableFuture como valor en lugar del resultado.

Trampa: no admite claves ni valores null, y es a propósito: con null permitido, get(k) == null sería ambiguo entre «no está» y «está con valor nulo», y en concurrencia no podrías distinguirlos ni con containsKey.

12. ¿Qué colecciones concurrentes conoces y cuándo usas cada una?

Las que de verdad se usan son media docena, y lo importante es el criterio de elección:

ColecciónCómo consigue la seguridadCuándo
ConcurrentHashMapCAS + cerrojo por bucketMapa compartido de propósito general. La opción por defecto.
ConcurrentSkipListMapLista de saltos sin cerrojosCuando necesitas orden y concurrencia (el TreeMap concurrente).
CopyOnWriteArrayListCopia todo el array en cada escrituraMuchísimas lecturas y escrituras rarísimas: listeners, configuración en caliente.
ArrayBlockingQueueUn cerrojo con condiciones, capacidad fijaProductor-consumidor con contrapresión: es la que casi siempre quieres.
LinkedBlockingQueueDos cerrojos (cabeza y cola)Más throughput, pero acótala siempre: sin límite es una bomba de memoria.
ConcurrentLinkedQueueCAS, sin bloqueo, sin límiteCola no bloqueante cuando el consumidor no debe esperar.
DelayQueue / PriorityBlockingQueueMontículo + cerrojoTareas programadas y reintentos con retardo.
Collections.synchronizedMapUn único cerrojo globalCasi nunca: serializa todos los accesos. Solo para código heredado.

Y el punto que hay que añadir siempre: la mejor colección concurrente es ninguna. Si puedes evitar el estado mutable compartido —pasando datos inmutables entre hilos, confinando el estado en un solo hilo o usando el resultado de un CompletableFuture— te ahorras toda esta categoría de problemas.

Trampa: CopyOnWriteArrayList con escrituras frecuentes es un desastre: cada add copia el array entero, así que insertar n elementos es O(n²). Se ha visto usada «porque es thread-safe» en bucles de escritura y tumbar un servicio.

13. ¿Qué es la complejidad amortizada? Explícalo con ArrayList.add.

Es el coste medio por operación a lo largo de una secuencia, no el coste del peor caso individual. Se usa cuando una operación es casi siempre baratísima y de vez en cuando caríssima, y el reparto de ese coste es lo que importa. Se usa cuando una operación es casi siempre baratísima y de vez en cuando muy costosa.

ArrayList con capacidad 10. add() al final:
· Las 10 primeras: O(1) cada una (escribir en el array).
· La 11ª: hay que crear un array de 15 (crecimiento de 1,5×) y copiar 10 → O(n).
· Las siguientes 4: O(1)…

¿Por qué el total es O(1) amortizado?
Al crecer multiplicando (no sumando), las copias se hacen cada vez más raras.
Insertar n elementos cuesta:  n + (n/2 + n/4 + n/8 + …) < n + n = 2n
Total O(n) para n inserciones → O(1) por inserción.

Si el array creciera de 10 en 10, habría n/10 copias de coste medio n/2
→ O(n²) en total → O(n) por inserción. La multiplicación es la clave.

Dónde importa en el mundo real: (1) en un sistema con requisitos de latencia estricta, ese O(n) ocasional es un pico de p99 y conviene dimensionar la lista al crearla; (2) el mismo razonamiento explica el resize de HashMap y el crecimiento de StringBuilder; (3) es la respuesta a «¿por qué ArrayList es O(1) al final si a veces copia todo?».

Trampa: no confundas amortizado con caso medio. Amortizado es una garantía sobre cualquier secuencia de operaciones; caso medio es una afirmación probabilística sobre la entrada. HashMap.get es O(1) en caso medio (depende del hash); ArrayList.add es O(1) amortizado (garantizado).

14. ¿Cuánta memoria ocupa un objeto Java?

Más de lo que parece, y saber estimarlo es lo que te permite responder «¿caben diez millones de estos en memoria?» sin adivinar. En una JVM de 64 bits con punteros comprimidos (por defecto por debajo de 32 GB de heap):

Cabecera de objeto:      12 bytes (mark word 8 + class pointer 4 comprimido)
Referencia:               4 bytes (comprimida) / 8 sin comprimir
int, float:               4 bytes      long, double: 8 bytes
boolean, byte:            1 byte       char, short:  2 bytes
Alineación:               el tamaño total se redondea a múltiplo de 8

Ejemplo: class Punto { int x; int y; }
  12 (cabecera) + 4 + 4 = 20 → alineado a 24 bytes

Ejemplo: Integer                    → 16 bytes (12 + 4)
Ejemplo: Long                       → 16 bytes (12 + 8 = 20 → 24, con padding)
Ejemplo: String "hola"              → objeto 24 + byte[] 24 ≈ 48 bytes por 4 caracteres
Ejemplo: entrada de HashMap         → nodo ~32 bytes + clave + valor + hueco en la tabla
Ejemplo: nodo de LinkedList         → 24 bytes solo de estructura, por elemento

Por eso:
· Un ArrayList<Integer> de 1 M ≈ 4 MB de array + 16 MB de Integers = 20 MB
· Un int[] de 1 M                 = 4 MB   ← cinco veces menos
· Un HashMap<Long, Long> de 1 M    ≈ 80-100 MB

Consecuencias prácticas: el boxing multiplica la memoria y añade indirección, así que en estructuras grandes de números merece la pena un array primitivo o una librería especializada (fastutil, Eclipse Collections). Y una List<String> con millones de cadenas repetidas es un candidato ideal para deduplicar.

Trampa: te pueden preguntar cómo lo mides en lugar de estimarlo. Con un heap dump y el árbol de dominadores (Eclipse MAT, VisualVM), o con -XX:+UseCompressedOops y la librería JOL (Java Object Layout), que imprime el diseño exacto de una clase. Estimar sirve para diseñar; medir es lo que se hace cuando ya hay un problema.

15. ¿Cómo comprobarías si una estructura de datos es el cuello de botella?

Midiendo, en este orden, y no cambiando código a ciegas —que es el error que quiere descartar esta pregunta—.

  1. Confirmar dónde está el tiempo, no suponerlo. Un perfilador de muestreo con bajo impacto (async-profiler o JFR) en el entorno lo más parecido a producción. Si el 80 % del tiempo está en esperar a la base de datos, cualquier optimización de colecciones es irrelevante.
  2. Leer el gráfico de llamas. Buscar frames sospechosos: HashMap.getNode, tu propio equals, ArrayList.indexOf (una búsqueda lineal escondida), System.arraycopy (redimensionados) o Arrays.sort.
  3. Mirar la asignación de memoria, no solo la CPU. JFR tiene un perfil de allocation: si una estructura genera basura a razón de gigabytes por minuto, el coste aparece como tiempo de GC en otro sitio.
  4. Comprobar la complejidad empíricamente. Duplica el tamaño de la entrada y mide: si el tiempo se cuadruplica, tienes un O(n²) escondido —el clásico es un contains sobre una List dentro de un bucle—.
  5. Medir el cambio con un microbenchmark serio (JMH), con warm-up y varias iteraciones. Sin JMH, lo que mides es el JIT calentándose y no tu código.
  6. Verificar en producción con una métrica antes y después. Si no puedes demostrar la mejora con un número, no sabes si has mejorado.
// El O(n²) escondido más frecuente de todos
for (var pedido : pedidos) {                       // n
    if (idsBloqueados.contains(pedido.id())) {     // List.contains → O(m)
        ...
    }
}
// Arreglo de una línea: idsBloqueados como Set<String> → O(1)
// Con 10.000 pedidos y 10.000 bloqueados: de 100 M de comparaciones a 10.000.

Trampa: si dices «usaría System.currentTimeMillis() antes y después», te van a preguntar por el calentamiento del JIT, por el GC y por el ruido del sistema. Nombrar JMH y explicar por qué hace falta calentamiento es la diferencia entre una respuesta de junior y una de senior.

16. ¿Cuándo merece la pena parallelStream()?

Casi nunca en una aplicación web, y solo tras medir. Los requisitos para que ayude son cuatro y hay que cumplirlos todos: muchos datos ya en memoria (decenas de miles como mínimo), coste por elemento apreciable, operaciones puras sin bloqueo ni estado compartido y una fuente que se parta bien (un ArrayList o un array sí; una LinkedList o Files.lines, mal).

El motivo por el que es peligroso en un servidor: parallelStream usa el ForkJoinPool.commonPool(), que es compartido por toda la JVM y tiene núcleos - 1 hilos. Si una petición lanza un stream paralelo con una operación bloqueante, secuestra ese pool y degrada a todas las demás peticiones del proceso.

// ❌ Lo peor: I/O bloqueante en el pool común
urls.parallelStream().map(this::llamadaHttp).toList();   // secuestra el commonPool

// ✅ Para I/O: hilos virtuales, un hilo por tarea
try (var ex = Executors.newVirtualThreadPerTaskExecutor()) {
    var futuros = urls.stream().map(u -> ex.submit(() -> llamadaHttp(u))).toList();
    var res = futuros.stream().map(Future::resultNow).toList();
}

// ✅ Para CPU con muchos datos, midiendo antes y después
long primos = LongStream.rangeClosed(2, 50_000_000).parallel().filter(this::esPrimo).count();

// Si de verdad necesitas paralelo con un pool propio (truco poco conocido):
var pool = new ForkJoinPool(4);
pool.submit(() -> datos.parallelStream().map(this::calcular).toList()).join();

Trampa: «¿y si hago forEach en paralelo y acumulo en una lista?». forEach en paralelo no garantiza orden y añadir a un ArrayList desde varios hilos corrompe la estructura. Para acumular está collect, que sí está diseñado para partir y combinar. Y si necesitas orden, forEachOrdered, que además elimina buena parte de la ventaja del paralelismo.

17. ¿Por qué Arrays.sort usa un algoritmo distinto según el tipo?

Porque los requisitos son distintos. Para primitivos usa dual-pivot quicksort: es O(n log n) medio, ordena en el sitio sin memoria extra y la estabilidad no importa (dos enteros iguales son indistinguibles). Para objetos usa TimSort, que es estable —conserva el orden relativo de los elementos equivalentes, algo que sí se nota cuando ordenas por un campo y luego por otro— y aprovecha las secuencias ya ordenadas, muy frecuentes en datos reales, a costa de memoria auxiliar.

// Estabilidad: ordenar por dos criterios encadenando (mismo resultado que un comparador compuesto)
pedidos.sort(comparing(Pedido::cliente));           // primero por cliente
pedidos.sort(comparing(Pedido::fecha));             // luego por fecha; dentro de cada
                                                    // fecha se conserva el orden por cliente

// Mejor y más explícito: un solo comparador
pedidos.sort(comparing(Pedido::fecha).thenComparing(Pedido::cliente).reversed());

// Cuidado con los null
pedidos.sort(comparing(Pedido::fechaEnvio, nullsLast(naturalOrder())));

Detalle que sorprende y que da puntos: un Comparator inconsistente —que no cumple que compare(a,b) y compare(b,a) tengan signos opuestos, o que no sea transitivo— hace que TimSort lance IllegalArgumentException: Comparison method violates its general contract!. La causa habitual es restar enteros (a.id() - b.id()) y desbordar, o comparar por un campo mutable que cambia durante la ordenación. Usa Integer.compare en lugar de la resta.

Trampa: ordenar en Java lo que podía ordenar la base de datos. Si los datos vienen de una consulta, el ORDER BY con un índice adecuado es más rápido, no consume memoria del proceso y permite paginar. Ordenar en memoria solo tiene sentido si los datos ya están ahí por otro motivo.

6 · Banco de preguntas · concurrencia y JVM

Es el bloque donde más candidatos se caen y, por eso mismo, el que más diferencia. La razón es que casi todo el mundo ha leído sobre synchronized y muy poca gente ha depurado un deadlock real o ha tenido que explicar por qué un servicio se comió toda la CPU un martes a las cinco de la tarde. Si puedes contar una de esas historias, tienes media entrevista ganada.

1. Diferencia entre proceso e hilo (y por qué importa en la JVM)

Un proceso tiene su propio espacio de memoria virtual aislado por el sistema operativo; un hilo es una unidad de ejecución dentro de un proceso que comparte el heap con los demás hilos y tiene su propia pila y su propio contador de programa.

Por qué importa: compartir memoria es lo que hace la comunicación entre hilos baratísima y, a la vez, lo que genera toda la categoría de problemas de concurrencia. Y el coste no es simétrico: crear un proceso cuesta milisegundos y megabytes; crear un hilo de plataforma cuesta unas decenas de microsegundos y en torno a 1 MB de pila reservada. Ese último número es el que explica por qué no puedes tener cien mil hilos de plataforma y por qué se inventaron los hilos virtuales, cuya pila vive en el heap y crece según necesita (empieza con unos cientos de bytes).

ProcesoHilo de plataformaHilo virtual
MemoriaAisladaCompartida (heap)Compartida (heap)
Coste de creación~ms~50-100 µs~1 µs
PilaPropia~1 MB reservado en memoria nativaEn el heap, crece y decrece
PlanificadorSistema operativoSistema operativoLa JVM (sobre un pool de carrier threads)
Cuántos cabenCientosMilesMillones

Trampa: la repregunta es «¿cuántos hilos tiene tu aplicación Spring Boot?». La respuesta con criterio: Tomcat trae 200 hilos de trabajo por defecto, más los del pool de conexiones, los de las tareas programadas, los del cliente HTTP y los del recolector de basura. Es fácil que una aplicación «sencilla» tenga 250 hilos, y en un contenedor con 1 GB de memoria eso ya es un consumo nativo considerable que no aparece en el heap.

2. ¿Cuáles son los estados de un hilo?

Seis, definidos en Thread.State, y saber distinguir los tres de espera es lo que sirve para leer un volcado de hilos:

NEW            creado pero sin start()
RUNNABLE       ejecutándose o listo para ejecutarse (¡también si está en I/O!)
BLOCKED        esperando para entrar en un bloque synchronized (esperando un monitor)
WAITING        esperando indefinidamente: wait(), join(), LockSupport.park()
TIMED_WAITING  esperando con plazo: sleep(ms), wait(ms), join(ms), tryLock(t)
TERMINATED     ha terminado (normal o por excepción)

El detalle que casi nadie sabe y que da muchos puntos: un hilo bloqueado en una lectura de socket o de fichero aparece como RUNNABLE, no como BLOCKED, porque la JVM no tiene forma de saber que el sistema operativo lo ha suspendido. Así que en un thread dump, cien hilos RUNNABLE con SocketInputStream.read en la cima de la pila no significan cien hilos consumiendo CPU: significan cien hilos esperando a un servicio remoto que no responde.

Cómo se usa esto en la práctica: jcmd <pid> Thread.print tres veces con cinco segundos de separación. Si los mismos hilos están en el mismo sitio, tienes un bloqueo; si están en BLOCKED apuntando a un monitor común, tienes contención; si cambian sin parar y están RUNNABLE en tu código, tienes un problema de CPU.

Trampa: «¿y Thread.stop()?». Está eliminado (lanzaba una excepción asíncrona en cualquier punto y dejaba invariantes rotos). La forma correcta de parar un hilo es cooperativa: interrupt() más comprobar Thread.currentThread().isInterrupted() y propagar la InterruptedException en lugar de tragársela.

3. synchronized frente a ReentrantLock

synchronized es del lenguaje: la JVM adquiere y libera el monitor por ti, así que es imposible olvidarse de liberar. ReentrantLock es una clase de la biblioteca: más potente y más peligrosa, porque liberar es tu responsabilidad.

CapacidadsynchronizedReentrantLock
Liberación automáticaSí (al salir del bloque, incluso con excepción)No: obliga a finally
Intento con plazoNotryLock(5, SECONDS)
Interrumpible mientras esperaNolockInterruptibly()
Equidad (FIFO)NoOpcional (new ReentrantLock(true))
Varias condicionesUna sola cola (wait/notify)newCondition(), tantas como quieras
DiagnósticoAparece en los thread dumps como monitorgetHoldCount, hasQueuedThreads
Con hilos virtualesPuede provocar pinning (mejorado en Java 24+)No hace pinning: siempre desmonta
// ReentrantLock: el finally NO es opcional
private final ReentrantLock lock = new ReentrantLock();
void transferir(Cuenta destino, BigDecimal importe) {
    if (!lock.tryLock(5, TimeUnit.SECONDS)) {      // no espero para siempre
        throw new RecursoOcupado("cuenta " + id);
    }
    try { saldo = saldo.subtract(importe); destino.ingresar(importe); }
    finally { lock.unlock(); }                     // ← si falta esto, deadlock permanente
}

// ReadWriteLock: muchas lecturas, pocas escrituras
private final var rw = new ReentrantReadWriteLock();
Config leer()  { rw.readLock().lock();  try { return config; } finally { rw.readLock().unlock(); } }
void escribir(Config c) { rw.writeLock().lock(); try { config = c; } finally { rw.writeLock().unlock(); } }
// Aunque para este caso concreto, un campo volatile con objeto inmutable es mejor.

La regla práctica: synchronized por defecto, y ReentrantLock solo cuando necesites timeout, interrupción, equidad o varias condiciones. Y antes de cualquiera de los dos, pregúntate si puedes eliminar el estado compartido, usar un atómico o una colección concurrente: es la mejor optimización de concurrencia que existe.

Trampa: los dos son reentrantes (el mismo hilo puede volver a adquirir el cerrojo que ya tiene). Si te preguntan por qué eso es necesario, la respuesta es que sin reentrada un método synchronized que llama a otro synchronized de la misma instancia se bloquearía consigo mismo.

4. ¿Qué garantiza volatile exactamente?

Dos cosas: visibilidad —una escritura se hace visible a los demás hilos inmediatamente, sin quedarse en la caché del procesador o en un registro— y ordenación —el compilador y la CPU no pueden reordenar accesos alrededor de un acceso volátil—. Lo que no garantiza es atomicidad de las operaciones compuestas.

// ✅ Caso de uso canónico: bandera de parada
private volatile boolean parar = false;
public void run() { while (!parar) { trabajar(); } }   // sin volatile, el JIT puede
public void detener() { parar = true; }                // cachear la condición y no salir NUNCA

// ❌ Lo que NO arregla: contador++ son tres operaciones (leer, sumar, escribir)
private volatile int n;
void incrementar() { n++; }        // sigue siendo una carrera → usa AtomicInteger

// ✅ Publicación segura de un objeto inmutable (patrón muy útil)
private volatile Config config;                   // se lee mucho, se escribe rarísimo
public Config get() { return config; }            // lectura sin cerrojos
public void recargar(Config nueva) { config = nueva; }  // sustitución atómica de referencia

Cómo funciona por debajo: una escritura volátil inserta una barrera de memoria (store fence) que vuelca las escrituras anteriores; una lectura volátil inserta una barrera de lectura. Eso crea una relación happens-before entre la escritura y cualquier lectura posterior, y con ella la garantía de que también ves todo lo que el otro hilo escribió antes de la escritura volátil.

Trampa: el ejemplo del double-checked locking. El campo de la instancia perezosa debe ser volatile; sin él, otro hilo puede ver la referencia ya asignada pero el objeto a medio construir. Y si te lo preguntan, la respuesta moderna es: no implementes double-checked locking, usa un enum, un holder estático o deja que el contenedor de Spring gestione el ciclo de vida.

5. ¿Qué es happens-before y por qué debería importarme?

Es la relación del modelo de memoria de Java que define cuándo un hilo tiene garantizado ver lo que escribió otro. Sin una relación happens-before entre dos accesos, el compilador, la CPU y las cachés tienen libertad para reordenar y para no propagar, y no hay ninguna garantía sobre lo que verás.

Las reglas que crean happens-before (las que hay que saber nombrar):

1. Orden del programa: dentro de un mismo hilo, todo va en orden.
2. Monitor: liberar un cerrojo happens-before adquirirlo el siguiente hilo.
3. Volatile: escribir un volatile happens-before leerlo.
4. Thread.start(): todo lo anterior al start() es visible al hilo nuevo.
5. Thread.join(): todo lo que hizo el hilo es visible tras el join().
6. Campos final: inicializados en el constructor, visibles sin sincronizar
   (siempre que el constructor no filtre this).
7. Transitividad: si A hb B y B hb C, entonces A hb C.

Además, todas las utilidades de java.util.concurrent lo garantizan:
poner en una BlockingQueue hb sacarlo, submit() hb ejecutar la tarea,
completar un Future hb leer su resultado, contar en un CountDownLatch hb await().

Por qué importa: es la razón por la que este código, que parece obviamente correcto, puede no terminar nunca o imprimir 0:

// Sin ninguna sincronización: no hay happens-before
int dato = 0;
boolean listo = false;

// Hilo A
dato = 42;
listo = true;

// Hilo B
while (!listo) { }            // puede no salir nunca (el JIT saca la lectura del bucle)
System.out.println(dato);      // y si sale, puede imprimir 0 (reordenación)

// Arreglado: volatile en 'listo' crea la relación y arrastra también a 'dato'
// (por la regla 3 + la regla 7).

Trampa: «entonces con synchronized en el setter basta, ¿no?». No: hace falta sincronizar las dos partes, la escritura y la lectura, sobre el mismo cerrojo. Un setter sincronizado y un getter sin sincronizar no crean ninguna relación y el bug es exactamente igual de real, solo más difícil de reproducir.

6. Muéstrame una condición de carrera en código y arréglala de tres formas

Una condición de carrera aparece cuando el resultado depende del entrelazado de los hilos. El caso canónico es leer-modificar-escribir sin atomicidad, y el ejemplo con más consecuencias reales es una comprobación de stock:

// ❌ El bug: dos hilos pueden pasar la comprobación con stock = 1
class Inventario {
    private int stock = 1;
    boolean reservar() {
        if (stock > 0) {          // hilo A lee 1 ─┐
            stock--;               // hilo B lee 1 ─┘ los dos reservan → stock = -1
            return true;
        }
        return false;
    }
}

// ✅ Solución 1: sincronizar la operación completa (no cada paso)
synchronized boolean reservar() {
    if (stock > 0) { stock--; return true; }
    return false;
}

// ✅ Solución 2: un atómico con CAS en bucle (sin cerrojos)
private final AtomicInteger stock = new AtomicInteger(1);
boolean reservar() {
    int actual;
    do {
        actual = stock.get();
        if (actual == 0) return false;
    } while (!stock.compareAndSet(actual, actual - 1));   // reintenta si otro se coló
    return true;
}
// O más corto y con la misma garantía:
// return stock.getAndUpdate(v -> v > 0 ? v - 1 : v) > 0;

// ✅ Solución 3 (la real en una aplicación con base de datos): que lo garantice el motor
// UPDATE producto SET stock = stock - 1 WHERE id = ? AND stock > 0;
// Si filas_afectadas == 0 → no había stock. Atómico, y funciona con N instancias.

La tercera solución es la que separa una respuesta académica de una respuesta de producción: en un sistema con varias instancias detrás de un balanceador, synchronized y AtomicInteger no sirven de nada, porque cada JVM tiene su propio cerrojo y su propio contador. La exclusión tiene que estar donde está el estado compartido: la base de datos.

Trampa: te pueden pedir el test que lo demuestra. Es esta receta: N hilos, un CountDownLatch para arrancarlos a la vez, y comprobar el invariante al final. Sin el latch, los hilos se arrancan escalonados y la carrera no se reproduce.

7. ¿Qué son las clases atómicas y qué es CAS?

Compare-And-Swap es una instrucción del procesador que, de forma atómica, compara el valor de una posición de memoria con el esperado y, si coincide, lo cambia. Las clases de java.util.concurrent.atomic se construyen sobre ella: consiguen atomicidad sin cerrojos, con un bucle de reintentos (optimistic locking en memoria).

var contador = new AtomicLong();
contador.incrementAndGet();                          // atómico, sin bloquear
contador.updateAndGet(v -> Math.min(v + 1, MAX));    // función arbitraria, con reintento
contador.accumulateAndGet(5, Long::sum);

var referencia = new AtomicReference<Config>(inicial);
referencia.compareAndSet(esperada, nueva);           // solo si nadie la cambió

// Alta contención: LongAdder reparte el contador en celdas por hilo
var peticiones = new LongAdder();
peticiones.increment();                              // escala mucho mejor con 64 hilos
long total = peticiones.sum();                        // el coste se paga al leer

Cuándo elegir cada uno: AtomicLong si lees el valor a menudo; LongAdder si escribes muchísimo y lees poco (métricas, contadores de peticiones), porque evita que todos los hilos peleen por la misma línea de caché. Y AtomicReference para sustituir de forma atómica un objeto inmutable completo, que es un patrón elegantísimo para configuración en caliente.

El límite de CAS es el problema ABA y la degradación con contención muy alta: si mil hilos reintentan sobre la misma variable, se quema CPU en reintentos y un cerrojo puede acabar siendo más eficiente. Para ABA existe AtomicStampedReference, que añade un contador de versión.

Trampa: «¿es lo mismo que volatile?». No. volatile da visibilidad y ordenación pero no atomicidad; los atómicos dan las tres. Un AtomicInteger es, conceptualmente, un int volatile más operaciones CAS.

8. ¿Qué es un deadlock? Provócalo, detéctalo y evítalo.

Dos o más hilos bloqueados para siempre, cada uno esperando un recurso que retiene otro. Hacen falta cuatro condiciones simultáneas (Coffman): exclusión mutua, retener y esperar, no preferencia (no se pueden quitar los cerrojos) y espera circular. Romper cualquiera de las cuatro elimina el problema.

// Provocarlo: dos cerrojos adquiridos en orden opuesto
void transferir(Cuenta origen, Cuenta destino, BigDecimal importe) {
    synchronized (origen) {
        synchronized (destino) { origen.restar(importe); destino.sumar(importe); }
    }
}
// transferir(A, B) en un hilo y transferir(B, A) en otro → deadlock

// ✅ Solución 1: ORDEN GLOBAL de adquisición (la más usada)
void transferir(Cuenta a, Cuenta b, BigDecimal importe) {
    Cuenta primero = a.id().compareTo(b.id()) < 0 ? a : b;
    Cuenta segundo = primero == a ? b : a;
    synchronized (primero) { synchronized (segundo) { ... } }
}

// ✅ Solución 2: tryLock con timeout y reintento (rompe "retener y esperar")
if (a.lock.tryLock(1, SECONDS)) {
    try {
        if (b.lock.tryLock(1, SECONDS)) {
            try { ... } finally { b.lock.unlock(); }
        } else { throw new NoSePudoAdquirir(); }   // libera y reintenta más tarde
    } finally { a.lock.unlock(); }
}

// ✅ Solución 3: eliminar el problema. Un único cerrojo de grano grueso, o mejor,
// una sola transacción de base de datos con las filas bloqueadas en orden de id.
# Detectarlo en producción: la JVM lo dice explícitamente
jcmd <pid> Thread.print | grep -A 30 "Found one Java-level deadlock"
# También: jstack <pid>, o VisualVM / JMC, que lo marcan en rojo.
# En un contenedor: kubectl exec -it pod -- jcmd 1 Thread.print

Un deadlock clásico de aplicaciones Spring que merece la pena mencionar: agotamiento del pool de conexiones. Un método @Transactional que, con la conexión ya tomada, llama a otro método que pide otra conexión del mismo pool. Con maximumPoolSize = 10 y diez peticiones simultáneas, las diez conexiones están retenidas y las diez esperan una undécima que no existe. No aparece como deadlock en el volcado de hilos: aparece como timeout de HikariCP, y eso lo hace más difícil de diagnosticar.

Trampa: te pueden preguntar si la JVM puede resolver un deadlock automáticamente. No: solo lo detecta y lo informa. La recuperación es reiniciar el proceso o haber diseñado con timeouts, que es la única defensa real en un sistema distribuido.

9. Livelock y starvation: ¿en qué se diferencian del deadlock?

En los tres casos no se avanza, pero por motivos distintos, y la diferencia importa porque se diagnostican de forma opuesta:

Qué pasaCómo se veEjemplo real
Deadlock Los hilos están bloqueados esperando cerrojos mutuamente. CPU al 0 %, hilos en BLOCKED, todo congelado. Dos transferencias con cerrojos en orden inverso.
Livelock Los hilos ejecutan, pero reaccionan unos a otros y nunca progresan. CPU al 100 %, hilos RUNNABLE, ningún trabajo terminado. Dos hilos con tryLock, que sueltan y reintentan a la vez indefinidamente; o dos servicios que se reintentan mutuamente sin jitter.
Starvation Un hilo no consigue nunca el recurso porque otros se lo llevan siempre. El sistema funciona, pero hay latencias p99 disparadas o tareas que no salen nunca. Un cerrojo no equitativo con hilos de prioridad distinta; una cola de prioridad donde las tareas de baja prioridad no se ejecutan jamás.

Las soluciones: para livelock, introducir aleatoriedad (retroceso exponencial con jitter) y un límite de reintentos; es exactamente lo mismo que se hace con los reintentos entre microservicios. Para starvation, cerrojos equitativos, colas FIFO, envejecimiento de prioridades y separar el trabajo en pools distintos (bulkhead) para que las tareas largas no ahoguen a las cortas.

Trampa: el livelock distribuido más frecuente es la «tormenta de reintentos»: un servicio se degrada, todos los clientes reintentan al mismo tiempo, la carga aumenta, se degrada más. Se ataca con jitter, circuit breaker y presupuesto de reintentos. Nombrar esa conexión entre concurrencia y sistemas distribuidos causa muy buena impresión.

10. ExecutorService: tipos de pool y qué hace cada uno

ExecutorService desacopla la tarea del hilo: tú envías trabajo y el pool decide cómo ejecutarlo. Los fabricados por Executors son atajos, y dos de ellos son trampas conocidas.

Executors.newFixedThreadPool(8);        // N hilos + LinkedBlockingQueue SIN LÍMITE
                                        // ⚠ la cola crece hasta el OutOfMemoryError
Executors.newCachedThreadPool();        // hilos ilimitados, se reciclan a los 60 s
                                        // ⚠ con carga alta crea miles de hilos y muere
Executors.newSingleThreadExecutor();    // serializa las tareas: útil para orden garantizado
Executors.newScheduledThreadPool(2);    // tareas periódicas o con retardo
Executors.newVirtualThreadPerTaskExecutor();  // Java 21: un hilo virtual por tarea

// ✅ En producción, casi siempre a mano: control total y cola ACOTADA
var ex = new ThreadPoolExecutor(
        8, 16,                                   // core, máximo
        60L, TimeUnit.SECONDS,                   // tiempo de vida de los hilos extra
        new ArrayBlockingQueue<>(1000),          // cola acotada = contrapresión
        new CustomizableThreadFactory("pedidos-"),// nombres útiles en los thread dumps
        new ThreadPoolExecutor.CallerRunsPolicy());// política de rechazo explícita

// Apagado ordenado: el patrón completo
ex.shutdown();                                    // no acepta más, termina lo pendiente
if (!ex.awaitTermination(30, TimeUnit.SECONDS)) {
    ex.shutdownNow();                             // interrumpe lo que queda
}

Las cuatro políticas de rechazo, que es una pregunta frecuente: AbortPolicy (por defecto, lanza RejectedExecutionException), CallerRunsPolicy (la ejecuta el hilo que la envió, lo que aplica contrapresión de forma natural y suele ser la mejor opción en un servidor), DiscardPolicy y DiscardOldestPolicy (tirar trabajo silenciosamente: peligrosísimo si la tarea importa).

Trampa: con ThreadPoolExecutor, los hilos por encima de corePoolSize solo se crean cuando la cola está llena. Si pones una cola ilimitada, el máximo nunca se alcanza y tu pool de «8 a 64 hilos» es en realidad un pool de 8. Es uno de los malentendidos más extendidos.

11. ¿Cómo dimensionas un pool de hilos?

Depende de si la tarea es de CPU o de espera, y en 2026 hay que añadir un tercer caso: con hilos virtuales no se dimensionan hilos, se limita el recurso escaso real.

Tareas de CPU:      hilos ≈ núcleos disponibles (o núcleos + 1)
                    Más hilos solo añaden cambios de contexto.
                    En contenedor: Runtime.availableProcessors() ya respeta los
                    límites de CPU del cgroup en JVM modernas.

Tareas de espera:   hilos ≈ núcleos × (1 + tiempo_espera / tiempo_cpu)
                    Ejemplo: 4 núcleos, 90 ms esperando la BD, 10 ms de CPU
                             4 × (1 + 90/10) = 40 hilos

Comprobación por la ley de Little:
                    concurrencia = throughput objetivo × latencia media
                    500 req/s × 0,2 s = 100 peticiones en vuelo

Con hilos virtuales (Java 21+):
                    No limites hilos. Limita lo que de verdad es escaso:
                    · pool de conexiones a la BD (HikariCP)
                    · semáforo para la cuota de una API externa
                    · bulkhead de Resilience4j por dependencia

La forma de decirlo que suena a experiencia: «la fórmula da un punto de partida, pero el número real sale de medir. Subo el pool y mido latencia p95 y throughput; llega un punto en que el throughput deja de subir y la latencia empieza a empeorar: ahí está el óptimo, y normalmente el cuello no es la CPU sino las conexiones a la base de datos.»

Trampa: el error clásico es poner un pool enorme «para aguantar picos». Doscientos hilos peleando por diez conexiones de base de datos no dan más rendimiento: dan más memoria consumida, más cambios de contexto y colas invisibles con latencias peores. El pool de hilos nunca debe ser mucho mayor que el recurso escaso al que espera.

12. CompletableFuture: composición, excepciones y elección de executor

Es la API para composición asíncrona: permite encadenar y combinar tareas sin bloquear ningún hilo hasta el final. Lo que se pregunta siempre son los tres puntos donde la gente se equivoca.

// Composición: tres llamadas en paralelo y combinación
var cliente  = supplyAsync(() -> clienteApi.buscar(id), ex);
var pedidos  = supplyAsync(() -> pedidosApi.de(id), ex);
var puntos   = supplyAsync(() -> fidelidadApi.puntos(id), ex);

var ficha = cliente
        .thenCombine(pedidos, FichaCliente::new)         // combinar dos
        .thenCombine(puntos,  FichaCliente::conPuntos)
        .orTimeout(2, TimeUnit.SECONDS)                  // límite global
        .exceptionally(e -> FichaCliente.degradada(id))   // fallback
        .join();

// thenApply vs thenCompose: el mismo par map/flatMap de siempre
CompletableFuture<CompletableFuture<Pedido>> mal = f.thenApply(this::cargarAsync);
CompletableFuture<Pedido> bien = f.thenCompose(this::cargarAsync);

// Manejo de errores: los tres métodos y sus diferencias
f.exceptionally(e -> valorPorDefecto);          // solo en error, devuelve valor
f.handle((v, e) -> e != null ? defecto : v);    // siempre, ve valor y error
f.whenComplete((v, e) -> log.info("fin"));      // siempre, NO transforma el resultado

// ⚠ El error silencioso número uno: no consumir el resultado
supplyAsync(this::algoQuePuedeFallar);   // si lanza, NADIE se enterará nunca

El executor por defecto es el ForkJoinPool.commonPool(), compartido por toda la JVM y con núcleos - 1 hilos. Usarlo para I/O es un error grave: bloqueas un recurso global. Pasa siempre un executor propio a los métodos *Async. Y ojo con el detalle fino: los métodos sin Async pueden ejecutarse en el hilo que completó el futuro anterior o en el hilo que llama, según los tiempos; si te importa dónde corre la continuación, usa la variante Async con executor explícito.

join() frente a get(): los dos bloquean, pero join lanza la CompletionException no comprobada y get obliga a capturar InterruptedException y ExecutionException. En código de composición se usa join por comodidad; con get(timeout) tienes plazo, que es lo importante.

Trampa: con hilos virtuales, muchísimo de esto se simplifica. Si la razón por la que usabas CompletableFuture era «no bloquear un hilo de plataforma», en Java 21 puedes escribir código secuencial bloqueante dentro de hilos virtuales, o usar concurrencia estructurada, y leerlo mucho mejor. CompletableFuture sigue siendo útil para composición y para APIs que ya lo devuelven.

13. ¿Qué es ThreadLocal y por qué provoca fugas de memoria?

Es una variable con una copia por hilo: cada hilo ve su propio valor. Se usa para propagar contexto sin pasarlo por parámetro, y por eso está en el corazón de muchas cosas de Spring: SecurityContextHolder, TransactionSynchronizationManager, el MDC de los logs y RequestContextHolder.

private static final ThreadLocal<String> TRACE = new ThreadLocal<>();

// ✅ El patrón obligatorio: try/finally con remove()
void manejar(Peticion p) {
    TRACE.set(p.traceId());
    try { procesar(p); }
    finally { TRACE.remove(); }     // ← sin esto, fuga garantizada en un pool
}

La fuga ocurre porque el valor se guarda en un mapa dentro del objeto Thread. En un servidor los hilos se reutilizan indefinidamente desde un pool, así que si no llamas a remove(), el valor sobrevive a la petición y queda retenido para siempre: si es un objeto grande (una entidad, un contexto con la sesión entera), multiplica por el número de hilos del pool y tienes megabytes retenidos. Y hay un segundo efecto, peor que la memoria: fuga de información, porque la petición siguiente que caiga en ese hilo verá el contexto del usuario anterior.

En 2026 hay que mencionar la alternativa: ScopedValue (Java 21+ en incubación, estabilizado en versiones posteriores), diseñado precisamente para esto. Es inmutable, tiene un ámbito delimitado y explícito, se limpia solo al salir del bloque y se hereda correctamente en concurrencia estructurada.

// ScopedValue: sin fugas por construcción
private static final ScopedValue<String> TRACE = ScopedValue.newInstance();

ScopedValue.where(TRACE, p.traceId()).run(() -> procesar(p));
// Dentro: TRACE.get(); fuera: no existe. No hay remove() que olvidar.

Trampa: con hilos virtuales, ThreadLocal funciona pero escala mal: si tienes un millón de hilos virtuales, tienes un millón de copias del valor. Es otro motivo por el que ScopedValue existe. Y InheritableThreadLocal con pools es directamente una fuente de bugs, porque el hilo hijo hereda el valor del hilo que lo creó, no del que envía la tarea.

14. ¿Qué son los hilos virtuales y qué cambian de verdad?

Hilos gestionados por la JVM en lugar de por el sistema operativo. Son baratísimos (microsegundos y cientos de bytes) y, cuando se bloquean en una operación de I/O, la JVM los desmonta del hilo portador y deja ese hilo del sistema libre para otra tarea. El resultado: el modelo «un hilo por petición», que es el más sencillo de leer y de depurar, escala a cientos de miles de tareas concurrentes.

// Spring Boot 3.5: una línea y todo el servidor pasa a hilos virtuales
// spring.threads.virtual.enabled=true

// A mano
try (var ex = Executors.newVirtualThreadPerTaskExecutor()) {
    for (var id : ids) ex.submit(() -> procesar(id));   // 100.000 tareas sin problema
}   // close() espera a que terminen todas

// Código bloqueante, secuencial y legible... y escalable
var cliente = clienteApi.buscar(id);      // bloquea el hilo VIRTUAL, no el del SO
var pedidos = pedidosApi.de(id);          // el portador atendió otras tareas mientras

Qué cambian en la práctica: eliminan el dilema entre «legible pero no escala» (hilos de plataforma) y «escala pero es un infierno de depurar» (programación reactiva). Un stack trace de un hilo virtual es un stack trace normal y completo, y el depurador funciona; con Reactor no.

Cuándo NO ayudan, que es lo que de verdad quieren oír:

  • Trabajo de CPU. No hay magia: si el cuello es el procesador, un millón de hilos virtuales no crean núcleos.
  • El cuello está detrás. Si tienes 10 conexiones a PostgreSQL, tener 50.000 hilos virtuales solo consigue que 49.990 esperen en el pool. El límite se mueve, no desaparece.
  • Pinning. Un hilo virtual bloqueado dentro de un bloque synchronized o en una llamada nativa (JNI) no podía desmontarse y ocupaba el portador. Con pocos portadores, eso puede paralizar el sistema. En Java 24 se eliminó el pinning por synchronized, pero si trabajas con 21 hay que sustituir synchronized por ReentrantLock en las secciones que hacen I/O.
  • ThreadLocal masivo y librerías que cachean por hilo: con un millón de hilos, un millón de copias.
  • No agrupes hilos virtuales. Un pool de hilos virtuales es un contrasentido: son tan baratos que crear uno por tarea es lo correcto.

Trampa: «entonces ya no hace falta programación reactiva». Casi: sigue teniendo sentido cuando necesitas contrapresión real de extremo a extremo, flujos de streaming (Server-Sent Events, WebSocket con muchísimas conexiones) u operadores de composición complejos. Pero para una API REST que llama a una base de datos y a dos servicios, hilos virtuales con código bloqueante es hoy la respuesta razonable.

15. ¿Qué es la concurrencia estructurada?

Un modelo en el que las tareas concurrentes tienen un ámbito léxico: se lanzan y se esperan dentro del mismo bloque, y ese bloque no termina hasta que todas han acabado, se han cancelado o han fallado. Elimina las tres plagas del ExecutorService suelto: tareas huérfanas, fugas de cancelación y errores que se pierden.

// Java 21+ (API en evolución; en 25 es StructuredTaskScope.open())
// Política "todas o ninguna": si una falla, las demás se cancelan
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    var cliente = scope.fork(() -> clienteApi.buscar(id));
    var pedidos = scope.fork(() -> pedidosApi.de(id));
    var puntos  = scope.fork(() -> fidelidadApi.puntos(id));

    scope.joinUntil(Instant.now().plusSeconds(2));   // espera con plazo
    scope.throwIfFailed(e -> new FichaNoDisponible(id, e));

    return new Ficha(cliente.get(), pedidos.get(), puntos.get());
}   // al salir del try, NINGUNA tarea sigue viva. Garantizado.

// Política "el primero que responda gana" (útil para réplicas o mirrors)
try (var scope = new StructuredTaskScope.ShutdownOnSuccess<Precio>()) {
    scope.fork(() -> proveedorA.precio(sku));
    scope.fork(() -> proveedorB.precio(sku));
    scope.join();
    return scope.result();                            // el otro se cancela solo
}

Las ventajas que hay que nombrar: la relación padre-hijo es explícita, así que la cancelación se propaga hacia abajo automáticamente; los errores se agregan y no se pierden; el stack trace incluye el contexto del padre, lo que hace que depurar concurrencia se parezca por fin a depurar código secuencial; y no puedes «olvidarte» de esperar una tarea, porque el try no te deja salir.

Trampa: si te preguntan la diferencia con CompletableFuture.allOf, la respuesta es la cancelación y la vida de las tareas. Con allOf, si una falla, las otras siguen ejecutándose consumiendo recursos y nadie las espera; con concurrencia estructurada, al salir del ámbito no queda nada vivo.

16. ¿Sobre qué objeto sincroniza un método static synchronized?

Sobre el objeto Class de la clase: MiClase.class. Un método de instancia synchronized sincroniza sobre this. Son dos cerrojos distintos, así que un método estático sincronizado y uno de instancia sincronizado pueden ejecutarse a la vez, y esa es la fuente de un bug sutil si comparten estado.

class Contador {
    private static int global;
    private int local;

    static synchronized void incGlobal() { global++; }   // cerrojo: Contador.class
    synchronized void incLocal()         { local++;  }   // cerrojo: this

    // ❌ El bug: este método toca estado estático con el cerrojo de la instancia
    synchronized void incGlobalMal() { global++; }
    // Dos instancias distintas → dos cerrojos distintos → carrera sobre 'global'

    // ✅ Bloques explícitos, que es lo recomendable: el cerrojo se ve
    private static final Object CERROJO_GLOBAL = new Object();
    void incGlobalBien() { synchronized (CERROJO_GLOBAL) { global++; } }
}

Buenas prácticas asociadas: sincroniza sobre un objeto privado y dedicado, no sobre this (porque cualquiera desde fuera puede sincronizar sobre tu instancia y bloquearte) y jamás sobre un objeto que pueda cambiar de referencia, ni sobre una cadena internada, ni sobre un envoltorio numérico del rango de la caché de Integer, porque podrías estar compartiendo cerrojo con código completamente ajeno.

Trampa: en un bean singleton de Spring, sincronizar sobre this parece razonable porque hay una sola instancia… hasta que alguien cambia el scope a prototype o el bean se envuelve en un proxy y de repente hay dos objetos. Un cerrojo privado y estático no tiene ese problema.

17. Implementa productor-consumidor y explica por qué la cola debe ser acotada

Es el patrón para desacoplar quien genera trabajo de quien lo procesa, con la cola actuando de amortiguador. La versión correcta usa una BlockingQueue, que hace toda la sincronización por ti.

// Cola ACOTADA: es la decisión de diseño importante
private final BlockingQueue<Pedido> cola = new ArrayBlockingQueue<>(1000);
private static final Pedido FIN = new Pedido("__FIN__");   // píldora envenenada

// Productor
void producir(Pedido p) throws InterruptedException {
    cola.put(p);                     // BLOQUEA si está llena → contrapresión
    // alternativa con plazo: if (!cola.offer(p, 2, SECONDS)) rechazar(p);  → 503
}

// Consumidores (N hilos)
void consumir() {
    try {
        while (true) {
            var p = cola.take();     // bloquea si está vacía
            if (p == FIN) { cola.put(FIN); break; }   // reinyecta para los demás
            procesar(p);
        }
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();          // restaurar la bandera: importante
    }
}

Por qué acotada: una cola ilimitada convierte un problema de rendimiento en una caída. Si el consumidor es más lento que el productor —y tarde o temprano lo será—, la cola crece hasta agotar el heap y el proceso muere con OutOfMemoryError, perdiendo todo el trabajo acumulado. Con una cola acotada, el productor se frena (put bloquea) o rechaza explícitamente (offer con plazo devuelve false y respondes 503 con Retry-After). Eso es contrapresión: el sistema se degrada de forma controlada en lugar de romperse.

La versión de producción de esto no es código propio: es una cola real (Kafka, RabbitMQ, SQS) para que el trabajo sobreviva a un reinicio del proceso. Decirlo demuestra que distingues un patrón de concurrencia de una arquitectura duradera.

Trampa: tragarse la InterruptedException sin restaurar la bandera de interrupción es un error clásico: el hilo deja de responder a la cancelación y el apagado ordenado de la aplicación se queda esperando treinta segundos hasta que Kubernetes lo mata con SIGKILL.

18. ¿Cómo está organizada la memoria de la JVM?

Dos grandes bloques: el heap, que gestiona el recolector de basura, y la memoria nativa (todo lo demás), que no aparece en -Xmx y es la causa de la mitad de los OOMKilled en contenedores.

HEAP (-Xms / -Xmx) ─ gestionado por el GC
  · Young generation: Eden + dos Survivor. Casi todos los objetos mueren aquí (minor GC).
  · Old generation (tenured): los que sobreviven varias colecciones.
  · G1 no usa zonas contiguas, sino regiones de 1-32 MB etiquetadas dinámicamente.

FUERA DEL HEAP ─ memoria nativa, NO la limita -Xmx
  · Metaspace: metadatos de clases. Crece dinámicamente (limita con -XX:MaxMetaspaceSize).
  · Code cache: código compilado por el JIT (~240 MB por defecto).
  · Pilas de hilos: ~1 MB por hilo de plataforma (-Xss). 250 hilos = 250 MB.
  · Direct buffers / mapped: ByteBuffer.allocateDirect, NIO, Netty (-XX:MaxDirectMemorySize).
  · Estructuras internas del GC, JIT, JNI y el propio proceso.

Regla práctica en contenedores:
  memoria del contenedor ≈ heap + metaspace + code cache + (hilos × Xss) + direct + ~100 MB
  Por eso -Xmx igual al límite del contenedor = OOMKilled seguro.
  Usa -XX:MaxRAMPercentage=70 y deja margen.

Y cada hilo tiene además su propia pila (marcos de método, variables locales y referencias) y su contador de programa. Los objetos siempre viven en el heap; en la pila solo hay primitivos locales y referencias —salvo cuando el JIT aplica escape analysis y decide que un objeto que no se escapa del método puede ir a la pila o desaparecer del todo—.

Trampa: te pueden preguntar por qué el contenedor muere con 1 GB de límite si -Xmx es 900 MB y el heap solo llega a 400 MB. La respuesta: metaspace, pilas de 250 hilos, code cache y buffers directos. La herramienta para verlo es Native Memory Tracking (-XX:NativeMemoryTracking=summary y jcmd <pid> VM.native_memory summary).

19. ¿Qué tipos de OutOfMemoryError hay y qué significa cada uno?

El mensaje después de los dos puntos es el diagnóstico, y saberlos distingue a quien ha estado de guardia de quien no:

MensajeQué pasaQué mirar primero
Java heap spaceNo cabe un objeto nuevo en el heap.Fuga (colección o caché que crece) o heap infradimensionado. Heap dump y árbol de dominadores.
GC overhead limit exceededMás del 98 % del tiempo en GC recuperando menos del 2 %.Casi siempre es una fuga en fase terminal. Mismo diagnóstico que el anterior.
MetaspaceDemasiados metadatos de clases.Classloader leak por redespliegues en caliente, o generación dinámica de clases (proxies, CGLIB, plantillas).
unable to create native threadEl sistema no da más hilos.Fuga de hilos (executor sin shutdown), límite de ulimit -u, o -Xss demasiado grande.
Direct buffer memorySe agotó la memoria directa.Netty o NIO sin liberar buffers; -XX:MaxDirectMemorySize.
Requested array size exceeds VM limitArray mayor que Integer.MAX_VALUE (aprox.).Un bug de cálculo de tamaño, o intentar leer un fichero enorme en un byte[].
# Configuración que deberías tener SIEMPRE en producción
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/dumps/
-XX:+ExitOnOutOfMemoryError          # que muera y lo reinicie el orquestador
-XX:MaxRAMPercentage=70              # en contenedor, en lugar de -Xmx fijo

# Diagnóstico en caliente, sin reiniciar
jcmd <pid> GC.heap_info
jcmd <pid> GC.class_histogram | head -30      # qué clases ocupan más
jcmd <pid> GC.heap_dump /tmp/dump.hprof       # ojo: pausa el proceso
jcmd <pid> VM.native_memory summary           # con NativeMemoryTracking activado

Trampa: OutOfMemoryError es un Error, no una Exception, y no se debe capturar: la JVM está en un estado en el que cualquier operación puede fallar, incluido el propio log. Lo correcto es morir, dejar el heap dump y que el orquestador levante una instancia nueva.

20. ¿Qué recolectores de basura hay y cuál elegirías?

Cuatro relevantes en 2026, y la elección se hace por el objetivo (latencia o rendimiento bruto) y por el tamaño del heap, no por cuál es más moderno.

GCPausasCuándo lo elijoCoste
SerialLargasContenedores diminutos (< 256 MB, 1 vCPU), procesos por lotes cortos, funciones serverless.Un solo hilo: no escala.
ParallelLargas pero eficientesProcesos por lotes donde solo importa el throughput total y la pausa no molesta.Pausas de cientos de ms o segundos.
G1 (por defecto)Objetivo configurable (~200 ms)El 95 % de las aplicaciones web. Heaps de 2 a 32 GB. Buen equilibrio.Algo más de CPU y de memoria que Parallel.
ZGC (generacional)SubmilisegundoLatencia estricta (p99 sensible), heaps grandes (16 GB a varios TB).~10-15 % más de CPU y más memoria.
ShenandoahSubmilisegundoIgual que ZGC; disponible en OpenJDK de Red Hat.Similar a ZGC.
# G1 con objetivo de pausa (por defecto desde Java 9)
-XX:+UseG1GC -XX:MaxGCPauseMillis=100

# ZGC generacional (recomendado desde Java 21; en 23+ es el modo por defecto de ZGC)
-XX:+UseZGC -XX:+ZGenerational

# Lo que SIEMPRE hay que hacer antes de tocar el GC: mirar los datos
-Xlog:gc*:file=/logs/gc.log:time,uptime,level,tags:filecount=5,filesize=10M
# Y analizarlo con GCViewer o gceasy.io: frecuencia, duración y si el heap
# después de un full GC baja o se queda alto (eso último es una fuga).

La respuesta que impresiona: «no cambiaría el GC como primera medida. En la práctica, el 90 % de los problemas que se achacan al recolector son en realidad demasiada basura generada o una fuga. Primero mediría con los logs de GC y un perfil de asignación; solo si el problema es de verdad la duración de las pausas y no puedo reducir la asignación, cambiaría a ZGC.»

Trampa: la ley fundamental del GC es que los objetos que mueren jóvenes son gratis. Un minor GC copia solo lo que sobrevive, así que crear muchos objetos de vida cortísima cuesta muy poco. Lo caro es que sobrevivan al Eden y promocionen a la generación antigua. Por eso una caché mal dimensionada duele mucho más que un bucle que crea objetos temporales.

21. Un servicio está al 100 % de CPU en producción. ¿Qué haces?

Primero estabilizar, después diagnosticar. Y el orden importa porque el objetivo inicial no es entender: es dejar de perder dinero.

  1. Contener (minutos 0-5). ¿Hay un despliegue reciente? Rollback. ¿Hay más instancias posibles? Escalar horizontalmente para repartir. Quitar del balanceador la instancia enferma pero sin matarla, que es la que vas a examinar.
  2. Confirmar que es Java. top -H -p <pid> para ver qué hilos consumen. Si la CPU está en hilos del GC, es un problema de memoria disfrazado de CPU.
  3. Identificar el hilo culpable. Toma el ID del hilo de top, pásalo a hexadecimal y búscalo en el volcado: ahí tienes la pila exacta del código que está quemando la CPU.
  4. Perfilar unos segundos. async-profiler o JFR durante 30-60 segundos y mirar el gráfico de llamas: es la forma más directa de ver el bucle caliente.
  5. Descartar las causas típicas por frecuencia: bucle infinito o while(true) sin espera; regex con retroceso catastrófico sobre una entrada inesperada; un HashMap mutado concurrentemente (antes de Java 8 podía crear un ciclo y colgar el get); serialización de respuestas gigantes; GC en bucle por fuga de memoria; log en nivel DEBUG activado por error en producción.
  6. Arreglar, verificar con la métrica y escribir el postmortem con una acción concreta: normalmente una alerta que lo habría detectado antes.
# La secuencia completa, en el orden en que se teclea
top -H -p $(pgrep -f mi-app)           # 1. hilo que consume, PID en decimal
printf '%x\n' 12345                     # 2. a hexadecimal → 0x3039
jcmd <pid> Thread.print > td1.txt       # 3. volcado
grep -A 25 '0x3039' td1.txt             # 4. la pila del culpable

# Tres volcados separados: si la pila no cambia, es un bucle
for i in 1 2 3; do jcmd <pid> Thread.print > td$i.txt; sleep 5; done

# Perfil con JFR, sin parar el servicio
jcmd <pid> JFR.start duration=60s filename=/tmp/perfil.jfr settings=profile

Trampa: la respuesta perdedora es «reiniciaría el servicio». Reiniciar puede ser parte de la contención, pero si reinicias sin tomar el volcado ni el perfil, has destruido la evidencia y el problema volverá. La frase que hay que decir es: «capturo el thread dump y un perfil antes de reiniciar».

22. Un servicio tiene una fuga de memoria. ¿Cómo lo confirmas y lo arreglas?

Una fuga en Java no es memoria «perdida»: es memoria alcanzable pero inútil. El GC hace su trabajo perfectamente; el problema es que algo mantiene una referencia viva.

  1. Confirmar que es una fuga y no un heap pequeño. La señal inequívoca está en los logs de GC: si el heap ocupado después de un full GC crece de forma monótona a lo largo de horas o días, es una fuga. Si sube y baja en diente de sierra volviendo siempre al mismo nivel, no lo es.
  2. Capturar dos heap dumps separados por una o dos horas de tráfico normal.
  3. Comparar con Eclipse MAT (histograma comparado y árbol de dominadores). La pregunta que responde MAT es la única que importa: ¿quién retiene esto?.
  4. Buscar los sospechosos habituales, que cubren casi todos los casos reales:
CausaCómo se veArreglo
Colección estática que creceUn HashMap con millones de entradas dominado por una claseCaché con límite y TTL (Caffeine), o quitar el estático
Caché sin límiteIgual, con nombre de cachémaximumSize + expireAfterWrite
ThreadLocal sin remove()Objetos retenidos por Thread en el árbol de dominadorestry/finally con remove(), o ScopedValue
Listeners no desregistradosObjetos retenidos por un publicador de eventosDesregistrar en @PreDestroy, o referencias débiles
Conexiones o streams sin cerrarCrece la memoria nativa y los descriptores de ficherotry-with-resources
Sesiones HTTP con objetos grandesMuchas instancias de sesión retenidasNo guardar entidades en sesión; reducir el timeout
Classloader leakCrece Metaspace, no el heapEvitar redespliegue en caliente; en contenedores, reiniciar el pod

Y la parte que casi nadie menciona y que cierra la respuesta: añadir una alerta sobre memoria usada tras full GC y un test que lo cubra si es posible (por ejemplo, comprobar que la caché no supera su tamaño máximo). Arreglar la fuga sin poner el detector es garantizar que la próxima también se descubra por una caída.

Trampa: «¿usarías System.gc()?». Nunca en producción: es una sugerencia, no una orden, y con la mayoría de los recolectores provoca un full GC con pausa. La única excepción legítima es forzar una colección antes de tomar un heap dump para no ver basura todavía no recogida, y para eso el propio jcmd GC.heap_dump ya lo hace.

23. ¿Qué es el JIT y por qué hay que calentar la JVM?

La JVM empieza interpretando el bytecode y, según ve qué métodos se ejecutan mucho (hot spots), los compila a código máquina y aplica optimizaciones muy agresivas basadas en el perfil de ejecución real. Por eso el mismo código puede ser diez o cien veces más rápido tras unos miles de ejecuciones.

Compilación por niveles (tiered):
  Nivel 0  · intérprete
  Nivel 1-3 · C1: compila rápido, optimiza poco, recoge perfil
  Nivel 4  · C2: compila despacio, optimiza muchísimo (con el perfil de C1)

Optimizaciones clave que hace C2:
  · Inlining: mete el cuerpo del método llamado en el llamante (la más importante).
  · Escape analysis: si un objeto no escapa del método, puede no asignarlo en el heap.
  · Desvirtualización: si en la práctica solo se ha visto una implementación de una
    interfaz, la llama directamente (con una guarda por si aparece otra).
  · Eliminación de código muerto, desenrollado de bucles, vectorización.

Deoptimización: si una suposición se rompe (aparece una segunda implementación,
una rama que nunca se había tomado), C2 descarta el código y vuelve al intérprete.

Consecuencias prácticas que se preguntan mucho:

  • Las primeras peticiones son lentas. Un servicio recién arrancado tiene p99 malísimo durante decenas de segundos. Por eso los despliegues necesitan readiness con margen, y por eso a veces se hace warm-up con tráfico sintético antes de meter la instancia en el balanceador.
  • Los microbenchmarks caseros mienten. Sin calentamiento estás midiendo el intérprete. Es la razón de ser de JMH.
  • El megamorfismo cuesta. Un punto de llamada con más de dos implementaciones distintas no se puede desvirtualizar ni inlinear; es un argumento real (y medible) contra las jerarquías de interfaces excesivas en bucles calientes.
  • Alternativas al calentamiento: CDS/AppCDS (comparte metadatos de clases y mejora el arranque), Project Leyden y los ahead-of-time caches de Java 24+, o GraalVM native image, que compila antes de ejecutar y arranca en milisegundos a cambio de perder el pico de rendimiento del JIT y de exigir configuración explícita de reflexión.

Trampa: «¿es Java más lento que C++ por el JIT?». La respuesta con criterio: en régimen estacionario, el JIT compite muy bien porque optimiza con información que un compilador estático no tiene (qué ramas se toman de verdad, qué tipos aparecen). Donde Java pierde es en el arranque, en la huella de memoria y en el control fino del diseño de memoria. Y ese es exactamente el hueco que intentan tapar Valhalla y los proyectos de arranque rápido.

24. ¿Cómo depuras un problema de concurrencia que no se reproduce?

Los bugs de concurrencia no se depuran con el depurador: poner un breakpoint cambia los tiempos y el problema desaparece (el clásico heisenbug). La estrategia es otra: hacerlo determinista para poder verlo.

  1. Escribe un test que fuerce el entrelazado. N hilos arrancando a la vez con un CountDownLatch, miles de iteraciones y comprobación del invariante al final. Repite el test cien veces: si falla una, tienes el bug.
  2. Añade estrés artificial: más hilos que núcleos, y Thread.yield() o una espera microscópica en el punto sospechoso para ampliar la ventana de la carrera.
  3. Usa herramientas de detección: -XX:+PrintConcurrentLocks en los volcados, jcstress para invariantes del modelo de memoria, y análisis estático (SpotBugs con los detectores de concurrencia, Error Prone con las anotaciones de @GuardedBy).
  4. Documenta el modelo de sincronización con anotaciones @ThreadSafe/@GuardedBy("cerrojo"): la mayoría de estos bugs vienen de que nadie sabía qué cerrojo protegía qué campo.
  5. Si el estado compartido está en la base de datos, el test es distinto: dos transacciones concurrentes reales con Testcontainers, y comprobar que la segunda falla con conflicto de versión o espera el bloqueo. Ese test sí es determinista.
@Test void no_debe_perder_incrementos() throws Exception {
    var contador = new Contador();
    int hilos = 50, vueltas = 1_000;
    var arranque = new CountDownLatch(1);
    var fin = new CountDownLatch(hilos);

    try (var ex = Executors.newVirtualThreadPerTaskExecutor()) {
        for (int i = 0; i < hilos; i++) {
            ex.submit(() -> {
                arranque.await();                       // todos salen a la vez
                for (int j = 0; j < vueltas; j++) contador.incrementar();
                fin.countDown();
                return null;
            });
        }
        arranque.countDown();
        assertTrue(fin.await(10, TimeUnit.SECONDS));
    }
    assertEquals(hilos * vueltas, contador.valor());     // 50.000 exactos
}

Trampa: «¿y si el test pasa siempre?». Entonces no has demostrado que no haya bug, solo que no lo has reproducido: la ausencia de fallo en un test de concurrencia es evidencia débil. Reconocerlo es señal de rigor. Lo fuerte es razonar sobre el modelo de memoria y sobre qué cerrojo protege qué, y usar el test como red de seguridad adicional.

25. ¿Qué es la contrapresión y cómo la implementas sin librerías reactivas?

Contrapresión es la capacidad de un consumidor para frenar al productor cuando no puede seguir su ritmo. Sin ella, el exceso de trabajo se acumula en algún sitio —una cola, un pool, un buffer— hasta que algo se rompe, y normalmente lo que se rompe es la memoria.

// 1. Cola acotada: la forma más simple y más efectiva
new ArrayBlockingQueue<Tarea>(1000);      // put() bloquea al productor

// 2. Semáforo: limitar la concurrencia hacia una dependencia frágil
private final Semaphore permisos = new Semaphore(20);   // máximo 20 en vuelo
public Respuesta llamar(Peticion p) {
    if (!permisos.tryAcquire(200, TimeUnit.MILLISECONDS)) {
        throw new SistemaSobrecargado();                 // → 503 + Retry-After
    }
    try { return clienteRemoto.enviar(p); } finally { permisos.release(); }
}

// 3. CallerRunsPolicy: el que produce, procesa; se frena solo
new ThreadPoolExecutor(..., new ThreadPoolExecutor.CallerRunsPolicy());

// 4. En la capa HTTP: rechazar antes de aceptar trabajo que no puedes hacer
//    Bulkhead de Resilience4j, o un rate limiter con 429 y Retry-After.

// 5. En Kafka: max.poll.records y la propia velocidad de commit son la contrapresión
//    natural. El broker guarda el trabajo; tú consumes al ritmo que puedes.

El principio que hay que verbalizar: rechazar rápido es mejor que aceptar y morir. Un 503 inmediato con Retry-After es una respuesta útil que el cliente puede gestionar; un timeout a los treinta segundos después de haber consumido recursos es lo peor de los dos mundos, porque has pagado el coste y además has fallado.

Trampa: los hilos virtuales quitan el límite natural que suponían los hilos de plataforma, así que hacen la contrapresión explícita más necesaria, no menos. Antes, tener 200 hilos de Tomcat era un límite de admisión accidental; ahora puedes aceptar cien mil peticiones simultáneas y hundir la base de datos. Ese razonamiento es exactamente el tipo de matiz que buscan en una entrevista de senior.

7 · Banco de preguntas · Spring y Spring Boot

Es el bloque más largo porque es el que más se pregunta en España: la mayoría de las ofertas de backend Java son ofertas de Spring. La clave aquí es que casi ninguna pregunta se responde bien con la definición: hay que explicar el mecanismo (proxies, autoconfiguración, ciclo de vida) porque de ahí salen todas las trampas del día a día.

1. ¿Qué son la inversión de control y la inyección de dependencias, y qué problema resuelven?

Inversión de control es el principio: la creación y el ciclo de vida de los objetos lo controla el contenedor, no cada clase. Inyección de dependencias es la técnica concreta: las dependencias llegan desde fuera en lugar de instanciarse dentro.

El problema que resuelve no es «escribir menos new», es el acoplamiento a implementaciones concretas, que te impide sustituir, probar y evolucionar.

// ❌ Sin DI: la clase decide con qué colabora → imposible probar sin base de datos real
class ServicioPedidos {
    private final RepositorioPedidos repo = new RepositorioJdbc(DriverManager.getConnection(...));
    private final PasarelaPago pago = new PasarelaStripe("clave-de-produccion");
}

// ✅ Con DI: dependencias explícitas, sustituibles y visibles en la firma
@Service
class ServicioPedidos {
    private final RepositorioPedidos repo;
    private final PasarelaPago pago;

    ServicioPedidos(RepositorioPedidos repo, PasarelaPago pago) {   // el contrato está aquí
        this.repo = repo;
        this.pago = pago;
    }
}
// En un test: new ServicioPedidos(new RepoEnMemoria(), new PagoFalso()) — sin Spring.

El beneficio secundario, que en Spring es enorme: al controlar la creación, el contenedor puede envolver los beans en proxies y añadir transacciones, caché, seguridad, métricas o reintentos sin que tu código lo sepa. Toda la parte declarativa de Spring depende de la inversión de control.

Trampa: «¿el constructor con seis parámetros no es incómodo?». Sí, y es una señal: un constructor con muchas dependencias suele indicar que la clase hace demasiado. La inyección por campo lo esconde; la inyección por constructor lo hace visible, y eso es una ventaja, no un inconveniente.

2. ¿Por qué inyección por constructor y no por campo?

Por cinco razones concretas, y merece la pena decirlas todas porque es una pregunta de criba:

  1. El objeto siempre está válido. No existe un estado intermedio en el que el bean esté construido pero sin dependencias.
  2. Campos final. Inmutabilidad y seguridad entre hilos gratis.
  3. Dependencias explícitas. La firma del constructor es la documentación. Si tiene ocho parámetros, el diseño te está avisando.
  4. Testeable sin framework. new ServicioPedidos(repoFalso, pagoFalso) y ya: sin @SpringBootTest, sin reflexión, sin @InjectMocks.
  5. Los ciclos de dependencias fallan al arrancar, no en la petición 500. Con inyección por campo, Spring puede resolver algunos ciclos y dejarte un problema latente.
// ❌ Inyección por campo
@Service class ServicioPedidos {
    @Autowired private RepositorioPedidos repo;   // no puede ser final, oculta la dependencia
}
// En un test necesitas reflexión o ReflectionTestUtils para ponerle un doble.

// ✅ Por constructor, y con un solo constructor NO hace falta @Autowired (desde Spring 4.3)
@Service
class ServicioPedidos {
    private final RepositorioPedidos repo;
    ServicioPedidos(RepositorioPedidos repo) { this.repo = repo; }
}

// ✅ Con Lombok, si el proyecto ya lo usa
@Service @RequiredArgsConstructor
class ServicioPedidos { private final RepositorioPedidos repo; }

La inyección por setter tiene un hueco legítimo: dependencias verdaderamente opcionales o reconfigurables en caliente. Es raro.

Trampa: la repregunta es «¿y si hay una dependencia circular?». La respuesta correcta no es @Lazy: es que el ciclo es un problema de diseño. Se rompe extrayendo la responsabilidad compartida a una tercera clase, o invirtiendo la relación con un evento de dominio. Spring Boot 2.6+ prohíbe los ciclos por defecto precisamente por eso.

3. @Component, @Service, @Repository y @Controller: ¿hay diferencia real?

Técnicamente los cuatro son @Component: los detecta el mismo escaneo y producen un bean idéntico. Las diferencias reales son dos: semántica (documentan la capa, y las herramientas y los compañeros lo leen) y comportamiento añadido en dos de ellos.

AnotaciónCapaComportamiento extra real
@ComponentGenéricaNinguno. Para utilidades y componentes de infraestructura.
@ServiceLógica de negocioNinguno técnico. Solo semántica (y es la capa donde suele ir @Transactional).
@RepositoryAcceso a datosSí: activa la traducción de excepciones. Convierte SQLException y las de JPA en la jerarquía DataAccessException de Spring.
@ControllerWebSí: lo reconoce el RequestMappingHandlerMapping para enrutar peticiones.
@RestControllerWeb (API)@Controller + @ResponseBody en todos los métodos.
@ConfigurationConfiguración@Component + proxy de la clase para que los @Bean sean singletons.

La traducción de excepciones de @Repository es la respuesta que se busca: gracias a ella tu capa de servicio puede capturar DuplicateKeyException u OptimisticLockingFailureException sin depender del proveedor concreto, y podrías cambiar de JPA a JDBC sin tocar el manejo de errores. Con Spring Data ya la tienes de serie porque las interfaces de repositorio la aplican.

Trampa: usar @Service en una clase que solo hace acceso a datos, o @Component en todo. Funciona, y por eso mucha gente cree que da igual; pero la entrevista busca precisamente que sepas que @Repository no es decorativo.

4. ¿@Bean o un estereotipo? ¿Cuándo cada uno?

Estereotipo (@Service, @Component…) para tus clases: Spring las descubre por escaneo. @Bean en un @Configuration para clases que no controlas (de una librería) o cuando la construcción requiere lógica.

@Configuration
class ConfiguracionClientes {

    // Clase de terceros: no puedo anotarla, así que la construyo yo
    @Bean
    RestClient clientePagos(RestClient.Builder builder,
                            @Value("${pagos.url}") String url) {
        return builder.baseUrl(url)
                .requestFactory(factoriaConTimeouts(2000, 5000))
                .defaultHeader("X-Origen", "pedidos")
                .build();
    }

    // Construcción condicional: imposible con un estereotipo
    @Bean
    @ConditionalOnProperty(name = "cache.tipo", havingValue = "redis")
    CacheManager cacheRedis(RedisConnectionFactory f) { return RedisCacheManager.create(f); }

    // Varios beans del mismo tipo con configuración distinta
    @Bean @Primary TaskExecutor ejecutorGeneral() { ... }
    @Bean("ejecutorInformes") TaskExecutor ejecutorInformes() { ... }
}

Un detalle que casi nadie sabe y que impresiona: el nombre del bean es el nombre del método, así que renombrar un método @Bean puede romper una inyección por nombre o un @Qualifier. Y los parámetros del método @Bean son inyección de dependencias: Spring los resuelve del contexto y además establece el orden de creación.

Trampa: declarar un @Bean de una de tus propias clases que ya tiene @Component crea dos beans del mismo tipo y provoca un NoUniqueBeanDefinitionException al inyectar. Elige uno de los dos mecanismos por clase.

5. ¿Qué hace @Configuration y qué es proxyBeanMethods?

@Configuration marca una clase como fuente de definiciones de beans y, por defecto, Spring la subclasea con CGLIB. Ese proxy intercepta las llamadas entre métodos @Bean para que devuelvan siempre la misma instancia en lugar de ejecutar el método otra vez.

@Configuration                       // proxyBeanMethods = true por defecto
class Config {
    @Bean A a() { return new A(); }
    @Bean B b() { return new B(a()); }   // ← a() NO crea otra A: el proxy devuelve el singleton
    @Bean C c() { return new C(a()); }   // ← la misma instancia de A que en b()
}

@Configuration(proxyBeanMethods = false)   // "modo ligero": sin proxy CGLIB
class ConfigLigera {
    @Bean A a() { return new A(); }
    @Bean B b() { return new B(a()); }   // ⚠ AHORA a() SÍ crea una A nueva
    @Bean B b(A a) { return new B(a); }   // ✅ la forma correcta: pedirla por parámetro
}

¿Por qué existe proxyBeanMethods = false? Porque el proxy CGLIB cuesta tiempo de arranque y memoria, y estorba a la compilación nativa de GraalVM. Todas las autoconfiguraciones de Spring Boot lo usan, y es la razón por la que Boot arranca más rápido que hace unos años. La regla: si tus métodos @Bean no se llaman entre sí (y no deberían: pide las dependencias por parámetro), desactívalo.

Trampa: con proxyBeanMethods = false y un método que llama a otro, tendrás dos instancias del mismo bean y un bug desconcertante: cambias la configuración en una y no se refleja en la otra. Es un error muy difícil de ver leyendo el código.

6. Describe el ciclo de vida de un bean

De principio a fin, con los puntos donde puedes intervenir:

1. Se lee la definición del bean (escaneo, @Bean, XML, registro programático).
2. BeanFactoryPostProcessor puede MODIFICAR las definiciones
   (aquí actúa, por ejemplo, PropertySourcesPlaceholderConfigurer).
3. Instanciación: se llama al constructor (con inyección por constructor).
4. Inyección de propiedades y de campos (@Autowired en campos, @Value).
5. Callbacks de conocimiento del contenedor: BeanNameAware, BeanFactoryAware,
   ApplicationContextAware.
6. BeanPostProcessor.postProcessBeforeInitialization
7. @PostConstruct → InitializingBean.afterPropertiesSet() → initMethod de @Bean
8. BeanPostProcessor.postProcessAfterInitialization
   ← AQUÍ se crean los PROXIES (@Transactional, @Async, @Cacheable, seguridad)
9. El bean está listo y en uso.
--- al cerrar el contexto ---
10. @PreDestroy → DisposableBean.destroy() → destroyMethod de @Bean
    (solo para singletons: los beans prototype NO reciben destrucción)

El paso 8 es la respuesta a un montón de preguntas aparentemente inconexas: por qué @Transactional no funciona si llamas al método desde dentro de la misma clase, por qué no puedes usar un bean proxificado dentro de su propio @PostConstruct esperando que las anotaciones apliquen, y por qué al inyectar un bean recibes a veces un objeto cuya clase termina en $$SpringCGLIB$$.

Para lógica de arranque en una aplicación real, hoy se prefiere ApplicationRunner o CommandLineRunner —o un @EventListener(ApplicationReadyEvent.class)— porque se ejecutan cuando todo el contexto está listo, no a mitad de la construcción de un bean.

Trampa: «¿por qué mi @Value es null en el constructor?» Porque los campos se inyectan en el paso 4, después del constructor (paso 3). Si necesitas el valor en la construcción, pásalo como parámetro del constructor con @Value en el parámetro.

7. ¿Qué scopes hay y cuándo usarías uno distinto de singleton?

singleton (por defecto, una instancia por contexto), prototype (una nueva en cada inyección o petición al contenedor) y los de ámbito web: request, session, application y websocket.

La verdad práctica: el 98 % de los beans son singleton y sin estado, y eso es lo correcto. Los demás ámbitos casi siempre son una señal de que se está guardando estado donde no toca.

// ⚠ El bug clásico: estado de la petición en un singleton
@Service
class ServicioMal {
    private String usuarioActual;            // ← compartido por TODOS los hilos
    void procesar(String usuario) {
        this.usuarioActual = usuario;         // dos peticiones simultáneas se pisan
        // ...
    }
}

// Si de verdad necesitas un bean con estado por petición:
@Bean @Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS)
ContextoPeticion contexto() { return new ContextoPeticion(); }
// El proxyMode es obligatorio para poder inyectarlo en un singleton: el proxy
// resuelve la instancia correcta en cada llamada.

// Un prototype inyectado en un singleton se resuelve UNA VEZ: si necesitas uno nuevo
// en cada llamada, inyecta ObjectProvider (o una factoría)
private final ObjectProvider<Informe> informes;
Informe nuevo() { return informes.getObject(); }

Trampa: «un singleton de Spring es seguro entre hilos, ¿no?». No: Spring garantiza una instancia, no seguridad. Es seguro si no guarda estado mutable. Y la forma correcta de tener contexto de petición no suele ser un bean con scope, es pasar el dato como parámetro o usar el contexto que ya ofrece el framework (SecurityContextHolder, MDC).

8. Hay dos beans del mismo tipo. ¿Cómo se resuelve la ambigüedad?

Con @Primary, @Qualifier o coincidencia por nombre. El orden de resolución de Spring: primero por tipo; si hay varios candidatos, aplica @Qualifier; si no lo hay, busca un @Primary; si tampoco, intenta coincidir el nombre del parámetro con el nombre del bean; y si nada funciona, falla el arranque con NoUniqueBeanDefinitionException.

@Bean @Primary PasarelaPago stripe() { ... }        // la elegida por defecto
@Bean("paypal") PasarelaPago paypal() { ... }

@Service
class ServicioPagos {
    ServicioPagos(PasarelaPago pasarela,                        // → stripe (@Primary)
                  @Qualifier("paypal") PasarelaPago alternativa) { ... }
}

// Mejor que @Qualifier con cadenas: un qualifier con tipo, que el compilador comprueba
@Qualifier @Retention(RUNTIME) @interface Paypal { }
@Bean @Paypal PasarelaPago paypal() { ... }
ServicioPagos(@Paypal PasarelaPago p) { ... }

// Elegir por configuración: la forma más limpia de todas
@Bean @ConditionalOnProperty(name = "pagos.proveedor", havingValue = "stripe")
PasarelaPago stripe() { ... }

Trampa: depender de la coincidencia por nombre del parámetro es frágil: si alguien renombra el parámetro (o compilas sin -parameters), la inyección cambia de bean en silencio. Usa @Qualifier explícito cuando haya más de un candidato.

9. ¿Puedo inyectar todas las implementaciones de una interfaz?

Sí, y es uno de los patrones más útiles de Spring: inyectar List, Set o Map de un tipo y recibir todos los beans que lo implementan. Con Map, la clave es el nombre del bean.

interface Validador { void validar(Pedido p); }

@Component @Order(1) class ValidadorStock implements Validador { ... }
@Component @Order(2) class ValidadorCredito implements Validador { ... }
@Component @Order(3) class ValidadorDireccion implements Validador { ... }

@Service
class ServicioPedidos {
    private final List<Validador> validadores;     // los tres, EN ORDEN por @Order

    ServicioPedidos(List<Validador> validadores) { this.validadores = validadores; }

    void crear(Pedido p) {
        validadores.forEach(v -> v.validar(p));      // añadir una regla = añadir una clase
    }
}

// Con Map: strategy por clave, sin ningún switch
@Component("es") class ImpuestoES implements Impuesto { ... }
@Component("pt") class ImpuestoPT implements Impuesto { ... }

@Service
class Calculadora {
    private final Map<String, Impuesto> porPais;
    Calculadora(Map<String, Impuesto> porPais) { this.porPais = porPais; }
}

Por qué esto es tan valioso en entrevista: es la implementación idiomática del principio abierto-cerrado en Spring. Añadir una regla de negocio nueva es añadir una clase, sin tocar ni una línea del código existente y sin ningún if ni switch que crezca con cada requisito.

Trampa: si no hay ninguna implementación, inyectar una List falla al arrancar. Para hacerlo opcional, usa ObjectProvider<List<Validador>> o @Autowired(required = false). Y si el orden importa de verdad, hazlo explícito con @Order: no dependas del orden del escaneo, que no está garantizado.

10. ¿Cómo funciona la autoconfiguración de Spring Boot de verdad?

Es un mecanismo de configuración condicional basado en lo que hay en el classpath y en las propiedades. Los pasos exactos:

1. @SpringBootApplication incluye @EnableAutoConfiguration.
2. AutoConfigurationImportSelector lee TODOS los ficheros del classpath en
   META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
   (antes de Boot 2.7 era META-INF/spring.factories).
3. Obtiene una lista de ~150 clases candidatas de @AutoConfiguration.
4. Filtra por las condiciones de cada una ANTES de cargar la clase (para no pagar
   el coste de clases que no aplican).
5. Las que sobreviven se procesan como @Configuration, después de las tuyas
   (por eso @ConditionalOnMissingBean funciona: tu bean ya existe).
6. Cada @Bean interno vuelve a tener sus propias condiciones.

Las condiciones que hay que saber nombrar:

CondiciónSe cumple si…
@ConditionalOnClassLa clase está en el classpath. Es la base de los starters: añades la dependencia y aparece la configuración.
@ConditionalOnMissingBeanNo has definido tú ese bean. Es lo que permite «convención con posibilidad de sobrescribir».
@ConditionalOnPropertyUna propiedad tiene cierto valor (o existe).
@ConditionalOnWebApplicationEs una aplicación web (servlet o reactiva).
@ConditionalOnBeanExiste otro bean (útil para ordenar autoconfiguraciones).
@ConditionalOnResourceExiste un recurso en el classpath.
// Un starter propio, completo y mínimo
@AutoConfiguration
@ConditionalOnClass(ClienteFacturacion.class)
@EnableConfigurationProperties(FacturacionProperties.class)
class FacturacionAutoConfiguration {

    @Bean
    @ConditionalOnMissingBean                       // el usuario puede poner el suyo
    @ConditionalOnProperty(prefix = "facturacion", name = "enabled", matchIfMissing = true)
    ClienteFacturacion clienteFacturacion(FacturacionProperties props) {
        return new ClienteFacturacion(props.url(), props.timeout());
    }
}
// Y se registra en:
// src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports

La herramienta de diagnóstico que hay que mencionar: arrancar con --debug imprime el informe de condiciones con tres secciones —coincidencias positivas, negativas y exclusiones—. Ante cualquier «¿por qué no se ha configurado esto?», ese informe da la respuesta en treinta segundos.

Trampa: «¿la autoconfiguración va antes o después de mi configuración?». Después, siempre. Si va antes, @ConditionalOnMissingBean no podría funcionar. Y por eso definir tu propio DataSource desactiva el de Boot sin que tengas que excluir nada.

11. ¿@Value o @ConfigurationProperties?

@ConfigurationProperties para cualquier configuración con más de dos propiedades relacionadas; @Value solo para valores sueltos y puntuales. La diferencia decisiva es que @ConfigurationProperties se puede validar, es tipada, agrupa, admite relajación de nombres y se documenta sola.

// ✅ Configuración tipada, inmutable y validada al arrancar
@ConfigurationProperties(prefix = "pagos")
@Validated
public record PagosProperties(
        @NotBlank String url,
        @NotNull Duration timeout,
        @Min(0) @Max(10) int reintentos,
        @NotNull @Valid Credenciales credenciales) {

    public record Credenciales(@NotBlank String clave, @NotBlank String secreto) { }
}

// Registro (o @ConfigurationPropertiesScan en la clase principal)
@EnableConfigurationProperties(PagosProperties.class)

// application.yml — relajación de nombres: pagos.timeout, PAGOS_TIMEOUT, pagos-timeout
pagos:
  url: https://api.pasarela.example
  timeout: 3s              # se convierte a Duration automáticamente
  reintentos: 3
  credenciales:
    clave: ${PAGOS_CLAVE}
    secreto: ${PAGOS_SECRETO}

El beneficio que hay que subrayar: con @Validated, una configuración mal puesta hace que la aplicación no arranque, con un mensaje claro de qué propiedad falta. Sin ello, el fallo aparece a las tres de la mañana en la primera petición que use ese valor. Es el principio de fallar pronto y ruidoso aplicado a la configuración.

Trampa: @Value en un campo se inyecta después del constructor, así que no puedes usarlo en el constructor, y con SpEL (#{...}) es fácil escribir configuración imposible de depurar. Además @Value no aparece en los metadatos de configuración, así que el autocompletado del IDE no lo conoce.

12. ¿Cómo funcionan los perfiles y en qué orden se leen las propiedades?

Los perfiles activan condicionalmente beans y ficheros de configuración. El orden de precedencia de las fuentes de propiedades es lo que se pregunta, porque explica el 90 % de los «¿por qué coge este valor y no el mío?».

De MAYOR a MENOR prioridad (lo de arriba gana):

 1. Devtools en $HOME/.config/spring-boot (solo desarrollo)
 2. @TestPropertySource y properties de @SpringBootTest
 3. Argumentos de línea de comandos:  --server.port=8081
 4. SPRING_APPLICATION_JSON (JSON en línea o en variable de entorno)
 5. ServletConfig / ServletContext init params
 6. JNDI (java:comp/env)
 7. Propiedades del sistema Java:  -Dserver.port=8081
 8. Variables de entorno:  SERVER_PORT=8081     ← la habitual en Docker/K8s
 9. application-{perfil}.yml FUERA del jar (config/ o directorio de trabajo)
10. application-{perfil}.yml dentro del jar
11. application.yml fuera del jar
12. application.yml dentro del jar
13. @PropertySource
14. SpringApplication.setDefaultProperties
spring:
  application:
    name: pedidos
  profiles:
    group:
      produccion: [ "prod-db", "prod-cache", "observabilidad" ]  # perfil compuesto
---
spring:
  config:
    activate:
      on-profile: dev            # documento condicional en el mismo fichero
logging:
  level:
    org.hibernate.SQL: debug

Buenas prácticas que conviene añadir: no metas secretos en application.yml (van en variables de entorno o en un gestor de secretos), evita @Profile en beans de negocio (mejor @ConditionalOnProperty, que es más explícito y no acopla el código a un nombre de entorno), y no uses un perfil test que cambie la lógica, porque entonces tus tests no prueban lo que se despliega.

Trampa: en Kubernetes las variables de entorno (posición 8) ganan a cualquier application.yml del jar. Si alguien define SERVER_PORT en el Deployment, tu configuración no se aplica y el diagnóstico es desconcertante hasta que conoces esta lista. /actuator/env te dice exactamente de qué fuente sale cada valor.

13. ¿Qué pasa exactamente cuando arranca una aplicación Spring Boot?

Merece la pena saberlo con detalle porque es la base para diagnosticar arranques lentos y errores de contexto:

 1. main() → SpringApplication.run(App.class, args)
 2. Se crea el SpringApplication: deduce el tipo de aplicación (servlet, reactiva o
    ninguna) mirando el classpath, y carga los ApplicationContextInitializer y
    ApplicationListener de los ficheros .imports/spring.factories.
 3. Environment: se preparan las fuentes de propiedades y se resuelven los perfiles
    activos. Se publica ApplicationEnvironmentPreparedEvent (aquí engancha la
    configuración externa: Config Server, Vault…).
 4. Banner, y creación del ApplicationContext del tipo adecuado.
 5. Se registran las definiciones de beans: la clase principal, el escaneo de
    componentes y las autoconfiguraciones.
 6. refresh():
    a. BeanFactoryPostProcessors (incluido ConfigurationClassPostProcessor, que
       procesa @Configuration, @Import, @ComponentScan y las condiciones).
    b. Se registran los BeanPostProcessors.
    c. Se crea el servidor web embebido (Tomcat) — pero aún no acepta tráfico.
    d. Se instancian todos los singletons no perezosos, en orden de dependencia.
       Aquí se ejecutan @PostConstruct y se crean los proxies.
 7. ApplicationStartedEvent → se ejecutan ApplicationRunner y CommandLineRunner.
 8. El servidor empieza a aceptar conexiones.
 9. ApplicationReadyEvent → readiness pasa a UP.
10. Se registra el hook de apagado (para el cierre ordenado).

Herramientas para diagnosticar un arranque lento, que es la pregunta que suele venir detrás: --debug para el informe de condiciones, la métrica /actuator/startup con BufferingApplicationStartup para ver el tiempo de cada paso, y las causas habituales: escaneo de componentes demasiado amplio, muchas autoconfiguraciones innecesarias, conexiones a servicios externos en @PostConstruct, y validación de esquema de Hibernate contra una base de datos lenta.

Trampa: hacer trabajo pesado o llamadas de red en un @PostConstruct retrasa el arranque y, peor, si falla, la aplicación no arranca. Ese trabajo va en un ApplicationRunner o en un listener de ApplicationReadyEvent, y con tolerancia a fallos.

14. Desmonta @SpringBootApplication

Son tres anotaciones en una, y saberlo permite responder «¿cómo limitaría el escaneo?» o «¿cómo excluyo una autoconfiguración?» sin dudar:

@SpringBootApplication
// ≡
@SpringBootConfiguration     // = @Configuration, y marca la clase raíz (la buscan los tests)
@EnableAutoConfiguration     // activa el mecanismo de autoconfiguración
@ComponentScan               // escanea el paquete de esta clase y sus subpaquetes
class Aplicacion { public static void main(String[] a) { SpringApplication.run(Aplicacion.class, a); } }

// Variantes útiles
@SpringBootApplication(exclude = DataSourceAutoConfiguration.class)
@SpringBootApplication(scanBasePackages = "com.miempresa.pedidos")
@SpringBootApplication(proxyBeanMethods = false)      // arranque más rápido

// Y en application.yml
spring.autoconfigure.exclude: org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration

Dos consecuencias prácticas que dan puntos: (1) la clase principal debe estar en el paquete raíz, porque el escaneo parte de ahí; ponerla en un subpaquete es la causa número uno de «no encuentra mis beans». (2) @SpringBootConfiguration es la marca que buscan @SpringBootTest y las slices para localizar la configuración: por eso los tests funcionan sin decirles qué clase cargar, y por eso solo puede haber una por aplicación.

Trampa: «¿y si quiero escanear otro paquete además del mío?» Añadir scanBasePackages con paquetes muy amplios (por ejemplo com) hace el arranque lentísimo y puede cargar beans inesperados de las librerías. Mejor importar explícitamente la configuración de la librería con @Import, o que la librería traiga su propia autoconfiguración.

15. ¿Cómo funciona AOP en Spring? ¿JDK o CGLIB?

Spring implementa AOP con proxies en tiempo de ejecución, no con manipulación de bytecode (eso es AspectJ). Cuando un bean tiene un aspecto aplicable, el BeanPostProcessor lo sustituye por un proxy que intercepta las llamadas.

Proxy dinámico JDKCGLIB
RequisitoEl bean implementa al menos una interfazNinguno (subclasea)
Cómo funcionaClase generada que implementa las mismas interfacesSubclase generada que sobrescribe los métodos
LimitacionesSolo se interceptan los métodos de la interfazNo puede con clases o métodos final, ni private, ni static
ConstructorNo lo necesitaLlama al constructor (en versiones antiguas hacía falta uno sin argumentos)
Cuándo se usaSi hay interfaz y proxyTargetClass = falsePor defecto en Spring Boot desde 2.x
// De aquí salen las tres limitaciones que SIEMPRE preguntan:

@Service
class ServicioPedidos {

    // 1. Autoinvocación: la llamada interna NO pasa por el proxy
    public void procesarTodos(List<Pedido> ps) {
        ps.forEach(this::procesarUno);      // ← this es el objeto real, no el proxy
    }
    @Transactional public void procesarUno(Pedido p) { ... }   // ¡sin transacción!

    // 2. Método privado: CGLIB no puede sobrescribirlo → la anotación se ignora
    @Transactional private void interno() { ... }

    // 3. Método final: idem
    @Transactional public final void tambienIgnorado() { ... }
}

// Soluciones a la autoinvocación, de mejor a peor:
// a) Extraer el método a otro bean e inyectarlo  ← la correcta
// b) Inyectar el propio bean con @Lazy y llamar al proxy
// c) TransactionTemplate programático
// d) AopContext.currentProxy() con exposeProxy=true  ← evítalo

Trampa: el síntoma de la autoinvocación es traicionero porque parece funcionar: en el caso feliz todo se guarda igual y solo descubres el problema cuando algo falla a mitad y no hay rollback. Si en la entrevista cuentas que te pasó y cómo lo detectaste, es una de las mejores historias técnicas que puedes tener preparadas.

16. @Async: cómo funciona y qué hay que configurar

Igual que @Transactional: un proxy intercepta la llamada, envía la ejecución a un TaskExecutor y devuelve inmediatamente. Requiere @EnableAsync y tiene las mismas limitaciones de proxy (no funciona en llamadas internas ni en métodos privados).

@Configuration @EnableAsync
class ConfigAsync implements AsyncConfigurer {

    @Bean("ejecutorNotificaciones")
    ThreadPoolTaskExecutor ejecutor() {
        var ex = new ThreadPoolTaskExecutor();
        ex.setCorePoolSize(4);
        ex.setMaxPoolSize(8);
        ex.setQueueCapacity(500);                       // ACOTADA
        ex.setThreadNamePrefix("notif-");               // nombres útiles en los dumps
        ex.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        ex.setWaitForTasksToCompleteOnShutdown(true);    // apagado ordenado
        ex.setAwaitTerminationSeconds(30);
        return ex;
    }

    // Sin esto, las excepciones de métodos void se PIERDEN en silencio
    @Override public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return (ex, metodo, args) -> log.error("fallo async en {}", metodo.getName(), ex);
    }
}

@Service
class Notificaciones {
    @Async("ejecutorNotificaciones")
    public CompletableFuture<Void> enviar(String destino) { ... }   // mejor que void
}

Los cuatro problemas prácticos que hay que nombrar: (1) si no defines executor, Boot usa uno por defecto —hoy un SimpleAsyncTaskExecutor con hilos virtuales si están activados, y antes un pool que podía crear hilos sin límite—, así que define siempre el tuyo; (2) las excepciones de métodos void se pierden si no hay handler; (3) el contexto no se propaga automáticamente: SecurityContextHolder, el MDC de los logs y la transacción no viajan al hilo nuevo; (4) @Async con @Transactional en el mismo método significa una transacción nueva en el hilo nuevo, no la del llamante.

Trampa: «¿usarías @Async para procesar un pedido en segundo plano?». Con cuidado: si el proceso muere, el trabajo en la cola en memoria se pierde. Para trabajo que no se puede perder, hace falta durabilidad: una tabla de tareas, el patrón outbox o una cola real. @Async es para cosas accesorias (enviar un correo, calentar una caché, registrar auditoría no crítica).

17. @Scheduled con varias instancias: ¿qué pasa y cómo lo resuelves?

Que la tarea se ejecuta en todas las instancias a la vez. Si el trabajo no es idempotente —enviar recordatorios, cobrar suscripciones, generar informes—, se duplica tantas veces como pods tengas. Es un incidente clásico al pasar de una instancia a tres.

@Scheduled(cron = "0 0 3 * * *", zone = "Europe/Madrid")   // ¡zona explícita!
void cobrarSuscripciones() { ... }

// Soluciones, de más simple a más robusta:

// 1. ShedLock: cerrojo distribuido en la base de datos. Lo más habitual.
@Scheduled(cron = "0 0 3 * * *")
@SchedulerLock(name = "cobros", lockAtMostFor = "30m", lockAtLeastFor = "5m")
void cobrarSuscripciones() { ... }

// 2. Marcar el trabajo en la base de datos con una condición atómica:
//    UPDATE tarea SET estado='EN_CURSO', instancia=? WHERE id=? AND estado='PENDIENTE'
//    Si filas_afectadas = 0, otra instancia se la llevó. Sirve también para reparto.

// 3. Un perfil o una propiedad que solo esté activa en una instancia (frágil:
//    si esa instancia se cae, la tarea no se ejecuta nunca).

// 4. Sacar la programación fuera: un CronJob de Kubernetes que llama a un endpoint,
//    o un planificador gestionado (EventBridge, Cloud Scheduler). Es lo más limpio
//    en un entorno cloud, porque separa "cuándo" de "qué".

// 5. Quartz en modo clúster, si necesitas persistencia de trabajos y reintentos.

Otros detalles importantes de @Scheduled: por defecto todas las tareas comparten un solo hilo, así que una tarea lenta retrasa a las demás (configura spring.task.scheduling.pool.size); las excepciones no capturadas cancelan las ejecuciones siguientes de un fixedDelay, así que envuelve el cuerpo en try/catch; y siempre especifica zone, porque si no usa la del servidor y en marzo y octubre te llevarás una sorpresa.

Trampa: diferencia entre fixedRate y fixedDelay. fixedRate mide desde el inicio de la ejecución anterior, así que si la tarea tarda más que el periodo se van solapando (y con un solo hilo, encolando). fixedDelay mide desde el fin, y por eso es la opción segura por defecto.

18. Eventos de aplicación: ¿para qué sirven y qué trampa tienen?

Para desacoplar: quien publica no conoce a quien escucha. Es la forma idiomática en Spring de reaccionar a hechos del dominio (un pedido creado) sin que el servicio de pedidos dependa del de notificaciones, del de facturación y del de analítica.

// El evento: un record inmutable con el hecho (en pasado)
record PedidoCreado(String pedidoId, String clienteId, BigDecimal total, Instant cuando) { }

@Service @RequiredArgsConstructor
class ServicioPedidos {
    private final ApplicationEventPublisher eventos;

    @Transactional
    public Pedido crear(CrearPedido cmd) {
        var pedido = repo.save(Pedido.de(cmd));
        eventos.publishEvent(new PedidoCreado(pedido.id(), cmd.clienteId(),
                                              pedido.total(), Instant.now()));
        return pedido;
    }
}

// ⚠ Por defecto los listeners son SÍNCRONOS y en la MISMA transacción:
// si el listener lanza, la transacción del pedido se revierte.
@Component
class Notificador {
    // ✅ Solo después del commit: el pedido ya está guardado
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    void al(PedidoCreado e) { correo.enviarConfirmacion(e.clienteId()); }

    // ✅ Y si además no quieres bloquear la respuesta
    @Async @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    void alAsync(PedidoCreado e) { ... }
}

La trampa principal es la de las dos primeras líneas del listener: por defecto los eventos son síncronos y participan en la transacción del publicador, así que un fallo al enviar el correo puede revertir el pedido. Y la trampa simétrica es la de AFTER_COMMIT: si el listener falla ahí, el pedido ya está guardado y el correo no se envía, sin ningún reintento. Si el efecto es importante, no basta con un evento en memoria: hace falta el patrón outbox, que guarda el evento en la misma transacción y lo publica después con reintentos.

Trampa: los eventos de aplicación son in-process: no cruzan instancias, no sobreviven a un reinicio y no tienen reintentos. Confundirlos con mensajería es un error conceptual grave. Son excelentes para desacoplar módulos dentro de un monolito modular (es la base de Spring Modulith) y no sirven como integración entre servicios.

19. RestClient, WebClient o RestTemplate: ¿cuál usas en 2026?

RestClient para código bloqueante nuevo (introducido en Spring Framework 6.1); WebClient si necesitas reactivo o streaming; RestTemplate solo en código existente: está en modo mantenimiento y no recibirá funcionalidad nueva.

// RestClient: API fluida de WebClient, semántica bloqueante y sin dependencia reactiva
@Bean
RestClient clientePagos(RestClient.Builder builder, PagosProperties props) {
    var factory = new JdkClientHttpRequestFactory();
    factory.setReadTimeout(Duration.ofSeconds(3));        // ← lo más importante
    return builder
            .baseUrl(props.url())
            .requestFactory(factory)
            .defaultStatusHandler(HttpStatusCode::is5xxServerError,
                (req, res) -> { throw new PasarelaNoDisponible(res.getStatusCode()); })
            .requestInterceptor(nuevoInterceptorDeTrazas())
            .build();
}

var respuesta = clientePagos.post()
        .uri("/cobros")
        .header("Idempotency-Key", claveIdempotencia)      // reintento seguro
        .body(new CobroRequest(pedidoId, total))
        .retrieve()
        .body(CobroResponse.class);

// Interfaz declarativa (Spring 6+), el sustituto de Feign sin dependencias extra
interface PagosApi {
    @PostExchange("/cobros") CobroResponse cobrar(@RequestBody CobroRequest r);
}
var api = HttpServiceProxyFactory.builderFor(RestClientAdapter.create(clientePagos))
        .build().createClient(PagosApi.class);

Lo que siempre hay que mencionar, y es el motivo real de la pregunta: los timeouts. Ni RestTemplate ni WebClient los traen configurados por defecto, así que una llamada puede quedarse esperando indefinidamente, agotar los hilos y tumbar tu servicio por culpa de otro. Un cliente HTTP sin timeout de conexión y de lectura es un incidente esperando a ocurrir. Y por encima, un circuit breaker y un límite de reintentos.

Trampa: RestTemplate no está «obsoleto» en el sentido de @Deprecated, y decir que lo está es un error de precisión que algunos entrevistadores corrigen. La formulación correcta es «en modo mantenimiento: se mantiene, pero lo nuevo va a RestClient».

20. ¿Cómo diseñas una API REST? (recursos, verbos, códigos)

Con recursos en sustantivo plural, verbos HTTP con su semántica, códigos de estado correctos y sin verbos en la URL. Lo que buscan es que conozcas las reglas y que sepas cuándo romperlas de forma consciente.

Recursos y verbos
  GET    /pedidos?estado=PAGADO&page=0&size=20     lista, seguro e idempotente
  GET    /pedidos/{id}                             uno
  POST   /pedidos                                  crear → 201 + Location
  PUT    /pedidos/{id}                             reemplazo completo, idempotente
  PATCH  /pedidos/{id}                             modificación parcial
  DELETE /pedidos/{id}                             borrar, idempotente
  GET    /pedidos/{id}/lineas                      subrecurso
  POST   /pedidos/{id}/cancelaciones               ⚠ acción modelada como recurso

Códigos que hay que usar bien
  200 OK           201 Created (+ Location)     202 Accepted (asíncrono)
  204 No Content   400 Bad Request (sintaxis)   401 (no autenticado)
  403 (no autorizado)  404 (no existe / no es tuyo)  409 Conflict (estado o versión)
  422 Unprocessable (validación de negocio)     429 Too Many Requests (+ Retry-After)
  500 (bug nuestro)   503 (dependencia caída, + Retry-After)

Lo que NO se hace
  POST /crearPedido            ← verbo en la URL
  GET  /pedidos/borrar/5       ← GET que modifica estado
  200 OK con {"error": "..."}  ← el código de estado ES parte de la respuesta
  Exponer la entidad JPA       ← acoplas el esquema a la API

Idempotencia: GET, PUT y DELETE lo son por definición; POST no. Para que un POST se pueda reintentar sin duplicar (pagos, pedidos) se usa una cabecera Idempotency-Key que el servidor almacena con el resultado: si llega otra vez la misma clave, se devuelve la respuesta original.

Paginación: page y size para catálogos pequeños; cursor (keyset) para listas grandes o infinitas, porque OFFSET grande es lento y además se duplican o se saltan filas si alguien inserta mientras el usuario navega. Devuelve siempre el total o el enlace al siguiente, y pon un límite máximo de size: sin él, un cliente puede pedir un millón de filas y tumbarte.

Versionado: prefijo de ruta (/v1/pedidos) es lo más práctico y lo que más se usa; cabecera de contenido (Accept: application/vnd.empresa.v2+json) es más purista y más incómodo de probar. Lo importante es la regla de evolución: añadir campos opcionales nunca rompe; quitar o renombrar sí, y entonces hace falta versión nueva y periodo de convivencia.

HATEOAS: honestidad: es elegante y casi nadie lo usa, porque los clientes reales no navegan hipermedia. Merece la pena si tienes muchos clientes que no controlas y flujos con estados; para una API interna consumida por tu propio frontend, añade complejidad sin beneficio.

Trampa: 404 frente a 403 cuando el recurso existe pero no es del usuario. Devolver 403 confirma que el recurso existe, lo que es una fuga de información (enumeración). Devuelve 404. Es un detalle pequeño que demuestra mentalidad de seguridad.

21. Validación con Bean Validation: dónde se pone y cómo se agrupa

La validación de formato va en el DTO de entrada con anotaciones, y se activa con @Valid en el controlador. La validación de reglas de negocio va en el dominio, y no es lo mismo: «el email tiene formato válido» es formato; «este cliente no puede pedir a crédito» es negocio.

public record CrearPedidoRequest(
        @NotBlank @Size(max = 36) String clienteId,
        @NotEmpty @Valid List<LineaRequest> lineas,     // @Valid en cascada
        @Email String emailContacto,
        @Future LocalDate entregaDeseada,
        @Positive @Digits(integer = 8, fraction = 2) BigDecimal total) { }

@PostMapping("/pedidos")
ResponseEntity<PedidoResponse> crear(@Valid @RequestBody CrearPedidoRequest req) { ... }
// Si falla → MethodArgumentNotValidException → 400 (lo maneja el @RestControllerAdvice)

// Validación en parámetros y en la capa de servicio
@Validated                                    // necesario a nivel de clase
@Service
class ServicioPedidos {
    Pedido buscar(@NotBlank String id) { ... }   // → ConstraintViolationException
}

// Grupos: la misma clase con reglas distintas según la operación
interface Crear { } interface Actualizar { }
record ClienteRequest(
        @Null(groups = Crear.class) @NotNull(groups = Actualizar.class) Long id,
        @NotBlank(groups = { Crear.class, Actualizar.class }) String nombre) { }

@PostMapping void crear(@Validated(Crear.class) @RequestBody ClienteRequest r) { }
@PutMapping  void editar(@Validated(Actualizar.class) @RequestBody ClienteRequest r) { }

// Validador propio para una regla que no cubren las anotaciones estándar
@Constraint(validatedBy = NifValidator.class)
@Target(FIELD) @Retention(RUNTIME)
public @interface Nif { String message() default "NIF no válido"; }

Trampa: @Valid frente a @Validated. @Valid es de Jakarta Bean Validation y sirve para cascada; @Validated es de Spring, admite grupos y es la que habilita la validación por AOP en métodos de beans. Y la regla de fondo: la validación del cliente nunca sustituye a la del servidor; la del cliente es experiencia de usuario, la del servidor es la única real.

22. ¿Cómo manejas los errores de forma global? (ProblemDetail)

Con un @RestControllerAdvice centralizado y el formato estándar ProblemDetail (RFC 9457, antes 7807), que Spring 6 trae de serie. Un formato único de error es una de las cosas que más agradecen los consumidores de una API y una de las que más se olvidan.

@RestControllerAdvice
class ManejadorErrores extends ResponseEntityExceptionHandler {

    @ExceptionHandler(PedidoNoEncontrado.class)
    ProblemDetail noEncontrado(PedidoNoEncontrado e) {
        var pd = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage());
        pd.setTitle("Pedido no encontrado");
        pd.setType(URI.create("https://api.miempresa.com/errores/pedido-no-encontrado"));
        pd.setProperty("pedidoId", e.pedidoId());
        pd.setProperty("traceId", traza.actual());          // para correlacionar con logs
        return pd;
    }

    @ExceptionHandler(ObjectOptimisticLockingFailureException.class)
    ProblemDetail conflicto(Exception e) {
        return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT,
                "El recurso ha sido modificado por otro usuario. Recárgalo e inténtalo de nuevo.");
    }

    // Validación: devolver TODOS los errores de campo, no solo el primero
    @Override
    protected ResponseEntity<Object> handleMethodArgumentNotValid(
            MethodArgumentNotValidException e, HttpHeaders h, HttpStatusCode s, WebRequest r) {
        var pd = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
        pd.setTitle("Datos de entrada no válidos");
        pd.setProperty("errores", e.getBindingResult().getFieldErrors().stream()
                .map(f -> Map.of("campo", f.getField(), "mensaje", f.getDefaultMessage()))
                .toList());
        return ResponseEntity.badRequest().body(pd);
    }

    // La red de seguridad: nunca filtrar detalles internos
    @ExceptionHandler(Exception.class)
    ProblemDetail inesperado(Exception e) {
        log.error("error no controlado", e);                 // el detalle, al log
        var pd = ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR,
                "Error interno. Referencia: " + traza.actual());
        return pd;                                           // al cliente, lo mínimo
    }
}
# Y desactivar la fuga de información por defecto
server:
  error:
    include-stacktrace: never
    include-message: never
    include-exception: false

Trampa: devolver el mensaje de la excepción al cliente. Un DataIntegrityViolationException lleva el SQL, el nombre de la restricción y a veces datos de otras filas; una NullPointerException revela rutas internas. La regla es: al log el detalle completo con el traceId, al cliente un mensaje útil y ese mismo identificador. Así soporte puede encontrar el error exacto sin exponer nada.

23. ¿Por qué no exponer la entidad JPA en la API?

Por cinco motivos, y conviene decirlos porque es una pregunta de criterio de diseño:

  1. Acoplas el contrato público al esquema de la base de datos. Renombrar una columna se convierte en un cambio incompatible para todos tus clientes.
  2. Fuga de datos. Todos los campos salen: el hash de la contraseña, el margen interno, las notas del comercial, la columna de auditoría con el usuario que lo modificó.
  3. Serialización de relaciones perezosas. Jackson toca los getters, dispara la carga de las asociaciones y provocas un N+1 o una LazyInitializationException según el estado de la sesión. Y con relaciones bidireccionales, recursión infinita.
  4. Vulnerabilidad de asignación masiva. Si aceptas la entidad como entrada, un cliente puede enviar campos que no debería tocar (estado, rol, id) y el binding los asigna.
  5. No puedes evolucionar. Una API necesita agregar, calcular y renombrar; la entidad necesita reflejar la tabla. Son dos modelos con motivos de cambio distintos.
// La estructura correcta: tres modelos con tres responsabilidades
record CrearPedidoRequest(String clienteId, List<LineaRequest> lineas) { }   // entrada
@Entity class Pedido { ... }                                                 // persistencia
record PedidoResponse(String id, String estado, BigDecimal total,
                      int numeroLineas, Instant creado) { }                  // salida

// Y una proyección directa cuando solo necesitas leer: evita cargar la entidad entera
interface PedidoRepo extends JpaRepository<Pedido, Long> {
    @Query("""
           select new com.x.PedidoResumen(p.id, p.estado, p.total, size(p.lineas))
             from Pedido p where p.clienteId = :cliente
           """)
    List<PedidoResumen> resumenPorCliente(String cliente);
}

El coste es el mapeo, y hay que reconocerlo: más clases y una conversión. Con MapStruct se genera en compilación (sin reflexión, con errores en tiempo de compilación si algo no cuadra) y con records pequeños a mano es media docena de líneas. El intercambio vale la pena en cualquier API que vaya a durar.

Trampa: «¿y para un CRUD interno pequeño?». Se puede admitir como atajo consciente, pero di el riesgo y la condición de salida: «en un CRUD interno lo he hecho, sabiendo que el día que la API tenga un cliente externo hay que introducir DTOs». Reconocer el atajo con criterio puntúa; no ver el problema, no.

24. ¿Mapeas a mano o con MapStruct?

Depende del tamaño: a mano si son pocos DTO y el mapeo es trivial (un método factoría estático en el propio record es lo más simple y explícito); MapStruct cuando hay decenas de mapeos, porque genera el código en compilación y avisa si un campo se queda sin mapear.

// A mano: explícito, sin dependencias, imposible de romper en silencio
record PedidoResponse(String id, String estado, BigDecimal total) {
    static PedidoResponse de(Pedido p) {
        return new PedidoResponse(p.getId().toString(), p.getEstado().name(), p.getTotal());
    }
}

// MapStruct: genera la implementación en tiempo de compilación
@Mapper(componentModel = "spring",
        unmappedTargetPolicy = ReportingPolicy.ERROR)   // ← la clave: falla si olvidas uno
interface PedidoMapper {
    @Mapping(target = "numeroLineas", expression = "java(p.getLineas().size())")
    @Mapping(target = "estado", source = "estado")
    PedidoResponse aResponse(Pedido p);
    List<PedidoResponse> aResponses(List<Pedido> ps);
}

Lo que hay que evitar es ModelMapper y los mapeadores por reflexión en tiempo de ejecución: son cómodos al principio y una fuente de bugs después, porque un renombrado deja el campo a null sin que nada falle ni en compilación ni en los tests, y cuestan rendimiento en cada llamada. Esa es la comparación que se busca: compilación frente a ejecución, error temprano frente a null silencioso.

Trampa: con MapStruct y entidades JPA, un mapeo que recorre relaciones perezosas puede disparar un N+1 escondido dentro del mapeador. Mapea desde proyecciones o desde entidades cargadas con @EntityGraph, y comprueba las consultas con un test que las cuente.

25. @Cacheable: cómo funciona y cuáles son sus trampas

Otro proxy: intercepta la llamada, calcula una clave, mira en la caché y solo ejecuta el método si no está. Requiere @EnableCaching. La implementación por defecto en Boot es un mapa concurrente sin límite — que es una fuga de memoria—, así que en la práctica se usa Caffeine (local) o Redis (distribuida).

@Service
class ServicioTarifas {

    @Cacheable(cacheNames = "tarifas", key = "#pais + ':' + #tipo",
               unless = "#result == null")                 // no cachear nulos
    Tarifa buscar(String pais, String tipo) { ... }

    @CachePut(cacheNames = "tarifas", key = "#t.clave()")   // actualiza sin leer
    Tarifa guardar(Tarifa t) { ... }

    @CacheEvict(cacheNames = "tarifas", key = "#clave")
    void borrar(String clave) { }

    @CacheEvict(cacheNames = "tarifas", allEntries = true)
    void recargarTodo() { }
}
spring:
  cache:
    type: caffeine
    caffeine:
      spec: maximumSize=10000,expireAfterWrite=10m,recordStats

Las trampas, que son la parte interesante de la respuesta:

  • Autoinvocación: igual que @Transactional. Llamar al método cacheado desde la misma clase se salta el proxy y la caché no se usa.
  • Clave por defecto: son todos los parámetros. Si uno es una entidad con hashCode mutable o un objeto grande, la clave es un desastre. Define siempre la clave con SpEL.
  • Invalidación olvidada: el bug más frecuente. Alguien actualiza el dato por otra vía (una migración, otro servicio, un UPDATE manual) y la caché sirve datos viejos indefinidamente. Sin TTL, «indefinidamente» significa hasta el próximo despliegue.
  • Sin límite de tamaño: OutOfMemoryError garantizado con claves de alta cardinalidad (cachear por identificador de usuario, por ejemplo).
  • Stampede: cuando expira una clave muy solicitada, mil peticiones simultáneas recalculan lo mismo. Se mitiga con sync = true (que bloquea en local) o con refresco asíncrono.
  • Excepciones y caché: si el método lanza, no se cachea nada; pero si devuelve un valor «vacío» por un fallo, cachearás el fallo. Ojo con unless.
  • Varias instancias: con caché local, cada pod tiene datos distintos y una invalidación en uno no llega a los demás. Hay que decidir si eso es tolerable.

Trampa: la pregunta de seguimiento suele ser «¿qué cachearías?». La respuesta con criterio: datos que se lean mucho, cambien poco y toleren estar un poco desactualizados, con TTL corto y tamaño máximo. Nunca datos de autorización ni saldos ni stock, donde un valor viejo tiene consecuencias.

26. ¿Para qué sirve Actuator y qué expondrías en producción?

Para operar la aplicación: salud, métricas, información de configuración y diagnóstico. Y la parte importante de la respuesta es la de seguridad: Actuator abierto es una de las fugas de información más comunes en aplicaciones Spring.

management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics,prometheus     # lista blanca explícita
      base-path: /actuator
  server:
    port: 8081                     # ← puerto SEPARADO, no expuesto al exterior
  endpoint:
    health:
      show-details: when-authorized
      probes:
        enabled: true              # /health/liveness y /health/readiness
  health:
    livenessstate.enabled: true
    readinessstate.enabled: true
  metrics:
    tags:
      application: ${spring.application.name}
  tracing:
    sampling:
      probability: 0.1

Los health groups son la clave para Kubernetes: liveness debe comprobar solo que el proceso está vivo y readiness las dependencias necesarias para atender tráfico. Si metes la base de datos en liveness, un corte de base de datos hace que Kubernetes reinicie todos los pods en bucle, convirtiendo una degradación en una caída total. Ese es el error arquitectónico que la pregunta busca.

// Un indicador de salud propio, para una dependencia crítica
@Component
class PasarelaHealthIndicator implements HealthIndicator {
    @Override public Health health() {
        try {
            var ms = pasarela.ping();
            return Health.up().withDetail("latenciaMs", ms).build();
        } catch (Exception e) {
            return Health.down(e).withDetail("proveedor", "stripe").build();
        }
    }
}

Trampa: /actuator/env, /actuator/configprops, /actuator/heapdump y /actuator/threaddump exponen configuración, cadenas de conexión y hasta el contenido de la memoria. Nunca deben ser accesibles desde internet. Y include: "*" en producción es un hallazgo directo en cualquier auditoría.

27. ¿Qué métricas expondrías de un servicio y cómo?

Con Micrometer, que es la fachada de métricas de Spring (el equivalente a SLF4J para logs), y exportando a Prometheus. Lo importante no es la herramienta, es qué mides: las cuatro señales de oro más las métricas de negocio.

QuéMétricaPara qué sirve
Latenciahttp.server.requests (histograma, p50/p95/p99)Es lo que siente el usuario. Nunca uses la media.
TráficoPeticiones por segundo por endpointContexto para todo lo demás.
ErroresProporción de 5xx y de 4xxBase de los SLO.
SaturaciónPool de conexiones (hikaricp.connections.*), hilos, memoria, cola de tareasPredice el problema antes de que sea visible.
DependenciasLatencia y errores de cada llamada externa; estado del circuit breakerLocaliza de quién es la culpa.
NegocioPedidos creados, pagos rechazados, mensajes en DLQEs la que detecta los incidentes que las técnicas no ven.
@Service
class ServicioPedidos {
    private final Counter creados;
    private final Timer tiempoCobro;

    ServicioPedidos(MeterRegistry registry) {
        this.creados = Counter.builder("pedidos.creados")
                .description("Pedidos creados correctamente")
                .tag("canal", "web")
                .register(registry);
        this.tiempoCobro = Timer.builder("pedidos.cobro")
                .publishPercentiles(0.5, 0.95, 0.99)
                .register(registry);
    }

    Pedido crear(CrearPedido cmd) {
        return tiempoCobro.record(() -> { var p = hacer(cmd); creados.increment(); return p; });
    }
}
// @Timed y @Counted en el método también funcionan (por AOP), con las limitaciones
// habituales del proxy.

Trampa: cardinalidad. Poner como etiqueta el identificador de usuario, el de pedido o la URL con parámetros crea millones de series temporales y tumba Prometheus. Las etiquetas deben tener pocos valores posibles (endpoint, método, estado, canal). Es un error muy común y muy caro, y mencionarlo demuestra experiencia real de operación.

28. WebFlux o MVC en 2026: ¿qué elegirías?

MVC con hilos virtuales para casi todo. La razón principal por la que se adoptaba WebFlux —no bloquear hilos de plataforma en llamadas de I/O— ya está resuelta desde Java 21 con código bloqueante normal, que es muchísimo más fácil de escribir, leer y depurar.

CriterioMVC (+ hilos virtuales)WebFlux
ModeloUn hilo (virtual) por petición, bloqueanteBucle de eventos, no bloqueante
Curva de aprendizajeBajaAlta: operadores, schedulers, contexto
DepuraciónStack traces normales, el depurador funcionaTrazas ilegibles, hay que aprender a leerlas
Contrapresión de extremo a extremoNo (hay que implementarla)Sí, integrada en el modelo
Streaming y SSE con muchas conexionesAceptableSu punto fuerte
EcosistemaTodo (JPA, JDBC, la mayoría de librerías)Solo librerías reactivas (R2DBC…)
Latencia y memoria por conexiónBuenaAlgo mejor con decenas de miles de conexiones vivas

Cuándo sí elegiría WebFlux: puertas de enlace y agregadores con muchísimas conexiones concurrentes y poca lógica, streaming real, sistemas que necesitan contrapresión de punta a punta, y proyectos donde el equipo ya domina Reactor y el resto del stack es reactivo. Cuándo no: una API REST con JPA. Mezclar los dos modelos —bloquear dentro de un flujo reactivo— es lo peor de ambos mundos y una fuente de incidentes muy difíciles de diagnosticar.

Trampa: si dices «WebFlux es más rápido», te van a pedir el número. La verdad es que en throughput con carga moderada son comparables, y que WebFlux gana en memoria y en número de conexiones simultáneas sostenidas. La respuesta que suena a experiencia es: «no elegiría por rendimiento, elegiría por el modelo de programación y por lo que el equipo puede mantener».

29. ¿Qué es Spring Modulith y qué problema resuelve?

Es una herramienta para construir monolitos modulares verificables: define los módulos por convención (los paquetes de primer nivel bajo la aplicación), permite verificar con un test que nadie viola los límites, y facilita la comunicación entre módulos por eventos con soporte de persistencia.

// Estructura: cada paquete de primer nivel es un módulo
// com.tienda
//   ├── pedidos      ← API pública del módulo: solo las clases de este paquete
//   │   └── internal ← implementación: inaccesible para otros módulos
//   ├── catalogo
//   └── facturacion

@Test void la_arquitectura_se_respeta() {
    var modulos = ApplicationModules.of(TiendaApplication.class);
    modulos.verify();                 // falla si un módulo accede al internal de otro
    new Documenter(modulos).writeDocumentation();   // genera diagramas C4 y PlantUML
}

// Eventos entre módulos, con reintentos y persistencia (el outbox integrado)
@ApplicationModuleListener            // = @Async + @Transactional + AFTER_COMMIT
void al(PedidoCompletado evento) { facturacion.emitir(evento.pedidoId()); }
// Con spring-modulith-events-jpa, el evento se guarda en una tabla y se reintenta
// si el consumidor falla: resuelve el agujero de @TransactionalEventListener.

Por qué importa en una entrevista de arquitectura: es la respuesta concreta a «monolito modular primero». El argumento clásico contra el monolito modular es que los límites se erosionan porque nada los defiende; Modulith los convierte en un test que falla en el pull request. Y si algún día un módulo tiene que salir a servicio propio, ya está delimitado y ya se comunica por eventos.

Trampa: no es un framework de microservicios ni un sustituto de Kafka. Los eventos siguen siendo internos al proceso (con persistencia y reintentos, pero internos). Confundirlo con integración entre servicios es un error conceptual.

30. ¿Qué diferencia Spring Boot de Spring Framework?

Spring Framework aporta el núcleo: contenedor de inversión de control, AOP, abstracción de transacciones, Spring MVC, la abstracción de acceso a datos. Spring Boot es una capa de opinión encima que elimina la configuración repetitiva.

Boot añadeQué te ahorra
AutoconfiguraciónConfigurar a mano DataSource, EntityManagerFactory, TransactionManager, Jackson, el servidor…
StartersElegir versiones compatibles de decenas de dependencias: el BOM lo resuelve.
Servidor embebidoDesplegar un WAR en un Tomcat externo. Ahora el artefacto es autónomo.
Configuración externalizadaUn mecanismo propio de perfiles y propiedades por entorno.
ActuatorImplementar salud, métricas y diagnóstico desde cero.
Empaquetado y capasUn JAR ejecutable y una imagen Docker en capas con bootBuildImage.
Utilidades de test@SpringBootTest, las slices, @MockitoBean, Testcontainers integrado.

La frase que resume bien: «Boot no es un framework nuevo, es Spring con convención sobre configuración y con todo lo necesario para producción de serie. Y lo importante es que todas las decisiones se pueden sobrescribir: si defino mi propio bean, la autoconfiguración se aparta.»

Trampa: te pueden preguntar si Boot añade rendimiento. No: añade productividad y estandarización. El rendimiento en tiempo de ejecución es el de Spring Framework; lo que Boot ha mejorado mucho en las últimas versiones es el arranque (menos proxies con proxyBeanMethods = false, más condiciones evaluadas sin cargar clases, y soporte de AOT y de imagen nativa).

8 · Banco de preguntas · datos, JPA y SQL

Cómo trabajar este banco: en entrevistas mid/senior casi siempre cae N+1, transacciones, índices y un SQL a mano. Responde en voz alta; si no escribes el SQL en la pizarra mental, no está listo.
1. ¿Qué es JPA y qué aporta Hibernate?

JPA es la especificación (anotaciones e interfaces: EntityManager, @Entity). Hibernate es la implementación más usada: traduce objetos a SQL, gestiona el contexto de persistencia, la caché de primer nivel y las estrategias de carga.

En Spring Data JPA usas repositorios; debajo sigue habiendo un EntityManager y una sesión de Hibernate. Si no entiendes esa capa, N+1 y LazyInitializationException parecen magia negra.

Trampa: decir «JPA es Hibernate» pierde precisión; son capa y motor.

2. Ciclo de vida de una entidad JPA.

Estados: transient (nueva, no gestionada), managed (en el contexto, los cambios se sincronizan), detached (tuvo id pero ya no está en el contexto) y removed (marcada para borrado).

Solo las managed se flush-ean automáticamente. Un DTO no es una entidad; un save sobre detached puede hacer merge o insert según tenga id.

3. ¿Por qué desactivar open-in-view?

Con spring.jpa.open-in-view=true (por defecto) la sesión permanece abierta durante el renderizado HTTP. Eso oculta N+1 (las relaciones se cargan al serializar) y mantiene conexiones del pool ocupadas más tiempo.

Lo correcto: desactivarlo, cargar lo necesario en el servicio (fetch join, entity graph o proyección) y devolver DTOs. Si aparece LazyInitializationException, es una señal útil: faltaba una carga explícita.

Trampa: «lo dejo activado porque es más cómodo» es una bandera roja en senior.

4. Explica el problema N+1 y tres formas de arreglarlo.

Una consulta trae N padres; al tocar una relación lazy se dispara 1 consulta por padre. Se detecta logueando SQL o contando statements en test.

Arreglos: join fetch / @EntityGraph, @BatchSize / default_batch_fetch_size, o proyección DTO/JdbcTemplate cuando no necesitas entidades.

// Mal: findAll() + getLineas() en un bucle
// Bien:
@Query("select p from Pedido p join fetch p.lineas where p.estado = :e")
List<Pedido> findConLineas(Estado e);

Trampa: fetch join + paginación (Pageable) puede duplicar filas; usa ventana o dos consultas.

5. Lazy vs Eager: cuándo cada uno.

Lazy por defecto en @ManyToOne/@OneToMany (en JPA 2+ ManyToOne es Eager por defecto — conviene forzarlo a Lazy). Eager carga siempre, aunque no uses la relación: peor en listados.

Regla: todo lazy; carga explícita cuando el caso de uso lo pide. Eager global es la causa número uno de consultas explosivas.

6. ¿Qué es la caché de primer y segundo nivel?

1er nivel: el contexto de persistencia (sesión). Misma entidad por id = misma instancia dentro de la transacción. Obligatoria.

2º nivel: compartida entre sesiones (p. ej. Ehcache/Infinispan). Útil para catálogos estables; peligrosa con datos mutables y en clúster sin invalidación correcta. No es un sustituto de Redis de aplicación.

7. Bloqueo optimista vs pesimista.

Optimista: columna @Version; al conflictar, OptimisticLockException → 409. No bloquea filas.

Pesimista: LockModeType.PESSIMISTIC_WRITE (SELECT … FOR UPDATE) en secciones cortas. Optimista por defecto; pesimista si la colisión es frecuente y la sección es breve.

Trampa: olvidar @Version en entidades concurrentes es un clásico.

8. ¿Cómo versionas el esquema?

Flyway o Liquibase: scripts versionados en el repo, aplicados en orden, ddl-auto=validate (nunca update en prod). Cambios compatibles hacia atrás en dos fases para despliegues sin downtime.

Nunca editar una migración ya aplicada; crear una nueva. En entrevista, menciona rollback/forward-only y datos de seed aparte.

9. Diferencia entre save, persist y merge.

persist: transient → managed; falla si ya tiene id gestionado de otra forma. merge: copia estado de detached a managed y devuelve la managed.

Spring Data save: si el id es null (o isNew), persist; si no, merge. Por eso un id asignado a mano puede hacer merge inesperado.

10. ¿Cuándo no usar JPA?

Reporting pesado, UPSERT masivos, CTEs complejas, bulk updates, o cuando el SQL es el producto. Ahí: JdbcTemplate, jOOQ o SQL nativo.

JPA brilla en el modelo transaccional de escritura del dominio; no es obligatorio para cada SELECT del sistema.

11. Escribe un SQL: pedidos del último mes con importe > 100, top 10 clientes.

Habla en voz alta del plan: filtrar por fecha e importe, agrupar por cliente, ordenar, limitar. Menciona índice en (fecha, importe) o al menos en fecha.

SELECT c.id, c.nombre, SUM(p.importe) AS total
FROM pedidos p
JOIN clientes c ON c.id = p.cliente_id
WHERE p.fecha >= CURRENT_DATE - INTERVAL '1 month'
  AND p.importe > 100
GROUP BY c.id, c.nombre
ORDER BY total DESC
LIMIT 10;

Trampa: en Oracle/SQL Server el dialecto de fechas/límite cambia; dilo.

12. ¿Qué es un índice y cuándo duele?

Estructura (B-tree habitual) que acelera filtros y joins a costa de espacio y de escrituras más lentas. Ayuda si la selectividad es alta y la consulta lo usa (cubre o busca y luego lookup).

Duele: índices no usados, duplicados, en columnas de baja cardinalidad solas, o demasiados en tablas de escritura intensa. EXPLAIN (ANALYZE) manda.

13. Explica isolation levels y un fantasma.

READ COMMITTED (Postgres por defecto): no lees sucio; puedes ver no-repetible. REPEATABLE READ / Snapshot evitan no-repetible. SERIALIZABLE evita anomalías de escritura concurrente con reintentos.

Lectura fantasma: una misma query ve filas nuevas insertadas por otra transacción entre dos lecturas. En RR de Postgres el snapshot lo evita para muchas anomalías.

14. ¿Qué es una proyección DTO y por qué?

Traer solo las columnas necesarias a un record/interfaz, sin entidad completa ni grafo. Evita lazy surprises, reduce I/O y aclara el contrato del caso de uso.

Con Spring Data: interfaces cerradas, Class-based projections o @Query con constructor expression.

15. Transacciones: propagación REQUIRED vs REQUIRES_NEW.

REQUIRED (default): se une a la existente o abre una. REQUIRES_NEW: suspende la actual y abre otra; su commit/rollback es independiente.

Útil para auditar un fallo aunque la operación padre revierta. Coste: más conexiones y riesgo de inconsistencia de negocio si se abusa.

Trampa: llamar a @Transactional desde la misma clase no pasa por el proxy.

16. ¿Cómo paginas bien?

Offset (LIMIT/OFFSET) es simple y se degrada en páginas profundas. Keyset/seek (WHERE id > :last ORDER BY id LIMIT n) escala mejor.

Nunca paginar en memoria tras un findAll. Con fetch join, cuidado con el conteo de Page.

9 · Banco de preguntas · arquitectura y microservicios

Objetivo: demostrar trade-offs, no memorizar patrones. Cada respuesta debe incluir un «cuándo sí» y un «cuándo no».
1. ¿Cuándo NO usarías microservicios?

Equipo pequeño, dominio poco entendido, sin CI/CD ni observabilidad, o cuando la mayoría de operaciones necesitan consistencia fuerte entre partes.

Un monolito modular (p. ej. Spring Modulith) da límites claros sin el coste de red, latencia y ops. Microservicios son una consecuencia del tamaño organizativo, no un premio.

Trampa: «siempre microservicios porque escalan» es respuesta junior.

2. ¿Qué es un bounded context?

En DDD, un límite donde un modelo tiene un significado coherente. «Cliente» en facturación no es el mismo que en marketing.

Entre contextos: traducción explícita (anti-corruption layer), no un único modelo compartido. En microservicios, un servicio ≈ un contexto (idealmente).

3. Consistencia entre servicios: ¿cómo la abordas?

Consistencia eventual: cada servicio es dueño de sus datos; eventos para propagar hechos; outbox/CDC para no perderlos; consumidores idempotentes; sagas con compensación para flujos multipaso.

Evita 2PC distribuido en el día a día: frágil y acopla disponibilidad.

4. Patrón outbox.

En la misma transacción local guardas el cambio de negocio y el evento en tabla outbox. Un publicador (polling o Debezium) lo envía al broker.

Resuelve el agujero «commit en DB + publish a Kafka» que no es atómico. Menciona idempotencia en el consumidor.

5. Saga: coreografía vs orquestación.

Coreografía: cada servicio reacciona a eventos; menos punto central, más difícil de seguir. Orquestación: un coordinador dirige pasos; más visible, riesgo de dios-orquestador.

Elige según complejidad del flujo y necesidad de visibilidad. Siempre define compensaciones.

6. Circuit breaker: estados y cuándo.

Cerrado → cuenta fallos; Abierto → falla rápido + fallback; Semiabierto → pruebas. Evita cascadas cuando una dependencia está enferma.

No sustituye timeouts ni bulkheads. Sin métricas y alertas, el breaker es teatro.

7. Idempotencia en APIs y mensajería.

Repetir la operación deja el mismo efecto que una vez. Clave de idempotencia almacenada, Upsert natural, o deduplicación por id de mensaje.

Imprescindible con at-least-once (Kafka, reintentos HTTP).

8. API gateway: responsabilidades.

Entrada única: TLS, authn gruesa, rate limit, enrutado, a veces agregación. No debe contener lógica de negocio profunda ni ser el único sitio de autorización fina (IDOR se arregla en el servicio).

9. ¿Cómo diseñas versionado de API?

Evolución compatible: campos nuevos opcionales, no reutilizar significados. Versionado por URL (/v2) o cabecera cuando el breaking es inevitable. Deprecación anunciada y métricas de uso.

10. Event-driven vs request/response.

R/R: simple, inmediato, acopla temporalmente. Eventos: desacoplan, escalan lectores, complican consistencia y depuración.

Híbrido habitual: comando síncrono para la decisión del usuario; eventos para fan-out.

11. ¿Qué es CAP y cómo lo usas en la práctica?

Ante partición de red, eliges consistencia o disponibilidad. En la práctica diseñas por caso de uso: saldo/caja → C; timeline social → A+eventual.

No es un eslogan para justificar Kafka: es una pregunta de requisitos.

12. Observabilidad: tres pilares + traces.

Logs (estructurados, correlation id), métricas (RED/USE), trazas distribuidas (traceId entre servicios). Sin eso, microservicios son una caja negra cara.

13. Contratos entre equipos.

Consumer-driven contracts, OpenAPI/AsyncAPI publicados, esquemas Avro/JSON Schema en registro. Romper un contrato es un cambio de producto, no un detalle de implementación.

14. Datos compartidos entre microservicios.

Anti-patrón: una DB compartida acopla despliegues y modelos. Preferible: API/eventos y réplicas de lectura propias si hace falta. «Shared database» solo como paso temporal consciente.

15. Hexagonal / ports & adapters en Spring.

Dominio en el centro; puertos (interfaces); adapters (JPA, REST, mensajería). Facilita tests y cambiar infraestructura.

En Spring: paquetes por feature, dominio sin anotaciones web, repositorios detrás de interfaces.

16. Rate limiting y bulkhead.

Rate limit protege capacidad; bulkhead aísla pools para que un vecino lento no agote todos los hilos/conexiones. Con virtual threads sigue haciendo falta limitar el recurso escaso (DB, API externa).

10 · Banco de preguntas · DevOps, cloud y seguridad

Nivel esperado: no te piden ser SRE, sí demostrar que has desplegado algo real y que no dejas secretos en el repo.
1. ¿Qué metes en una imagen Docker de Spring Boot?

Multi-stage o bootBuildImage/Buildpacks: JRE mínimo o runtime jlink, usuario no root, capas de dependencias cacheables, sin secretos ni herramientas de debug en prod.

Healthchecks alineados con Actuator. Distroless o alpine solo si conoces los trade-offs de libc/DNS.

2. Liveness vs readiness.

Liveness: proceso vivo (reiniciar si falla). Readiness: ¿acepto tráfico? (sacar del balanceador si la DB no está lista). Mezclarlos provoca reinicios en bucle durante degradaciones.

3. 12-factor: configuración.

Config por entorno (vars, secretos del orquestador), no por WAR recompilado. Perfiles Spring sí; credenciales en Git nunca. Menciona Externalized Config y rotación.

4. Pipeline CI mínimo para un servicio Java.

Build + test + análisis (SpotBugs/Checkstyle) + SCA de dependencias + imagen firmada + deploy a staging. Fallar el pipeline ante CVE HIGH/CRITICAL y secretos detectados.

5. ¿Cómo gestionas secretos?

Vault/Secrets Manager/SOPS + inyección en runtime. Nunca en imágenes ni en logs. Rotación y principio de mínimo privilegio en roles IAM/K8s.

6. Kubernetes: Deployment vs StatefulSet.

Deployment: pods intercambiables (APIs). StatefulSet: identidad estable y disco (brokers, DB). La mayoría de microservicios stateless van en Deployment + PVC solo si realmente hace falta.

7. OWASP: menciona tres riesgos con mitigación Spring.

Injection: parámetros enlazados, nunca concatenar SQL. Broken access control: checks en servicio + ownership en query. Security misconfig: Actuator expuesto, CORS abierto, defaults de seguridad desactivados.

8. JWT: qué validas siempre.

Firma y algoritmo (deny list none), exp/nbf, iss, aud, kid. Un JWT no revoca solo: diseña TTL cortos + refresh/denylist si hace falta.

Trampa: olvidar aud es el fallo más citado.

9. OAuth2 vs OIDC en una frase.

OAuth2 autoriza acceso a recursos; OIDC autentica identidad (id_token). Para apps: Authorization Code + PKCE.

10. SBOM y cadena de suministro.

Inventario de dependencias (CycloneDX/Syft), escaneo (Trivy/Grype), firma (cosign) y políticas de admisión. Tú escribes poco del binario que ejecutas.

11. Observabilidad en prod: qué miras tras un deploy.

Error rate, latencia p95/p99, saturación (CPU, pool DB, cola), logs con traceId, y comparación con baseline. Rollback criterion definido antes del deploy.

12. Blue/green vs canary.

Blue/green: dos entornos, corte rápido, rollback simple, más coste. Canary: % de tráfico, detecta regresiones con menos blast radius, necesita métricas buenas.

13. ¿Cómo evitas IDOR?

Autorización por objeto: la query filtra por usuario/tenant (where id=:id and owner=:user). Tests de acceso cruzado. No confiar solo en ocultar el id en la UI.

11 · Entrevista de comportamiento con método STAR

Las preguntas de comportamiento no son relleno: predicen cómo te comportarás bajo presión. El método STAR (Situación, Tarea, Acción, Resultado) evita divagar y fuerza evidencia.

11.1 La plantilla en 90–120 segundos

  1. Situación (15 s): contexto mínimo — equipo, sistema, restricción.
  2. Tarea (10 s): tu responsabilidad concreta, no la del equipo entero.
  3. Acción (45–60 s): lo que hiciste, en primera persona, con detalle técnico o de proceso.
  4. Resultado (15–20 s): número, aprendizaje o decisión posterior. Si salió mal, qué cambiaste.

11.2 Seis historias que debes tener preparadas

HistoriaPregunta típicaQué demostrar
Conflicto técnico«Cuéntame un desacuerdo»Datos > ego; documentar trade-off
Incidente en prod«Un fallo grave»Contención, comunicación, postmortem
Entrega bajo presión«Un plazo imposible»Recortar alcance, no calidad silenciosa
Mentoría / liderazgo«Ayudaste a alguien»Multiplicar al equipo
Error tuyo«Un fallo personal»Responsabilidad + prevención
Ambigüedad«Requisitos confusos»Preguntas, hipótesis, validación
Ejemplo STAR: incidente de latencia en producción

S: En un Black Friday el p95 de checkout pasó de 200 ms a 4 s. T: Yo era el on-call del servicio de precios. A: Congelé el deploy, miré trazas (el 80 % del tiempo estaba en un N+1 nuevo), revertí el PR, añadí test que cuenta SQL y una alerta de p95. R: Recuperamos en 25 minutos; el postmortem cambió la checklist de PR. Aprendí que un benchmark local no sustituye un test de consultas.

12 · Live coding: método y 8 katas con solución

12.1 Protocolo de 5 minutos antes de teclear

  1. Repite el enunciado y pregunta entrada/salida, tamaños, nulos, duplicados, orden.
  2. Da un ejemplo manual (incluido un caso borde).
  3. Propón complejidad objetivo y un plan en 2–3 frases.
  4. Código limpio: nombres, early return, tests mentales mientras escribes.
  5. Al final: complejidad real, mejoras posibles, qué testearías.

Kata 1 · Dos suma (Two Sum)

Dado un array y un objetivo, devuelve índices de dos números que sumen el objetivo. O(n) con mapa valor→índice.

int[] twoSum(int[] nums, int target) {
    Map<Integer, Integer> visto = new HashMap<>();
    for (int i = 0; i < nums.length; i++) {
        Integer j = visto.get(target - nums[i]);
        if (j != null) return new int[]{j, i};
        visto.put(nums[i], i);
    }
    throw new IllegalArgumentException("sin solución");
}

Kata 2 · Paréntesis válidos

boolean validos(String s) {
    Deque<Character> pila = new ArrayDeque<>();
    Map<Character, Character> par = Map.of(')', '(', ']', '[', '}', '{');
    for (char c : s.toCharArray()) {
        if (!par.containsKey(c)) pila.push(c);
        else if (pila.isEmpty() || pila.pop() != par.get(c)) return false;
    }
    return pila.isEmpty();
}

Kata 3 · Merge de intervalos

List<int[]> merge(List<int[]> intervalos) {
    intervalos.sort(Comparator.comparingInt(a -> a[0]));
    List<int[]> out = new ArrayList<>();
    for (int[] iv : intervalos) {
        if (out.isEmpty() || out.getLast()[1] < iv[0]) out.add(iv);
        else out.getLast()[1] = Math.max(out.getLast()[1], iv[1]);
    }
    return out;
}

Kata 4 · LRU Cache (esqueleto)

HashMap + lista doblemente enlazada (o LinkedHashMap con removeEldestEntry). get/put O(1).

class LRUCache extends LinkedHashMap<Integer, Integer> {
    private final int cap;
    LRUCache(int cap) { super(cap, 0.75f, true); this.cap = cap; }
    public Integer get(int k) { return getOrDefault(k, -1); }
    public void put(int k, int v) { super.put(k, v); }
    @Override protected boolean removeEldestEntry(Map.Entry<Integer,Integer> e) {
        return size() > cap;
    }
}

Kata 5 · Nivel por nivel (BFS árbol)

List<List<Integer>> niveles(TreeNode root) {
    List<List<Integer>> res = new ArrayList<>();
    if (root == null) return res;
    Queue<TreeNode> q = new ArrayDeque<>(List.of(root));
    while (!q.isEmpty()) {
        int n = q.size();
        List<Integer> nivel = new ArrayList<>(n);
        for (int i = 0; i < n; i++) {
            TreeNode x = q.poll();
            nivel.add(x.val);
            if (x.left != null) q.offer(x.left);
            if (x.right != null) q.offer(x.right);
        }
        res.add(nivel);
    }
    return res;
}

Kata 6 · Top K frecuentes

int[] topK(int[] nums, int k) {
    Map<Integer, Long> freq = Arrays.stream(nums).boxed()
        .collect(Collectors.groupingBy(i -> i, Collectors.counting()));
    return freq.entrySet().stream()
        .sorted(Map.Entry.<Integer, Long>comparingByValue().reversed())
        .limit(k).mapToInt(Map.Entry::getKey).toArray();
    // Alternativa O(n log k): heap mínimo de tamaño k
}

Kata 7 · Detectar ciclo en lista (Floyd)

boolean tieneCiclo(ListNode head) {
    ListNode slow = head, fast = head;
    while (fast != null && fast.next != null) {
        slow = slow.next;
        fast = fast.next.next;
        if (slow == fast) return true;
    }
    return false;
}

Kata 8 · Rate limiter simple (token bucket mental)

final class TokenBucket {
    private final long capacidad, refillPorSeg;
    private double tokens; private long lastNanos;
    TokenBucket(long cap, long refill) {
        this.capacidad = cap; this.refillPorSeg = refill;
        this.tokens = cap; this.lastNanos = System.nanoTime();
    }
    synchronized boolean tryConsume() {
        long now = System.nanoTime();
        tokens = Math.min(capacidad, tokens + (now - lastNanos) / 1e9 * refillPorSeg);
        lastNanos = now;
        if (tokens < 1) return false;
        tokens -= 1; return true;
    }
}

13 · System design en 45 minutos

13.1 Plantilla reutilizable

  1. Requisitos (5 min): funcionales + no funcionales (QPS, latencia, tamaño datos, consistencia). Escribe números.
  2. API y modelo (5 min): endpoints, eventos, entidades principales.
  3. Diagrama de cajas (10 min): clientes → gateway → servicios → almacenes → async.
  4. Datos (8 min): esquema, claves de partición, índices, caché, retención.
  5. Puntos difíciles (10 min): hot keys, consistencia, fallos, idempotencia, backpressure.
  6. Evolución (5 min): qué harías con 10× tráfico; métricas y alertas.

Regla de oro: verbaliza trade-offs. Un diseño «perfecto» sin números ni fallos no convence.

13.2 Caso A · Acortador de URLs

Req: crear enlace corto, redirigir, 10 k QPS lectura, 500 QPS escritura, alta disponibilidad.

13.3 Caso B · Timeline / feed sencillo

Req: publicar post, ver feed de seguidos, eventual OK, p95 < 300 ms en lectura.

13.4 Caso C · Servicio de pagos / checkout

Req: crear cobro, webhook del PSP, exactly-once de negocio, auditoría.

14 · La prueba en casa (take-home)

Qué suele puntuar

  • README que arranca en < 5 minutos (Docker Compose).
  • Tests que demuestran el requisito difícil.
  • API clara, errores consistentes, validación.
  • Commits legibles; sin secretos; .gitignore correcto.
  • Nota de «qué haría con más tiempo».

Errores fatales

  • No arranca; README vacío; «works on my machine».
  • Overengineering (Kafka+K8s) para un CRUD.
  • Cero tests o solo tests triviales.
  • Copiar un tutorial sin adaptarlo al enunciado.
  • Entregar tarde sin avisar.
Estrategia de tiempo (4–8 h típicas): 20 % entender y acotar alcance por escrito, 50 % happy path + test del núcleo, 20 % DX (Docker, README, ejemplos), 10 % pulido y nota de limitaciones. Si te atascan, reduce alcance y documenta el corte.

15 · Cierre, oferta y negociación

15.1 Preguntas que debes hacer tú

15.2 Negociar sin improvisar

Investiga bandas (niveles, ciudad/remoto, tipo de empresa). Da un rango basado en datos, no un número suelto por miedo. Negocia el paquete: fijo, variable, remoto, equipo, vacaciones, formación. Pide la oferta por escrito. Nunca mientas sobre otra oferta; sí puedes decir que estás en otros procesos avanzados.

Señal roja: presión para firmar en 24 h sin detalles, opacidad total de banda, o «el salario no se habla».

16 · Errores comunes que hunden candidaturas

  • Recitar definiciones sin ejemplo ni límite.
  • Inventar experiencia con una tecnología.
  • Codificar sin aclarar el enunciado.
  • Culpar a compañeros o empresas anteriores.
  • No tener preguntas al final.
  • CV con buzzwords indefendibles.
  • Silencio largo sin narrar el pensamiento.
  • Ignorar requisitos no funcionales en diseño.
  • Take-home que no arranca.
  • Llegar sin haber mirado el producto.
  • Discutir con el entrevistador en lugar de explorar.
  • Aceptar la primera oferta por pánico.

17 · Autoevaluación y agenda días 26–27

17.1 Rúbrica rápida (1–5)

17.2 Agenda sugerida (≈ 6 h)

BloqueTiempoActividad
Día 26 · mañana90 minBancos Java + concurrencia en voz alta (solo falladas)
Día 26 · tarde90 minSpring + JPA/SQL + 2 katas cronometradas
Día 27 · mañana90 minArquitectura + DevOps + 1 system design
Día 27 · tarde90 minSTAR + simulacro 45 min + repaso errores
Cierre30 minActualizar CV con logros medibles y lista de dudas

18 · Resumen

Ideas que debes llevarte

  1. La entrevista mide cómo piensas y cómo lo explicas, no solo qué has memorizado.
  2. Estructura cada respuesta técnica: definición → por qué → ejemplo → límite.
  3. CV de una página con números; GitHub con un proyecto que arranque.
  4. Dominar N+1, transacciones, idempotencia y trade-offs de microservicios separa mid de senior.
  5. Live coding: aclarar → ejemplo → plan → código → complejidad.
  6. System design: números, fallos y evolución; no solo cajas bonitas.
  7. STAR con evidencia; negociación con rango escrito; take-home que arranca.
  8. Practica en voz alta. Leer no es lo mismo que rendir.
Siguiente paso: el módulo 13 concentra recursos, cheatsheets y el proyecto integrador para seguir practicando con material concreto cuando ya estés en procesos reales.