Plan de estudio Java 2026
Módulo 13 proyecto Día 28 ≈ 2 h + proyecto continuo

Recursos y proyecto final integrador

Los doce módulos anteriores te han dado piezas: el lenguaje, la concurrencia, Spring, la persistencia, el SQL, los tests, el reparto en servicios, los contenedores y la seguridad. Este módulo las junta en una sola cosa que se puede arrancar con un comando y defender delante de un desconocido. No es un listado de enlaces con un proyecto de adorno al final: es el enunciado completo de un sistema real, su arquitectura razonada, su modelo de datos, su contrato de API, seis fases de construcción con código de arranque, la definición de «hecho» que separa un ejercicio de un producto, y el plan de lo que viene después del día 28. Los recursos externos están al final, y a propósito: se consultan cuando el proyecto te obliga a consultarlos, no antes.

Progreso de este módulo0 / 0
Cómo usar este módulo: no se lee de un tirón, se habita durante varias semanas. Las secciones 1 a 4 se leen enteras antes de escribir la primera línea de código: son el enunciado, la arquitectura y los contratos, y cambiarlos a mitad de camino cuesta diez veces más que pensarlos ahora. La sección 5 es la que tendrás abierta a diario: seis fases con tareas marcables, criterio de «hecho» y el módulo del plan que hay que releer en cada una. Las secciones 6 a 8 se aplican cuando el proyecto ya respira: calidad, documentación y presencia profesional. Las secciones 9 a 12 son de consulta y planificación. Y la 13 es el examen final del plan completo: si puedes marcar todas sus casillas con honestidad, has terminado.
Regla de oro antes de empezar: el proyecto de esta guía es una propuesta, no un dogma. Si tienes un dominio propio que te interesa de verdad (una liga de pádel, una biblioteca personal, un gestor de gastos compartidos, la gestión de una asociación), cámbialo y quédate con el esqueleto: los actores, los requisitos no funcionales, las fases, los criterios de «hecho» y la definición de calidad son idénticos. El dominio importa mucho menos que el rigor con el que lo trates, y trabajarás mejor sobre algo que te importa.

1 · Por qué un proyecto integrador

Es la pregunta legítima antes de invertir treinta o cuarenta horas: ¿por qué construir esto, si ya he hecho los ejercicios de cada módulo? La respuesta corta es que los ejercicios demuestran que sabes usar una herramienta y el proyecto demuestra que sabes tomar decisiones. La respuesta larga ocupa esta sección, porque entender qué se evalúa cambia radicalmente cómo lo construyes.

1.1 Qué demuestra un proyecto que no demuestra un certificado

Un certificado, un curso terminado o una lista de tecnologías en el currículum certifican exposición: has estado delante del material. Un proyecto certifica criterio: has tenido que elegir entre opciones incompatibles, con información incompleta, y vivir con las consecuencias. Esa es exactamente la habilidad por la que se paga un salario de desarrollador, y la única que no se puede fingir en una conversación de cuarenta minutos.

SeñalLo que aporta un certificado o un cursoLo que aporta un proyecto propio
Conocimiento declarativo Alto: sabes qué es una transacción, un índice o un circuit breaker. Alto también, pero anclado a un caso: sabes por qué tu confirmación de pedido es transaccional.
Capacidad de decidir Ninguna: el curso ya decidió por ti qué tecnología usar y cómo configurarla. Es el núcleo: elegiste base de datos, estilo de arquitectura, mensajería y estrategia de tests, y puedes justificarlo.
Tolerancia al problema mal definido Nula: los enunciados de curso están limpios y tienen solución conocida. Alta: has tenido que convertir «los pedidos se pagan» en estados, errores, reintentos y compensaciones.
Depuración real Baja: si algo falla, hay un vídeo con la solución. Muy alta: te has peleado con un LazyInitializationException, un puerto ocupado y un test intermitente.
Comunicación técnica Nula. Es tangible: README, ADR, diagramas y mensajes de commit son artefactos que alguien puede leer y juzgar.
Verificabilidad Alta pero superficial: el certificado dice que aprobaste un examen tipo test. Total: el código está ahí, se puede clonar, ejecutar y criticar. No hay dónde esconderse.
Coste de falsificación Bajo: memorizar un temario o usar un volcado de preguntas. Muy alto: para tener un repositorio coherente hay que haberlo escrito, o entenderlo tan bien como si lo hubieras escrito.

Esto no significa que los certificados no sirvan para nada; la sección 10 los trata con detalle y hay casos concretos en los que compensan. Significa que ocupan un lugar distinto: el certificado puede ayudarte a pasar un filtro automático o el requisito formal de un contrato público; el proyecto es lo que hace que la conversación técnica vaya bien. Si tienes cuarenta horas y tienes que elegir, el proyecto gana casi siempre.

El argumento que más pesa, y casi nadie usa: un proyecto propio te permite responder a la pregunta «cuéntame un problema técnico difícil que hayas resuelto» con algo tuyo, reciente y verificable. Sin experiencia laboral previa esa pregunta es demoledora; con un proyecto exigente, se convierte en tu mejor momento de la entrevista. Prepárala explícitamente: elige de antemano los dos o tres problemas del proyecto que más te costaron (la reserva de stock bajo concurrencia, el consumidor duplicado, el test intermitente) y ensaya cómo los contarías. La sección 7 incluye un guion de tres minutos para eso.

1.2 Los seis primeros minutos: cómo mira tu repositorio un entrevistador

Quien revisa tu repositorio no lo lee: lo escanea. Tiene diez candidaturas esa tarde y va a dedicar entre cinco y diez minutos a la tuya antes de decidir si merece una conversación. Conocer el orden exacto en el que mira las cosas te dice dónde invertir el esfuerzo. Este es el recorrido típico, minuto a minuto, con lo que concluye en cada paso.

MinutoQué miraQué concluye si está bienQué concluye si está mal
0:00 – 0:45 El README.md, sin hacer scroll: primer párrafo, un diagrama, un bloque de arranque. «Esta persona sabe explicar qué ha hecho y para quién.» Sigue leyendo. «Otro Spring Boot Demo.» Cierra la pestaña. Es el filtro que más candidaturas elimina.
0:45 – 1:30 El árbol de directorios de primer nivel y el nombre de los paquetes. «Hay una intención de diseño: se ve el dominio separado de la infraestructura.» «controller, service, repository y una clase Util con 900 líneas.»
1:30 – 2:30 El historial de commits: cantidad, mensajes, distribución en el tiempo. «Trabajo incremental, mensajes que explican el porqué. Esto lo ha hecho de verdad.» Un único commit «initial commit» con 12.000 líneas: parece copiado o generado sin revisar.
2:30 – 3:30 La carpeta de tests y el badge de CI. Cuenta cuántos tests hay y de qué tipo. «Tests de dominio rápidos y tests de integración con Testcontainers. Sabe lo que hace.» Cero tests, o un único contextLoads() generado por el arquetipo. Descartado para puestos con responsabilidad.
3:30 – 4:30 Intenta arrancarlo: git clone y docker compose up. Levanta a la primera: «puedo evaluar de verdad lo que ha construido». Falla por una variable de entorno sin documentar. No lo va a depurar por ti; puntúa lo que ha visto.
4:30 – 5:30 Un endpoint con curl o la interfaz de OpenAPI. Prueba un caso normal y uno inválido. «Valida la entrada, devuelve un error estructurado y códigos correctos. Ha pensado en el consumidor.» Un 500 con la traza de excepción en el cuerpo de la respuesta.
5:30 – 6:00 Abre una clase de negocio al azar y lee treinta líneas. «Nombres del dominio, métodos cortos, sin magia. Podría revisarle un pull request Un método de 200 líneas con siete niveles de if y variables llamadas aux2.

Fíjate en la asimetría: el 60% del tiempo se va en cosas que no son código —README, estructura, commits, arranque—, y sin embargo es donde casi nadie invierte. Un proyecto mediocre con un README excelente y un arranque de un comando obtiene mejor evaluación que un proyecto brillante que no arranca. No es injusto: es que la segunda situación es indistinguible, desde fuera, de un proyecto que no funciona.

El fallo más caro y más fácil de evitar: que el proyecto no arranque en una máquina limpia. Pruébalo de verdad: clona tu propio repositorio en un directorio temporal, sin tu ~/.m2 caliente si puedes, y sigue tu propio README al pie de la letra sin usar nada que solo esté en tu portátil. Ese ejercicio de veinte minutos, hecho una vez al mes, es la mejor inversión de todo el proyecto. Un truco: pídele a alguien que lo intente y no le ayudes; apunta cada vez que le veas dudar, porque cada duda es una línea que falta en el README.
# Prueba del "clon en frío". Hazla antes de enseñar el repositorio a nadie.
# Simula exactamente lo que hará quien te evalúe.

TMP=$(mktemp -d) && cd "$TMP"
git clone https://github.com/tu-usuario/cafeteria-tech.git
cd cafeteria-tech

# 1. ¿Arranca con un solo comando, como promete el README?
time docker compose up -d --wait          # el --wait falla si algún healthcheck no pasa

# 2. ¿Responde el servicio y dice que está sano?
curl -fsS localhost:8080/actuator/health | jq .

# 3. ¿Se puede hacer algo útil sin leer el código?
curl -fsS -X POST localhost:8080/api/v1/pedidos \
     -H 'Content-Type: application/json' \
     -H 'Idempotency-Key: prueba-en-frio-1' \
     -d @ejemplos/crear-pedido.json | jq .

# 4. ¿Pasa la suite completa en limpio?
./mvnw -q verify

# 5. Limpieza
docker compose down -v && cd - && rm -rf "$TMP"

# Criterio: si cualquiera de los cinco pasos requiere que tú expliques algo
# que no está en el README, el README está incompleto. No el usuario.

1.3 Los ocho errores típicos del proyecto de portfolio

Estos errores se repiten con una regularidad asombrosa. Todos tienen el mismo origen: confundir «demostrar que sé usar una tecnología» con «demostrar que sé construir un sistema». Cada uno viene con su antídoto.

1 · CRUD sin criterio

Cuatro entidades, cuatro controladores idénticos generados casi por copia, ninguna regla de negocio. Técnicamente correcto y absolutamente indistinguible de los otros doscientos repositorios iguales. No hay nada que preguntar en una entrevista, y por tanto no genera conversación.

Antídoto: exige que el sistema tenga al menos tres reglas de negocio que puedan fallar (no hay stock, el pago se rechaza, el pedido ya está cancelado) y una situación de concurrencia real (dos clientes compran la última unidad a la vez). Ahí empieza la ingeniería.

2 · Sobreingeniería

Siete microservicios, Kafka, CQRS con event sourcing, service mesh y Kubernetes… para gestionar una lista de tareas de un usuario. Señala exactamente lo contrario de lo que pretende: que no se sabe calibrar el coste de una decisión. Un entrevistador con experiencia lo lee como riesgo.

Antídoto: cada pieza de infraestructura debe tener un requisito escrito que la justifique. Si no puedes escribir el ADR, la pieza sobra. Este proyecto usa Kafka por un motivo concreto y documentado, no porque quede bien.

3 · Sin tests (o con tests de adorno)

La ausencia de tests se interpreta, con razón, como «esta persona no ha trabajado en un equipo donde el código lo mantiene otro». Peor aún: tests que solo verifican mocks (comprobar que se llamó al repositorio) y no comportamiento. Dan cobertura y cero confianza.

Antídoto: al menos un test por cada regla de negocio, un test de integración con base de datos real vía Testcontainers y un test que demuestre la concurrencia. Ese último es el que te van a comentar en la entrevista.

4 · Sin despliegue ni contenedores

«Funciona en mi máquina» significa, en la práctica, que nadie más lo va a ver funcionando. Y un proyecto que no se ve funcionando se evalúa solo por el código, que es la parte más lenta de revisar y la que menos se revisa.

Antídoto: Dockerfile multietapa, compose.yaml con todas las dependencias y healthchecks, y si es posible una demo desplegada. Aunque sea en una máquina pequeña que apagas cuando no la usas.

5 · Sin README o con el README del arquetipo

El clásico «This is a Spring Boot project. Run mvn spring-boot:run». Desperdicia el único artefacto que se lee con seguridad. Todo el trabajo que hay debajo queda invisible.

Antídoto: la plantilla de la sección 7, con las cinco cosas que nadie pone: qué problema resuelve, arranque en un comando, decisiones con alternativas, números medidos y qué harías con más tiempo.

6 · El proyecto eterno e inacabado

Seis meses de refactors, tres reescrituras del framework de configuración y ninguna funcionalidad completa de punta a punta. Es la trampa favorita de quien disfruta programando: siempre hay algo más elegante que hacer antes de terminar.

Antídoto: fases con criterio de «hecho» explícito (sección 5) y una regla dura: nada de fase N+1 hasta que la fase N cumpla su criterio. Terminar es una habilidad y se entrena.

7 · Copiar un tutorial y cambiarle los nombres

Se detecta en noventa segundos: estructura idéntica a un vídeo conocido, comentarios en otro idioma, dependencias que no se usan, y una respuesta vacilante a «¿por qué aquí usaste esto?». La consecuencia no es que te descarten por el proyecto: es que se pone en duda todo lo demás.

Antídoto: partir de un tutorial está bien; lo que no vale es no digerirlo. Si copias un fragmento, escribe en un comentario o en el ADR de dónde viene y por qué encaja, y asegúrate de poder explicar cada línea.

8 · Datos de mentira y demo imposible

Base de datos vacía, sin usuarios de prueba, sin datos de ejemplo. Quien lo arranca ve una lista vacía y no puede probar nada sin leerse el modelo entero para inventarse un JSON válido.

Antídoto: seed de datos realistas al arrancar (perfil demo), una colección de peticiones de ejemplo en ejemplos/ y un script demo.sh que ejecute el recorrido completo del caso de uso principal.

El error número cero, del que derivan casi todos: empezar a programar antes de saber qué se está construyendo. Media hora escribiendo las historias de usuario y los requisitos no funcionales (sección 2) te ahorra semanas de código que luego hay que tirar. Y no es solo eficiencia: esa media hora es también lo que te permite defender el proyecto, porque la pregunta de entrevista no es «¿cómo lo hiciste?» sino «¿por qué así?».

1.4 Criterios de un proyecto que sí destaca

Frente a la lista de errores, la lista de lo que sí funciona. La he ordenado por relación entre el esfuerzo que cuesta y la diferencia que marca, que no es el orden en el que la gente suele trabajarlas.

#CriterioPor qué importaCosteImpacto
1 Arranca con un comando Es la diferencia entre que evalúen tu sistema o solo tu código estático. 2–3 h Altísimo
2 README que responde qué, cómo y por qué Es el único documento que se lee con certeza. Marca el tono de toda la revisión. 2–4 h Altísimo
3 Decisiones documentadas (ADR) Demuestra que consideraste alternativas. Convierte «usé Kafka» en «elegí Kafka frente a X por Y». 2 h Muy alto
4 Un problema difícil resuelto y explicado Concurrencia sobre stock, idempotencia, consistencia entre servicios. Es tu historia de entrevista. 4–8 h Muy alto
5 Tests con intención Distingue a quien ha trabajado en equipo. Además te permite refactorizar sin miedo. 8–12 h Muy alto
6 Historial de commits limpio Prueba de trabajo incremental y de disciplina. Gratis si lo haces desde el principio; imposible después. 0 h Alto
7 Errores tratados como parte del contrato Códigos HTTP correctos, ProblemDetail, validación. Es lo primero que prueba quien evalúa. 3 h Alto
8 Números medidos «p95 de 480 ms a 120 ms tras eliminar un N+1» es la frase que nadie más tiene. 3–4 h Alto
9 Observabilidad Métricas de negocio y trazas indican mentalidad de producción, no de ejercicio. 4–6 h Medio-alto
10 CI en verde y visible Un badge verde vale más que un párrafo prometiendo calidad. 1–2 h Medio-alto
11 Alcance cerrado y declarado Decir «esto no lo hago y por qué» demuestra madurez; parecer incompleto sin decirlo, lo contrario. 15 min Medio
12 Despliegue accesible Una URL que funciona permite que te evalúen sin instalar nada. 3–6 h Medio

Suma los costes de los cuatro primeros: unas doce horas que casi nadie invierte y que cambian por completo la percepción del trabajo. Compáralo con las ochenta horas que puedes gastar añadiendo la sexta funcionalidad que nadie va a mirar. Ese es todo el argumento de este módulo.

Cuántos proyectos hacen falta: uno bueno y terminado, más uno o dos pequeños con un propósito claro. Tres repositorios a medias puntúan peor que un repositorio completo, porque quien evalúa asume que el más flojo es representativo. La sección 8 desarrolla cómo organizar el perfil para que el proyecto bueno sea lo primero que se ve.

1.5 La rúbrica: puntúate antes de que te puntúen

Muchas empresas usan una rúbrica para evaluar la prueba técnica o el proyecto. Esta es una versión típica, equivalente a las que se usan para valorar un take-home de perfil junior o mid. Úsala dos veces: una al terminar la fase 3, para corregir el rumbo, y otra al final. Puntúa de 0 a 3 con criterio duro; puntuarte generosamente no engaña a nadie más que a ti.

Dimensión0 · Ausente1 · Básico2 · Sólido3 · Destacado
Funcionalidad No arranca o los casos principales fallan. El camino feliz funciona. Casos límite y errores tratados. Concurrencia, idempotencia y recuperación ante fallos incluidas.
Diseño Todo en el controlador. Capas clásicas, dominio anémico. Dominio con comportamiento, límites claros. Hexagonal verificada con ArchUnit y módulos con contratos explícitos.
Datos ddl-auto=update y sin índices. Esquema razonable, migraciones manuales. Flyway, validate, índices pensados. Restricciones de integridad, concurrencia controlada y plan de consulta revisado.
Tests Ninguno. Algunos unitarios. Pirámide razonable con Testcontainers. Cobertura con criterio, mutación, tests de arquitectura y de concurrencia.
API Sin validación, errores en HTML o 500. Códigos correctos en el camino feliz. Validación, ProblemDetail, paginación, OpenAPI. Versionado, idempotencia, contrato estable y documentado con ejemplos.
Seguridad Abierta o con permitAll() global. Autenticación básica. JWT con roles y comprobación de propiedad. Autorización por recurso probada con tests de acceso cruzado, secretos fuera del repositorio.
Operación Solo main() en el IDE. Dockerfile. Compose completo, healthchecks, CI. Kubernetes, métricas, trazas, panel y prueba de carga.
Comunicación Sin README. README de instalación. README completo con diagramas. ADR, guion de presentación y decisiones defendibles.

Interpretación honesta de la suma sobre 24 puntos: por debajo de 8, el proyecto todavía no cuenta como portfolio; entre 8 y 14, es un proyecto de aprendizaje correcto que conviene terminar antes de enseñarlo; entre 15 y 19, ya es un buen argumento en una entrevista; por encima de 20, es un proyecto que genera la conversación en lugar de sufrirla y que está por encima de lo que se ve habitualmente en perfiles de dos o tres años de experiencia.

Compromisos que asumes al empezar (márcalos cuando los aceptes de verdad)

2 · El proyecto: «Cafetería Tech»

A partir de aquí el módulo deja de hablar en general y se convierte en un enunciado. Todo lo que sigue está escrito como te lo daría un cliente razonable: contexto, actores, vocabulario, historias con criterios de aceptación, requisitos no funcionales medibles y, muy importante, lo que no hay que hacer. Léelo entero antes de programar. Si algo no se entiende, la sección 3 lo traduce a arquitectura y la sección 4 a tablas y endpoints.

2.1 Contexto de negocio

Cafetería Tech es una cadena pequeña de cafeterías de especialidad con cuatro locales en una misma ciudad y una tienda en línea que vende café en grano, cápsulas compostables y accesorios. Hoy funciona con una hoja de cálculo compartida para el inventario, un cuaderno para los pedidos de recogida en tienda y una tienda alojada en una plataforma genérica que no se comunica con el inventario. El resultado diario: se venden productos que no hay, se pierden pedidos de recogida, y nadie sabe qué se vende de verdad hasta que alguien cuadra la hoja el domingo.

El encargo es sustituir todo eso por una plataforma propia de catálogo y pedidos con dos canales —recogida en tienda y envío a domicilio—, inventario por local, cobro simulado y un panel mínimo para el personal. El objetivo de negocio es concreto y medible: cero ventas de producto sin existencias y visibilidad del estado de cada pedido en tiempo real.

Es un dominio pequeño en superficie pero con toda la sustancia técnica que importa: dinero (hay que sumar bien y no perder importes), concurrencia real (dos clientes compran la última bolsa a la vez), procesos asíncronos (confirmar un pedido dispara trabajo que no puede bloquear la respuesta), consistencia entre partes (si el pago falla hay que liberar la reserva) y autorización por recurso (cada cliente ve sus pedidos y solo los suyos). Nada de esto es decorativo: cada uno corresponde a un módulo del plan y a una pregunta de entrevista.

Por qué este dominio y no una API de películas o un blog: porque necesitas un sistema donde las cosas puedan salir mal de formas interesantes. Un catálogo de películas se lee y ya está; no hay invariantes que proteger, ni carreras, ni compensaciones. En cambio, «no vendas lo que no tienes» obliga a hablar de transacciones, bloqueo optimista o pesimista, reservas con caducidad y consistencia eventual. Todo lo que un entrevistador quiere preguntarte cabe en esas cinco palabras.

2.2 Actores y objetivos

ActorQuién esQué quiere conseguirQué le frustra hoy
Cliente (rol ROLE_CLIENTE) Persona registrada que compra café. Ver el catálogo con disponibilidad real, pedir para recoger o para casa, y saber en qué estado va su pedido. Comprar algo y recibir una llamada media hora después diciendo que no queda.
Visitante (sin autenticar) Cualquiera que entra a mirar. Consultar el catálogo y los precios sin registrarse. Que le obliguen a crear una cuenta para ver un precio.
Barista (rol ROLE_STAFF) Personal de un local concreto. Ver la cola de pedidos de su local, marcarlos como preparados y entregados. Un cuaderno con pedidos apuntados a mano y llamadas para confirmar.
Encargado de tienda (rol ROLE_ADMIN) Responsable de catálogo e inventario. Dar de alta productos, ajustar precios, corregir existencias tras un recuento y ver qué se vende. Una hoja de cálculo que tres personas editan a la vez.
Pasarela de pago (sistema externo) Servicio de cobro simulado dentro del proyecto. Autorizar o rechazar un cargo y notificar el resultado.
Sistema de facturación (sistema externo) Consumidor de eventos, fuera del alcance de la implementación. Enterarse de cada pedido confirmado para emitir su factura.

Cuatro actores humanos y dos sistemas: suficiente para que la autorización sea interesante (tres roles con permisos distintos y una comprobación de propiedad) sin que el proyecto se convierta en un ERP. Fíjate en que el barista está limitado a su local: eso obliga a autorizar por atributo del recurso y no solo por rol, que es justo la diferencia entre un ejercicio y algo realista.

2.3 Glosario del dominio (lenguaje ubicuo)

Este glosario no es un adorno: es un contrato de nombres. Los términos que aparecen aquí son los que se usan en las clases, en las tablas, en los endpoints y en los mensajes de error. Si el negocio dice «línea de pedido», el código no dice OrderItemDTO: dice LineaPedido. Mantener un único vocabulario es lo que evita las traducciones mentales que producen errores.

TérminoDefinición precisaQué NO es
Producto Artículo vendible identificado por un SKU único, con nombre, descripción, precio vigente y estado (activo o retirado). No es la existencia física: un producto existe aunque no haya unidades.
SKU Código estable e inmutable del producto (CAF-ETIOPIA-250). Es la clave de negocio. No es la clave primaria técnica de la tabla, aunque sea única.
Local Cada una de las cuatro cafeterías. Tiene su propio inventario y su propia cola de pedidos. No es un almacén central: no existe un inventario global.
Existencias (stock) Unidades físicas de un producto en un local. Un número que solo cambia por venta, recepción o recuento. No es lo mismo que «disponible»: hay unidades comprometidas por reservas.
Reserva Compromiso temporal de N unidades de un producto en un local para un pedido concreto, con caducidad de 15 minutos. No es una venta: si caduca, las unidades vuelven a estar disponibles.
Disponible Existencias menos reservas vivas. Es el número que ve el cliente en el catálogo. Nunca se almacena como columna independiente sin un mecanismo que garantice su coherencia.
Carrito Conjunto de líneas en preparación de un cliente. Vive en el cliente o en caché; no reserva nada. No es un pedido, y no garantiza precio ni disponibilidad.
Pedido Intención de compra formalizada: cliente, canal, local, líneas con precio congelado, estado y total. No es un cobro; el pedido puede existir sin haber pagado.
Línea de pedido SKU, descripción y precio unitario en el momento de la compra, cantidad e importe. No es una referencia viva al producto: el precio es una instantánea inmutable.
Canal RECOGIDA (se retira en un local, con hora estimada) o ENVIO (dirección de entrega). No es el método de pago.
Pago Intento de cobro asociado a un pedido, con importe, estado y referencia de la pasarela. No es el pedido: un pedido puede tener varios intentos de pago fallidos.
Confirmación Acto por el que un pedido pasa de borrador a comprometido: se reserva stock y se cobra. No es la entrega ni la preparación.
Evento de dominio Hecho ocurrido y ya inmutable: PedidoConfirmado, PagoRechazado. Se nombra en pasado. No es una orden ni una petición a otro componente.
Clave de idempotencia Identificador que envía el cliente para que reintentar una operación no la ejecute dos veces. No es el identificador del pedido ni un token de sesión.
Por qué escribir el glosario antes que el código: porque cada ambigüedad que dejes aquí se convierte en un bug más adelante. El caso clásico es «stock»: si no distingues existencias de disponible, acabarás restando reservas dos veces o vendiendo unidades comprometidas. Diez minutos de glosario ahorran una tarde de depuración y, de propina, te dan las palabras exactas para explicar el sistema en una entrevista.

2.4 Las quince historias de usuario

Cada historia sigue el formato como … quiero … para … y lleva criterios de aceptación en formato dado / cuando / entonces, que es el que se traduce directamente a un test. La columna de la izquierda indica en qué fase de la sección 5 se implementa. Las historias marcadas con núcleo son obligatorias; el resto se pueden posponer sin que el sistema deje de tener sentido.

HU-01 · Consultar el catálogo núcleo — Fase 3

Como visitante o cliente, quiero ver la lista de productos activos con su precio y su disponibilidad en el local que elija, para decidir qué comprar sin llevarme una sorpresa al confirmar.

  • Dado que hay 30 productos activos y 5 retirados, cuando pido GET /api/v1/productos?page=0&size=20, entonces recibo 20 productos activos, el total 30, y ningún producto retirado.
  • Dado un local con 3 unidades del SKU CAF-ETIOPIA-250 y 1 reservada, cuando consulto el catálogo con ?localId=…, entonces el campo disponible vale 2.
  • Dado que no envío localId, cuando consulto el catálogo, entonces recibo los productos sin información de disponibilidad y un aviso en la respuesta, nunca un error.
  • Dado que pido size=500, cuando el máximo es 100, entonces recibo 400 con un ProblemDetail que indica el límite, y no un volcado de la base de datos.
HU-02 · Buscar y filtrar productos — Fase 3

Como cliente, quiero filtrar por categoría, buscar por texto y ordenar por precio, para encontrar rápido lo que busco en un catálogo que crecerá.

  • Dado el filtro ?categoria=CAFE&q=etiopia&sort=precio,asc, cuando consulto, entonces recibo solo productos de esa categoría cuyo nombre o descripción contengan «etiopia», sin distinguir mayúsculas ni acentos, ordenados por precio ascendente.
  • Dado un campo de ordenación no permitido (sort=coste_interno), cuando consulto, entonces recibo 400: la lista de campos ordenables está en una lista blanca, nunca se pasa directamente a la consulta.
  • Dado que la búsqueda no encuentra nada, cuando consulto, entonces recibo 200 con una lista vacía y totalElements: 0, no un 404.
HU-03 · Registrarse y autenticarse núcleo — Fase 4

Como visitante, quiero crear una cuenta con correo y contraseña y obtener un token, para poder hacer pedidos y consultar los míos.

  • Dado un correo no registrado y una contraseña de al menos 12 caracteres, cuando hago POST /api/v1/auth/registro, entonces recibo 201, se crea el cliente con rol ROLE_CLIENTE y la contraseña se guarda con BCrypt (jamás en claro ni con SHA-256 a secas).
  • Dado un correo ya registrado, cuando me registro, entonces recibo 409 con un mensaje genérico que no permita enumerar cuentas existentes.
  • Dado credenciales correctas, cuando hago POST /api/v1/auth/login, entonces recibo un JWT con sub, roles y exp a 15 minutos, más un refresh token opaco.
  • Dado credenciales incorrectas, cuando hago login, entonces recibo 401 con el mismo mensaje tanto si el usuario no existe como si la contraseña falla, y el tiempo de respuesta es similar en ambos casos.
HU-04 · Crear un pedido en borrador núcleo — Fase 2 y 3

Como cliente autenticado, quiero crear un pedido con varias líneas indicando canal y local, para revisarlo antes de pagar.

  • Dado un cuerpo válido con 2 líneas, cuando hago POST /api/v1/pedidos, entonces recibo 201, la cabecera Location con la URL del pedido, estado BORRADOR y el total calculado en el servidor a partir del precio vigente de cada producto.
  • Dado que el cuerpo incluye un campo total, cuando creo el pedido, entonces ese campo se ignora por completo: el importe nunca lo decide el cliente.
  • Dado un SKU inexistente o retirado, cuando creo el pedido, entonces recibo 422 indicando qué línea es inválida y por qué.
  • Dado una cantidad de 0 o negativa, o más de 50 líneas, cuando creo el pedido, entonces recibo 400 con los errores de validación por campo.
  • Dado canal ENVIO sin dirección, cuando creo el pedido, entonces recibo 400: la validación es condicional según el canal.
HU-05 · Confirmar un pedido núcleo — Fase 2, 4 y 5

Como cliente, quiero confirmar mi pedido y pagarlo, para que la cafetería lo prepare.

  • Dado un pedido en BORRADOR con existencias suficientes, cuando hago POST /api/v1/pedidos/{id}/confirmacion, entonces se reservan las unidades, se solicita el cobro, el pedido pasa a CONFIRMADO y se publica el evento PedidoConfirmado.
  • Dado que falta stock de una línea, cuando confirmo, entonces recibo 409 indicando qué SKU y cuántas unidades hay, no se reserva nada (todo o nada) y el pedido sigue en BORRADOR.
  • Dado que la pasarela rechaza el cobro, cuando confirmo, entonces se liberan las reservas, el pedido queda en PAGO_RECHAZADO y puedo reintentar.
  • Dado un pedido que ya está confirmado, cuando vuelvo a confirmar con la misma Idempotency-Key, entonces recibo la misma respuesta que la primera vez y no se cobra dos veces.
  • Dado un pedido de otro cliente, cuando intento confirmarlo, entonces recibo 404 (no 403: no se revela que existe).
HU-06 · Consultar mis pedidos núcleo — Fase 3 y 4

Como cliente, quiero ver la lista de mis pedidos y el detalle de cada uno, para saber en qué estado están.

  • Dado que tengo 12 pedidos, cuando hago GET /api/v1/pedidos?estado=CONFIRMADO, entonces recibo solo los míos con ese estado, ordenados por fecha descendente y paginados.
  • Dado el identificador de un pedido ajeno, cuando pido el detalle, entonces recibo 404, y existe un test automático que lo demuestra.
  • Dado el rol ROLE_ADMIN, cuando consulto con ?clienteId=…, entonces puedo ver los pedidos de cualquier cliente.
HU-07 · Cancelar un pedido — Fase 2 y 3

Como cliente, quiero cancelar un pedido mientras se pueda, para no pagar algo que ya no quiero.

  • Dado un pedido en BORRADOR o CONFIRMADO y no preparado, cuando hago POST /api/v1/pedidos/{id}/cancelacion, entonces pasa a CANCELADO, se liberan las reservas y, si había cobro, se registra el reembolso.
  • Dado un pedido ya ENTREGADO, cuando intento cancelarlo, entonces recibo 409 con un mensaje que explica la transición no permitida.
  • Dado un pedido ya cancelado, cuando lo cancelo otra vez, entonces recibo 200 (operación idempotente), no un error.
HU-08 · Cola de pedidos del local — Fase 3 y 4

Como barista, quiero ver los pedidos pendientes de mi local ordenados por antigüedad, para prepararlos en orden.

  • Dado el rol ROLE_STAFF asignado al local A, cuando hago GET /api/v1/locales/{idA}/cola, entonces recibo los pedidos CONFIRMADO y EN_PREPARACION de ese local.
  • Dado ese mismo rol, cuando consulto la cola del local B, entonces recibo 403: la autorización comprueba el atributo del recurso, no solo el rol.
  • Dado el rol ROLE_ADMIN, cuando consulto cualquier cola, entonces tengo acceso.
HU-09 · Avanzar el estado de un pedido — Fase 3

Como barista, quiero marcar un pedido como en preparación, listo y entregado, para que el cliente sepa cuándo recogerlo.

  • Dado un pedido CONFIRMADO, cuando hago PATCH /api/v1/pedidos/{id}/estado con EN_PREPARACION, entonces la transición se acepta y se registra quién y cuándo.
  • Dado un pedido BORRADOR, cuando intento pasarlo a LISTO, entonces recibo 409: las transiciones válidas están en la máquina de estados del dominio, no en el controlador.
  • Dado el paso a ENTREGADO, cuando se confirma, entonces las reservas se consumen definitivamente y las existencias se decrementan de forma permanente.
HU-10 · Gestionar el catálogo — Fase 3 y 4

Como encargado, quiero crear productos, editar precios y retirar artículos, para mantener el catálogo al día.

  • Dado el rol ROLE_ADMIN, cuando hago POST /api/v1/productos con un SKU nuevo, entonces recibo 201.
  • Dado un SKU repetido, cuando creo el producto, entonces recibo 409, y la restricción está también en la base de datos, no solo en el código.
  • Dado un cambio de precio, cuando se aplica, entonces los pedidos ya creados no cambian de importe: la línea guarda el precio de su momento.
  • Dado el rol ROLE_CLIENTE, cuando intento crear un producto, entonces recibo 403.
HU-11 · Ajustar existencias tras un recuento — Fase 2 y 3

Como encargado, quiero corregir las existencias de un producto en un local, para cuadrar el sistema con la realidad del almacén.

  • Dado un ajuste de +10 con motivo RECEPCION, cuando hago POST /api/v1/inventario/ajustes, entonces las existencias suben y queda un registro con usuario, motivo, cantidad y fecha.
  • Dado un ajuste que dejaría las existencias por debajo de las reservas vivas, cuando se aplica, entonces recibo 409: no se puede invalidar un compromiso ya adquirido con un cliente.
  • Dado cualquier ajuste, cuando se guarda, entonces es auditable: el histórico de movimientos permite reconstruir el saldo actual sumando desde cero.
HU-12 · Liberar reservas caducadas núcleo — Fase 5

Como negocio, quiero que las reservas de pedidos que nunca se pagaron se liberen solas, para no bloquear producto vendible.

  • Dado una reserva creada hace más de 15 minutos cuyo pedido sigue en BORRADOR, cuando se ejecuta la tarea programada, entonces la reserva se libera y el disponible aumenta.
  • Dado varias instancias del servicio, cuando la tarea se ejecuta a la vez en todas, entonces cada reserva se libera exactamente una vez (bloqueo o SKIP LOCKED).
  • Dado que se liberan reservas, cuando termina la tarea, entonces queda una métrica reservas_liberadas_total y una línea de log con el recuento.
HU-13 · Notificar al cliente el cambio de estado — Fase 5

Como cliente, quiero recibir un aviso cuando mi pedido esté listo, para ir a recogerlo sin esperar en el local.

  • Dado el evento PedidoListo, cuando lo consume el módulo de notificaciones, entonces se registra un aviso (correo simulado escrito en log o en tabla) con el identificador del pedido.
  • Dado el mismo evento entregado dos veces, cuando se consume, entonces solo se genera un aviso: el consumidor es idempotente por eventoId.
  • Dado un fallo del consumidor, cuando se agotan los reintentos, entonces el mensaje acaba en la cola de mensajes fallidos y no bloquea la partición.
HU-14 · Panel de ventas del día — Fase 6

Como encargado, quiero ver cuántos pedidos e importe lleva cada local hoy, para tomar decisiones sin cuadrar hojas de cálculo.

  • Dado el rol ROLE_ADMIN, cuando hago GET /api/v1/informes/ventas?desde=…&hasta=…, entonces recibo el número de pedidos y el importe agregado por local y por día.
  • Dado un rango de más de 92 días, cuando consulto, entonces recibo 400: los informes tienen límites explícitos para no tumbar la base de datos.
  • Dado el mismo rango consultado dos veces en un minuto, cuando consulto, entonces la segunda respuesta sale de caché y se ve en la métrica de aciertos.
HU-15 · Operar el sistema núcleo — Fase 1 y 6

Como responsable técnico, quiero saber si el sistema está sano y qué está pasando dentro, para detectar problemas antes de que los detecte un cliente.

  • Dado el servicio arrancado, cuando consulto /actuator/health/readiness, entonces refleja el estado real de la base de datos y del broker, y Kubernetes lo usa como sonda.
  • Dado tráfico en el sistema, cuando consulto /actuator/prometheus, entonces existen métricas de negocio (pedidos_confirmados_total, reservas_fallidas_total) además de las técnicas.
  • Dado una petición que atraviesa la API y el consumidor de eventos, cuando la busco por su traceId, entonces veo la traza completa y los logs correlacionados.
Cómo se usan estas historias: cada criterio de aceptación es, literalmente, el nombre de un test. confirmar_pedido_sin_stock_devuelve_409_y_no_reserva_nada() sale tal cual de HU-05. Esta es la manera más directa de que la suite de tests signifique algo: no pruebas métodos, pruebas acuerdos con el usuario. Y cuando en la entrevista te pregunten «¿cómo decidiste qué probar?», tienes una respuesta que no es «intenté llegar al 80%».

2.5 Reglas de negocio e invariantes

Las historias describen interacciones; las reglas describen lo que siempre tiene que ser cierto, haga lo que haga el usuario. Van numeradas porque las vas a citar en el código y en los tests (// RN-04 es un comentario que sí aporta información).

#ReglaDónde se garantiza
RN-01 Las existencias de un producto en un local nunca son negativas. CHECK (cantidad >= 0) en la tabla y validación en el agregado de inventario.
RN-02 La suma de reservas vivas de un producto en un local nunca supera sus existencias. Transacción con bloqueo sobre la fila de inventario al reservar.
RN-03 El importe de una línea es cantidad × precio_unitario, y el total del pedido es la suma de las líneas más gastos de envío. Calculado en el dominio, nunca aceptado del cliente. Test de propiedad con importes aleatorios.
RN-04 El precio de una línea es inmutable desde que se crea el pedido. Columna sin actualización posterior; el cambio de precio del producto no propaga.
RN-05 Todos los importes se manejan con BigDecimal de escala 2 y redondeo HALF_UP; jamás con double. Tipo numeric(12,2) en la base de datos y test que lo verifica.
RN-06 Un pedido solo transita entre estados permitidos por su máquina de estados. Método Pedido.transitarA(Estado) que lanza excepción de dominio; test exhaustivo de la matriz.
RN-07 Confirmar es atómico: o se reservan todas las líneas o ninguna. Una sola transacción; test que fuerza el fallo de la última línea y comprueba que no queda nada reservado.
RN-08 Una reserva caduca a los 15 minutos si el pedido no se ha pagado. Columna expira_en y tarea programada; el disponible se calcula ignorando las caducadas.
RN-09 Un cliente solo accede a sus propios pedidos; el barista, a los de su local; el administrador, a todo. Filtro en la consulta (no en memoria) y tests de acceso cruzado.
RN-10 Una operación con la misma clave de idempotencia produce el mismo resultado y un solo efecto. Tabla de claves con restricción única; test con dos peticiones concurrentes.
RN-11 Ningún evento publicado se pierde si el broker está caído en el momento del commit. Patrón outbox: el evento se escribe en la misma transacción que el cambio de estado.
RN-12 Todo cambio de estado de un pedido queda registrado con actor, instante y motivo. Tabla de histórico, escrita en la misma transacción.

2.6 La máquina de estados del pedido

Los estados son el corazón del dominio. Modelarlos explícitamente —y no con un String y una colección de if— es lo que convierte RN-06 en algo que el compilador y los tests protegen.

                  crear                    confirmar (reserva OK + cobro OK)
   [ · ] ────────────────────► BORRADOR ──────────────────────────► CONFIRMADO
                                  │  │                                  │
                cancelar          │  │  confirmar (cobro rechazado)     │  aceptar en local
                                  │  └──────────► PAGO_RECHAZADO        │
                                  │                    │  reintentar    ▼
                                  │                    └──────────► EN_PREPARACION
                                  ▼                                     │
                              CANCELADO ◄──── cancelar ────┐            │ marcar listo
                                  ▲                        │            ▼
                                  │                        └───────── LISTO
                                  │  caducidad (15 min sin pago)        │
                                  │                                     │ entregar
                              (tarea programada)                        ▼
                                                                    ENTREGADO

   Transiciones permitidas (matriz completa, RN-06):
     BORRADOR        → CONFIRMADO, PAGO_RECHAZADO, CANCELADO
     PAGO_RECHAZADO  → CONFIRMADO, CANCELADO
     CONFIRMADO      → EN_PREPARACION, CANCELADO
     EN_PREPARACION  → LISTO, CANCELADO
     LISTO           → ENTREGADO
     ENTREGADO       → (final)
     CANCELADO       → (final)

   Efectos laterales de cada transición:
     → CONFIRMADO      reserva stock, cobra, escribe evento PedidoConfirmado en la outbox
     → PAGO_RECHAZADO  libera reservas, escribe evento PagoRechazado
     → CANCELADO       libera reservas, reembolsa si había cobro, evento PedidoCancelado
     → LISTO           evento PedidoListo (dispara la notificación al cliente)
     → ENTREGADO       consume las reservas: descuenta existencias de forma definitiva
Detalle que marca la diferencia: implementa la matriz de transiciones como un EnumMap<EstadoPedido, Set<EstadoPedido>> o con un switch exhaustivo sobre el enum, no con condicionales dispersos. Ganas tres cosas: el compilador te avisa si añades un estado y olvidas tratarlo, el test de la matriz completa cabe en veinte líneas, y cuando te pregunten «¿cómo evitas transiciones inválidas?» tienes una respuesta de arquitectura y no una de parcheo. El módulo 02 (records y sealed) explica las herramientas del lenguaje que lo hacen cómodo.

2.7 Requisitos no funcionales medibles

Un requisito no funcional sin número no es un requisito, es un deseo. «Que sea rápido» no se puede probar ni incumplir; «p95 por debajo de 200 ms con 50 peticiones por segundo» sí. Estos son los objetivos del proyecto, dimensionados para un negocio real de cuatro locales, no para un unicornio imaginario.

RequisitoObjetivo medibleCómo se verificaPor qué ese número
Latencia de lectura p95 < 200 ms y p99 < 500 ms en GET /productos con 50 rps. Prueba de carga con k6, 5 minutos, informe en docs/carga.md. Por encima de 200 ms el catálogo se percibe lento al desplazarse.
Latencia de escritura p95 < 500 ms en la confirmación de pedido, incluyendo reserva y cobro simulado. Escenario k6 dedicado; se mide sin contar el trabajo asíncrono posterior. Es el límite en el que una acción con botón deja de sentirse inmediata.
Rendimiento sostenido 50 rps de lectura y 10 rps de escritura con 2 instancias y 2 vCPU cada una. k6 con rampa; se comprueba que no hay errores ni saturación del pool. Cuatro locales en hora punta más la tienda: unas 600 operaciones por minuto con margen ×5.
Disponibilidad 99,5% mensual en horario comercial (≈ 3,6 h de indisponibilidad al mes). Sondas de Kubernetes, dos réplicas, despliegue sin corte, alerta de errores 5xx. Objetivo honesto para un equipo pequeño; prometer 99,99% sin guardia sería mentira.
RPO (pérdida máxima de datos) 5 minutos. Copia diaria completa más archivado continuo del WAL; restauración probada mensualmente. Perder cinco minutos de pedidos es recuperable por teléfono; perder un día, no.
RTO (tiempo de recuperación) 1 hora hasta servicio restablecido. Ensayo documentado de restauración con cronómetro. Es el tiempo que la cafetería puede funcionar con papel y boli sin caos.
Volumen de datos 500 productos, 4 locales, 20.000 clientes, 300 pedidos/día (≈ 110.000/año, 350.000 líneas/año). Seed de datos sintéticos a ese volumen para probar las consultas de verdad. Con estos números los índices importan y los OFFSET grandes ya duelen: exactamente lo que quieres practicar.
Usuarios concurrentes 200 sesiones activas en hora punta, 20 escrituras simultáneas. Escenario de carga con usuarios virtuales; pool de conexiones dimensionado en consecuencia. Fija el tamaño del pool y descarta soluciones que solo funcionan con un usuario.
Consistencia del stock Cero sobreventas. Es un requisito absoluto, no estadístico. Test con 50 hilos comprando la última unidad: exactamente uno gana. Es la razón de ser del sistema; si esto falla, el proyecto no sirve.
Latencia del proceso asíncrono El aviso al cliente sale en menos de 30 s desde el cambio de estado, p99. Métrica de retraso de consumo (consumer lag) y traza de punta a punta. Consistencia eventual sí, pero con un plazo comprometido y observable.
Arranque El servicio está listo en menos de 15 s desde el arranque del contenedor. Medido en CI; si crece, se investiga. Afecta al despliegue sin corte y al escalado ante un pico.
Seguridad Cero secretos en el repositorio, cero vulnerabilidades críticas o altas en dependencias al desplegar. gitleaks y análisis de dependencias en CI, con fallo del pipeline. Es verificable automáticamente, así que no hay excusa para no cumplirlo.
Observabilidad Toda petición tiene traceId, y existen métricas de negocio además de las técnicas. Buscar un pedido por su identificador en los logs y ver la traza completa. Sin esto, depurar el flujo asíncrono en producción es imposible.
Los requisitos no funcionales se escriben antes, no después: son los que deciden la arquitectura. «Cero sobreventas» descarta el «lo arreglo en memoria»; «RPO de 5 minutos» obliga a hablar de copias y de WAL; «p95 < 200 ms con 500 productos» decide qué índices necesitas. Si los dejas para el final, lo que tendrás no es un sistema con requisitos, sino un sistema con excusas. Y en una entrevista de diseño, empezar preguntando por los números es exactamente la señal que buscan.

2.8 Fuera de alcance (y por qué)

Decir explícitamente qué no se hace es un acto de ingeniería, no una disculpa. Un alcance cerrado permite terminar, y declararlo en el README demuestra que sabes distinguir lo esencial de lo accesorio. Esta lista va tal cual en la sección «Alcance» de tu README.

No se hacePor qué se deja fueraQué se hace en su lugar
Pasarela de pago real Integrar Stripe o Redsys añade cuentas, claves y webhooks firmados: mucho trabajo de fontanería y poco aprendizaje nuevo. Un adaptador simulado con modos configurables (aprueba, rechaza, tarda, falla) que sirve para probar todos los caminos.
Interfaz web completa El objetivo es el backend. Un frontend a medias resta más de lo que suma. OpenAPI navegable, colección de peticiones de ejemplo y un script de demo. Opcionalmente, una página estática mínima.
Multi-idioma y multi-moneda Multiplica la complejidad del modelo (precios por divisa, redondeos, conversión) sin aportar conceptos nuevos. Euros y español. Los mensajes de error se externalizan para que añadir idiomas sea posible después.
Facturación fiscal Numeración legal, series, rectificativas y normativa. Es un dominio en sí mismo. Se publica el evento PedidoConfirmado para que un sistema externo facture. El contrato existe; la implementación no.
Reparto y logística Rutas, repartidores y seguimiento en tiempo real son otro sistema entero. El canal ENVIO guarda la dirección y una fecha estimada. Nada más.
Descuentos, promociones y fidelización Es el clásico agujero sin fondo: reglas que se combinan y multiplican los casos de prueba. Un gasto de envío fijo y nada más. Queda anotado en el roadmap.
Microservicios separados El ADR-002 lo justifica: con este volumen y una sola persona, el coste operativo supera al beneficio. Monolito modular con límites explícitos y comunicación por eventos, listo para partirse si hiciera falta.
Alta disponibilidad multirregión El objetivo de disponibilidad es 99,5% en horario comercial; multirregión es una respuesta a otra pregunta. Dos réplicas, sondas, despliegue sin corte y copias probadas.
Panel de administración con interfaz gráfica Duplicaría el trabajo del frontend descartado. Endpoints de administración documentados y protegidos por rol. Y Grafana para lo operativo.

Antes de escribir código: cierra el enunciado

3 · Arquitectura y decisiones

Con el enunciado cerrado, toca decidir la forma del sistema. Esta sección no propone «la mejor arquitectura», porque no existe: propone una arquitectura justificada para estos requisitos y, sobre todo, enseña a escribir la justificación. Los cinco ADR del final son el entregable más valioso de todo el proyecto en términos de entrevista, porque son la prueba escrita de que consideraste alternativas.

3.1 Nivel 1: contexto del sistema

El diagrama de contexto responde a una sola pregunta: ¿quién habla con el sistema y para qué? No aparece ni una tecnología. Es el diagrama que se le enseña a alguien de negocio, y el que deberías poder dibujar en una pizarra en noventa segundos.

┌──────────────────────────────────────────────────────────────────────────────┐
│                        NIVEL 1 · CONTEXTO DEL SISTEMA                        │
└──────────────────────────────────────────────────────────────────────────────┘

     [Cliente]              [Barista]              [Encargado]
   persona que compra     personal del local     responsable de catálogo
         │                       │                       │
         │ consulta catálogo,    │ gestiona la cola      │ altas de producto,
         │ crea y paga pedidos   │ de su local           │ precios, inventario,
         │                       │                       │ informes
         └───────────┬───────────┴───────────┬───────────┘
                     ▼                       ▼
        ╔══════════════════════════════════════════════════╗
        ║              CAFETERÍA TECH                      ║
        ║   Plataforma de catálogo, inventario y pedidos   ║
        ║   con recogida en tienda y envío a domicilio     ║
        ╚══════════════════════════════════════════════════╝
                     │                       │
      solicita cobro │                       │ publica PedidoConfirmado
      y reembolso    ▼                       ▼
          [Pasarela de pago]        [Sistema de facturación]
           sistema externo            sistema externo
          (simulado en este           (fuera de alcance:
           proyecto)                   solo se publica el evento)

  Fronteras: el sistema NO gestiona reparto, ni facturación fiscal, ni fidelización.
  Todo lo que cruza el borde lo hace por un contrato explícito: API REST o evento.

3.2 Nivel 2: contenedores

El nivel 2 abre la caja y muestra las piezas desplegables y sus protocolos. Aquí sí hay tecnología, y cada elemento tiene que estar justificado por un requisito. Si no lo está, sobra.

┌──────────────────────────────────────────────────────────────────────────────┐
│                        NIVEL 2 · CONTENEDORES                                │
└──────────────────────────────────────────────────────────────────────────────┘

   Navegador / curl / Postman
              │  HTTPS · JSON · JWT Bearer
              ▼
   ┌────────────────────────────────────────────────────────────────┐
   │  cafeteria-api            Spring Boot 3 · Java 21              │
   │  ────────────────────────────────────────────────────────────  │
   │   módulos:  catalogo │ inventario │ pedidos │ pagos │          │
   │             identidad │ notificaciones │ informes             │
   │   adaptadores de entrada:  REST, planificador, consumidor      │
   │   adaptadores de salida:   JPA, Kafka, Redis, pasarela         │
   └───┬─────────────┬──────────────┬───────────────┬───────────────┘
       │ JDBC        │ Redis        │ Kafka         │ HTTP
       │             │ protocol     │ protocol      │ (simulado)
       ▼             ▼              ▼               ▼
 ┌───────────┐ ┌───────────┐  ┌──────────────┐  ┌──────────────────┐
 │ PostgreSQL│ │  Redis 7  │  │  Kafka 3.x   │  │ pasarela-fake    │
 │    16     │ │           │  │  (KRaft)     │  │ (WireMock o un   │
 │ fuente de │ │ caché de  │  │ pedidos.v1   │  │  perfil interno) │
 │  verdad   │ │ catálogo, │  │ pedidos.dlq  │  └──────────────────┘
 │ + outbox  │ │ idempot., │  └──────┬───────┘
 └───────────┘ │ cerrojos  │         │ consume
               └───────────┘         │
                             ┌───────▼────────────────────────┐
                             │ consumidor (mismo despliegue,  │
                             │ perfil "worker" separable)     │
                             └────────────────────────────────┘

   Observabilidad (transversal):
     Micrometer → Prometheus → Grafana   ·   OpenTelemetry → Tempo/Jaeger
     Logs JSON con traceId → consola (y Loki si se despliega el stack completo)

   Justificación de cada pieza:
     PostgreSQL  RN-01..RN-07 exigen transacciones e integridad referencial.
     Redis       RNF de latencia del catálogo y cerrojo de la tarea programada.
     Kafka       HU-13 y RN-11: desacoplar la notificación y no perder eventos.
     Consumidor  Puede vivir en el mismo proceso (perfil) o separarse sin tocar el dominio.
Un detalle deliberado: el consumidor de eventos se despliega en el mismo artefacto, activado por un perfil. Así demuestras que entiendes la separación lógica (un adaptador de entrada distinto) sin pagar el coste operativo de un segundo servicio. Y si alguien en la entrevista pregunta «¿y si el consumo satura la API?», la respuesta ya está preparada: se arranca el mismo contenedor con el perfil worker y escala por separado. Diseño reversible, coste cero hoy.

3.3 Monolito modular frente a microservicios

Es la decisión estructural del proyecto, y la que más se pregunta. La respuesta correcta no es «microservicios porque es moderno» ni «monolito porque es simple»: es una comparación con los requisitos delante. Estos son los criterios que de verdad deciden, aplicados al caso.

Criterio de decisiónMonolito modularMicroserviciosQué gana aquí y por qué
Tamaño del equipo Ideal de 1 a 8 personas. Rentable a partir de varios equipos autónomos. Monolito. Una persona. La razón de ser de los microservicios es la autonomía organizativa, y aquí no hay organización que autonomizar.
Consistencia de datos Transacción local: RN-02 y RN-07 salen gratis. Sagas, compensaciones y consistencia eventual para lo mismo. Monolito. «Cero sobreventas» es un invariante fuerte; resolverlo con una transacción es correcto y sencillo. Con sagas sería un ejercicio de sufrimiento.
Escalado Se escala el conjunto; suficiente si el perfil de carga es homogéneo. Se escala cada parte por separado. Empate. 50 rps con dos réplicas no justifica escalar por componentes. El perfil de carga es uniforme.
Despliegue Un artefacto, un pipeline, una versión. N artefactos, N pipelines, compatibilidad entre versiones. Monolito. Con una persona, cada pipeline extra es tiempo que no se dedica al producto.
Depuración Una traza de pila completa. Trazas distribuidas obligatorias para entender cualquier fallo. Monolito. Aunque el proyecto añade trazas igualmente, porque el flujo asíncrono las necesita.
Aislamiento de fallos Un bug de memoria afecta a todo el proceso. Un servicio caído degrada solo su función. Microservicios, pero con un objetivo de 99,5% y dos réplicas, la diferencia real es pequeña.
Coste de infraestructura Una base de datos, un despliegue. Varias bases, gateway, descubrimiento, malla. Monolito. Diferencia de un orden de magnitud en euros y en horas de operación.
Reversibilidad Partir un monolito bien modularizado es viable si los módulos ya no comparten tablas. Volver a juntar microservicios es raro y doloroso. Monolito. Es la decisión que deja más puertas abiertas, y por eso es la que se toma cuando hay incertidumbre.
Valor didáctico Enseña límites, contratos y disciplina interna. Enseña operación distribuida y consistencia eventual. Empate. Por eso el proyecto añade Kafka y outbox: se practican los patrones distribuidos sin pagar el coste de repartir el sistema.

El resultado es un monolito modular con eventos: un solo artefacto desplegable, dividido en módulos con fronteras reales, que se comunican entre sí por interfaces publicadas o por eventos de dominio, y que no comparten tablas. Es exactamente la arquitectura que recomienda la mayoría de la industria cuando el equipo es pequeño y el dominio no está aún estabilizado, y es también la respuesta que mejor puntúa en una entrevista, porque demuestra que sabes que los microservicios son una solución organizativa con un coste técnico, no un objetivo. El módulo 08 · Arquitecturas desarrolla la comparación completa.

La trampa del «monolito modular» de mentira: llamar módulos a unos paquetes que se importan libremente entre sí no es modularidad, es un monolito normal con carpetas bonitas. La modularidad solo existe si está verificada: paquete interno que nadie externo puede importar, tablas que solo toca su módulo, y un test de ArchUnit que falla el build cuando alguien cruza la frontera. Sin ese test, la frontera se erosiona en tres semanas. La sección 6 trae las reglas listas para copiar.

3.4 Arquitectura hexagonal dentro de cada módulo

Dentro de cada módulo se aplica puertos y adaptadores. La idea es una sola frase: el dominio no conoce a nadie; todo lo demás lo conoce a él. Las dependencias apuntan hacia dentro. Traducido a reglas prácticas y verificables:

Lo que sí puede haber en dominio

  • Entidades y objetos de valor con comportamiento, no solo con getters.
  • Excepciones de dominio (StockInsuficienteException).
  • Interfaces de puerto de salida: PedidoRepositorio, PasarelaPago.
  • Servicios de dominio para reglas que no pertenecen a una sola entidad.
  • Eventos de dominio como record inmutables.
  • java.* y poco más. Ni @Entity, ni @Service, ni Jackson.

Lo que va fuera, en infraestructura

  • Entidades JPA y el mapeo desde y hacia el dominio.
  • Controladores REST, DTO de petición y respuesta, validación de formato.
  • Productores y consumidores de Kafka, serialización de eventos.
  • Configuración de Spring, seguridad, caché y métricas.
  • Clientes HTTP hacia sistemas externos.
  • Todo lo que cambiaría si mañana cambiara la tecnología, no el negocio.

¿Merece la pena la ceremonia de mapear entre entidad de dominio y entidad JPA? Con honestidad: en un CRUD puro, no. Aquí sí, por dos razones. La primera, práctica: el dominio tiene reglas que quieres poder probar en milisegundos, sin Spring y sin base de datos; con el dominio limpio, el 70% de tus tests son instantáneos. La segunda, de entrevista: te permite explicar el trade-off con conocimiento de causa en lugar de repetir consignas. Y si en algún módulo trivial decides no separar —el de informes, por ejemplo, que es solo lectura— dilo en el ADR: reconocer una excepción razonada puntúa más que aplicar un dogma.

3.5 Estructura de paquetes completa

Este es el árbol real del proyecto. Cópialo tal cual: tener la estructura decidida elimina cien microdecisiones durante las siguientes semanas y hace que el repositorio se entienda de un vistazo, que es el minuto 0:45 de la sección 1.

cafeteria-tech/
├── README.md                        ← el documento que más se lee (sección 7)
├── compose.yaml                     ← todo el entorno con un comando
├── Dockerfile                       ← multietapa, usuario sin privilegios
├── pom.xml                          ← o build.gradle.kts
├── .github/workflows/ci.yml         ← build, tests, análisis, imagen
├── docs/
│   ├── adr/                         ← una decisión por fichero, numeradas
│   │   ├── 0001-postgresql-como-fuente-de-verdad.md
│   │   ├── 0002-monolito-modular-con-eventos.md
│   │   ├── 0003-kafka-con-patron-outbox.md
│   │   ├── 0004-jwt-propio-frente-a-keycloak.md
│   │   └── 0005-estrategia-de-tests.md
│   ├── arquitectura.md              ← C4 nivel 1 y 2 en Mermaid
│   ├── api.md                       ← convenciones del contrato REST
│   └── carga.md                     ← informe de la prueba de carga
├── ejemplos/                        ← peticiones listas para curl
│   ├── crear-pedido.json
│   └── demo.sh                      ← recorrido completo del caso de uso
└── src/
    ├── main/java/dev/cafeteria/
    │   ├── CafeteriaApplication.java
    │   ├── comun/                          ← lo verdaderamente transversal
    │   │   ├── dominio/                    ← Dinero, Sku, IdPedido, Resultado
    │   │   ├── web/                        ← ManejadorGlobalErrores, ProblemDetail
    │   │   ├── idempotencia/               ← filtro + almacén de claves
    │   │   └── config/                     ← seguridad, caché, OpenAPI, reloj
    │   │
    │   ├── catalogo/                       ← MÓDULO 1
    │   │   ├── dominio/
    │   │   │   ├── Producto.java           ← entidad de dominio, sin anotaciones
    │   │   │   ├── Categoria.java
    │   │   │   ├── Precio.java             ← objeto de valor
    │   │   │   └── ProductoRepositorio.java    ← PUERTO de salida (interfaz)
    │   │   ├── aplicacion/
    │   │   │   ├── ConsultarCatalogo.java      ← caso de uso (puerto de entrada)
    │   │   │   ├── AltaProducto.java
    │   │   │   └── CambiarPrecio.java
    │   │   ├── infraestructura/
    │   │   │   ├── rest/ProductoControlador.java
    │   │   │   ├── rest/dto/                   ← ProductoRespuesta, CrearProductoPeticion
    │   │   │   └── jpa/                        ← ProductoJpa, ProductoRepositorioJpa, mapeador
    │   │   └── CatalogoApi.java            ← ÚNICA clase pública para otros módulos
    │   │
    │   ├── inventario/                     ← MÓDULO 2
    │   │   ├── dominio/                    ← Existencias, Reserva, PoliticaReserva
    │   │   ├── aplicacion/                 ← ReservarStock, LiberarReserva, AjustarExistencias
    │   │   ├── infraestructura/
    │   │   │   ├── rest/  jpa/  programado/    ← LiberadorReservasCaducadas
    │   │   └── InventarioApi.java
    │   │
    │   ├── pedidos/                        ← MÓDULO 3 (el núcleo del dominio)
    │   │   ├── dominio/
    │   │   │   ├── Pedido.java             ← raíz de agregado, máquina de estados
    │   │   │   ├── LineaPedido.java
    │   │   │   ├── EstadoPedido.java       ← enum con la matriz de transiciones
    │   │   │   ├── evento/PedidoConfirmado.java
    │   │   │   └── PedidoRepositorio.java
    │   │   ├── aplicacion/
    │   │   │   ├── CrearPedido.java
    │   │   │   ├── ConfirmarPedido.java    ← orquesta inventario + pagos + outbox
    │   │   │   ├── CancelarPedido.java
    │   │   │   └── AvanzarEstado.java
    │   │   ├── infraestructura/
    │   │   │   ├── rest/  jpa/  outbox/        ← EventoOutbox, PublicadorOutbox
    │   │   └── PedidosApi.java
    │   │
    │   ├── pagos/                          ← MÓDULO 4
    │   │   ├── dominio/                    ← Pago, ResultadoCobro, PasarelaPago (puerto)
    │   │   ├── aplicacion/                 ← Cobrar, Reembolsar
    │   │   └── infraestructura/simulada/   ← PasarelaSimulada con modos de fallo
    │   │
    │   ├── identidad/                      ← MÓDULO 5
    │   │   ├── dominio/                    ← Usuario, Rol, Credenciales
    │   │   ├── aplicacion/                 ← Registrar, Autenticar, RefrescarToken
    │   │   └── infraestructura/            ← jwt/, jpa/, rest/
    │   │
    │   ├── notificaciones/                 ← MÓDULO 6 (solo consume eventos)
    │   │   ├── dominio/                    ← Aviso, CanalAviso (puerto)
    │   │   ├── aplicacion/                 ← AvisarPedidoListo
    │   │   └── infraestructura/kafka/      ← ConsumidorPedidos (idempotente)
    │   │
    │   └── informes/                       ← MÓDULO 7 (solo lectura, sin dominio)
    │       └── infraestructura/            ← consultas SQL directas + caché
    │
    ├── main/resources/
    │   ├── application.yaml                ← configuración base
    │   ├── application-dev.yaml            ← perfiles: dev, test, demo, prod, worker
    │   └── db/migration/                   ← V1__esquema_inicial.sql, V2__..., Flyway
    │
    └── test/java/dev/cafeteria/
        ├── arquitectura/ReglasArquitecturaTest.java   ← ArchUnit
        ├── catalogo/     ← unitarios de dominio + slice web + slice JPA
        ├── pedidos/      ← incluye ConfirmarPedidoConcurrenciaTest
        ├── integracion/  ← Testcontainers: BaseIntegracionTest, flujo completo
        └── util/         ← builders de datos de prueba (ObjectMother)
Las tres decisiones de estructura que más rentabilidad dan: (1) paquetes por módulo de negocio y no por capa técnica, para que abrir pedidos/ te enseñe todo lo relacionado con pedidos y no tengas que saltar entre cuatro carpetas; (2) una clase de fachada por módulo (CatalogoApi, InventarioApi) que sea lo único público hacia fuera, lo que convierte el contrato entre módulos en algo explícito y comprobable; (3) tests que reflejan la estructura del código, para que sea evidente qué está probado y qué no.

3.6 Contratos entre módulos

Un módulo no puede llamar a las tripas de otro. Solo hay tres formas legítimas de que dos módulos se relacionen, y las tres son explícitas:

FormaCuándo se usaEjemplo en el proyectoAcoplamiento
Llamada síncrona a la fachada Cuando el resultado es necesario ahora para decidir. ConfirmarPedido llama a InventarioApi.reservar(...): sin reserva no hay confirmación. Alto pero controlado: solo se conoce la interfaz y sus tipos, nunca las entidades internas.
Evento de dominio en proceso Cuando la reacción puede ocurrir después y no debe bloquear. PedidoListo lo escucha notificaciones con @TransactionalEventListener. Bajo: el emisor no sabe quién escucha ni le importa.
Evento publicado en Kafka Cuando el consumidor podría vivir fuera del proceso, o hay que garantizar la entrega. PedidoConfirmado vía outbox: lo consumen notificaciones hoy y facturación mañana. Mínimo: el contrato es el esquema del mensaje.
// ---------------------------------------------------------------------------
//  CONTRATO ENTRE MÓDULOS: la fachada de inventario.
//  Es lo ÚNICO público del módulo. Los tipos que expone son propios del
//  contrato (records planos), nunca entidades internas ni entidades JPA:
//  si expusiera Existencias, cualquier cambio interno rompería a los demás.
// ---------------------------------------------------------------------------
package dev.cafeteria.inventario;

import dev.cafeteria.comun.dominio.Sku;
import java.util.List;
import java.util.UUID;

public interface InventarioApi {

    /** Reserva todas las líneas o ninguna (RN-07). Idempotente por pedidoId. */
    ResultadoReserva reservar(UUID pedidoId, UUID localId, List<LineaReserva> lineas);

    /** Libera las reservas de un pedido. No falla si ya estaban liberadas. */
    void liberar(UUID pedidoId);

    /** Consume definitivamente las reservas: descuenta existencias (RN-09). */
    void consumir(UUID pedidoId);

    /** Disponible = existencias − reservas vivas. Para el catálogo. */
    int disponible(UUID localId, Sku sku);

    record LineaReserva(Sku sku, int cantidad) {}

    /** Resultado explícito en lugar de excepción: el llamante DEBE tratarlo. */
    sealed interface ResultadoReserva {
        record Reservado(UUID reservaId) implements ResultadoReserva {}
        record SinStock(List<Faltante> faltantes) implements ResultadoReserva {}
        record LocalDesconocido(UUID localId) implements ResultadoReserva {}
    }

    record Faltante(Sku sku, int solicitado, int disponible) {}
}

Fíjate en dos detalles que parecen menores y no lo son. El primero: ResultadoReserva es un sealed interface, de modo que el llamante tiene que tratar los tres casos y el compilador se lo exige con un switch exhaustivo. Falta de stock no es una excepción: es un resultado esperado del negocio, y modelarlo así elimina toda una categoría de errores. El segundo: la fachada devuelve la lista de faltantes con el SKU y el disponible, para que la API pueda contarle al cliente exactamente qué le falta en lugar de un «no hay stock» inútil.

// El llamante, en el módulo de pedidos. El switch exhaustivo sobre el sealed
// interface hace imposible olvidarse de un caso: si mañana se añade
// ResultadoReserva.LocalCerrado, esto deja de compilar. Eso es una red de
// seguridad que ningún test te da.
var resultado = inventario.reservar(pedido.id(), pedido.localId(), lineas);

return switch (resultado) {
    case Reservado r        -> cobrarYConfirmar(pedido, r.reservaId());
    case SinStock s         -> ResultadoConfirmacion.sinStock(s.faltantes());
    case LocalDesconocido l -> throw new IllegalStateException(
                                   "Pedido con local inexistente: " + l.localId());
};

3.7 Cinco decisiones de arquitectura (ADR completos)

Un ADR (Architecture Decision Record) es un documento corto que captura una decisión y su contexto en el momento en que se toma. Su valor no está en el presente sino en el futuro: dentro de seis meses nadie recuerda por qué se eligió algo, y sin el ADR la decisión se revierte por ignorancia o se mantiene por miedo. En el proyecto van en docs/adr/, en Markdown, numerados, y no se editan: cuando una decisión cambia, se escribe un ADR nuevo que sustituye al anterior.

La estructura es siempre la misma: título, estado, contexto, decisión, alternativas consideradas, consecuencias. Que sean cortos es una virtud: una página es suficiente y se lee. Aquí van los cinco del proyecto, escritos enteros para que puedas copiarlos y adaptarlos.

ADR-0001 · PostgreSQL como única fuente de verdad

Estado: aceptada · Fecha: inicio del proyecto · Decisores: el equipo (una persona)

Contexto. El sistema gestiona dinero e inventario. Los invariantes RN-01, RN-02 y RN-07 exigen que la reserva de varias líneas sea atómica y que las existencias nunca queden negativas ni sobrecomprometidas. El volumen previsto es modesto (500 productos, ~110.000 pedidos al año) y las consultas incluyen agregaciones por local y por día para el informe de ventas. Hay una persona operando el sistema, sin guardias ni experiencia previa en administración de bases de datos.

Decisión. Usar PostgreSQL 16 como única fuente de verdad para todos los módulos, con esquema gestionado por Flyway y ddl-auto=validate. Redis se usa exclusivamente como caché y como soporte de cerrojos e idempotencia: ningún dato vive solo en Redis; si Redis se vacía, el sistema sigue siendo correcto, solo más lento.

Alternativas consideradas.

  • MongoDB. El modelo de pedido con líneas encaja bien como documento, y el rendimiento de lectura sería excelente. Descartada porque las reservas de stock cruzan documentos (producto, local, pedido) y las transacciones multidocumento existen pero añaden complejidad; además, el informe de ventas es una agregación relacional natural.
  • MySQL/MariaDB. Perfectamente válida. Descartada por preferencia: PostgreSQL tiene mejor soporte de jsonb, tipos ricos, SKIP LOCKED maduro (que se usa en la tarea programada) y un EXPLAIN más informativo, útil para el objetivo didáctico del proyecto.
  • Una base de datos por módulo. Coherente con microservicios, pero rompería la atomicidad de la confirmación y obligaría a sagas para un problema que se resuelve con una transacción. Descartada por ADR-0002.
  • H2 en memoria para simplificar. Descartada de plano: usar en tests un motor distinto al de producción esconde exactamente los errores que importan (tipos, restricciones, bloqueos, dialecto). Se usa PostgreSQL real vía Testcontainers.

Consecuencias.

  • Positivas: transacciones ACID sin esfuerzo; integridad referencial garantizada por el motor; consultas analíticas triviales; una sola tecnología que operar y respaldar; el patrón outbox es sencillo porque el evento se escribe en la misma transacción.
  • Negativas: la base de datos es un punto único de fallo y de contención; escalar escrituras exigirá particionado o réplicas más adelante; el acoplamiento por esquema entre módulos es un riesgo real que se mitiga con la regla «cada tabla pertenece a un módulo y solo él la escribe», verificada en revisión.
  • Reversible: si un módulo necesitara su propio almacén, su puerto de salida ya está aislado y solo habría que escribir otro adaptador.
ADR-0002 · Monolito modular con eventos, no microservicios

Estado: aceptada · Sustituye a: — · Relacionada con: ADR-0001, ADR-0003

Contexto. El sistema tiene siete áreas funcionales identificadas (catálogo, inventario, pedidos, pagos, identidad, notificaciones, informes). Lo desarrolla y opera una sola persona. La carga esperada es de 50 peticiones por segundo en lectura y 10 en escritura. El dominio es nuevo y sus fronteras todavía pueden moverse: es probable que en un mes descubramos que inventario y catálogo comparten más de lo previsto, o menos.

Decisión. Construir un monolito modular: un único artefacto desplegable con módulos de fronteras explícitas (una fachada pública por módulo, resto del paquete interno), sin tablas compartidas entre módulos, comunicación síncrona solo a través de fachadas y asíncrona mediante eventos. La modularidad se verifica automáticamente con ArchUnit en cada build.

Alternativas consideradas.

  • Microservicios desde el principio. Habría dado experiencia operativa distribuida, pero con un equipo de una persona multiplica pipelines, despliegues y depuración por siete, y convierte invariantes locales (RN-02, RN-07) en sagas. Descartada: el coste no lo paga ningún requisito. Se compensa el valor didáctico introduciendo Kafka y el patrón outbox dentro del monolito.
  • Monolito por capas clásicas (controller/service/repository globales). Más rápido al principio y muy conocido. Descartada porque no establece fronteras de negocio: acaba en dependencias cruzadas y en la imposibilidad de razonar sobre una parte sin entenderlo todo.
  • Dos servicios (pedidos y almacén). Punto intermedio tentador y defendible. Descartada por coherencia: partir por «pedidos frente a almacén» rompe justo la transacción que más nos importa. Si hubiera que partir, la línea natural sería sacar notificaciones e informes, que no participan en ningún invariante.

Consecuencias.

  • Positivas: un pipeline, un despliegue, una traza de pila; refactorizar fronteras cuesta un rename y no una migración; consistencia fuerte donde el negocio la exige.
  • Negativas: no hay aislamiento de fallos entre módulos ni escalado independiente; la disciplina de fronteras depende de una herramienta (si el test de ArchUnit se desactiva, la arquitectura se erosiona en semanas); el tiempo de arranque y la suite crecen con todo el sistema.
  • Criterio de revisión: si el equipo pasa de cuatro personas, o si un módulo necesita un perfil de escalado radicalmente distinto, se reevalúa. Los candidatos a salir primero son notificaciones e informes.
ADR-0003 · Kafka con patrón outbox para los eventos de dominio

Estado: aceptada · Relacionada con: ADR-0002

Contexto. Al confirmar un pedido hay que notificar al cliente (HU-13) y publicar el hecho para un futuro sistema de facturación. Ninguna de las dos cosas debe formar parte de la respuesta HTTP: el cliente no puede esperar a que se envíe un correo, ni la confirmación debe fallar porque un consumidor esté caído. RN-11 exige además que ningún evento se pierda aunque el broker no esté disponible en el instante del commit.

Decisión. Publicar los eventos de dominio en Kafka usando el patrón outbox transaccional: el cambio de estado y la fila de la tabla evento_outbox se escriben en la misma transacción de base de datos; un publicador programado lee las filas pendientes y las envía al broker con reintentos, marcándolas como publicadas. Los consumidores son idempotentes por identificador de evento, porque la entrega es «al menos una vez».

Alternativas consideradas.

  • Publicar directamente tras el commit (con @TransactionalEventListener(AFTER_COMMIT) y un KafkaTemplate). Es lo más simple y funciona el 99% de las veces. Descartada como mecanismo principal porque ese 1% —el proceso muere entre el commit y el envío— es precisamente el fallo que RN-11 prohíbe. Sí se usa para eventos internos sin garantía de entrega.
  • Solo eventos en proceso de Spring. Suficiente hoy, ya que el consumidor vive en el mismo artefacto. Descartada porque no sobrevive a un reinicio ni permite que facturación se suscriba mañana sin tocar el código.
  • RabbitMQ. Más sencillo de operar y con enrutamiento más rico. Descartada por dos razones: se quiere el modelo de registro particionado y ordenado por clave (los eventos de un mismo pedido deben procesarse en orden), y Kafka es lo que más aparece en las ofertas del mercado objetivo.
  • Debezium leyendo el WAL (CDC). Técnicamente superior: elimina el publicador programado. Descartada por coste operativo (Kafka Connect, configuración de replicación lógica) desproporcionado para el tamaño del proyecto. Anotada como evolución natural.

Consecuencias.

  • Positivas: garantía de que el evento existe si el cambio existe; la confirmación no depende de la disponibilidad del broker; los consumidores se añaden sin tocar al productor; el orden por clave de pedido está garantizado dentro de la partición.
  • Negativas: una tabla más y un proceso más que vigilar (hay que alertar si crecen las filas pendientes); latencia añadida igual al intervalo del publicador; obligación de que todos los consumidores sean idempotentes, lo que hay que probar explícitamente; Kafka es la pieza más pesada del compose.yaml.
  • Métrica de control: outbox_pendientes y antigüedad de la fila más vieja, con alerta si supera un minuto.
ADR-0004 · JWT propio en lugar de un proveedor de identidad externo

Estado: aceptada · Revisar si: aparece un segundo cliente o se pide inicio de sesión social

Contexto. El sistema tiene tres roles y una comprobación de propiedad por recurso (RN-09). Los consumidores son un cliente HTTP y, potencialmente, una web propia. No hay requisito de inicio de sesión con Google, ni de federación, ni de single sign-on con sistemas de la empresa. El proyecto tiene además un objetivo didáctico: entender qué hay dentro de un token y cómo se valida.

Decisión. Implementar autenticación propia con JWT firmado (HS256 con secreto de 256 bits en variable de entorno, o RS256 si se despliega en la nube), acceso de 15 minutos y refresh token opaco y revocable almacenado en base de datos. Spring Security 6 se configura como resource server validando el token, y la autorización combina roles con comprobación de propiedad dentro de la consulta, nunca filtrando en memoria.

Alternativas consideradas.

  • Keycloak. Es lo correcto en un entorno profesional: gestión de usuarios, OIDC completo, federación, MFA y rotación de claves resueltos. Descartada aquí porque añade un contenedor de ~700 MB al compose.yaml, ralentiza el arranque de la demo y desplaza el foco del proyecto. Se documenta cómo migrar: como el único punto de contacto es la configuración del resource server, el cambio es una clase de configuración y un issuer.
  • Sesiones con cookie. Más seguras por defecto para una web (revocación inmediata, HttpOnly), y perfectamente válidas. Descartada porque el consumidor principal es una API y se quiere practicar el flujo con token, que es lo que se pregunta en entrevista.
  • OAuth2 con un proveedor comercial. Descartada por dependencia externa y coste; imposible de ejecutar sin conexión, lo que rompería el requisito de que el proyecto arranque en local con un comando.
  • Autenticación básica. Descartada: no permite expresar roles ni caducidad y transmite las credenciales en cada petición.

Consecuencias.

  • Positivas: cero dependencias externas; arranque rápido; control total sobre las reclamaciones del token; valor didáctico alto (se entiende qué se firma y qué se valida).
  • Negativas: somos responsables de la seguridad de la autenticación, que es exactamente lo que no se debe hacer en producción sin motivo; la revocación del token de acceso no es inmediata (se mitiga con caducidad corta); no hay MFA ni recuperación de contraseña; el secreto de firma es un activo crítico que hay que rotar.
  • Mitigación explícita: el README declara que en un entorno real se usaría Keycloak o el proveedor corporativo, y el código lo deja preparado. Reconocer esto por escrito puntúa más que fingir que un JWT casero es lo ideal.
ADR-0005 · Estrategia de tests: pirámide con base ancha y Testcontainers

Estado: aceptada · Relacionada con: ADR-0001

Contexto. El proyecto se construye en seis fases a lo largo de varias semanas, con refactorizaciones continuas. Sin una red de seguridad rápida, cada cambio dará miedo y el proyecto se congelará. A la vez, la suite se ejecutará en cada push en CI, así que su duración importa: por encima de diez minutos se deja de ejecutar en local y se pierde el beneficio.

Decisión. Cuatro niveles con propósito distinto y presupuesto de tiempo explícito:

  • Unitarios de dominio (≈70% de los tests, <5 s en total): reglas de negocio, máquina de estados, cálculo de importes. Sin Spring, sin base de datos, sin mocks salvo para puertos de salida.
  • Slices de Spring (≈20%, <30 s): @WebMvcTest para serialización, validación, códigos de estado y seguridad; @DataJpaTest contra PostgreSQL real para consultas y mapeos.
  • Integración de punta a punta (≈8%, <3 min): @SpringBootTest con Testcontainers (PostgreSQL, Kafka, Redis) para los flujos completos, incluido el asíncrono.
  • Especializados (≈2%): concurrencia (50 hilos por la última unidad), arquitectura (ArchUnit), y contrato (validación del esquema OpenAPI).

Umbrales: cobertura de líneas ≥ 80% global y ≥ 90% en los paquetes dominio; puntuación de mutación ≥ 60% en el dominio con PIT. La suite completa por debajo de cinco minutos en CI.

Alternativas consideradas.

  • Solo tests de integración («escribe tests que prueben el sistema de verdad»). Dan mucha confianza por test, pero son lentos y diagnostican mal: cuando fallan, no dicen dónde. Descartada como estrategia única; se usan donde aportan (flujos completos).
  • H2 en lugar de Testcontainers. Más rápido de arrancar. Descartada por ADR-0001: probar contra un motor distinto al de producción da falsos verdes en tipos, restricciones, bloqueos y SQL específico. Con contenedores reutilizables el coste real es de unos segundos.
  • Sin umbral de cobertura. Tentadora, porque el número se puede inflar. Descartada, pero con la matización de que el umbral solo evita el olvido; la calidad real la mide la puntuación de mutación, que es por lo que se añade PIT en el dominio.
  • TDD estricto en todo el proyecto. Excelente disciplina y se usa en el dominio, donde el diseño es lo que está en juego. No se impone en los adaptadores, donde a menudo es más eficiente escribir el adaptador y después su test.

Consecuencias.

  • Positivas: se puede refactorizar sin miedo; los fallos se diagnostican rápido porque el nivel que falla indica dónde está el problema; la suite funciona igual en local y en CI; hay tests que demuestran cosas difíciles (concurrencia, idempotencia) que son argumento directo en entrevista.
  • Negativas: exige Docker en la máquina de desarrollo; los tests de integración son frágiles a los tiempos de espera y requieren disciplina con las esperas activas (nada de Thread.sleep: se usa Awaitility); mantener el dominio libre de framework obliga a mapeos que alargan un poco el desarrollo.
Cómo se usan los ADR en la entrevista: cuando te pregunten «¿por qué elegiste X?», no respondas solo con la decisión: responde con la alternativa que descartaste y el criterio. «Elegí un monolito modular; consideré dos servicios, pero partir por pedidos e inventario habría convertido en saga la única transacción que el negocio exige que sea atómica». Esa frase demuestra más criterio que quince minutos hablando de tecnologías, y sale directamente de tener los ADR escritos.

Cierre de la fase de diseño

4 · Modelo de datos y contrato de la API

Estas son las dos piezas más caras de cambiar una vez que hay datos y clientes. El esquema condiciona qué invariantes puedes garantizar y qué consultas serán rápidas; el contrato condiciona a todo el que te consuma. Merecen el rato que vas a dedicarles ahora.

4.1 Decisiones de modelado, explicadas

DecisiónAlternativaPor qué así
Claves primarias UUID v7 (o v4 con índice adecuado) bigserial El identificador viaja en la URL y en los eventos; con secuencias se filtra información de negocio (cuántos pedidos llevas) y se facilita el sondeo de identificadores ajenos. UUID v7 conserva el orden temporal, así que el índice no se fragmenta como con v4.
SKU como clave natural única, no como clave primaria SKU como PK Las claves naturales acaban cambiando (una reorganización del catálogo) y arrastran todas las claves foráneas. Se mantiene la unicidad con una restricción y se referencia por el identificador técnico.
numeric(12,2) para importes double precision o céntimos en bigint RN-05. Los binarios de coma flotante no representan 0,10 exactamente y los errores se acumulan al sumar líneas. Céntimos en entero es válido y muy usado, pero obliga a convertir en cada frontera; con numeric el motor y BigDecimal se entienden directamente.
Precio congelado en la línea Consultar el precio del producto al leer el pedido RN-04. Un pedido es un documento histórico: si el precio cambia mañana, el importe cobrado no puede cambiar. No es desnormalización sino captura de un hecho.
Existencias y reservas en tablas separadas Una columna disponible mantenida a mano El disponible es un cálculo (existencias menos reservas vivas) y mantenerlo como columna crea dos fuentes de verdad que divergen. Si el rendimiento lo exigiera, se añadiría una columna mantenida por el motor y una consulta de reconciliación.
Histórico de movimientos de inventario Solo el saldo actual RN-11 de auditoría: cualquier saldo debe poder explicarse. Además, permite detectar el momento exacto en que algo se descuadró, que es lo primero que pregunta el negocio.
Estado como text con CHECK Tipo enum de PostgreSQL u ordinal de Java Nunca ordinal: insertar un valor en medio del enum Java corrompe los datos existentes. El tipo enum nativo obliga a un ALTER TYPE para cada valor nuevo; text con CHECK es legible en psql y fácil de evolucionar.
timestamptz siempre timestamp sin zona Un instante sin zona es ambiguo, y en España el cambio de hora produce dos veces la misma hora local cada octubre. Se guarda en UTC y se convierte en la frontera de presentación.
Columna version para bloqueo optimista Bloqueo pesimista en todo Las colisiones sobre un mismo pedido son raras: el optimista es más barato y no bloquea. En cambio, la fila de inventario sí usa bloqueo pesimista, porque ahí la colisión es el caso normal y reintentar sería peor.
Tabla de outbox en el mismo esquema Publicar directamente al broker ADR-0003: es lo que permite que el evento participe de la misma transacción que el cambio de estado.
Borrado lógico en productos, físico en el resto Borrado lógico en todo Un producto retirado sigue apareciendo en pedidos antiguos, así que no puede desaparecer. En cambio, el borrado lógico generalizado ensucia todas las consultas con WHERE activo y acaba produciendo errores por olvido.

4.2 Esquema completo (DDL comentado)

Este es el esquema entero del proyecto. Está escrito para PostgreSQL 16 y pensado para leerse: cada restricción tiene un comentario que dice qué invariante protege. Las restricciones no son decoración defensiva; son la última línea que impide que un bug corrompa datos que luego nadie sabrá arreglar.

-- =============================================================================
--  V1__esquema_inicial.sql
--  Cafetería Tech · esquema base. PostgreSQL 16.
--  Convenciones: nombres en singular, snake_case, timestamptz en UTC,
--  importes numeric(12,2), claves primarias uuid.
-- =============================================================================

CREATE EXTENSION IF NOT EXISTS "pgcrypto";   -- gen_random_uuid()
CREATE EXTENSION IF NOT EXISTS "unaccent";   -- búsqueda sin acentos (HU-02)

-- ---------------------------------------------------------------- IDENTIDAD --

CREATE TABLE usuario (
    id              uuid         PRIMARY KEY DEFAULT gen_random_uuid(),
    email           text         NOT NULL,
    hash_password   text         NOT NULL,          -- BCrypt, coste 12
    nombre          text         NOT NULL,
    telefono        text,
    activo          boolean      NOT NULL DEFAULT true,
    creado_en       timestamptz  NOT NULL DEFAULT now(),
    version         bigint       NOT NULL DEFAULT 0,

    -- El email es la clave natural de acceso: único sin distinguir mayúsculas.
    CONSTRAINT uq_usuario_email UNIQUE (email),
    CONSTRAINT ck_usuario_email_formato CHECK (email = lower(email) AND position('@' in email) > 1)
);

CREATE TABLE rol_usuario (
    usuario_id  uuid  NOT NULL REFERENCES usuario(id) ON DELETE CASCADE,
    rol         text  NOT NULL,
    PRIMARY KEY (usuario_id, rol),
    CONSTRAINT ck_rol CHECK (rol IN ('ROLE_CLIENTE','ROLE_STAFF','ROLE_ADMIN'))
);

-- El barista pertenece a un local: base de la autorización por recurso (RN-09).
CREATE TABLE usuario_local (
    usuario_id  uuid NOT NULL REFERENCES usuario(id) ON DELETE CASCADE,
    local_id    uuid NOT NULL,
    PRIMARY KEY (usuario_id, local_id)
);

-- Refresh tokens revocables: el token de acceso es corto y no se revoca (ADR-0004).
CREATE TABLE refresh_token (
    id           uuid        PRIMARY KEY DEFAULT gen_random_uuid(),
    usuario_id   uuid        NOT NULL REFERENCES usuario(id) ON DELETE CASCADE,
    hash_token   text        NOT NULL,   -- se guarda el hash, no el token
    expira_en    timestamptz NOT NULL,
    revocado_en  timestamptz,
    creado_en    timestamptz NOT NULL DEFAULT now(),
    CONSTRAINT uq_refresh_hash UNIQUE (hash_token)
);
CREATE INDEX ix_refresh_usuario ON refresh_token (usuario_id) WHERE revocado_en IS NULL;

-- ----------------------------------------------------------------- CATÁLOGO --

CREATE TABLE categoria (
    id      uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    codigo  text NOT NULL,
    nombre  text NOT NULL,
    CONSTRAINT uq_categoria_codigo UNIQUE (codigo)
);

CREATE TABLE producto (
    id            uuid          PRIMARY KEY DEFAULT gen_random_uuid(),
    sku           text          NOT NULL,
    nombre        text          NOT NULL,
    descripcion   text,
    categoria_id  uuid          NOT NULL REFERENCES categoria(id),
    precio        numeric(12,2) NOT NULL,
    activo        boolean       NOT NULL DEFAULT true,   -- retirada = borrado lógico
    creado_en     timestamptz   NOT NULL DEFAULT now(),
    version       bigint        NOT NULL DEFAULT 0,

    CONSTRAINT uq_producto_sku   UNIQUE (sku),            -- HU-10: SKU irrepetible
    CONSTRAINT ck_producto_precio CHECK (precio > 0),     -- nunca precio 0 o negativo
    CONSTRAINT ck_producto_sku    CHECK (sku ~ '^[A-Z0-9-]{3,40}$')
);

-- Índice para el listado por defecto del catálogo: filtra activos y ordena por nombre.
-- Parcial porque el 95% de las consultas solo miran productos activos.
CREATE INDEX ix_producto_activo_nombre ON producto (nombre) WHERE activo;
CREATE INDEX ix_producto_categoria     ON producto (categoria_id) WHERE activo;

-- Búsqueda por texto sin acentos ni mayúsculas (HU-02). Índice GIN sobre la
-- expresión: sin él, el ILIKE '%…%' obliga a recorrer la tabla entera.
CREATE INDEX ix_producto_busqueda ON producto
    USING gin (to_tsvector('spanish', unaccent(nombre || ' ' || coalesce(descripcion, ''))));

-- --------------------------------------------------------------- INVENTARIO --

CREATE TABLE local (
    id         uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    codigo     text NOT NULL,
    nombre     text NOT NULL,
    direccion  text NOT NULL,
    activo     boolean NOT NULL DEFAULT true,
    CONSTRAINT uq_local_codigo UNIQUE (codigo)
);

-- Saldo actual por producto y local. Es la fila que se bloquea al reservar.
CREATE TABLE existencias (
    local_id     uuid    NOT NULL REFERENCES local(id),
    producto_id  uuid    NOT NULL REFERENCES producto(id),
    cantidad     integer NOT NULL DEFAULT 0,
    version      bigint  NOT NULL DEFAULT 0,
    PRIMARY KEY (local_id, producto_id),

    -- RN-01: las existencias nunca son negativas. La garantía definitiva vive
    -- aquí, no en el código: aunque un bug intente restar de más, la base falla.
    CONSTRAINT ck_existencias_no_negativas CHECK (cantidad >= 0)
);

-- Histórico de todo lo que ha movido el saldo (HU-11). Permite reconstruirlo.
CREATE TABLE movimiento_inventario (
    id           uuid        PRIMARY KEY DEFAULT gen_random_uuid(),
    local_id     uuid        NOT NULL,
    producto_id  uuid        NOT NULL,
    delta        integer     NOT NULL,
    motivo       text        NOT NULL,
    referencia   uuid,                    -- pedido que lo provocó, si aplica
    usuario_id   uuid        REFERENCES usuario(id),
    creado_en    timestamptz NOT NULL DEFAULT now(),

    CONSTRAINT ck_movimiento_motivo CHECK (motivo IN
        ('RECEPCION','VENTA','RECUENTO','MERMA','DEVOLUCION')),
    CONSTRAINT ck_movimiento_delta CHECK (delta <> 0),
    FOREIGN KEY (local_id, producto_id) REFERENCES existencias(local_id, producto_id)
);
CREATE INDEX ix_movimiento_producto_fecha
    ON movimiento_inventario (local_id, producto_id, creado_en DESC);

-- Compromisos temporales. "Viva" = estado ACTIVA y expira_en en el futuro.
CREATE TABLE reserva (
    id           uuid        PRIMARY KEY DEFAULT gen_random_uuid(),
    pedido_id    uuid        NOT NULL,
    local_id     uuid        NOT NULL,
    producto_id  uuid        NOT NULL,
    cantidad     integer     NOT NULL,
    estado       text        NOT NULL DEFAULT 'ACTIVA',
    expira_en    timestamptz NOT NULL,
    creado_en    timestamptz NOT NULL DEFAULT now(),

    CONSTRAINT ck_reserva_cantidad CHECK (cantidad > 0),
    CONSTRAINT ck_reserva_estado   CHECK (estado IN ('ACTIVA','CONSUMIDA','LIBERADA')),
    -- RN-10: reservar dos veces la misma línea del mismo pedido es imposible.
    CONSTRAINT uq_reserva_pedido_producto UNIQUE (pedido_id, producto_id)
);
-- Índice parcial: la tarea de caducidad solo mira reservas activas y vencidas.
CREATE INDEX ix_reserva_caducidad ON reserva (expira_en) WHERE estado = 'ACTIVA';
CREATE INDEX ix_reserva_disponible ON reserva (local_id, producto_id) WHERE estado = 'ACTIVA';

-- ------------------------------------------------------------------ PEDIDOS --

CREATE TABLE pedido (
    id             uuid          PRIMARY KEY DEFAULT gen_random_uuid(),
    numero         bigint        GENERATED BY DEFAULT AS IDENTITY,  -- humano, para el ticket
    cliente_id     uuid          NOT NULL REFERENCES usuario(id),
    local_id       uuid          NOT NULL REFERENCES local(id),
    canal          text          NOT NULL,
    estado         text          NOT NULL DEFAULT 'BORRADOR',
    total          numeric(12,2) NOT NULL DEFAULT 0,
    gastos_envio   numeric(12,2) NOT NULL DEFAULT 0,
    direccion      text,                       -- obligatoria solo si canal = ENVIO
    recogida_desde timestamptz,                -- estimación para canal = RECOGIDA
    creado_en      timestamptz   NOT NULL DEFAULT now(),
    confirmado_en  timestamptz,
    version        bigint        NOT NULL DEFAULT 0,   -- bloqueo optimista

    CONSTRAINT ck_pedido_canal  CHECK (canal IN ('RECOGIDA','ENVIO')),
    CONSTRAINT ck_pedido_estado CHECK (estado IN
        ('BORRADOR','PAGO_RECHAZADO','CONFIRMADO','EN_PREPARACION','LISTO','ENTREGADO','CANCELADO')),
    CONSTRAINT ck_pedido_total  CHECK (total >= 0),
    -- HU-04: la dirección es obligatoria para envío. La regla condicional vive
    -- también en la base: así ninguna vía de escritura puede saltársela.
    CONSTRAINT ck_pedido_direccion CHECK (canal <> 'ENVIO' OR direccion IS NOT NULL),
    CONSTRAINT uq_pedido_numero UNIQUE (numero)
);

-- Consulta más frecuente: "mis pedidos, los más recientes primero" (HU-06).
-- El índice cubre filtro y orden, así que el plan no necesita ordenar.
CREATE INDEX ix_pedido_cliente_fecha ON pedido (cliente_id, creado_en DESC);
-- Cola del local (HU-08): índice parcial, solo los estados que se muestran.
CREATE INDEX ix_pedido_cola ON pedido (local_id, creado_en)
    WHERE estado IN ('CONFIRMADO','EN_PREPARACION');
-- Informe de ventas por día y local (HU-14).
CREATE INDEX ix_pedido_local_confirmado ON pedido (local_id, confirmado_en)
    WHERE confirmado_en IS NOT NULL;

CREATE TABLE linea_pedido (
    id               uuid          PRIMARY KEY DEFAULT gen_random_uuid(),
    pedido_id        uuid          NOT NULL REFERENCES pedido(id) ON DELETE CASCADE,
    producto_id      uuid          NOT NULL REFERENCES producto(id),
    sku              text          NOT NULL,     -- copia: el SKU podría cambiar
    descripcion      text          NOT NULL,     -- copia: el nombre podría cambiar
    precio_unitario  numeric(12,2) NOT NULL,     -- RN-04: instantánea inmutable
    cantidad         integer       NOT NULL,
    importe          numeric(12,2) NOT NULL,

    CONSTRAINT ck_linea_cantidad CHECK (cantidad > 0 AND cantidad <= 100),
    CONSTRAINT ck_linea_precio   CHECK (precio_unitario >= 0),
    -- RN-03: el importe cuadra siempre. Si un bug lo descuadra, falla el INSERT.
    CONSTRAINT ck_linea_importe  CHECK (importe = round(precio_unitario * cantidad, 2)),
    -- Un producto no puede aparecer dos veces: se agrupa en una línea con más cantidad.
    CONSTRAINT uq_linea_pedido_producto UNIQUE (pedido_id, producto_id)
);
CREATE INDEX ix_linea_pedido ON linea_pedido (pedido_id);

-- Auditoría de transiciones (RN-12): quién, cuándo, de dónde a dónde y por qué.
CREATE TABLE historico_pedido (
    id             uuid        PRIMARY KEY DEFAULT gen_random_uuid(),
    pedido_id      uuid        NOT NULL REFERENCES pedido(id) ON DELETE CASCADE,
    estado_previo  text,
    estado_nuevo   text        NOT NULL,
    actor_id       uuid,
    motivo         text,
    creado_en      timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX ix_historico_pedido ON historico_pedido (pedido_id, creado_en);

-- -------------------------------------------------------------------- PAGOS --

CREATE TABLE pago (
    id              uuid          PRIMARY KEY DEFAULT gen_random_uuid(),
    pedido_id       uuid          NOT NULL REFERENCES pedido(id),
    importe         numeric(12,2) NOT NULL,
    estado          text          NOT NULL,
    referencia_ext  text,                       -- identificador de la pasarela
    motivo_rechazo  text,
    creado_en       timestamptz   NOT NULL DEFAULT now(),

    CONSTRAINT ck_pago_estado  CHECK (estado IN ('AUTORIZADO','RECHAZADO','REEMBOLSADO')),
    CONSTRAINT ck_pago_importe CHECK (importe > 0)
);
-- Un pedido puede tener varios intentos, pero solo un cobro autorizado vigente.
CREATE UNIQUE INDEX uq_pago_autorizado ON pago (pedido_id) WHERE estado = 'AUTORIZADO';
CREATE INDEX ix_pago_pedido ON pago (pedido_id, creado_en DESC);

-- ------------------------------------------------------- OUTBOX E IDEMPOTENCIA --

-- ADR-0003. Se escribe en la MISMA transacción que el cambio de estado.
CREATE TABLE evento_outbox (
    id             uuid        PRIMARY KEY DEFAULT gen_random_uuid(),
    tipo           text        NOT NULL,     -- 'PedidoConfirmado', 'PedidoListo'…
    clave          text        NOT NULL,     -- clave de partición: el id del pedido
    agregado_tipo  text        NOT NULL,
    agregado_id    uuid        NOT NULL,
    carga          jsonb       NOT NULL,
    creado_en      timestamptz NOT NULL DEFAULT now(),
    publicado_en   timestamptz,
    intentos       integer     NOT NULL DEFAULT 0,
    ultimo_error   text
);
-- El publicador solo mira lo pendiente: índice parcial diminuto aunque la
-- tabla acumule millones de filas ya publicadas.
CREATE INDEX ix_outbox_pendiente ON evento_outbox (creado_en)
    WHERE publicado_en IS NULL;

-- RN-10. Guarda la respuesta para poder devolver exactamente lo mismo.
CREATE TABLE clave_idempotencia (
    clave          text        NOT NULL,
    usuario_id     uuid        NOT NULL,
    endpoint       text        NOT NULL,
    hash_peticion  text        NOT NULL,   -- detecta reutilizar la clave con otro cuerpo
    estado_http    integer,
    respuesta      jsonb,
    creado_en      timestamptz NOT NULL DEFAULT now(),
    completado_en  timestamptz,
    PRIMARY KEY (clave, usuario_id, endpoint)
);
CREATE INDEX ix_idempotencia_limpieza ON clave_idempotencia (creado_en);

-- Eventos ya procesados por cada consumidor: idempotencia del lado receptor.
CREATE TABLE evento_procesado (
    consumidor  text        NOT NULL,
    evento_id   uuid        NOT NULL,
    procesado_en timestamptz NOT NULL DEFAULT now(),
    PRIMARY KEY (consumidor, evento_id)
);
Los cinco detalles del esquema que un revisor con experiencia busca: índices parciales (WHERE activo, WHERE estado = 'ACTIVA') que ocupan una fracción y se usan igual; índices compuestos que cubren filtro y orden a la vez, para que el plan no tenga que ordenar; restricciones CHECK que protegen invariantes de negocio y no solo tipos; el índice único parcial de pago, que expresa «solo un cobro autorizado por pedido» sin ninguna línea de código Java; y el uso de timestamptz en todas partes. Cada uno de los cinco es una conversación de dos minutos en una entrevista. El módulo 06 · Índices y planes explica el porqué de cada uno.

4.3 Migraciones con Flyway

Nada de ddl-auto=update. El esquema es código, vive en el repositorio, se revisa en un pull request y se aplica igual en tu portátil, en CI y en producción. En Spring Boot basta con la dependencia y los ficheros en src/main/resources/db/migration.

FicheroContenidoFase
V1__esquema_inicial.sqlTodo el DDL anterior: tablas, restricciones e índices.F2
V2__datos_maestros.sqlLos 4 locales, las categorías y el usuario administrador inicial. Datos que el sistema necesita para funcionar.F2
V3__outbox_e_idempotencia.sqlSe separa para que el histórico refleje cuándo se añadió la mensajería.F5
V4__indice_busqueda_texto.sqlEl índice GIN, añadido tras medir que la búsqueda recorría la tabla entera.F3
V5__historico_pedido.sqlAuditoría de transiciones (RN-12).F3
R__vista_ventas_diarias.sqlRepetible (prefijo R): se reejecuta cuando cambia su contenido. Ideal para vistas y funciones.F6
afterMigrate.sqlSolo en el perfil demo: carga datos sintéticos para que la demo tenga contenido.F1
# application.yaml — la configuración que hace que el esquema sea de fiar
spring:
  flyway:
    enabled: true
    locations: classpath:db/migration
    baseline-on-migrate: false      # en un proyecto nuevo no hay nada que "adoptar"
    validate-on-migrate: true       # falla si alguien editó una migración ya aplicada
    clean-disabled: true            # jamás un clean accidental (por defecto ya lo está)
  jpa:
    hibernate:
      ddl-auto: validate            # Hibernate COMPRUEBA el esquema, no lo toca
    open-in-view: false             # ver módulo 05: evita consultas fuera de la transacción
    properties:
      hibernate.jdbc.time_zone: UTC
Las tres reglas de las migraciones que se aprenden por las malas: (1) una migración aplicada no se edita jamás, ni para corregir una errata; se escribe otra, porque Flyway guarda su suma de verificación y el arranque fallará en el entorno donde ya se aplicó. (2) Toda migración debe ser compatible con la versión anterior del código durante el despliegue: si borras una columna que el código antiguo todavía lee, tienes un corte; se hace en dos pasos, dejando de usarla primero y borrándola después. (3) Los datos de prueba nunca van en migraciones versionadas: acabarían en producción.

4.4 Convenciones del contrato REST

Antes de la tabla de endpoints, las reglas que se aplican a todos. Escribirlas en docs/api.md hace que la API sea predecible, que es la principal virtud de un contrato.

Recursos y rutas

  • Sustantivos en plural: /pedidos, no /getPedido.
  • Jerarquía solo cuando el hijo no tiene sentido sin el padre: /pedidos/{id}/lineas.
  • Las acciones que no son CRUD se modelan como subrecursos: POST /pedidos/{id}/confirmacion. Mejor que /confirmar porque la confirmación es una entidad con estado propio.
  • Prefijo de versión en la ruta: /api/v1/….
  • Nada de verbos ni de ?action= en la URL.

Métodos y semántica

  • GET nunca modifica nada y es cacheable.
  • POST crea o ejecuta; no es idempotente salvo con clave.
  • PUT reemplaza el recurso completo; es idempotente.
  • PATCH modifica parcialmente. Aquí solo para el cambio de estado.
  • DELETE es idempotente: borrar dos veces devuelve 204 las dos.

Formatos

  • JSON con nombres en camelCase.
  • Fechas e instantes en ISO-8601 con zona: 2026-03-14T10:15:30Z.
  • Importes como cadena decimal ("12.50") o número con dos decimales; nunca coma flotante que el cliente pueda redondear mal.
  • Identificadores como UUID en texto.
  • Enumerados en mayúsculas y estables: son parte del contrato.

Reglas transversales

  • Todo error usa application/problem+json (RFC 9457).
  • Toda lista está paginada, con un tamaño máximo.
  • Toda respuesta lleva X-Request-Id para correlacionar con los logs.
  • Los POST que mueven dinero aceptan Idempotency-Key.
  • Ningún endpoint devuelve entidades del dominio: siempre DTO explícitos.

4.5 Tabla completa de endpoints

Método y rutaQuiénQué haceÉxitoErrores posibles
GET /api/v1/productosPúblico Catálogo paginado con filtros (q, categoria, localId, sort). 200 400 parámetro inválido o size excesivo
GET /api/v1/productos/{sku}Público Detalle de un producto por SKU. 200 404 no existe o está retirado
POST /api/v1/productosADMIN Alta de producto. 201 + Location 400 validación · 401 · 403 · 409 SKU duplicado
PUT /api/v1/productos/{sku}ADMIN Actualiza nombre, descripción, categoría y precio. 200 400 · 403 · 404 · 409 conflicto de versión
DELETE /api/v1/productos/{sku}ADMIN Retirada lógica (activo = false). 204 403 · 404
POST /api/v1/auth/registroPúblico Crea la cuenta de cliente. 201 400 · 409 correo ya registrado
POST /api/v1/auth/loginPúblico Devuelve token de acceso y de refresco. 200 400 · 401 credenciales · 429 demasiados intentos
POST /api/v1/auth/refreshCon refresh Renueva el token de acceso y rota el de refresco. 200 401 caducado o revocado
POST /api/v1/pedidosCLIENTE Crea un pedido en borrador. Acepta Idempotency-Key. 201 + Location 400 · 401 · 422 SKU inválido o retirado
GET /api/v1/pedidosCLIENTE / ADMIN Mis pedidos (o los de un cliente si eres ADMIN), con filtro por estado y paginación. 200 400 · 401 · 403
GET /api/v1/pedidos/{id}Propietario / STAFF del local / ADMIN Detalle con líneas, estado y pagos. 200 401 · 404 (también si es de otro: no se revela su existencia)
POST /api/v1/pedidos/{id}/confirmacionPropietario Reserva stock, cobra y confirma. Requiere Idempotency-Key. 200 400 falta la clave · 402 pago rechazado · 404 · 409 sin stock o estado inválido · 422
POST /api/v1/pedidos/{id}/cancelacionPropietario / ADMIN Cancela y libera reservas. Idempotente por naturaleza. 200 404 · 409 transición no permitida
PATCH /api/v1/pedidos/{id}/estadoSTAFF del local / ADMIN Avanza el estado (preparación, listo, entregado). 200 403 otro local · 404 · 409 transición inválida
GET /api/v1/localesPúblico Lista de locales activos con dirección. 200
GET /api/v1/locales/{id}/colaSTAFF del local / ADMIN Pedidos pendientes de preparar, por antigüedad. 200 401 · 403 local ajeno · 404
GET /api/v1/inventarioSTAFF / ADMIN Existencias, reservas y disponible por local. 200 401 · 403
POST /api/v1/inventario/ajustesADMIN Ajuste de existencias con motivo. Deja rastro auditable. 201 400 · 403 · 409 dejaría el saldo por debajo de las reservas
GET /api/v1/informes/ventasADMIN Pedidos e importe por local y día en un rango. 200 400 rango > 92 días · 403
GET /actuator/healthInterno Estado del servicio y sus dependencias. 200 / 503
GET /v3/api-docs y /swagger-ui.htmlPúblico en dev Especificación OpenAPI y navegador interactivo. 200

4.6 Peticiones y respuestas de ejemplo

# --- Crear un pedido -----------------------------------------------------------
curl -i -X POST localhost:8080/api/v1/pedidos \
  -H 'Authorization: Bearer '"$TOKEN" \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: 0e2c1b6a-2f3d-4c1a-9f6e-5b8c7d4a1e02' \
  -d '{
        "localId": "3f1a...c9",
        "canal": "RECOGIDA",
        "lineas": [
          { "sku": "CAF-ETIOPIA-250", "cantidad": 2 },
          { "sku": "ACC-TAZA-350",    "cantidad": 1 }
        ]
      }'
HTTP/1.1 201 Created
Location: /api/v1/pedidos/9b2f6c10-7d3e-4a55-8f21-6c0f2b1d4e77
X-Request-Id: 4b1c9e70-1f2a-49ab-8f0e-2c7d5a9b3f18
Content-Type: application/json

{
  "id": "9b2f6c10-7d3e-4a55-8f21-6c0f2b1d4e77",
  "numero": 1042,
  "estado": "BORRADOR",
  "canal": "RECOGIDA",
  "local": { "id": "3f1a...c9", "nombre": "Cafetería Centro" },
  "lineas": [
    { "sku": "CAF-ETIOPIA-250", "descripcion": "Etiopía Yirgacheffe 250 g",
      "cantidad": 2, "precioUnitario": "12.50", "importe": "25.00" },
    { "sku": "ACC-TAZA-350", "descripcion": "Taza cerámica 350 ml",
      "cantidad": 1, "precioUnitario": "9.90", "importe": "9.90" }
  ],
  "gastosEnvio": "0.00",
  "total": "34.90",
  "creadoEn": "2026-03-14T10:15:30Z",
  "_links": {
    "self":         { "href": "/api/v1/pedidos/9b2f6c10-…" },
    "confirmacion": { "href": "/api/v1/pedidos/9b2f6c10-…/confirmacion", "method": "POST" },
    "cancelacion":  { "href": "/api/v1/pedidos/9b2f6c10-…/cancelacion",  "method": "POST" }
  }
}

Los _links son opcionales, pero comunican algo valioso: qué se puede hacer ahora con este recurso según su estado. Un pedido ENTREGADO no ofrece enlace de cancelación, y así el cliente no tiene que replicar la máquina de estados. Es HATEOAS en su versión útil y sin ceremonia.

--- Confirmación con éxito ------------------------------------------------------
POST /api/v1/pedidos/9b2f6c10-…/confirmacion
Idempotency-Key: 7c4e2a91-33b8-4f0d-91a2-8e5b6c1d7f34

HTTP/1.1 200 OK
{
  "id": "9b2f6c10-…",
  "estado": "CONFIRMADO",
  "confirmadoEn": "2026-03-14T10:16:02Z",
  "pago": { "estado": "AUTORIZADO", "importe": "34.90", "referencia": "sim_9f21ab" },
  "recogidaDesde": "2026-03-14T10:31:00Z"
}

--- Confirmación sin stock: 409 con detalle accionable --------------------------
HTTP/1.1 409 Conflict
Content-Type: application/problem+json

{
  "type":     "https://cafeteria.dev/errores/stock-insuficiente",
  "title":    "Stock insuficiente",
  "status":   409,
  "detail":   "No hay unidades suficientes de 1 de los productos del pedido.",
  "instance": "/api/v1/pedidos/9b2f6c10-…/confirmacion",
  "requestId":"4b1c9e70-…",
  "faltantes": [
    { "sku": "CAF-ETIOPIA-250", "solicitado": 2, "disponible": 1 }
  ]
}

--- Validación fallida: 400 con errores por campo -------------------------------
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json

{
  "type":     "https://cafeteria.dev/errores/validacion",
  "title":    "La petición no es válida",
  "status":   400,
  "detail":   "2 campos no cumplen las restricciones.",
  "instance": "/api/v1/pedidos",
  "requestId":"1a9f3c22-…",
  "errores": [
    { "campo": "lineas[0].cantidad", "mensaje": "debe ser mayor que 0", "valor": 0 },
    { "campo": "direccion", "mensaje": "obligatoria cuando el canal es ENVIO" }
  ]
}

--- Pago rechazado: 402, el pedido sigue vivo y se puede reintentar -------------
HTTP/1.1 402 Payment Required
{
  "type":   "https://cafeteria.dev/errores/pago-rechazado",
  "title":  "El pago ha sido rechazado",
  "status": 402,
  "detail": "La pasarela rechazó el cargo: fondos insuficientes.",
  "estadoPedido": "PAGO_RECHAZADO",
  "reintentable": true
}
Por qué un 409 con lista de faltantes y no un 400 genérico: porque el código de estado y el cuerpo juntos deben permitir que el cliente decida qué hacer sin llamar a nadie. 400 significa «tu petición está mal formada, corrígela»; 409 significa «tu petición es correcta pero choca con el estado actual del sistema». Con la lista de faltantes, una interfaz puede ofrecer «reducir la cantidad a 1» automáticamente. Este nivel de cuidado en los errores es de lo que más distingue a una API profesional de un ejercicio, y se nota en los primeros treinta segundos de prueba.

4.7 Paginación, filtrado y ordenación

ParámetroValoresPor defectoNotas
pageEntero ≥ 00Paginación por desplazamiento, válida para el catálogo (500 productos).
size1 a 10020Por encima de 100 se devuelve 400, no se recorta en silencio: recortar sin avisar produce clientes que creen tener todos los datos.
sortcampo,asc|descnombre,ascLista blanca de campos ordenables. Nunca se concatena el valor en el SQL.
qTexto, 2 a 60 caracteresBúsqueda sin acentos ni mayúsculas sobre nombre y descripción.
cursorCadena opaca en Base64Solo en /pedidos, donde el volumen crece sin límite y el OFFSET se degrada.
--- Respuesta paginada (formato estable, propio, no el de Spring por defecto) ---
GET /api/v1/productos?page=1&size=2&sort=precio,desc

{
  "contenido": [ { "sku": "CAF-GEISHA-100", "precio": "28.00" },
                 { "sku": "CAF-ETIOPIA-250", "precio": "12.50" } ],
  "pagina":        1,
  "tamano":        2,
  "totalElementos": 37,
  "totalPaginas":   19,
  "primera":        false,
  "ultima":         false
}

--- Paginación por cursor para "mis pedidos" (estable ante inserciones) ---------
GET /api/v1/pedidos?size=20
{
  "contenido": [ … ],
  "siguienteCursor": "eyJmIjoiMjAyNi0wMy0xNFQxMDoxNTozMFoiLCJpZCI6IjliMmY2YzEwIn0="
}
GET /api/v1/pedidos?size=20&cursor=eyJmIjoiMjAyNi0…

Dos advertencias que se aprenden con dolor. La primera: no expongas el objeto Page de Spring Data directamente; su forma serializada ha cambiado entre versiones y arrastra campos internos (pageable, sort.unsorted) que no quieres en tu contrato público. Define tu propio record PaginaRespuesta<T>. La segunda: el OFFSET grande es un problema real —la página 5.000 obliga al motor a descartar 100.000 filas— y además es inestable, porque si alguien inserta mientras el usuario navega, se repiten o se saltan filas. Por eso los pedidos usan cursor. El módulo 06 · Optimización lo mide con números.

4.8 Idempotencia: que reintentar no cueste dinero

El escenario es cotidiano: el cliente pulsa «Confirmar», la respuesta tarda, la red se corta y la aplicación reintenta. Sin protección, se cobra dos veces. Con Idempotency-Key, la segunda petición devuelve exactamente la misma respuesta que la primera y no produce ningún efecto nuevo.

FLUJO DE UNA PETICIÓN CON Idempotency-Key

  Cliente                    API                        Base de datos
     │  POST /confirmacion     │                              │
     │  Idempotency-Key: K ──► │                              │
     │                         │ INSERT clave K (estado nulo) │
     │                         │ ───────────────────────────► │
     │                         │                              │
     │      ┌──────────────────┴─────────────────┐            │
     │      │ ¿El INSERT ha fallado por clave    │            │
     │      │  duplicada?                        │            │
     │      └──────┬──────────────────────┬──────┘            │
     │             │ no (primera vez)     │ sí (reintento)    │
     │             ▼                      ▼                   │
     │      ejecuta la operación   ¿hay respuesta guardada?    │
     │      guarda estado+cuerpo    ├── sí → devuelve la misma │
     │             │                └── no → 409 "en curso,    │
     │             │                        reintenta luego"   │
     │  ◄──────────┴──────────────────────────────────────────│

  Detalles que importan:
   · La clave se guarda ANTES de ejecutar: la restricción única de la base de
     datos es lo que resuelve la carrera entre dos peticiones simultáneas.
   · Se almacena el hash del cuerpo: si llega la misma clave con otro contenido,
     se responde 422 (uso incorrecto del cliente), no se ejecuta.
   · Las claves caducan: una tarea borra las de más de 24 h.
   · Ámbito: (clave, usuario, endpoint). La clave de un usuario no colisiona
     con la de otro.
// Filtro/aspecto de idempotencia. Se aplica a los POST anotados con
// @Idempotente. Lo importante no es el código, es la secuencia: reservar la
// clave PRIMERO y dejar que la base de datos resuelva la concurrencia.
@Transactional
public <T> RespuestaIdempotente<T> ejecutar(String clave, UUID usuarioId,
                                            String endpoint, String hashPeticion,
                                            Supplier<RespuestaIdempotente<T>> operacion) {

    try {
        registro.reservar(clave, usuarioId, endpoint, hashPeticion);   // INSERT
    } catch (DuplicateKeyException e) {
        var previa = registro.buscar(clave, usuarioId, endpoint).orElseThrow();

        if (!previa.hashPeticion().equals(hashPeticion)) {
            throw new ClaveIdempotenciaReutilizadaException(clave);    // 422
        }
        if (previa.completadoEn() == null) {
            throw new OperacionEnCursoException(clave);                // 409 + Retry-After
        }
        return previa.respuestaGuardada();                             // misma respuesta
    }

    var resultado = operacion.get();
    registro.completar(clave, usuarioId, endpoint, resultado);
    return resultado;
}

4.9 Versionado y evolución del contrato

Tipo de cambio¿Rompe?Cómo se hace
Añadir un campo opcional a una respuestaNoSe añade sin más. Los clientes deben ignorar lo que no conocen (regla escrita en docs/api.md).
Añadir un endpointNoDirecto.
Añadir un parámetro opcionalNoCon valor por defecto igual al comportamiento anterior.
Renombrar un campoSe publican los dos a la vez, se marca el antiguo como obsoleto en OpenAPI, se avisa y se retira en la versión siguiente.
Cambiar un tipo (número a cadena)Campo nuevo junto al viejo. Nunca cambiar el tipo en sitio.
Añadir un valor a un enumeradoDependeRompe a los clientes que hacen switch exhaustivo. Se documenta desde el principio que los enumerados pueden crecer.
Hacer obligatorio un campo opcionalExige versión nueva.
Cambiar un código de estadoExige versión nueva; es de los cambios que más silenciosamente rompen a los clientes.

La estrategia del proyecto es versión en la ruta (/api/v1) por una razón pragmática: es visible en los logs, trivial de enrutar y no requiere que nadie recuerde poner una cabecera. Las alternativas —cabecera Accept con tipo de medio versionado, o parámetro— son más puristas y se usan en API públicas grandes, pero añaden fricción. Lo que no se hace nunca es tener v2 como copia entera de v1 con un campo cambiado: se evoluciona v1 aditivamente todo lo posible, y v2 solo aparece si hay una ruptura de verdad.

4.10 Errores con ProblemDetail

// Un único manejador global para toda la aplicación. La regla: ninguna
// excepción llega al cliente sin pasar por aquí, y ninguna traza de pila sale
// en el cuerpo de la respuesta (filtra información y no ayuda a nadie).
@RestControllerAdvice
public class ManejadorGlobalErrores extends ResponseEntityExceptionHandler {

    private static final String BASE = "https://cafeteria.dev/errores/";

    @ExceptionHandler(StockInsuficienteException.class)
    ProblemDetail stockInsuficiente(StockInsuficienteException ex) {
        var pd = ProblemDetail.forStatus(HttpStatus.CONFLICT);
        pd.setType(URI.create(BASE + "stock-insuficiente"));
        pd.setTitle("Stock insuficiente");
        pd.setDetail("No hay unidades suficientes de %d de los productos del pedido."
                        .formatted(ex.faltantes().size()));
        pd.setProperty("faltantes", ex.faltantes());   // información accionable
        return pd;
    }

    @ExceptionHandler(TransicionNoPermitidaException.class)
    ProblemDetail transicion(TransicionNoPermitidaException ex) {
        var pd = ProblemDetail.forStatus(HttpStatus.CONFLICT);
        pd.setType(URI.create(BASE + "transicion-no-permitida"));
        pd.setTitle("Transición de estado no permitida");
        pd.setDetail("Un pedido en %s no puede pasar a %s."
                        .formatted(ex.desde(), ex.hacia()));
        pd.setProperty("estadoActual", ex.desde());
        pd.setProperty("transicionesPermitidas", ex.permitidas());
        return pd;
    }

    // Validación de @Valid: se transforma la lista de errores de campo en algo
    // que un cliente pueda pintar junto a cada input del formulario.
    @Override
    protected ResponseEntity<Object> handleMethodArgumentNotValid(
            MethodArgumentNotValidException ex, HttpHeaders h,
            HttpStatusCode s, WebRequest r) {

        var errores = ex.getBindingResult().getFieldErrors().stream()
            .map(fe -> Map.of("campo", fe.getField(),
                              "mensaje", Objects.requireNonNullElse(fe.getDefaultMessage(), "inválido")))
            .toList();

        var pd = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
        pd.setType(URI.create(BASE + "validacion"));
        pd.setTitle("La petición no es válida");
        pd.setDetail("%d campos no cumplen las restricciones.".formatted(errores.size()));
        pd.setProperty("errores", errores);
        return ResponseEntity.badRequest().body(pd);
    }

    // Red de seguridad: cualquier excepción no prevista. Se registra con el
    // identificador de petición para poder encontrarla en los logs, y al
    // cliente solo se le da ese identificador.
    @ExceptionHandler(Exception.class)
    ProblemDetail inesperado(Exception ex, HttpServletRequest req) {
        String requestId = MDC.get("requestId");
        log.error("Error no controlado [requestId={}] en {}", requestId, req.getRequestURI(), ex);

        var pd = ProblemDetail.forStatus(HttpStatus.INTERNAL_SERVER_ERROR);
        pd.setType(URI.create(BASE + "interno"));
        pd.setTitle("Error interno");
        pd.setDetail("Se ha producido un error inesperado. Cita este identificador si contactas con soporte.");
        pd.setProperty("requestId", requestId);
        return pd;
    }
}
CódigoCuándo se usa aquíError frecuente
400La petición está mal formada o incumple validaciones de formato.Usarlo para conflictos de negocio, que son 409 o 422.
401No hay token o no es válido.Confundirlo con 403: 401 es «no sé quién eres».
402Pago rechazado por la pasarela.Poco habitual, pero es exactamente su semántica.
403Sé quién eres y no puedes.Usarlo para recursos ajenos, revelando que existen. Preferimos 404.
404No existe, o existe pero no es tuyo.Devolver 200 con cuerpo vacío.
409Conflicto con el estado actual: sin stock, transición inválida, versión obsoleta.Devolver 500 ante una OptimisticLockException.
422Sintaxis correcta, semántica imposible: SKU inexistente, clave de idempotencia reutilizada.Mezclarlo con 400 sin criterio; elige uno y sé coherente.
429Demasiados intentos de login.Olvidar la cabecera Retry-After.
500Fallo no previsto.Incluir la traza en el cuerpo: filtra rutas, versiones y estructura interna.
503Dependencia caída y sonda de disponibilidad en rojo.Devolver 200 en /health aunque la base de datos no responda.

4.11 OpenAPI: el contrato ejecutable

Con springdoc-openapi la especificación se genera desde el código y las anotaciones. La clave es documentar los errores y los ejemplos, no solo el camino feliz: un OpenAPI que solo describe respuestas 200 es un catálogo, no un contrato.

# Fragmento de /v3/api-docs (generado). Así se ve el endpoint crítico.
openapi: 3.1.0
info:
  title: Cafetería Tech API
  version: "1.0.0"
  description: Catálogo, inventario y pedidos con recogida y envío.
servers:
  - url: http://localhost:8080
    description: Entorno local (docker compose)

paths:
  /api/v1/pedidos/{id}/confirmacion:
    post:
      tags: [Pedidos]
      summary: Confirma un pedido reservando stock y cobrando
      description: |
        Operación **idempotente** mediante la cabecera `Idempotency-Key`.
        Reintentar con la misma clave devuelve la respuesta original sin
        volver a cobrar. Todo o nada: si falta stock de una línea, no se
        reserva ninguna.
      operationId: confirmarPedido
      security: [ { bearerAuth: [] } ]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: Idempotency-Key
          in: header
          required: true
          description: UUID generado por el cliente. Válido durante 24 horas.
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Pedido confirmado y cobrado
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PedidoRespuesta' }
              examples:
                confirmado:
                  value:
                    id: "9b2f6c10-7d3e-4a55-8f21-6c0f2b1d4e77"
                    estado: "CONFIRMADO"
                    total: "34.90"
        "402":
          description: La pasarela rechazó el pago
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/ProblemDetail' }
        "409":
          description: Sin stock suficiente o estado no válido
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/ProblemDetail' }
              examples:
                sinStock:
                  value:
                    type: "https://cafeteria.dev/errores/stock-insuficiente"
                    title: "Stock insuficiente"
                    status: 409
                    faltantes:
                      - { sku: "CAF-ETIOPIA-250", solicitado: 2, disponible: 1 }

components:
  securitySchemes:
    bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT }
  schemas:
    ProblemDetail:
      type: object
      properties:
        type:     { type: string, format: uri }
        title:    { type: string }
        status:   { type: integer }
        detail:   { type: string }
        instance: { type: string }
      required: [type, title, status]
Convierte la especificación en un test. Añade una comprobación en CI que descargue /v3/api-docs del contexto de test y valide que no ha cambiado respecto al fichero guardado en docs/openapi.json. Así, cualquier cambio del contrato es visible en el diff del pull request y nadie rompe a un cliente sin darse cuenta. Es cinco minutos de trabajo y suena a equipo con experiencia, porque lo es.

Cierre de datos y contrato

5 · Plan de construcción en seis fases

Esta es la sección que tendrás abierta a diario. Cada fase trae objetivo (qué debe existir al acabar), tareas marcables, criterio de «hecho» —una lista objetiva, sin interpretación posible—, tiempo estimado y el módulo del plan que conviene releer. El código de cada fase es real y está pensado para copiarlo y seguir tirando del hilo, no para leerlo.

FaseQué existe al terminarHorasMódulos a releer
F1 · EsqueletoProyecto que arranca, contenedores, salud y CI en verde.4–6 h04, 09
F2 · Dominio y datosModelo con reglas, persistencia, Flyway y tests con base de datos real.10–14 h01, 05, 06, 07
F3 · APIREST completa con validación, errores, paginación y OpenAPI.8–12 h04, 07
F4 · SeguridadAutenticación con JWT, roles y autorización por recurso probada.6–8 h10, 10.4
F5 · Eventos y cachéOutbox, Kafka, consumidor idempotente, tarea programada y Redis.10–14 h08, 03, 06.9
F6 · OperaciónContenedores afinados, Kubernetes, métricas, trazas, panel y carga.8–12 h09, 08.9
Sobre las estimaciones: son horas de trabajo concentrado de alguien que ya ha estudiado los módulos. Si tardas el doble, no es que vayas mal: es que estás aprendiendo, que es el objetivo. Lo que sí debe preocuparte es lo contrario, tardar mucho menos: casi siempre significa que has saltado el criterio de «hecho». Total aproximado: 46 a 66 horas, entre tres y ocho semanas a ritmo realista compaginando con otras obligaciones.

5.1 Fase 1 · Esqueleto, configuración, salud y CI mínima

Objetivo: que exista un repositorio que cualquiera pueda clonar y arrancar, con el entorno completo en contenedores y un pipeline que se ponga verde. Cero funcionalidad de negocio. Parece poco y es la fase que más veces se hace mal: si el esqueleto no es sólido, todo lo demás se construye sobre arena.

# compose.yaml — todo el entorno con un comando. El --wait de docker compose
# depende de estos healthcheck: sin ellos, "up" devuelve antes de que la base
# de datos acepte conexiones y la aplicación falla al arrancar.
services:
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: cafeteria
      POSTGRES_USER: cafeteria
      POSTGRES_PASSWORD: ${DB_PASSWORD:-desarrollo}
    ports: ["5432:5432"]
    volumes: ["pgdata:/var/lib/postgresql/data"]
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U cafeteria -d cafeteria"]
      interval: 5s
      timeout: 3s
      retries: 10
    command: >
      postgres -c shared_preload_libraries=pg_stat_statements
               -c log_min_duration_statement=200

  redis:
    image: redis:7-alpine
    command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru
    ports: ["6379:6379"]
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      retries: 10

  kafka:
    image: apache/kafka:3.7.0          # KRaft: sin ZooKeeper
    ports: ["9092:9092"]
    environment:
      KAFKA_NODE_ID: 1
      KAFKA_PROCESS_ROLES: broker,controller
      KAFKA_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093
      KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092
      KAFKA_CONTROLLER_QUORUM_VOTERS: 1@localhost:9093
      KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
      KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
      KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
    healthcheck:
      test: ["CMD-SHELL", "/opt/kafka/bin/kafka-topics.sh --bootstrap-server localhost:9092 --list"]
      interval: 10s
      retries: 12

  api:
    build: .
    depends_on:
      db:    { condition: service_healthy }
      redis: { condition: service_healthy }
      kafka: { condition: service_healthy }
    environment:
      SPRING_PROFILES_ACTIVE: demo
      SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/cafeteria
      SPRING_DATASOURCE_PASSWORD: ${DB_PASSWORD:-desarrollo}
      SPRING_DATA_REDIS_HOST: redis
      SPRING_KAFKA_BOOTSTRAP_SERVERS: kafka:9092
    ports: ["8080:8080"]
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:8080/actuator/health/readiness"]
      interval: 10s
      retries: 12

volumes:
  pgdata:
# Dockerfile — multietapa. Tres decisiones que importan:
#  1. Las dependencias se descargan en una capa propia: si solo cambia el
#     código, esa capa se reutiliza y el build baja de minutos a segundos.
#  2. Se extrae el jar por capas (Spring Boot layertools): las dependencias,
#     que casi nunca cambian, van en una capa distinta de tus clases.
#  3. Usuario sin privilegios y JRE, no JDK: menos superficie de ataque.

FROM eclipse-temurin:21-jdk-alpine AS build
WORKDIR /app
COPY .mvn/ .mvn/
COPY mvnw pom.xml ./
RUN ./mvnw -B dependency:go-offline
COPY src ./src
RUN ./mvnw -B -DskipTests package && \
    java -Djarmode=layertools -jar target/*.jar extract --destination target/capas

FROM eclipse-temurin:21-jre-alpine
RUN addgroup -S app && adduser -S app -G app
WORKDIR /app
COPY --from=build /app/target/capas/dependencies/          ./
COPY --from=build /app/target/capas/spring-boot-loader/    ./
COPY --from=build /app/target/capas/snapshot-dependencies/ ./
COPY --from=build /app/target/capas/application/           ./
USER app
EXPOSE 8080
# Contenedores: deja que la JVM lea los límites del cgroup en vez de fijar -Xmx.
ENV JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75 -XX:+UseG1GC -Djava.security.egd=file:/dev/./urandom"
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]
# .github/workflows/ci.yml — el pipeline mínimo pero honesto de la fase 1.
# En F6 se le añaden el escaneo de imagen y la publicación.
name: CI
on:
  push: { branches: [main] }
  pull_request:

jobs:
  build:
    runs-on: ubuntu-latest
    timeout-minutes: 20
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: '21'
          cache: maven                     # sin esto, cada build baja medio internet

      - name: Compilar y pasar los tests
        run: ./mvnw -B verify              # verify incluye los tests de integración

      - name: Publicar el informe de tests
        if: always()                       # también cuando fallan: es cuando interesa
        uses: mikepenz/action-junit-report@v4
        with: { report_paths: '**/target/*-reports/TEST-*.xml' }

      - name: Comprobar el formato
        run: ./mvnw -B spotless:check

      - name: Analizar dependencias vulnerables
        run: ./mvnw -B org.owasp:dependency-check-maven:check -DfailBuildOnCVSS=7

Criterio de «hecho» de la fase 1

  • Un git clone seguido de docker compose up -d --wait deja el sistema en pie sin intervención manual.
  • curl localhost:8080/actuator/health devuelve {"status":"UP"} con los componentes de base de datos, Redis y Kafka en verde.
  • ./mvnw verify pasa en limpio, y el badge de CI está en verde en el README.
  • Parar la base de datos hace que /actuator/health/readiness pase a DOWN. Si no ocurre, la sonda es decorativa.
  • No hay ni una contraseña real en el repositorio (compruébalo con gitleaks detect).

5.2 Fase 2 · Dominio y persistencia

Objetivo: el corazón del sistema. Al terminar, las reglas de negocio existen, están probadas en milisegundos sin arrancar Spring, y se guardan en PostgreSQL a través de un adaptador. Todavía no hay API: los casos de uso se ejercitan desde los tests. Esta separación es deliberada, porque obliga a que el dominio no dependa de la web.

// ---------------------------------------------------------------------------
//  DOMINIO · El agregado Pedido. Ni una anotación de framework: se puede
//  instanciar en un test en microsegundos. Fíjate en que NO hay setters: la
//  única forma de cambiar el estado es a través de métodos que representan
//  operaciones del negocio y que protegen los invariantes.
// ---------------------------------------------------------------------------
package dev.cafeteria.pedidos.dominio;

public final class Pedido {

    private final IdPedido id;
    private final IdCliente clienteId;
    private final IdLocal localId;
    private final Canal canal;
    private final List<LineaPedido> lineas;
    private final Instant creadoEn;
    private EstadoPedido estado;
    private Instant confirmadoEn;
    private final List<EventoDominio> eventos = new ArrayList<>();

    private Pedido(IdPedido id, IdCliente clienteId, IdLocal localId, Canal canal,
                   List<LineaPedido> lineas, Instant creadoEn) {
        // Las precondiciones del agregado se comprueban una sola vez, aquí.
        if (lineas.isEmpty())      throw new PedidoSinLineasException();
        if (lineas.size() > 50)    throw new DemasiadasLineasException(lineas.size());
        this.id = requireNonNull(id);
        this.clienteId = requireNonNull(clienteId);
        this.localId = requireNonNull(localId);
        this.canal = requireNonNull(canal);
        this.lineas = List.copyOf(lineas);      // copia defensiva: nadie muta por fuera
        this.creadoEn = creadoEn;
        this.estado = EstadoPedido.BORRADOR;
    }

    /** Fábrica: agrupa líneas repetidas y congela el precio (RN-04). */
    public static Pedido crear(IdCliente cliente, IdLocal local, Canal canal,
                               List<LineaSolicitada> solicitadas, Catalogo catalogo,
                               Reloj reloj) {
        var lineas = solicitadas.stream()
            .collect(groupingBy(LineaSolicitada::sku,
                                summingInt(LineaSolicitada::cantidad)))
            .entrySet().stream()
            .map(e -> {
                var p = catalogo.buscarVigente(e.getKey())
                                .orElseThrow(() -> new ProductoNoDisponibleException(e.getKey()));
                return LineaPedido.de(p.sku(), p.nombre(), p.precio(), e.getValue());
            })
            .toList();
        return new Pedido(IdPedido.nuevo(), cliente, local, canal, lineas, reloj.ahora());
    }

    /** RN-03: el total lo calcula SIEMPRE el dominio, jamás el cliente. */
    public Dinero total() {
        return lineas.stream()
                     .map(LineaPedido::importe)
                     .reduce(Dinero.CERO_EUR, Dinero::mas)
                     .mas(gastosEnvio());
    }

    private Dinero gastosEnvio() {
        return canal == Canal.ENVIO ? Dinero.euros("3.90") : Dinero.CERO_EUR;
    }

    /** RN-06: única puerta de entrada a un cambio de estado. */
    public void transitarA(EstadoPedido nuevo, Instant cuando) {
        if (!estado.permite(nuevo)) {
            throw new TransicionNoPermitidaException(estado, nuevo, estado.siguientes());
        }
        var previo = this.estado;
        this.estado = nuevo;
        if (nuevo == EstadoPedido.CONFIRMADO) this.confirmadoEn = cuando;
        eventos.add(EventoDominio.cambioEstado(id, previo, nuevo, cuando));
    }

    public boolean perteneceA(IdCliente candidato) {   // RN-09
        return clienteId.equals(candidato);
    }

    /** Los eventos se recogen y se vacían al persistir (patrón outbox). */
    public List<EventoDominio> drenarEventos() {
        var copia = List.copyOf(eventos);
        eventos.clear();
        return copia;
    }
}
// La máquina de estados como enum: el compilador y un único test protegen RN-06.
public enum EstadoPedido {
    BORRADOR, PAGO_RECHAZADO, CONFIRMADO, EN_PREPARACION, LISTO, ENTREGADO, CANCELADO;

    private static final Map<EstadoPedido, Set<EstadoPedido>> TRANSICIONES = Map.of(
        BORRADOR,       EnumSet.of(CONFIRMADO, PAGO_RECHAZADO, CANCELADO),
        PAGO_RECHAZADO, EnumSet.of(CONFIRMADO, CANCELADO),
        CONFIRMADO,     EnumSet.of(EN_PREPARACION, CANCELADO),
        EN_PREPARACION, EnumSet.of(LISTO, CANCELADO),
        LISTO,          EnumSet.of(ENTREGADO),
        ENTREGADO,      EnumSet.noneOf(EstadoPedido.class),
        CANCELADO,      EnumSet.noneOf(EstadoPedido.class));

    public boolean permite(EstadoPedido destino)  { return siguientes().contains(destino); }
    public Set<EstadoPedido> siguientes()         { return TRANSICIONES.get(this); }
    public boolean esFinal()                      { return siguientes().isEmpty(); }
}
// ---------------------------------------------------------------------------
//  ADAPTADOR DE SALIDA · La entidad JPA vive aquí, separada del dominio, y el
//  mapeador traduce. Coste: dos clases más. Beneficio: el dominio no arrastra
//  proxies perezosos, ni constructor vacío, ni equals basado en el id de base.
// ---------------------------------------------------------------------------
@Entity @Table(name = "pedido")
class PedidoJpa {
    @Id private UUID id;
    @Column(nullable = false) private UUID clienteId;
    @Column(nullable = false) private UUID localId;
    @Enumerated(EnumType.STRING) @Column(nullable = false) private Canal canal;
    @Enumerated(EnumType.STRING) @Column(nullable = false) private EstadoPedido estado;
    @Column(nullable = false, precision = 12, scale = 2) private BigDecimal total;
    private Instant creadoEn;
    private Instant confirmadoEn;

    @Version private long version;      // bloqueo optimista: 409 en vez de sobrescribir

    // Cascada porque las líneas son parte del agregado y no viven sin él.
    @OneToMany(mappedBy = "pedido", cascade = ALL, orphanRemoval = true)
    private List<LineaPedidoJpa> lineas = new ArrayList<>();

    protected PedidoJpa() { }           // exigido por JPA, no por ti
}

@Repository
class PedidoRepositorioJpa implements PedidoRepositorio {   // implementa el PUERTO

    private final PedidoJpaSpringData springData;
    private final PedidoMapeador mapeador;

    @Override
    public Optional<Pedido> buscar(IdPedido id) {
        return springData.findById(id.valor()).map(mapeador::aDominio);
    }

    /** RN-09: la propiedad va DENTRO de la consulta, no se filtra en memoria.
     *  Así es imposible que un olvido convierta esto en un IDOR. */
    @Override
    public Optional<Pedido> buscarDeCliente(IdPedido id, IdCliente cliente) {
        return springData.findByIdAndClienteId(id.valor(), cliente.valor())
                         .map(mapeador::aDominio);
    }

    @Override
    public Pedido guardar(Pedido pedido) {
        return mapeador.aDominio(springData.save(mapeador.aJpa(pedido)));
    }
}

interface PedidoJpaSpringData extends JpaRepository<PedidoJpa, UUID> {

    // EntityGraph para traer las líneas en la misma consulta: sin esto,
    // cargar 20 pedidos con sus líneas dispara 21 consultas (N+1).
    @EntityGraph(attributePaths = "lineas")
    Optional<PedidoJpa> findByIdAndClienteId(UUID id, UUID clienteId);

    Page<PedidoJpa> findByClienteIdAndEstadoOrderByCreadoEnDesc(
            UUID clienteId, EstadoPedido estado, Pageable pageable);
}
// ---------------------------------------------------------------------------
//  CASO DE USO · Orquesta, no decide. Las reglas están en el dominio; aquí solo
//  se coordinan puertos y se delimita la transacción.
// ---------------------------------------------------------------------------
@Service
public class ConfirmarPedido {

    private final PedidoRepositorio pedidos;
    private final InventarioApi inventario;
    private final PasarelaPago pasarela;
    private final RegistroEventos outbox;
    private final Reloj reloj;

    @Transactional            // RN-07: reserva, cobro y cambio de estado, atómicos
    public ResultadoConfirmacion ejecutar(IdPedido id, IdCliente solicitante) {

        var pedido = pedidos.buscarDeCliente(id, solicitante)
                            .orElseThrow(() -> new PedidoNoEncontradoException(id));

        var lineas = pedido.lineas().stream()
                           .map(l -> new LineaReserva(l.sku(), l.cantidad()))
                           .toList();

        return switch (inventario.reservar(id.valor(), pedido.localId().valor(), lineas)) {

            case SinStock s -> ResultadoConfirmacion.sinStock(s.faltantes());

            case LocalDesconocido l -> throw new IllegalStateException("Local inexistente: " + l);

            case Reservado r -> {
                var cobro = pasarela.cobrar(id, pedido.total());
                if (cobro.rechazado()) {
                    inventario.liberar(id.valor());
                    pedido.transitarA(EstadoPedido.PAGO_RECHAZADO, reloj.ahora());
                    pedidos.guardar(pedido);
                    yield ResultadoConfirmacion.pagoRechazado(cobro.motivo());
                }
                pedido.transitarA(EstadoPedido.CONFIRMADO, reloj.ahora());
                pedidos.guardar(pedido);
                // Outbox: el evento se escribe en ESTA transacción (RN-11).
                outbox.registrar(pedido.drenarEventos());
                yield ResultadoConfirmacion.confirmado(pedido);
            }
        };
    }
}
// ---------------------------------------------------------------------------
//  EL TEST QUE VAS A ENSEÑAR EN LA ENTREVISTA. Demuestra que la reserva es
//  correcta bajo concurrencia real, contra PostgreSQL real. RNF "cero sobreventas".
// ---------------------------------------------------------------------------
@SpringBootTest
@Testcontainers
class ReservarStockConcurrenciaTest {

    @Container @ServiceConnection
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");

    @Autowired InventarioApi inventario;
    @Autowired ExistenciasTestFixture datos;

    @Test
    void solo_un_pedido_se_lleva_la_ultima_unidad() throws Exception {
        var local = datos.local();
        var sku   = datos.productoConExistencias(local, 1);   // ¡una sola unidad!
        int hilos = 50;

        var barrera   = new CyclicBarrier(hilos);   // arrancan todos a la vez
        var exitos    = new AtomicInteger();
        var sinStock  = new AtomicInteger();

        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            var tareas = IntStream.range(0, hilos).<Callable<Void>>mapToObj(i -> () -> {
                barrera.await();
                var r = inventario.reservar(UUID.randomUUID(), local,
                                            List.of(new LineaReserva(sku, 1)));
                if (r instanceof Reservado) exitos.incrementAndGet(); else sinStock.incrementAndGet();
                return null;
            }).toList();
            executor.invokeAll(tareas);
        }

        assertThat(exitos.get()).isEqualTo(1);            // exactamente uno gana
        assertThat(sinStock.get()).isEqualTo(hilos - 1);
        assertThat(datos.disponible(local, sku)).isZero();  // y no queda en negativo
    }
}
Este test falla si tu implementación es ingenua, y esa es la gracia. Leer las existencias, comprobar en Java y escribir después produce sobreventa: entre la lectura y la escritura caben otros 49 hilos. Las dos soluciones correctas: bloqueo pesimista sobre la fila (SELECT … FOR UPDATE, es decir @Lock(PESSIMISTIC_WRITE)), que serializa a los competidores; o un UPDATE condicional atómico (SET cantidad = cantidad - :n WHERE cantidad >= :n) comprobando cuántas filas se han actualizado. Aquí se usa la segunda para la reserva simple y la primera cuando hay varias líneas, porque ordenar los bloqueos por identificador evita interbloqueos. Poder explicar esto en dos minutos vale más que diez proyectos CRUD. Los detalles están en 06 · Transacciones.

Criterio de «hecho» de la fase 2

  • Los tests de dominio corren en menos de cinco segundos y no arrancan Spring.
  • El test de concurrencia pasa cien veces seguidas (-Dsurefire.rerunFailingTestsCount=0 y ejecútalo en bucle: si es intermitente, no está resuelto).
  • Ningún @Entity ni @Autowired aparece dentro de un paquete dominio.
  • La aplicación arranca con ddl-auto=validate: el esquema de Flyway y el mapeo coinciden.
  • Cargar un pedido con diez líneas emite una consulta, demostrado con un test que las cuenta.

5.3 Fase 3 · API REST completa

Objetivo: exponer los casos de uso con un contrato que cumpla lo pactado en la sección 4. Aquí no se añade lógica de negocio: si te ves escribiendo un if de negocio en un controlador, es que falta un método en el dominio.

@RestController
@RequestMapping("/api/v1/pedidos")
@Validated
class PedidoControlador {

    private final CrearPedido crearPedido;
    private final ConfirmarPedido confirmarPedido;
    private final ConsultarPedidos consultarPedidos;

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    ResponseEntity<PedidoRespuesta> crear(@Valid @RequestBody CrearPedidoPeticion peticion,
                                         @AuthenticationPrincipal Usuario usuario,
                                         UriComponentsBuilder uri) {
        var pedido = crearPedido.ejecutar(peticion.aComando(usuario.id()));
        var cuerpo = PedidoRespuesta.de(pedido);
        // 201 con Location: el cliente sabe dónde vive el recurso que ha creado.
        return ResponseEntity
                .created(uri.path("/api/v1/pedidos/{id}").build(pedido.id().valor()))
                .body(cuerpo);
    }

    @PostMapping("/{id}/confirmacion")
    @Idempotente                       // el aspecto de la sección 4.8
    ResponseEntity<?> confirmar(@PathVariable UUID id,
                                @RequestHeader("Idempotency-Key") @NotBlank String clave,
                                @AuthenticationPrincipal Usuario usuario) {

        // El resultado sellado obliga a mapear cada caso a su código HTTP.
        return switch (confirmarPedido.ejecutar(new IdPedido(id), usuario.idCliente())) {
            case Confirmado c     -> ResponseEntity.ok(PedidoRespuesta.de(c.pedido()));
            case SinStockResultado s -> throw new StockInsuficienteException(s.faltantes());
            case PagoRechazado p  -> ResponseEntity.status(HttpStatus.PAYMENT_REQUIRED)
                                                   .body(ErrorPago.de(p));
        };
    }

    @GetMapping
    PaginaRespuesta<PedidoResumen> mios(@RequestParam(required = false) EstadoPedido estado,
                                        @RequestParam(defaultValue = "0")  @Min(0) int page,
                                        @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size,
                                        @AuthenticationPrincipal Usuario usuario) {
        return consultarPedidos.deCliente(usuario.idCliente(), estado, page, size);
    }
}

// DTO de entrada: validación declarativa y ni un campo de más. Nótese que NO
// existe un campo "total": el importe no lo decide el cliente (HU-04).
record CrearPedidoPeticion(
        @NotNull  UUID localId,
        @NotNull  Canal canal,
        @Size(max = 200) String direccion,
        @NotEmpty @Size(max = 50) @Valid List<LineaPeticion> lineas) {

    // Validación condicional: la anotación por campo no puede expresar
    // "obligatoria solo si el canal es ENVIO".
    @AssertTrue(message = "la dirección es obligatoria cuando el canal es ENVIO")
    boolean isDireccionCoherente() {
        return canal != Canal.ENVIO || (direccion != null && !direccion.isBlank());
    }

    record LineaPeticion(@NotBlank @Pattern(regexp = "^[A-Z0-9-]{3,40}$") String sku,
                         @Min(1) @Max(100) int cantidad) {}
}
// Slice web: arranca solo la capa MVC con los colaboradores simulados. Rápido
// (milisegundos) y perfecto para verificar el CONTRATO: códigos, cabeceras,
// forma del JSON y forma del error. Lo que no comprueba es la persistencia:
// para eso está el test de integración.
@WebMvcTest(PedidoControlador.class)
@Import({ManejadorGlobalErrores.class, ConfiguracionSeguridadTest.class})
class PedidoControladorTest {

    @Autowired MockMvc mvc;
    @MockitoBean CrearPedido crearPedido;
    @MockitoBean ConfirmarPedido confirmarPedido;

    @Test @WithMockUser(roles = "CLIENTE")
    void crear_pedido_valido_devuelve_201_con_location() throws Exception {
        given(crearPedido.ejecutar(any())).willReturn(unPedido());

        mvc.perform(post("/api/v1/pedidos").with(csrf())
                .contentType(APPLICATION_JSON)
                .content("""
                    { "localId": "3f1a0e64-0000-4000-8000-000000000001",
                      "canal": "RECOGIDA",
                      "lineas": [ { "sku": "CAF-ETIOPIA-250", "cantidad": 2 } ] }
                    """))
           .andExpect(status().isCreated())
           .andExpect(header().exists("Location"))
           .andExpect(jsonPath("$.estado").value("BORRADOR"))
           .andExpect(jsonPath("$.total").value("25.00"));
    }

    @Test @WithMockUser(roles = "CLIENTE")
    void envio_sin_direccion_devuelve_400_con_problem_detail() throws Exception {
        mvc.perform(post("/api/v1/pedidos").with(csrf())
                .contentType(APPLICATION_JSON)
                .content("""
                    { "localId": "3f1a0e64-0000-4000-8000-000000000001",
                      "canal": "ENVIO",
                      "lineas": [ { "sku": "CAF-ETIOPIA-250", "cantidad": 1 } ] }
                    """))
           .andExpect(status().isBadRequest())
           .andExpect(content().contentTypeCompatibleWith("application/problem+json"))
           .andExpect(jsonPath("$.title").value("La petición no es válida"))
           .andExpect(jsonPath("$.errores[0].campo").value("direccionCoherente"));
    }

    @Test
    void sin_autenticar_devuelve_401() throws Exception {
        mvc.perform(post("/api/v1/pedidos").with(csrf()).contentType(APPLICATION_JSON).content("{}"))
           .andExpect(status().isUnauthorized());
    }
}

Criterio de «hecho» de la fase 3

  • Todos los endpoints de la tabla responden, y el script demo.sh recorre el caso de uso principal de punta a punta sin intervención.
  • Ninguna respuesta de error devuelve una traza de excepción ni un HTML de Whitelabel.
  • Pedir size=1000 o un sort no permitido devuelve 400, no un volcado.
  • La interfaz de OpenAPI permite ejecutar un pedido completo sin leer el código.
  • Cada línea de log de una petición lleva su requestId, y ese identificador aparece en la respuesta de error.

5.4 Fase 4 · Seguridad: autenticación y autorización

Objetivo: que el sistema deje de confiar en quien lo llama. No basta con «hay login»: lo que se evalúa es la autorización por recurso (RN-09), que es donde falla la mayoría de las aplicaciones reales.

@Configuration @EnableWebSecurity @EnableMethodSecurity
class ConfiguracionSeguridad {

    @Bean
    SecurityFilterChain api(HttpSecurity http, JwtDecoder decoder) throws Exception {
        return http
            // API sin estado con token: CSRF no aplica. Si hubiera cookies, sí.
            .csrf(AbstractHttpConfigurer::disable)
            .sessionManagement(s -> s.sessionCreationPolicy(STATELESS))
            .authorizeHttpRequests(reg -> reg
                // De lo MÁS específico a lo más general: la primera regla que
                // encaja es la que manda.
                .requestMatchers(HttpMethod.POST, "/api/v1/auth/**").permitAll()
                .requestMatchers(HttpMethod.GET,  "/api/v1/productos/**",
                                                  "/api/v1/locales").permitAll()
                .requestMatchers("/actuator/health/**").permitAll()
                .requestMatchers("/actuator/**").hasRole("ADMIN")
                .requestMatchers(HttpMethod.POST,   "/api/v1/productos/**").hasRole("ADMIN")
                .requestMatchers(HttpMethod.PUT,    "/api/v1/productos/**").hasRole("ADMIN")
                .requestMatchers(HttpMethod.DELETE, "/api/v1/productos/**").hasRole("ADMIN")
                .requestMatchers("/api/v1/inventario/**").hasAnyRole("STAFF", "ADMIN")
                .requestMatchers("/api/v1/informes/**").hasRole("ADMIN")
                .anyRequest().authenticated())          // deny by default
            .oauth2ResourceServer(o -> o.jwt(j -> j.decoder(decoder)
                                                   .jwtAuthenticationConverter(convertidor())))
            .exceptionHandling(e -> e
                .authenticationEntryPoint(this::sin401ConProblemDetail)
                .accessDeniedHandler(this::con403ProblemDetail))
            .headers(h -> h.contentSecurityPolicy(c -> c.policyDirectives("default-src 'none'"))
                           .frameOptions(FrameOptionsConfig::deny))
            .build();
    }

    @Bean PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(12); }
}

// Autorización por atributo del recurso: el barista solo ve su local (HU-08).
// Se expresa como una expresión reutilizable en vez de repetir el if.
@Component("localAuth")
class AutorizacionLocal {
    boolean puedeOperar(Authentication auth, UUID localId) {
        if (tieneRol(auth, "ROLE_ADMIN")) return true;
        return tieneRol(auth, "ROLE_STAFF") && localesDe(auth).contains(localId);
    }
}

@GetMapping("/api/v1/locales/{localId}/cola")
@PreAuthorize("@localAuth.puedeOperar(authentication, #localId)")
List<PedidoResumen> cola(@PathVariable UUID localId) { … }
// El test que demuestra que no hay IDOR. Es el que hay que enseñar cuando
// pregunten por seguridad: prueba una VULNERABILIDAD concreta, no una config.
@SpringBootTest @AutoConfigureMockMvc @Testcontainers
class AccesoCruzadoTest {

    @Test
    void un_cliente_no_puede_ver_el_pedido_de_otro() throws Exception {
        var pedidoDeAna = datos.pedidoDe(ana);

        mvc.perform(get("/api/v1/pedidos/{id}", pedidoDeAna.id())
                .header("Authorization", "Bearer " + tokenDe(bruno)))
           // 404 y no 403: un 403 confirmaría que el pedido existe, lo que ya
           // es una filtración de información aprovechable.
           .andExpect(status().isNotFound());
    }

    @Test
    void un_barista_no_puede_ver_la_cola_de_otro_local() throws Exception {
        mvc.perform(get("/api/v1/locales/{id}/cola", localSur.id())
                .header("Authorization", "Bearer " + tokenDe(baristaDelNorte)))
           .andExpect(status().isForbidden());
    }

    @Test
    void un_token_firmado_con_otra_clave_se_rechaza() throws Exception {
        mvc.perform(get("/api/v1/pedidos")
                .header("Authorization", "Bearer " + tokenFirmadoConClaveAjena()))
           .andExpect(status().isUnauthorized());
    }

    @Test
    void un_token_caducado_se_rechaza() throws Exception {
        mvc.perform(get("/api/v1/pedidos")
                .header("Authorization", "Bearer " + tokenCaducado(ana)))
           .andExpect(status().isUnauthorized());
    }
}

Criterio de «hecho» de la fase 4

  • Sin token, ningún endpoint de negocio responde 200.
  • Los cuatro tests de acceso cruzado están escritos y pasan.
  • Cambiar un identificador en la URL por el de otro usuario devuelve 404, no datos ajenos.
  • gitleaks detect y el análisis de dependencias pasan en CI sin hallazgos altos ni críticos.
  • La contraseña de un usuario en la base de datos empieza por $2b$12$ y no se parece a la original.

5.5 Fase 5 · Eventos, idempotencia y caché

Objetivo: desacoplar lo que no tiene que ocurrir dentro de la petición y hacerlo sin perder información. Es la fase con más contenido de entrevista por hora invertida: outbox, entrega «al menos una vez», consumidores idempotentes y caché con sus problemas.

// PUBLICADOR DE LA OUTBOX. Tres detalles: SKIP LOCKED para que varias
// instancias no se pisen, lote acotado para no monopolizar el hilo, y marcado
// del error para poder diagnosticar sin adivinar.
@Component
class PublicadorOutbox {

    @Scheduled(fixedDelay = 1000)
    @Transactional
    void publicarPendientes() {
        var lote = repositorio.tomarPendientes(100);   // SELECT … FOR UPDATE SKIP LOCKED
        for (var evento : lote) {
            try {
                kafka.send(new ProducerRecord<>("pedidos.v1", evento.clave(), evento.carga()))
                     .get(5, TimeUnit.SECONDS);        // esperamos la confirmación del broker
                repositorio.marcarPublicado(evento.id(), reloj.ahora());
            } catch (Exception e) {
                // No relanzamos: un evento problemático no debe bloquear al resto.
                repositorio.registrarFallo(evento.id(), e.getMessage());
                log.warn("Fallo publicando evento {} (intento {})", evento.id(), evento.intentos() + 1, e);
            }
        }
    }
}
-- La consulta que hace segura la concurrencia entre instancias. Sin
-- SKIP LOCKED, dos publicadores se bloquean mutuamente; con él, cada uno
-- se lleva un lote distinto sin esperar.
SELECT *
FROM   evento_outbox
WHERE  publicado_en IS NULL
ORDER  BY creado_en
LIMIT  100
FOR UPDATE SKIP LOCKED;
// CONSUMIDOR IDEMPOTENTE. Kafka entrega "al menos una vez": un reequilibrio o
// un reinicio provocan repeticiones. Sin esta comprobación, el cliente recibe
// el mismo aviso tres veces y el proyecto pierde toda credibilidad.
@Component
class ConsumidorPedidos {

    private static final String CONSUMIDOR = "notificaciones";

    @KafkaListener(topics = "pedidos.v1", groupId = "notificaciones")
    @Transactional
    public void consumir(@Payload EventoPedido evento, Acknowledgment ack) {
        if (!procesados.marcarSiEsNuevo(CONSUMIDOR, evento.eventoId())) {
            log.debug("Evento {} ya procesado, se ignora", evento.eventoId());
            ack.acknowledge();
            return;
        }
        switch (evento.tipo()) {
            case "PedidoListo"     -> avisos.avisarPedidoListo(evento.agregadoId());
            case "PedidoConfirmado"-> avisos.confirmarRecepcion(evento.agregadoId());
            default                -> log.debug("Tipo no manejado: {}", evento.tipo());
        }
        ack.acknowledge();
    }
}
# Configuración del consumidor: los valores por defecto NO son los que quieres
# en producción, y explicar por qué es una pregunta clásica de entrevista.
spring:
  kafka:
    consumer:
      group-id: notificaciones
      auto-offset-reset: earliest      # un consumidor nuevo lee desde el principio
      enable-auto-commit: false        # confirmamos NOSOTROS, tras procesar
      max-poll-records: 50
      properties:
        isolation.level: read_committed
    listener:
      ack-mode: manual_immediate
      concurrency: 3                   # como máximo, tantos como particiones
    producer:
      acks: all                        # confirmación de todas las réplicas sincronizadas
      properties:
        enable.idempotence: true       # evita duplicados por reintento del productor
        max.in.flight.requests.per.connection: 5
        delivery.timeout.ms: 120000
// TAREA PROGRAMADA con varias instancias (HU-12). El error clásico es asumir
// que solo hay un pod: con dos réplicas, la tarea se ejecuta dos veces.
// Solución sin dependencias nuevas: un cerrojo en la propia base de datos.
@Scheduled(cron = "0 * * * * *")        // cada minuto
@Transactional
void liberarReservasCaducadas() {
    // pg_try_advisory_xact_lock: si otra instancia lo tiene, esta se va sin hacer nada.
    if (!cerrojos.intentarAdquirir(CLAVE_LIBERACION)) return;

    int liberadas = reservas.liberarCaducadas(reloj.ahora());
    if (liberadas > 0) {
        contador.increment(liberadas);
        log.info("Liberadas {} reservas caducadas", liberadas);
    }
}
// CACHÉ con los tres problemas resueltos. Sin sync=true, cien peticiones
// simultáneas tras una expiración van todas a la base de datos (avalancha).
@Cacheable(value = "catalogo", key = "#localId + ':' + #pagina", sync = true)
public PaginaRespuesta<ProductoResumen> catalogo(UUID localId, int pagina) { … }

@CacheEvict(value = "catalogo", allEntries = true)   // el precio cambió: se invalida
public void cambiarPrecio(Sku sku, Dinero nuevo) { … }

@Bean
RedisCacheConfiguration configuracionCache() {
    return RedisCacheConfiguration.defaultCacheConfig()
        .entryTtl(Duration.ofMinutes(5))
        // Sin esto, todas las claves caducan a la vez y se produce un pico.
        // Spring no ofrece jitter nativo: se añade en el generador de claves
        // o con TTL por caché ligeramente distintos.
        .disableCachingNullValues()      // no cachear "no existe": invita a envenenar la caché
        .serializeValuesWith(SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));
}

Criterio de «hecho» de la fase 5

  • Parar Kafka, confirmar un pedido y volver a arrancarlo: el evento se publica igualmente. Esa es la prueba de que la outbox sirve.
  • Reenviar el mismo evento manualmente no genera un segundo aviso.
  • Con dos instancias arrancadas, la tarea programada libera cada reserva una sola vez.
  • La métrica de aciertos de caché sube al repetir la consulta del catálogo.
  • Ningún test usa Thread.sleep: todos esperan condiciones con Awaitility.

5.6 Fase 6 · Contenedores, Kubernetes y observabilidad

Objetivo: que el proyecto se vea como algo que podría estar en producción. Es la fase que convierte «he hecho una aplicación» en «he construido un sistema».

# k8s/deployment.yaml — con las tres sondas bien diferenciadas, que es donde
# casi todo el mundo se equivoca.
apiVersion: apps/v1
kind: Deployment
metadata: { name: cafeteria-api }
spec:
  replicas: 2
  strategy:
    rollingUpdate: { maxSurge: 1, maxUnavailable: 0 }   # despliegue sin corte
  selector: { matchLabels: { app: cafeteria-api } }
  template:
    metadata:
      labels: { app: cafeteria-api }
      annotations:
        prometheus.io/scrape: "true"
        prometheus.io/path: "/actuator/prometheus"
    spec:
      securityContext: { runAsNonRoot: true, runAsUser: 1000, fsGroup: 1000 }
      containers:
        - name: api
          image: ghcr.io/tu-usuario/cafeteria-api:sha-abc1234   # nunca :latest
          ports: [{ containerPort: 8080 }]
          env:
            - name: SPRING_PROFILES_ACTIVE
              value: prod
            - name: SPRING_DATASOURCE_PASSWORD
              valueFrom: { secretKeyRef: { name: cafeteria-db, key: password } }
          # startup: da margen al arranque sin relajar las otras dos sondas.
          startupProbe:
            httpGet: { path: /actuator/health/liveness, port: 8080 }
            failureThreshold: 30
            periodSeconds: 2
          # liveness: ¿hay que REINICIAR? Solo fallos irrecuperables. Nunca
          # dependas aquí de la base de datos: si la BD cae, reiniciar no ayuda
          # y entrarías en un bucle de reinicios.
          livenessProbe:
            httpGet: { path: /actuator/health/liveness, port: 8080 }
            periodSeconds: 10
          # readiness: ¿le mando TRÁFICO? Aquí sí miran las dependencias.
          readinessProbe:
            httpGet: { path: /actuator/health/readiness, port: 8080 }
            periodSeconds: 5
          resources:
            requests: { cpu: "250m", memory: "512Mi" }
            limits:   { memory: "1Gi" }      # sin límite de CPU: evita el throttling
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities: { drop: ["ALL"] }
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata: { name: cafeteria-api }
spec:
  scaleTargetRef: { apiVersion: apps/v1, kind: Deployment, name: cafeteria-api }
  minReplicas: 2
  maxReplicas: 6
  metrics:
    - type: Resource
      resource: { name: cpu, target: { type: Utilization, averageUtilization: 70 } }
// Métricas de NEGOCIO: son las que demuestran que entiendes para qué sirve la
// observabilidad. "http_server_requests" lo da Spring gratis; esto no.
@Component
class MetricasPedidos {

    private final Counter confirmados;
    private final Counter rechazadosPorStock;
    private final Timer   duracionConfirmacion;
    private final DistributionSummary importe;

    MetricasPedidos(MeterRegistry registro) {
        this.confirmados = Counter.builder("pedidos.confirmados")
            .description("Pedidos confirmados con éxito")
            .tag("canal", "todos")
            .register(registro);
        this.rechazadosPorStock = Counter.builder("pedidos.rechazados")
            .tag("motivo", "sin_stock").register(registro);
        this.duracionConfirmacion = Timer.builder("pedidos.confirmacion.duracion")
            .publishPercentiles(0.5, 0.95, 0.99)    // el p95 del RNF sale de aquí
            .register(registro);
        this.importe = DistributionSummary.builder("pedidos.importe")
            .baseUnit("EUR").register(registro);
    }
}

// Y una métrica de salud de la outbox: si crece, algo va mal aguas abajo.
@Bean
MeterBinder outboxPendientes(OutboxRepositorio repo) {
    return registro -> Gauge.builder("outbox.pendientes", repo::contarPendientes)
                            .description("Eventos aún no publicados")
                            .register(registro);
}
# k6 · prueba de carga que verifica los RNF de la sección 2.7.
# El valor del informe no es el número: es el ANTES y DESPUÉS de un cambio.
cat > carga.js <<'EOF'
import http from 'k6/http';
import { check } from 'k6';

export const options = {
  stages: [ { duration: '1m', target: 50 },    // rampa
            { duration: '3m', target: 50 },    // meseta: aquí se mide
            { duration: '1m', target: 0 } ],
  thresholds: {
    'http_req_duration{grupo:catalogo}': ['p(95)<200'],   // RNF de lectura
    'http_req_duration{grupo:confirmar}': ['p(95)<500'],  // RNF de escritura
    http_req_failed: ['rate<0.01'],
  },
};

export default function () {
  const r = http.get(`${__ENV.BASE}/api/v1/productos?page=0&size=20`,
                     { tags: { grupo: 'catalogo' } });
  check(r, { 'catálogo 200': (x) => x.status === 200 });
}
EOF

k6 run -e BASE=http://localhost:8080 carga.js

# Informe en docs/carga.md:
#   | Escenario | p95 antes | p95 después | Cambio aplicado                    |
#   |-----------|-----------|-------------|------------------------------------|
#   | catálogo  |   480 ms  |    120 ms   | índice parcial + caché de 5 min    |
#   | confirmar |   910 ms  |    340 ms   | eliminado N+1 en la carga de líneas|

Criterio de «hecho» de la fase 6

  • kubectl apply -k k8s/ deja el sistema funcionando en kind, con los comandos copiados del README.
  • Matar un pod no produce ni un error visible para el cliente durante la prueba de carga.
  • El panel muestra las cuatro gráficas con datos reales y está exportado al repositorio.
  • Existe una traza que atraviesa la petición HTTP, la publicación del evento y su consumo.
  • docs/carga.md tiene números de antes y después de un cambio concreto, no una promesa.
Si solo tienes tiempo para tres fases: haz la 1, la 2 y la 3, y dedica las horas restantes al README y a los ADR. Un sistema con dominio sólido, API impecable y documentación excelente puntúa por encima de uno con Kubernetes y un dominio anémico. La observabilidad y el despliegue impresionan, pero solo después de que lo básico esté impecable: nadie perdona un 500 con traza porque haya un panel de Grafana bonito.

6 · Calidad y definición de «hecho»

Un proyecto sin definición de «hecho» nunca termina: siempre queda «casi». Esta sección convierte la calidad en algo comprobable por una máquina siempre que se pueda, y en una lista corta de comprobación manual cuando no. La diferencia entre un desarrollador con criterio y uno sin él se ve aquí más que en ningún otro sitio.

6.1 Estrategia de tests: qué se prueba dónde

NivelQué pruebaHerramientasCuántosTiempoQué NO prueba
Unitario de dominio Reglas de negocio, cálculo de importes, matriz de transiciones, invariantes del agregado. JUnit 5, AssertJ. Sin Spring, sin base de datos, sin mocks salvo puertos. ≈ 70% < 5 s Que el mapeo a la base funcione, que el JSON salga bien, que la transacción exista.
Slice web Códigos de estado, serialización, validación, forma del error, reglas de seguridad por URL. @WebMvcTest, MockMvc, @MockitoBean. ≈ 12% < 15 s Que la consulta sea eficiente o que la lógica sea correcta.
Slice de persistencia Consultas derivadas y JPQL, mapeos, restricciones de la base, bloqueo optimista. @DataJpaTest + Testcontainers con PostgreSQL real. ≈ 8% < 30 s El comportamiento de punta a punta.
Integración Flujos completos: crear, confirmar, evento, consumo, notificación. @SpringBootTest, Testcontainers (PostgreSQL, Kafka, Redis), Awaitility. ≈ 7% < 3 min Casos límite (hazlos abajo, son cien veces más baratos).
Concurrencia Reserva de la última unidad, doble confirmación con la misma clave, tarea programada duplicada. Hilos virtuales, CyclicBarrier, Testcontainers. 3–5 tests < 30 s Nada más: son caros y frágiles; solo para lo que de verdad compite.
Arquitectura Que las dependencias apunten hacia dentro y que las fronteras de módulo se respeten. ArchUnit. 8–12 reglas < 5 s Comportamiento; solo estructura.
Contrato Que el OpenAPI publicado no cambie sin querer. Comparación del /v3/api-docs con el fichero guardado. 1 < 10 s Que la implementación cumpla el contrato (para eso están los slices).
La pregunta que decide en qué nivel va un test: «¿cuál es la causa más probable de que esto se rompa?». Si la respuesta es «un error en la regla», va abajo, donde el fallo señala la línea exacta. Si es «un cambio en el esquema o en la configuración», va a integración. Escribir un test de integración para comprobar un cálculo es pagar tres minutos por una información que cuesta tres milisegundos. Y al revés: un test unitario con seis mocks encadenados no prueba nada útil, solo que sabes usar la librería de simulación.

6.2 Umbrales de cobertura y de mutación

MétricaUmbralÁmbitoQué significa de verdad
Cobertura de líneas≥ 80%GlobalQue no hay áreas enteras sin tocar. No dice nada sobre la calidad de las aserciones.
Cobertura de líneas≥ 90%Paquetes dominioAhí vive el valor; cubrirlo es barato porque los tests son rápidos.
Cobertura de ramas≥ 70%GlobalMás informativa que la de líneas: obliga a probar los dos lados de cada condición.
Puntuación de mutación≥ 60%dominioLa métrica honesta: PIT altera el código y comprueba si algún test falla. Si no falla, tu test no probaba nada.
Duración de la suite< 5 min en CITotalPor encima, se deja de ejecutar en local y la red de seguridad desaparece.
Tests intermitentesCero toleranciaTotalUn test que falla una de cada veinte veces destruye la confianza en toda la suite. Se arregla o se borra el mismo día.
<!-- JaCoCo con umbral que ROMPE el build. Un umbral que solo informa se ignora. -->
<plugin>
  <groupId>org.jacoco</groupId>
  <artifactId>jacoco-maven-plugin</artifactId>
  <executions>
    <execution><goals><goal>prepare-agent</goal></goals></execution>
    <execution>
      <id>comprobar-cobertura</id>
      <phase>verify</phase>
      <goals><goal>check</goal></goals>
      <configuration>
        <rules>
          <rule>
            <element>PACKAGE</element>
            <includes><include>dev.cafeteria.*.dominio*</include></includes>
            <limits><limit>
              <counter>LINE</counter><value>COVEREDRATIO</value><minimum>0.90</minimum>
            </limit></limits>
          </rule>
          <rule>
            <element>BUNDLE</element>
            <limits><limit>
              <counter>LINE</counter><value>COVEREDRATIO</value><minimum>0.80</minimum>
            </limit></limits>
          </rule>
        </rules>
      </configuration>
    </execution>
  </executions>
</plugin>
La cobertura es un indicador, no un objetivo. Es trivial llegar al 95% sin una sola aserción útil: basta con ejecutar el código y no comprobar nada. Por eso el proyecto añade mutación en el dominio: PIT cambia un > por un >=, elimina una llamada, invierte una condición, y luego comprueba si algún test se entera. Si el 40% de las mutaciones sobreviven, el 40% de tu lógica no está realmente protegida, por mucho verde que muestre el informe. Usa la mutación solo en el dominio: en los adaptadores es lenta y aporta poco.

6.3 ArchUnit: la arquitectura que se defiende sola

Sin este test, la arquitectura hexagonal dura tres semanas. Un día tienes prisa, importas PedidoJpa desde el dominio «solo un momento», y ya nunca vuelve atrás. ArchUnit convierte las reglas de la sección 3 en algo que rompe el build.

@AnalyzeClasses(packages = "dev.cafeteria",
                importOptions = ImportOption.DoNotIncludeTests.class)
class ReglasArquitecturaTest {

    // ---- 1. El dominio no conoce a nadie -----------------------------------
    @ArchTest
    static final ArchRule dominio_sin_framework =
        noClasses().that().resideInAPackage("..dominio..")
            .should().dependOnClassesThat().resideInAnyPackage(
                "org.springframework..", "jakarta.persistence..",
                "com.fasterxml.jackson..", "org.hibernate..")
            .because("el dominio debe poder probarse sin arrancar nada y sobrevivir a un cambio de framework");

    @ArchTest
    static final ArchRule dominio_no_depende_de_infraestructura =
        noClasses().that().resideInAPackage("..dominio..")
            .should().dependOnClassesThat().resideInAPackage("..infraestructura..")
            .because("las dependencias apuntan hacia dentro, nunca hacia fuera");

    // ---- 2. Fronteras entre módulos ----------------------------------------
    @ArchTest
    static final ArchRule modulos_solo_se_hablan_por_su_fachada =
        slices().matching("dev.cafeteria.(*)..")
            .namingSlices("módulo $1")
            .ignoreDependency(alwaysTrue(), resideInAPackage("dev.cafeteria.comun.."))
            .should().notDependOnEachOther()
            .ignoreDependency(alwaysTrue(), simpleNameEndingWith("Api"))
            .because("un módulo solo puede ver la fachada pública de otro (ADR-0002)");

    @ArchTest
    static final ArchRule sin_ciclos_entre_modulos =
        slices().matching("dev.cafeteria.(*)..").should().beFreeOfCycles();

    // ---- 3. Reglas de capa dentro del módulo -------------------------------
    @ArchTest
    static final ArchRule controladores_no_tocan_repositorios =
        noClasses().that().areAnnotatedWith(RestController.class)
            .should().dependOnClassesThat().haveSimpleNameEndingWith("Repositorio")
            .because("el controlador orquesta casos de uso, no accede a datos");

    @ArchTest
    static final ArchRule entidades_jpa_no_salen_de_su_paquete =
        classes().that().areAnnotatedWith(Entity.class)
            .should().resideInAPackage("..infraestructura.jpa..")
            .because("una entidad JPA que se filtra al dominio o a la API lo contamina todo");

    // ---- 4. Convenciones que evitan sorpresas ------------------------------
    @ArchTest
    static final ArchRule sin_system_out =
        noClasses().should().callMethod(System.class, "currentTimeMillis")
            .orShould().accessField(System.class, "out")
            .because("usa el Reloj inyectable (testeable) y un logger (configurable)");

    @ArchTest
    static final ArchRule transaccional_solo_en_casos_de_uso =
        methods().that().areAnnotatedWith(Transactional.class)
            .should().beDeclaredInClassesThat().resideInAPackage("..aplicacion..")
            .because("la frontera transaccional es el caso de uso, no el repositorio ni el controlador");

    @ArchTest
    static final ArchRule campos_no_publicos =
        fields().that().areNotStatic().should().notBePublic();
}
La regla del reloj merece un párrafo. Prohibir System.currentTimeMillis() y Instant.now() dentro del dominio, y obligar a inyectar un Reloj (o el java.time.Clock del JDK), tiene una consecuencia enorme: puedes probar la caducidad de una reserva sin esperar quince minutos. En el test se avanza el reloj y ya está. Es una de esas decisiones pequeñas que un revisor con experiencia detecta al instante y valora mucho, porque revela que has sufrido tests lentos y has aprendido la lección.

6.4 Análisis estático y dependencias

HerramientaQué detectaDónde¿Rompe el build?
Compilador con -Xlint:allLo que ya sabe Java y casi nadie escucha.Local y CISí, con -Werror si te atreves
Error ProneErrores reales: comparar con ==, formatos incorrectos, resultados ignorados.CompilaciónSí, los de severidad alta
SpotBugsPosibles NullPointerException, recursos sin cerrar, concurrencia sospechosa.CISí, categorías High
SpotlessFormato. Elimina los comentarios de revisión sobre espacios.Local (auto) y CI
OWASP Dependency-Check o TrivyVulnerabilidades conocidas en dependencias.CI y semanalSí, a partir de CVSS 7
gitleaksSecretos en el código o en el historial.Pre-commit y CISí, siempre
SonarQube / SonarCloudDuplicación, complejidad, deuda, y un buen informe visual.CI (opcional)Con quality gate

Consejo de dosis: activa Spotless, gitleaks y el análisis de dependencias desde la fase 1, y añade Error Prone y SpotBugs en la fase 3, cuando ya hay código suficiente. Si los activas todos el primer día con la configuración más estricta, pasarás la primera tarde peleando con avisos en lugar de construyendo, y acabarás desactivándolos. Empezar suave y apretar es la estrategia que sobrevive.

6.5 Revisión propia: la lista antes de fusionar

Trabajas solo, así que el revisor eres tú mismo veinticuatro horas después. Suena raro y funciona: abre tu propio pull request, léelo entero en la vista de diferencias y pásale esta lista. Encontrarás cosas que no ves escribiendo, porque leer un diff activa una atención distinta.

6.6 Commits, ramas y plantilla de pull request

El historial es la única documentación que se escribe sola y que un evaluador va a mirar seguro. Cuesta cero hacerlo bien desde el principio e es imposible arreglarlo después.

# Convención de commits (Conventional Commits). La forma importa menos que la
# constancia, pero esta tiene una ventaja: permite generar el changelog solo.
#
#   <tipo>(<ámbito>): <qué cambia, en imperativo y en minúscula>
#
#   (línea en blanco)
#   POR QUÉ se hace. El "qué" ya está en el diff; el "por qué" solo está en tu
#   cabeza, y dentro de tres meses tampoco.
#
# Tipos: feat, fix, refactor, test, docs, chore, perf, build, ci

# --- Ejemplos buenos ---
feat(pedidos): reservar stock antes de cobrar

La secuencia anterior cobraba primero y reservaba después, así que un fallo
de stock dejaba un cargo que había que reembolsar a mano. Reservar primero
convierte el caso frecuente (sin stock) en un 409 sin efectos secundarios.

fix(inventario): evitar sobreventa con UPDATE condicional

El check-then-act permitía que dos hilos leyeran cantidad=1 y ambos
restaran. Se sustituye por UPDATE ... WHERE cantidad >= :n comprobando las
filas afectadas. Test con 50 hilos añadido.

perf(catalogo): índice parcial sobre productos activos

p95 de GET /productos de 480 ms a 120 ms con 500 productos (docs/carga.md).

# --- Ejemplos malos ---
# "cambios"            → no dice nada
# "arreglado el bug"   → ¿cuál?
# "WIP"                → no debería llegar a la rama principal
# "feat: mil cosas"    → si el commit toca 40 ficheros de 6 temas, son 6 commits
AspectoConvención del proyectoPor qué
Rama principalmain, siempre desplegable y protegida.Si main puede estar roto, el badge verde no significa nada.
Ramas de trabajofeat/reserva-stock, fix/n-mas-1-lineas, de vida corta (1–3 días).Las ramas largas producen conflictos y revisiones imposibles.
IntegraciónPull request aunque trabajes solo, con CI obligatoria.Te da el punto de revisión y deja constancia del razonamiento.
FusiónSquash si la rama tiene ruido, fusión normal si los commits ya cuentan una historia.El historial de main se lee como un relato, no como un registro de teclas.
Etiquetasv0.1.0 al cerrar cada fase, con notas de versión.Demuestra progreso por hitos y permite volver a un punto conocido.
TamañoMenos de 400 líneas por pull request cuando se pueda.Por encima, la calidad de la revisión cae en picado, también la propia.
<!-- .github/pull_request_template.md -->
## Qué cambia y por qué

<!-- Dos o tres frases. El "por qué" es lo importante: el "qué" se ve en el diff. -->

Cierra #

## Cómo lo he probado

- [ ] Tests automáticos nuevos o modificados: `...`
- [ ] Probado a mano: `curl ...` (pega la respuesta si es relevante)
- [ ] Caso triste probado: entrada inválida / sin permisos / dependencia caída

## Decisiones tomadas

<!-- Alternativas descartadas y por qué. Si es una decisión estructural, ¿toca ADR? -->

## Riesgos y vuelta atrás

<!-- ¿Qué puede romperse? ¿Hay migración? ¿Se puede revertir sin perder datos? -->

## Lista de comprobación

- [ ] `./mvnw verify` en verde en local
- [ ] Sin secretos, sin `System.out`, sin código comentado
- [ ] Documentación actualizada (README / OpenAPI / ADR) si aplica
- [ ] Migración compatible con la versión anterior del código
- [ ] Rendimiento revisado: sin N+1, sin consultas sin índice
Sí, escribir la plantilla de pull request trabajando solo tiene sentido. Primero, porque te obliga a responder «cómo lo he probado» antes de fusionar, que es exactamente el momento en el que uno se ahorra un fallo. Segundo, porque quien mire tu repositorio verá un proceso y no solo código; para un puesto donde vas a trabajar en equipo, esa señal vale mucho. Y tercero, porque el día que entres en un equipo ya tendrás el hábito.

7 · README y documentación que venden el proyecto

Vuelve al minuto 0:00 de la sección 1: el README es lo primero y, si es malo, lo único. Aquí tienes una plantilla completa lista para copiar, más lo que la rodea: decisiones, diagramas que no envejecen, capturas y el guion de tres minutos para contarlo en una entrevista.

7.1 Plantilla de README completa

# Cafetería Tech · plataforma de catálogo y pedidos

[![CI](https://github.com/tu-usuario/cafeteria-tech/actions/workflows/ci.yml/badge.svg)](…)
[![Cobertura](https://img.shields.io/badge/cobertura-84%25-brightgreen)](…)
[![Java](https://img.shields.io/badge/Java-21-orange)](…)
[![Licencia](https://img.shields.io/badge/licencia-MIT-blue)](LICENSE)

Sistema de pedidos para una cadena de cuatro cafeterías: catálogo con
disponibilidad real por local, reserva de stock sin sobreventas, cobro,
preparación en tienda y avisos al cliente. Construido como monolito modular
con Spring Boot 3 y Java 21, con eventos, observabilidad y despliegue en
contenedores.

> **Por qué existe:** el problema real es vender producto que no hay. Todo el
> diseño gira alrededor de un invariante: *las reservas nunca superan las
> existencias*, ni siquiera con cincuenta clientes comprando a la vez.

---

## Arranque en un comando

```bash
git clone https://github.com/tu-usuario/cafeteria-tech.git
cd cafeteria-tech
docker compose up -d --wait        # Postgres, Redis, Kafka y la API
./ejemplos/demo.sh                 # recorre el caso de uso completo
```

| Recurso | URL |
|---|---|
| API | http://localhost:8080/api/v1 |
| OpenAPI | http://localhost:8080/swagger-ui.html |
| Salud | http://localhost:8080/actuator/health |
| Grafana | http://localhost:3000 (admin/admin) |

Usuarios de demostración: `ana@example.com` / `Contrasena-Demo-1` (cliente),
`admin@example.com` / `Contrasena-Demo-1` (administrador).

## Qué demuestra este proyecto

- **Concurrencia real:** reserva de la última unidad con 50 hilos compitiendo;
  exactamente uno gana ([test](src/test/…/ReservarStockConcurrenciaTest.java)).
- **Consistencia sin perder eventos:** patrón *outbox* transaccional; se puede
  parar Kafka, confirmar un pedido y el evento se publica al volver.
- **Idempotencia:** confirmar dos veces con la misma `Idempotency-Key` cobra
  una sola vez.
- **Autorización por recurso:** la propiedad se comprueba dentro de la consulta,
  con tests de acceso cruzado que lo demuestran.
- **Arquitectura verificada:** hexagonal por módulo, con reglas de ArchUnit que
  rompen el build si alguien cruza una frontera.
- **Operación:** métricas de negocio, trazas de punta a punta, sondas, HPA y
  una prueba de carga con resultados medidos.

## Arquitectura

```mermaid
graph TD
  Cliente[Cliente / curl] -->|HTTPS + JWT| API[cafeteria-api · Spring Boot 3]
  API -->|JDBC| PG[(PostgreSQL 16)]
  API -->|caché e idempotencia| RD[(Redis 7)]
  API -->|outbox → eventos| KF[(Kafka)]
  KF --> CONS[Consumidor de notificaciones]
  CONS --> PG
```

Monolito modular con siete módulos (`catalogo`, `inventario`, `pedidos`,
`pagos`, `identidad`, `notificaciones`, `informes`). Cada uno expone una única
fachada pública; dentro, arquitectura hexagonal. Detalle en
[docs/arquitectura.md](docs/arquitectura.md).

## Decisiones técnicas

| Decisión | Alternativas descartadas | Motivo |
|---|---|---|
| PostgreSQL como fuente única | MongoDB, una BD por módulo | Los invariantes de stock y dinero exigen transacciones ([ADR-0001](docs/adr/0001-…)) |
| Monolito modular | Microservicios, dos servicios | Una persona; partir convertiría en saga la única transacción crítica ([ADR-0002](…)) |
| Kafka + outbox | Publicar tras el commit, RabbitMQ | No perder eventos si el broker cae ([ADR-0003](…)) |
| JWT propio | Keycloak, sesiones | Sin dependencias externas para la demo; migración documentada ([ADR-0004](…)) |
| Pirámide con Testcontainers | Solo integración, H2 | Diagnóstico rápido y fidelidad con producción ([ADR-0005](…)) |

## Rendimiento medido

k6, 50 usuarios virtuales, 5 minutos, 2 réplicas de 2 vCPU:

| Escenario | p95 antes | p95 después | Qué cambió |
|---|---|---|---|
| `GET /productos` | 480 ms | **120 ms** | Índice parcial + caché de 5 min |
| `POST /confirmacion` | 910 ms | **340 ms** | N+1 eliminado con `@EntityGraph` |

Informe completo y metodología en [docs/carga.md](docs/carga.md).

## Tests

```bash
./mvnw verify                 # todo: unitarios, slices e integración
./mvnw test -Dtest='*Test'    # solo los rápidos
./mvnw org.pitest:pitest-maven:mutationCoverage   # mutación en el dominio
```

284 tests · 84% de cobertura global, 93% en el dominio · 67% de puntuación de
mutación · suite completa en 3 min 40 s.

## Alcance

**Incluido:** catálogo, inventario por local, pedidos con recogida y envío,
cobro simulado, avisos, informes básicos, autenticación y roles.

**Deliberadamente fuera:** pasarela de pago real, interfaz web, facturación
fiscal, logística de reparto, promociones y multidivisa. El porqué de cada
exclusión está en [docs/alcance.md](docs/alcance.md).

## Roadmap

- [ ] Sustituir el JWT propio por Keycloak con OIDC
- [ ] Sustituir el publicador de la outbox por CDC con Debezium
- [ ] Particionar `pedido` por fecha cuando supere los 10 millones de filas
- [ ] Panel de operaciones con actualización en vivo

## Estructura

```
src/main/java/dev/cafeteria/
  comun/         objetos de valor, errores, idempotencia, configuración
  catalogo/      dominio · aplicacion · infraestructura · CatalogoApi
  inventario/    reservas, existencias, caducidad
  pedidos/       agregado Pedido, máquina de estados, outbox
  …
docs/adr/        decisiones de arquitectura
k8s/             manifiestos de Kubernetes
ejemplos/        peticiones de ejemplo y script de demostración
```

## Licencia

MIT. Proyecto de aprendizaje; los datos son ficticios.
Sección del READMEPregunta que respondeError habitual
Título y párrafo inicial¿Qué es esto y para quién?Empezar por «proyecto realizado con Spring Boot y MySQL». Nadie pregunta con qué, sino qué.
Arranque¿Cómo lo veo funcionando en dos minutos?Instrucciones de cinco pasos con variables sin explicar.
Qué demuestra¿Por qué debería seguir leyendo?Listar tecnologías en vez de capacidades. «Usa Kafka» no es una capacidad; «no pierde eventos si el broker cae» sí.
Arquitectura¿Cómo encajan las piezas?Una imagen PNG hecha a mano que queda desactualizada el segundo día.
Decisiones¿Pensó o copió?Omitir las alternativas descartadas, que es justo la parte valiosa.
Números¿Sabe medir?Decir «es rápido» sin ninguna cifra.
Alcance¿Sabe priorizar?No decirlo y parecer incompleto.
Roadmap¿Sabe lo que le falta?Prometer diez cosas y no hacer ninguna: es peor que no tenerlo.

7.2 Diagramas que no envejecen

Un diagrama exportado a PNG desde una herramienta gráfica está desactualizado en dos semanas y nadie lo corrige, porque corregirlo implica abrir la herramienta, exportar y subir la imagen. La solución es diagramas como texto, versionados junto al código: se revisan en el pull request, se ven las diferencias y se corrigen en treinta segundos. Mermaid es la opción más cómoda porque GitHub lo renderiza directamente en el README.

<!-- Diagrama de secuencia de la confirmación. Es el que más se mira, porque
     es donde está la lógica interesante del sistema. -->

```mermaid
sequenceDiagram
    autonumber
    participant C as Cliente
    participant A as API (pedidos)
    participant I as Inventario
    participant P as Pasarela
    participant D as PostgreSQL
    participant K as Kafka

    C->>A: POST /pedidos/{id}/confirmacion (Idempotency-Key)
    A->>D: reservar clave de idempotencia (INSERT)
    A->>I: reservar(pedidoId, lineas)
    I->>D: UPDATE existencias WHERE cantidad >= n
    alt sin stock
        I-->>A: SinStock(faltantes)
        A-->>C: 409 + lista de faltantes
    else reservado
        A->>P: cobrar(total)
        alt pago rechazado
            P-->>A: RECHAZADO
            A->>I: liberar(pedidoId)
            A-->>C: 402 (reintentable)
        else autorizado
            A->>D: estado=CONFIRMADO + fila en evento_outbox (misma transacción)
            A-->>C: 200 PedidoRespuesta
            Note over D,K: después del commit, el publicador envía el evento
            D->>K: PedidoConfirmado
        end
    end
```
Tres diagramas y ni uno más. Contexto (quién usa el sistema), contenedores (qué piezas hay) y una secuencia del caso de uso principal. Ese tercero es el que más se agradece, porque explica en veinte segundos lo que un párrafo no consigue. Diagramas de clases, de entidad-relación completo o de despliegue detallado: casi nunca se leen y siempre están desactualizados. Si necesitas el modelo de datos, genera el diagrama desde el esquema con una herramienta y márcalo como generado.

7.3 Capturas, GIF y demostración

Un backend sin interfaz parece invisible. Estas cuatro cosas lo hacen tangible en el README, cuestan menos de una hora en total y multiplican la probabilidad de que alguien entienda lo que has hecho:

Guárdalas en docs/img/ con nombres descriptivos y péinalas: recorta, sube el contraste y no incluyas tu barra de tareas ni pestañas del navegador con cosas personales. Un detalle: comprueba que no se filtra ningún token ni correo real en las capturas.

7.4 El guion de tres minutos

«Cuéntame un proyecto tuyo» es la pregunta más previsible de cualquier entrevista y, aun así, casi todo el mundo la improvisa y se va por las ramas. Tres minutos, cuatro bloques, ensayado en voz alta al menos cinco veces. No se memoriza palabra por palabra: se memoriza la estructura.

BloqueTiempoQué dicesQué NO dices
1 · El problema25 s «Una cadena de cuatro cafeterías vendía producto que no tenía porque el inventario estaba en una hoja de cálculo. Construí la plataforma de catálogo y pedidos que lo resuelve.» La lista de tecnologías. Todavía no.
2 · La forma40 s «Monolito modular en Spring Boot 3 y Java 21, siete módulos con fachada pública, hexagonal por dentro, PostgreSQL como fuente de verdad y Kafka con outbox para lo asíncrono. Elegí monolito porque soy uno y porque partirlo habría convertido en saga la única transacción que el negocio exige atómica.» Enumerar dependencias del pom.xml.
3 · El problema difícil70 s «Lo más interesante fue la reserva de stock. Mi primera versión leía existencias y comprobaba en Java: con cincuenta hilos comprando la última unidad, vendía tres. Lo cambié por un UPDATE condicional atómico comprobando las filas afectadas, y para pedidos de varias líneas ordeno los bloqueos por identificador para evitar interbloqueos. Tengo un test con cincuenta hilos virtuales que lo demuestra, y forma parte de la CI.» Contarlo sin el «antes»: el error inicial es lo que hace creíble el aprendizaje.
4 · Qué aprendí y qué haría distinto45 s «Lo que más me sorprendió es cuánto trabajo evita poner las restricciones en la base de datos: un CHECK me pilló un bug que los tests no cubrían. Con más tiempo sustituiría el publicador de la outbox por CDC con Debezium y el JWT propio por Keycloak, que es lo correcto en producción.» «Nada, quedó perfecto». Es la peor respuesta posible.
El truco del bloque 3: elige el problema por el que quieras que te pregunten. Todo lo que cuentes con detalle invita a repreguntas, así que dirige la conversación hacia el terreno donde estás más cómodo. Y prepara dos niveles de profundidad: la versión de un minuto y la versión con el código en pantalla, porque si el entrevistador se interesa te va a pedir que lo enseñes. El módulo 12 · Entrevistas trabaja la técnica de respuesta con más detalle.

8 · Portfolio y presencia profesional

El proyecto ya existe. Falta que alguien lo encuentre y lo entienda en treinta segundos. Esta sección va de eso, sin trucos de marca personal: solo lo que de verdad cambia la probabilidad de que te llamen.

8.1 Qué proyectos tener y cuántos

PiezaPara qué sirveTamañoSeñal que envía
El proyecto principal (este)Demostrar que sabes construir un sistema completo con criterio.40–60 h«Puede trabajar en nuestro producto.»
Una herramienta pequeña que uses de verdadUn CLI, un exportador, un bot que resuelva una molestia tuya.4–8 h«Programa por iniciativa propia, no solo por deberes.»
Una exploración técnica documentadaComparar dos enfoques con medidas: hilos virtuales frente a pool, JPA frente a SQL directo, JSON frente a columnas.6–10 h«Sabe medir y sacar conclusiones, no repite opiniones.»
Contribuciones a proyectos libresAunque sean pequeñas: documentación, un test, un bug reproducible.Continuo«Sabe moverse en un código que no es suyo.»

Tres o cuatro piezas es el número correcto. Más no suma: quien evalúa mira una o dos y asume que el resto es parecido. Y hay una asimetría cruel que conviene interiorizar: un repositorio malo resta más de lo que suma uno bueno. Si tienes cinco repositorios de tutoriales a medias, archívalos o hazlos privados. No es esconder nada: es no pedirle a nadie que rebusque para encontrar lo mejor de tu trabajo.

8.2 El perfil de GitHub

Lo que se mira, en orden

  1. La foto y el nombre real: un perfil sin cara ni nombre parece abandonado.
  2. La biografía de una línea: «Backend Java/Spring · Valencia · buscando primer puesto» dice más que cualquier eslogan.
  3. Los repositorios fijados (hasta seis; usa tres o cuatro).
  4. La descripción y los temas de cada repositorio fijado.
  5. El README del perfil, si existe.
  6. La actividad, solo por encima: si hay commits recientes.

Errores que restan

  • Repositorios sin descripción: en la lista se ven como una fila vacía.
  • Forks sin modificar ocupando el perfil.
  • Ejercicios de curso con nombres como practica3-final-BUENO.
  • README de perfil lleno de insignias animadas y de la gráfica de la serpiente: ocupa la pantalla y no dice nada.
  • Presumir de una racha de contribuciones inflada con commits automáticos: es transparente y quema la credibilidad.
  • El último commit hace catorce meses sin explicación.
<!-- README del perfil (repositorio con tu propio nombre de usuario).
     Corto, concreto y con enlaces. Sesenta segundos de lectura como mucho. -->

## Hola, soy [Nombre]

Desarrollador backend centrado en **Java 21 y Spring Boot 3**. Vengo de
[tu contexto: otra rama, otro lenguaje, un grado] y llevo [tiempo] construyendo
sistemas con bases de datos relacionales, mensajería y contenedores.

**En qué estoy ahora:** terminando [Cafetería Tech](enlace), una plataforma de
pedidos con reserva de stock sin sobreventas, outbox transaccional y
observabilidad completa.

**Lo que más me interesa:** el rendimiento de la capa de datos y las decisiones
de arquitectura que se pueden justificar con números.

- Proyecto principal: [cafeteria-tech](enlace) — Java 21 · Spring Boot 3 · PostgreSQL · Kafka
- Notas técnicas: [enlace al blog o a las notas públicas]
- Contacto: LinkedIn · correo
Los quince minutos mejor invertidos de todo el proceso: escribir una descripción de una línea y añadir cinco temas a cada repositorio fijado. La descripción es lo único que se ve en la lista y en las búsquedas, y la mayoría de los perfiles la tiene vacía. «Plataforma de pedidos con reserva de stock concurrente, eventos y despliegue en Kubernetes» frente a nada: la diferencia entre que abran el repositorio o pasen de largo.

8.3 Publicar lo que aprendes

No hace falta convertirse en creador de contenido. Escribir tres o cuatro entradas cortas al año sobre problemas que has resuelto de verdad tiene un efecto desproporcionado: te obliga a entender bien lo que cuentas (no puedes escribir sobre lo que no dominas sin que se note), te deja un archivo al que volver, y aparece cuando alguien te busca por tu nombre.

FormatoEsfuerzoQué contarDónde
Nota técnica corta (300–600 palabras)1 hUn problema concreto, el diagnóstico y la solución. «Por qué mi @Transactional no hacía rollback».El propio repositorio en docs/notas/, dev.to, Medium o un blog estático.
Comparativa medida4–6 hDos enfoques, la misma carga, números y conclusión honesta (incluido «no hay diferencia»).Blog propio; es el formato que más se comparte.
Notas de lectura30 minQué te llevas de un capítulo y cómo lo aplicas a tu proyecto.Repositorio de notas públicas.
Charla interna o meetup8 hVeinte minutos sobre algo que has construido.Grupos locales de Java o Spring; casi todos buscan ponentes.

Dos advertencias. La primera: escribe sobre lo que has hecho, no resúmenes de documentación; el mundo no necesita otro «Introducción a Spring Boot», y además se nota. La segunda: si publicas algo incorrecto, corrígelo cuando te lo digan y déjalo anotado. La honestidad al corregir es una señal profesional mucho más fuerte que no equivocarse nunca.

8.4 Contribuir a proyectos libres, de forma realista

La fantasía es enviar una funcionalidad a Spring Framework. La realidad útil empieza mucho antes y aporta igual: leer código ajeno de calidad es de las cosas que más rápido te hacen mejorar.

PasoQué hacerDificultad
1Usar y observar. Elige una librería que ya uses (Testcontainers, Flyway, Resilience4j, una de Spring). Lee su código cuando dudes de algo.Baja
2Abrir una incidencia buena. Un caso reproducible mínimo, versiones, comportamiento esperado y obtenido. Esto ya es una contribución valiosa y muy escasa.Baja
3Documentación. Corregir un ejemplo obsoleto o aclarar un párrafo confuso. Suele aceptarse rápido y te enseña el proceso de contribución.Baja
4Un test que falta. Cubrir un caso límite documentado pero no probado. Muy bien recibido y sin riesgo de romper nada.Media
5Un arreglo pequeño etiquetado como good first issue, con test que demuestre el fallo antes y después.Media
6Una funcionalidad pequeña, siempre después de proponerla en una incidencia y de que alguien con permisos diga que encaja.Alta

Buenas prácticas al contribuir

  • Lee CONTRIBUTING.md entero antes de escribir una línea. Muchos rechazos son por saltarse el proceso, no por el código.
  • Pregunta antes de invertir tiempo en algo grande: «estoy pensando en hacer X, ¿encaja?».
  • Un pull request, un cambio. No aproveches para reformatear medio fichero.
  • Sigue el estilo del proyecto aunque no te guste. No es tu casa.
  • Responde a la revisión sin ponerte a la defensiva y con plazos realistas; si no puedes seguir, dilo.
  • La paciencia es parte del trato: hay proyectos que tardan semanas en responder, y no es personal.

8.5 Alinear el proyecto con las ofertas que te interesan

Ejercicio de una hora, muy rentable: coge diez ofertas reales a las que aspiras (no las de ensueño: las alcanzables), copia sus requisitos en una hoja y cuenta cuántas veces aparece cada tecnología o capacidad. El resultado te da una lista ordenada por frecuencia que te dice exactamente qué reforzar. En el mercado español de backend Java suele salir algo parecido a esto, aunque conviene que hagas tu propio recuento porque varía por ciudad y por sector:

Aparece casi siempreAparece a menudoDiferencial
Java 11/17/21, Spring Boot, Spring Data JPA, SQL, REST, Git, Maven o Gradle, JUnit. Docker, Kubernetes, CI/CD, Kafka o RabbitMQ, microservicios, alguna nube, Testcontainers, OpenAPI. Observabilidad, rendimiento medido, seguridad aplicada, arquitectura justificada, inglés técnico.

Después, mapea cada requisito frecuente a una línea concreta de tu README. Si una oferta pide Kafka y tu README no menciona eventos, no lo va a adivinar nadie. Y al revés: si nadie en tu mercado pide programación reactiva, no dediques tres semanas a WebFlux por completar el currículum. La carta de presentación se escribe igual: una frase que conecte su problema con tu proyecto, no un párrafo genérico sobre tu pasión por la tecnología.

9 · Recursos: qué leer y en qué orden

Lista larga, uso selectivo. La regla de la sección 9.6 es la más importante de todas: consumir recursos no es progresar. Aquí están los que merecen la pena, con qué leer exactamente de cada uno, para que no te enfrentes a mil páginas sin saber por dónde entrar.

9.1 Documentación oficial imprescindible

FuenteQué leer exactamenteCuándo
Documentación de Java (Oracle / OpenJDK)
docs.oracle.com/en/java/javase/21
El Javadoc de java.util.concurrent, java.time y java.util.stream. No se lee entero: se consulta con una duda concreta. La documentación del paquete (arriba del todo) suele ser mejor que muchos artículos. Continuo
Índice de JEP
openjdk.org/jeps/0
La JEP de cada característica que uses (virtual threads es la 444; pattern matching, la 441). Explican la motivación y las alternativas descartadas: son ADR de verdad, escritos por quien diseñó la característica. Al estudiar una novedad
dev.java Los tutoriales oficiales modernos. Especialmente los de colecciones, streams y concurrencia estructurada. Refuerzo del módulo 01
Referencia de Spring Boot
docs.spring.io/spring-boot
Por secciones, nunca de un tirón: «Externalized Configuration» entera, «Profiles», «Testing», «Production-ready Features» (Actuator) y «Container Images». Son cuatro tardes y eliminan el 80% de las dudas. Fases 1 y 3
Spring Framework Core «The IoC Container» y «Data Access» (sobre todo la gestión de transacciones, que explica la propagación mejor que ningún tutorial). Fase 2
Spring Data JPA Derivación de nombres de método, @Query, proyecciones, Specification y paginación. Fase 2
Spring Security «Architecture» (la cadena de filtros) y «Authorization» completas. Entender la cadena convierte errores misteriosos en diagnósticos de treinta segundos. Fase 4
Guía de usuario de Hibernate Los capítulos de fetching, de tipos de bloqueo y de caché de segundo nivel. El de fetching es el que evita los N+1 para siempre. Fase 2
Manual de PostgreSQL
postgresql.org/docs/current
«Indexes» entero, «Performance Tips», «Concurrency Control» (MVCC y niveles de aislamiento) y «Explicit Locking». Son las cuatro secciones que más rentabilidad dan de toda la documentación técnica que existe. Fase 2 y 6
Docker «Best practices for Dockerfile» y la referencia de compose (dependencias con condición y healthchecks). Fase 1
Kubernetes
kubernetes.io/docs/concepts
«Workloads» (Pod, Deployment, ReplicaSet), «Services», «Configuration» (ConfigMap y Secret) y «Configure Liveness, Readiness and Startup Probes». Esa última página resuelve el error más común al desplegar Java. Fase 6
Testcontainers El módulo de PostgreSQL, los contenedores reutilizables y @ServiceConnection de Spring Boot 3.1+, que elimina casi toda la configuración manual. Fase 2
Micrometer Conceptos de meter, tipos de métrica y, muy importante, el aviso sobre la cardinalidad de las etiquetas: meter un identificador de usuario como etiqueta tumba Prometheus. Fase 6
OpenTelemetry para Java El agente automático y los conceptos de traza, span y contexto. Con Spring Boot, además, la integración de Micrometer Tracing. Fase 6
Kafka
kafka.apache.org/documentation
«Design» (registro, particiones, garantías) y la configuración de consumidor y productor. Los valores por defecto no son los que quieres, y saber por qué es una pregunta clásica. Fase 5
OWASP Top 10 y las Cheat Sheets de almacenamiento de contraseñas, autenticación y JWT. Fase 4
Verifica siempre la versión y la fecha. Media internet sigue explicando Spring Boot 2, Java 8 y WebSecurityConfigurerAdapter. Señales inequívocas de material caducado: javax.persistence en lugar de jakarta.persistence, WebSecurityConfigurerAdapter, @MockBean en lugar de @MockitoBean, antMatchers en lugar de requestMatchers, o cualquier ejemplo con RestTemplate presentado como la opción actual. Si un artículo no dice qué versión usa, desconfía.

9.2 Libros, por tema y nivel

No hace falta leerlos todos, ni siquiera enteros. La mayoría son libros de consulta que se leen por capítulos cuando el problema aparece. La columna «cuándo» es la clave: un libro leído en el momento equivocado se olvida entero.

Java y la JVM

Libro y autorPara qué sirveCuándo
Effective Java (3.ª ed.) — Joshua BlochNoventa reglas de diseño con su justificación. Es el libro de Java: enseña a escribir API que otros puedan usar sin sufrir.Después del módulo 01, por capítulos sueltos
Java Concurrency in Practice — Brian Goetz y otrosEl modelo de memoria, la publicación segura y los pools explicados como en ningún otro sitio. Anterior a Loom, pero los fundamentos no han cambiado.Junto al módulo 03, si trabajas con concurrencia
Optimizing Java — Benjamin Evans, James Gough, Chris NewlandCómo funcionan de verdad el JIT, el recolector de basura y el perfilado. Enseña a medir en lugar de suponer, que es la mitad del trabajo.Cuando tengas un problema de rendimiento real
Modern Java in Action — Urma, Fusco, MycroftLambdas, streams y estilo funcional con profundidad. Buen puente si vienes de Java 8 clásico.Refuerzo del módulo 02

Persistencia y datos

Libro y autorPara qué sirveCuándo
High-Performance Java Persistence — Vlad MihalceaJPA e Hibernate a fondo, con medidas. Resuelve para siempre los N+1, el fetching, el bloqueo y el batching.Cuando JPA te dé el primer disgusto de rendimiento
SQL Performance Explained — Markus WinandÍndices y planes de ejecución explicados con una claridad excepcional. Corto y directo. Su web Use The Index, Luke! es el mismo contenido en abierto.Junto al módulo 06
Designing Data-Intensive Applications — Martin KleppmannEl mejor libro de sistemas de datos y distribuidos que existe. Replicación, particionado, consenso y consistencia con rigor y sin humo. Cambia cómo piensas.Después del módulo 08. Léelo despacio
Database Internals — Alex PetrovQué hay dentro de un motor: árboles B, LSM, WAL, consenso. Para cuando quieras saber por qué las cosas son como son.Opcional, después de Kleppmann

Spring

Libro y autorPara qué sirveCuándo
Spring in Action (6.ª ed.) — Craig WallsRecorrido amplio y práctico por el ecosistema. Buen mapa si vienes de cero, aunque la documentación oficial es mejor referencia diaria.Antes o durante el módulo 04
Spring Boot: Up and Running — Mark HecklerEnfoque directo y moderno, orientado a construir. Más corto que el anterior.Alternativa al anterior
Spring Security in Action — Laurentiu SpilcaLa única obra extensa y clara sobre Spring Security. Comprueba que sea la edición para Spring Security 6.Junto al módulo 10

Diseño y arquitectura

Libro y autorPara qué sirveCuándo
Clean Code — Robert C. MartinNombres, funciones pequeñas y formato. Útil como primer contacto con la idea de que el código se lee más de lo que se escribe. Léelo con espíritu crítico: hay consenso amplio en que algunos consejos (funciones de tres líneas a toda costa, comentarios como fracaso, ciertos ejemplos del final) llevan la idea demasiado lejos y producen código más difícil de seguir. Búscale las críticas razonadas: el debate enseña más que el libro.Pronto, con lectura crítica
Clean Architecture — Robert C. MartinLa regla de dependencia y los límites entre capas. Es el origen del enfoque que usa este proyecto. Mismo aviso: la idea central es valiosa, la aplicación literal en todos los casos no.Junto al módulo 08
Implementing Domain-Driven Design — Vaughn VernonDDD llevado a la práctica: agregados, contextos, eventos. Denso pero aplicable. La alternativa breve es Domain-Driven Design Distilled del mismo autor.Después de tener un dominio propio con el que comparar
Learning Domain-Driven Design — Vlad KhononovLa mejor puerta de entrada a DDD que hay hoy: moderna, clara y con criterio sobre cuándo no aplicarlo.Antes que Vernon
Building Microservices (2.ª ed.) — Sam NewmanDescomposición, contratos, datos distribuidos y operación. Honesto con los costes, que es lo que lo hace valioso.Antes de partir cualquier monolito
Monolith to Microservices — Sam NewmanPatrones de migración incremental. Más práctico que el anterior si ya estás en ello.Cuando la migración sea real
Release It! (2.ª ed.) — Michael NygardPatrones de estabilidad: circuit breaker, bulkhead, tiempos de espera, y antipatrones con casos reales de caídas. Se lee como una novela de terror con moraleja.Junto al módulo 08
Fundamentals of Software Architecture — Mark Richards y Neal FordPanorama de estilos arquitectónicos con sus compensaciones y las habilidades no técnicas del papel de arquitecto.Cuando empieces a decidir estructura

Calidad, pruebas y oficio

Libro y autorPara qué sirveCuándo
Effective Software Testing — Maurício AnicheCómo decidir qué probar con criterio sistemático (particiones, valores límite, cobertura estructural), con Java. El más práctico de su categoría.Junto al módulo 07
Unit Testing: Principles, Practices, and Patterns — Vladimir KhorikovLa mejor explicación de qué es un buen test, cuándo un mock ayuda y cuándo estorba, y por qué probar detalles de implementación arruina la suite. Los ejemplos son en C#, se leen igual.Cuando tus tests empiecen a estorbar
Refactoring (2.ª ed.) — Martin FowlerCatálogo de transformaciones seguras con sus indicios. Enseña a mejorar código sin romperlo, en pasos pequeños.Continuo, como referencia
Working Effectively with Legacy Code — Michael FeathersCómo meter tests en código que no los tiene y que no se deja. Es el libro para el trabajo real, donde casi nada empieza de cero.En cuanto tengas un empleo
The Pragmatic Programmer (20.º aniversario) — Hunt y ThomasHábitos y actitud profesional. Envejece bien porque habla de oficio, no de tecnología.Cualquier momento
A Philosophy of Software Design — John OusterhoutCorto y afilado: complejidad, profundidad de los módulos y por qué las abstracciones finas empeoran las cosas. Excelente contrapunto a Clean Code.Después de Clean Code

Operación, plataforma y cultura de equipo

Libro y autorPara qué sirveCuándo
Kubernetes: Up and Running — Burns, Beda, Hightower, EvensonIntroducción sólida y práctica a los objetos de Kubernetes y su lógica.Junto al módulo 09
Site Reliability Engineering — Google (disponible en abierto)SLO, presupuesto de error, guardias, post mortem sin culpables. Cambia la forma de pensar sobre la fiabilidad: deja de ser «que no falle» y pasa a ser «cuánto puede fallar».Cuando tengas algo en producción
The Site Reliability Workbook — GoogleLa parte práctica del anterior: cómo definir SLO de verdad.Después del anterior
Accelerate — Forsgren, Humble, KimLas cuatro métricas de entrega (frecuencia de despliegue, plazo, tasa de fallo, tiempo de restauración) y la evidencia de qué prácticas mejoran el rendimiento. Es lo que te permite argumentar con datos por qué merece la pena la CI.Cuando quieras cambiar cómo trabaja tu equipo
Continuous Delivery — Humble y FarleyEl libro fundacional del despliegue continuo. Denso, pero explica el porqué de todo lo que hoy damos por hecho.Opcional, después de Accelerate
The Phoenix Project — Kim, Behr, SpaffordNovela sobre una transformación DevOps. Se lee en un fin de semana y explica muy bien los problemas organizativos.Lectura ligera
Team Topologies — Skelton y PaisCómo la estructura de los equipos determina la arquitectura (ley de Conway aplicada). Útil para entender por qué en tu empresa las cosas son como son.Cuando trabajes en una organización mediana

9.3 Cursos y plataformas

RecursoQué esPara quién
Spring Academy (spring.academy)Formación oficial de Spring, con cursos gratuitos y la ruta de la certificación.Quien quiera la fuente oficial
Guías de spring.ioTutoriales cortos y oficiales de 15–30 minutos por tema.Todos: son el mejor primer contacto con cada tecnología
BaeldungRecetas concretas para problemas concretos. Enorme y desigual: comprueba siempre la fecha y la versión.Consulta puntual, no aprendizaje estructurado
Java Brains, Amigoscode, Dan Vega, Marco CodesCanales de vídeo de calidad sobre Java y Spring. Marco Codes destaca en herramientas y en interioridades de Spring.Quien aprenda mejor en vídeo
Vlad Mihalcea (blog y cursos)La referencia mundial de JPA, Hibernate y rendimiento de persistencia.Cuando pelees con JPA
PortSwigger Web Security AcademyGratuita, con laboratorios legales. La mejor formación práctica en seguridad web que existe, y no cuesta nada.Todos, junto al módulo 10
Testcontainers Workshops y guías de SpringMaterial práctico para tests de integración de verdad.Fase 2
Katacoda-style / killercoda y Play with DockerEntornos temporales en el navegador para practicar Kubernetes y Docker sin instalar nada.Fase 6
Udemy / Coursera / PluralsightCursos largos de calidad variable. Sirven si necesitas estructura y un ritmo impuesto; comprueba fecha de actualización y opiniones recientes.Quien necesite un itinerario cerrado

9.4 Blogs, boletines, pódcast y canales

Fuentes oficiales y de referencia

  • Inside Java (inside.java): artículos y pódcast del equipo del JDK. La fuente sobre el futuro del lenguaje.
  • Blog de Spring (spring.io/blog): notas de cada versión. Leer las de las versiones mayores evita sorpresas.
  • InfoQ Java: resúmenes y análisis con criterio editorial.
  • Foojay.io: comunidad de OpenJDK con artículos prácticos.
  • Martin Fowler (martinfowler.com): el archivo de artículos sobre arquitectura y refactorización sigue siendo referencia.

Voces individuales que merecen la pena

  • Vlad Mihalcea: JPA, Hibernate y rendimiento con medidas.
  • Thorben Janssen: JPA e Hibernate, más didáctico.
  • Nicolai Parlog (nipafx): Java moderno explicado con precisión, en texto y vídeo.
  • Brian Goetz: sus intervenciones y charlas sobre diseño del lenguaje son formación pura.
  • Aleksey Shipilëv: rendimiento y recolección de basura al máximo nivel técnico.
  • Jakob Jenkov: tutoriales claros de concurrencia y fundamentos.

Pódcast

  • Inside Java Podcast: oficial, con los diseñadores del lenguaje.
  • A Bootiful Podcast (Josh Long): entrevistas del ecosistema Spring.
  • Software Engineering Radio: episodios largos y técnicos sobre todo tipo de temas.
  • The InfoQ Podcast: tendencias con perspectiva.
  • En español: Coffee & Tips, Programar es una mierda y Codely tienen episodios útiles; la oferta técnica en español es menor pero está creciendo.

Canales de vídeo

  • Java (canal oficial de Oracle): charlas de JavaOne y sesiones técnicas.
  • Devoxx: el archivo de charlas más valioso que existe en Java, y gratis.
  • Spring Developer: SpringOne y sesiones oficiales.
  • GOTO Conferences: charlas de arquitectura y diseño de altísimo nivel.
  • Codely (en español): buen material sobre arquitectura, DDD y testing.

9.5 Conferencias y comunidades en España

QuéDónde y cuándoPara qué te sirve
Commit ConfMadrid, anual. Gran conferencia generalista de desarrollo, heredera de Codemotion Madrid.Panorama amplio y muchísima gente con la que hablar.
JavaCro / JBCNConf (Barcelona Java Conference)Barcelona, anual. Centrada en Java y JVM con ponentes internacionales.La cita más específica de Java en España.
CodemotionMadrid y otras ciudades.Generalista, buena para descubrir temas nuevos.
Grupos de usuarios Java (JUG)MadridJUG, BarcelonaJUG, MalagaJUG, Sevilla, Valencia, Zaragoza, Bilbao… Charlas mensuales, muchas en línea.Lo más rentable: gratis, cercano y con gente que trabaja en tu ciudad.
Spring I/OBarcelona, anual. Es la conferencia europea de Spring y ocurre aquí.Si solo vas a una conferencia en tu vida y haces Spring, esta.
DevOpsDays y T3chFestVarias ciudades; T3chFest en Madrid (Universidad Carlos III), con entrada gratuita.Operación, cultura y temas transversales sin coste.
Comunidades en líneaServidores de Discord y Slack de comunidades hispanohablantes de desarrollo; Reddit (r/java, r/springboot, r/programacion); Stack Overflow.Resolver dudas y ver cómo piensan otros. Contribuir respondiendo enseña más que preguntar.
Cómo aprovechar una conferencia de verdad: las charlas se graban casi siempre y se pueden ver después; lo que no se graba son las conversaciones de los pasillos. Ve con dos o tres preguntas concretas preparadas («¿cómo gestionáis las migraciones sin corte?», «¿qué usáis para trazas?») y pregúntaselas a quien esté a tu lado en la cola del café. Un meetup local de veinte personas suele darte más contactos útiles que una conferencia de mil.

9.6 Plataformas de práctica y cómo elegir qué consumir

PlataformaPara qué es buenaCómo usarla sin perder el tiempo
Exercism (ruta de Java)Ejercicios con revisión de personas reales y foco en el idioma del lenguaje.Lo mejor para pulir estilo. Pide revisión y léete las soluciones ajenas: ahí está el valor.
CodewarsKatas cortas y adictivas, con soluciones de la comunidad.Quince minutos de calentamiento. Compara siempre tu solución con las más votadas.
LeetCodeAlgoritmos y estructuras de datos para procesos con prueba de código.Con criterio: en el mercado español de backend Java, la mayoría de las entrevistas no son de LeetCode duro. Dedícale tiempo solo si apuntas a grandes tecnológicas o si el proceso lo exige. Treinta minutos diarios durante un mes en los patrones básicos (dos punteros, ventana deslizante, mapas, BFS/DFS) cubren casi todo.
HackerRankSimilar, y es lo que usan bastantes empresas para el filtro automático.Familiarízate con su editor antes de una prueba real: perder diez minutos peleando con la interfaz es tirar la prueba.
Advent of Code25 problemas cada diciembre, con narrativa y comunidad enorme.Excelente para practicar un lenguaje nuevo o probar características modernas. No busques la solución óptima: busca terminar y comparar.
PostgreSQL Exercises y SQLZooSQL con soluciones comentadas.Una hora a la semana mantiene el SQL en forma mejor que cualquier lectura.
Coding dojos y katas en grupoPracticar TDD y programación en pareja con otras personas.Muchos JUG organizan uno mensual. Es la forma más rápida de aprender hábitos de otros.
System Design Primer (repositorio)Guion de estudio para entrevistas de diseño.Úsalo como índice de temas, no como texto para memorizar.

Cómo elegir qué consumir sin dispersarse

  1. Regla del problema primero. No leas «por si acaso»: lee cuando tengas un problema concreto. El conocimiento sin gancho al que agarrarse se evapora en una semana.
  2. Un recurso por tema a la vez. Tres libros de arquitectura abiertos a la vez es cero libros de arquitectura leídos.
  3. Aplicar en menos de 48 horas. Si lees sobre índices parciales, ponlos en tu proyecto antes de dos días. Lo que no se aplica no se aprende.
  4. Prioriza la fuente primaria. Ante la duda, la documentación oficial o la JEP, no el resumen del resumen de un vídeo.
  5. Límite de tiempo para el consumo. Como mucho un 30% de tu tiempo de estudio leyendo o viendo; el 70% restante, escribiendo código.
  6. Lista de espera, no pestañas abiertas. Apunta lo interesante en un fichero y revísalo una vez al mes. La mayoría dejará de parecerte urgente, y eso ya es información.
  7. Desconfía de lo que promete atajos. «Domina Kubernetes en 3 horas» es entretenimiento, no formación.

10 · Certificaciones: qué valen y cuándo compensan

Pregunta recurrente y respuesta incómoda: en el mercado español, casi ninguna certificación te conseguirá un puesto por sí sola, pero algunas ayudan en situaciones muy concretas. Lo importante es saber cuáles son esas situaciones para no gastar dinero y semanas por el motivo equivocado.

CertificaciónQué mideCoste aprox.EsfuerzoValor real en España
Oracle Certified Professional: Java SE Developer (versión 17 o 21) Conocimiento profundo y a veces quisquilloso del lenguaje y su biblioteca. ≈ 250 € 60–100 h Medio-bajo. Se reconoce y se menciona, pero rara vez decide. Su mejor efecto es indirecto: estudiarla te obliga a leer el lenguaje con lupa.
Spring Certified Professional (VMware/Broadcom) Spring Framework y Spring Boot: contenedor, datos, web, seguridad, tests. ≈ 200–300 € (a veces incluida en la formación oficial) 40–80 h Medio. Más útil que la de Java en consultoras y en empresas con partenariado. El temario coincide bastante con lo que se usa a diario.
AWS Certified Developer – Associate (o Solutions Architect Associate) Servicios de AWS y cómo integrarlos. ≈ 150 $ 40–80 h Medio-alto en consultoras y empresas que venden proyectos en la nube: muchas necesitan un número de personas certificadas para mantener su nivel de socio. Ahí sí es un factor de contratación.
Azure Developer Associate (AZ-204) Equivalente en Azure. ≈ 165 € 40–80 h Medio-alto en sector público, banca y grandes cuentas, donde Azure es muy común.
Google Cloud Associate Cloud Engineer Equivalente en GCP. ≈ 125 $ 40–80 h Bajo-medio: menos demandada en España que AWS y Azure.
CKAD (Certified Kubernetes Application Developer) Examen práctico: resolver tareas reales en un clúster con tiempo limitado. ≈ 445 $ (con descuentos frecuentes) 40–60 h Alto para lo que cuesta demostrar de otra forma. Al ser práctica, se percibe como una prueba de habilidad, no de memoria.
CKA (Certified Kubernetes Administrator) Administración del clúster: más orientada a plataforma que a desarrollo. ≈ 445 $ 60–100 h Alto si quieres moverte hacia plataforma o SRE; poco relevante para un puesto de desarrollo puro.

Cuándo SÍ compensa

  • Trabajas o quieres entrar en una consultora que necesita certificaciones para su nivel de socio: ahí te la pagan y suma en la evaluación.
  • Buscas empleo en el sector público o en licitaciones donde la certificación puntúa formalmente en el baremo.
  • Estás cambiando de área (de sistemas a desarrollo, o al revés) y necesitas una señal verificable en un terreno donde no tienes experiencia laboral.
  • Necesitas estructura y una fecha para estudiar algo. Pagar el examen es un compromiso que funciona.
  • La certificación es práctica (CKAD, CKA): ahí sí demuestra habilidad real.
  • Te la paga la empresa y te dan horas. En ese caso, el análisis coste-beneficio es trivial.

Cuándo NO compensa

  • Crees que sustituye a la experiencia o a un proyecto. No lo hace, y quien entrevista lo sabe.
  • La usas para posponer el momento de construir algo. Es la forma más común de procrastinación productiva.
  • Es de una tecnología que no vas a usar en los próximos seis meses: se olvida entera.
  • Tienes que pagarla tú y ese dinero te haría falta para otra cosa. 250 € son muchos libros.
  • Tu currículum ya tiene señales más fuertes: proyecto sólido, experiencia relevante, contribuciones.
Alternativas que casi siempre rinden más por hora invertida: terminar este proyecto con las seis fases; escribir tres artículos técnicos sobre lo que has resuelto; hacer una contribución real a un proyecto libre; montar en tu empresa actual algo que no existía (la CI, los tests de integración, un panel); o preparar a fondo las entrevistas con el módulo 12. Todo eso produce evidencia, que es lo que una certificación intenta aproximar. Si aun así quieres una, el orden que más sentido tiene para un backend Java en España es: CKAD (práctica y diferencial), luego la de nube que use tu sector objetivo, y solo después las de lenguaje o framework.

11 · Preguntas frecuentes

Batería rápida para comprobar que el módulo quedó asimilado. Si no puedes responder en voz alta, vuelve a la sección citada.

¿Qué idea de este módulo explicaría primero en una entrevista?

La que conecta el problema de negocio con la solución técnica y sus contrapartidas. No recites APIs: cuenta un caso, una decisión y qué descartaste.

¿Cómo sé si lo he entendido de verdad?

Si puedes escribir un ejemplo mínimo de memoria, explicar el fallo típico y decir cuándo no usar la técnica. La checklist del final de cada sección es el listón.

¿Qué debo practicar con teclado y no solo leer?

Todo lo que tenga bloque de código en el módulo: cópialo, rómpelo, mídelo. La lectura sin ejecución no fija el contrato de equals, un plan de ejecución o un probe de Kubernetes.

¿Cómo relaciono este módulo con el proyecto final?

Cada concepto debe aparecer en el repositorio del módulo 13 (Cafetería Tech / MiniShop): un commit, un test o una decisión documentada. Si no aparece, no cuenta como aprendido.

¿Qué preguntas trampa debo anticipar?

Las que piden el por qué y el cuándo no. Prepárate a decir “depende” seguido de dos criterios medibles, no de una preferencia estética.

¿Cuánto tiempo debo dedicarle a este módulo en el plan?

El que indica el badge de la cabecera. Si vas corto de días, prioriza las secciones marcadas como críticas en el índice y los ejercicios numerados; deja el resto para el repaso del fin de semana.

¿Qué hago si un ejemplo no compila con mi versión?

Comprueba Java 21+ y Spring Boot 3.x. Las APIs nuevas (virtual threads, RestClient, ProblemDetail) no están en Java 8 ni en Spring Boot 2. Ajusta o sube versión; no “arregles” degradando el ejemplo.

¿Debo memorizar flags, anotaciones y comandos?

Memoriza el mapa mental y tres ejemplos. Los flags exactos se consultan; lo que se evalúa es saber cuál buscar y por qué lo necesitas.

¿Cómo evito estudiar en modo pasivo?

Cierra el HTML y escribe de memoria: un test, una entidad, un Dockerfile o una respuesta de entrevista de 90 segundos. Luego contrasta. Ese ciclo es el 70 % teclado del plan.

¿Qué enlazo con otros módulos?

Usa el aside y los enlaces internos. Persistencia remite a SQL (06) y a Spring Boot (04); despliegue a microservicios (08) y seguridad (10). No dupliques: profundiza donde el plan te manda.

¿Cómo demuestro esto en el CV o en GitHub?

Con un commit claro, un test que falle sin el arreglo, y una línea en el README del proyecto (“detectamos N+1 / OOMKilled / … y lo medimos”). Evidencia > adjetivos.

Si solo me queda una hora, ¿qué hago?

Lee la sección de errores comunes, responde tres FAQ en voz alta y marca dos ejercicios como hechos solo si los has ejecutado. Mejor poco sólido que mucho subrayado.

12 · Ejercicios y retos

Autoevaluación

13 · Resumen y recursos

Qué debes recordar

  • El por qué manda sobre la lista de APIs.
  • Mide antes de optimizar; los síntomas engañan.
  • Documenta decisiones y contrapartidas en el proyecto.
  • Los tests y la observabilidad cierran el aprendizaje.

Siguiente paso

  • Completa las checklists marcadas arriba.
  • Pasa al módulo siguiente solo con los ejercicios 1–3 hechos.
  • Anota dudas para el simulacro del módulo 12.
Cierre: no intentes dominar todo el módulo de una sentada. Domina el núcleo, demuéstralo con código y vuelve a las secciones avanzadas cuando el proyecto te las exija.