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.
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ñal | Lo que aporta un certificado o un curso | Lo 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.
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.
| Minuto | Qué mira | Qué concluye si está bien | Qué 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.
~/.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.
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.
| # | Criterio | Por qué importa | Coste | Impacto |
|---|---|---|---|---|
| 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.
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ón | 0 · Ausente | 1 · Básico | 2 · Sólido | 3 · 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.
2.2 Actores y objetivos
| Actor | Quién es | Qué quiere conseguir | Qué 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érmino | Definición precisa | Qué 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. |
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-250y 1 reservada, cuando consulto el catálogo con?localId=…, entonces el campodisponiblevale 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 recibo400con unProblemDetailque 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 recibo400: 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
200con una lista vacía ytotalElements: 0, no un404.
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 recibo201, se crea el cliente con rolROLE_CLIENTEy 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
409con un mensaje genérico que no permita enumerar cuentas existentes. - Dado credenciales correctas, cuando hago
POST /api/v1/auth/login, entonces recibo un JWT consub,rolesyexpa 15 minutos, más un refresh token opaco. - Dado credenciales incorrectas, cuando hago login,
entonces recibo
401con 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 recibo201, la cabeceraLocationcon la URL del pedido, estadoBORRADORy 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
422indicando 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
400con los errores de validación por campo. - Dado canal
ENVIOsin dirección, cuando creo el pedido, entonces recibo400: 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
BORRADORcon existencias suficientes, cuando hagoPOST /api/v1/pedidos/{id}/confirmacion, entonces se reservan las unidades, se solicita el cobro, el pedido pasa aCONFIRMADOy se publica el eventoPedidoConfirmado. - Dado que falta stock de una línea, cuando confirmo,
entonces recibo
409indicando qué SKU y cuántas unidades hay, no se reserva nada (todo o nada) y el pedido sigue enBORRADOR. - Dado que la pasarela rechaza el cobro, cuando confirmo,
entonces se liberan las reservas, el pedido queda en
PAGO_RECHAZADOy 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(no403: 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
BORRADORoCONFIRMADOy no preparado, cuando hagoPOST /api/v1/pedidos/{id}/cancelacion, entonces pasa aCANCELADO, se liberan las reservas y, si había cobro, se registra el reembolso. - Dado un pedido ya
ENTREGADO, cuando intento cancelarlo, entonces recibo409con 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_STAFFasignado al local A, cuando hagoGET /api/v1/locales/{idA}/cola, entonces recibo los pedidosCONFIRMADOyEN_PREPARACIONde 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 hagoPATCH /api/v1/pedidos/{id}/estadoconEN_PREPARACION, entonces la transición se acepta y se registra quién y cuándo. - Dado un pedido
BORRADOR, cuando intento pasarlo aLISTO, entonces recibo409: 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 hagoPOST /api/v1/productoscon un SKU nuevo, entonces recibo201. - 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 recibo403.
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 hagoPOST /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_totaly 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 hagoGET /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.
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).
| # | Regla | Dó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
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.
| Requisito | Objetivo medible | Cómo se verifica | Por 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. |
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 hace | Por qué se deja fuera | Qué 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.
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ón | Monolito modular | Microservicios | Qué 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.
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
recordinmutables. 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)
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:
| Forma | Cuándo se usa | Ejemplo en el proyecto | Acoplamiento |
|---|---|---|---|
| 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 LOCKEDmaduro (que se usa en la tarea programada) y unEXPLAINmá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/repositoryglobales). 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
notificacioneseinformes, 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
notificacioneseinformes.
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 unKafkaTemplate). 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_pendientesy 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):
@WebMvcTestpara serialización, validación, códigos de estado y seguridad;@DataJpaTestcontra PostgreSQL real para consultas y mapeos. - Integración de punta a punta (≈8%, <3 min):
@SpringBootTestcon 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.
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ón | Alternativa | Por 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)
);
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.
| Fichero | Contenido | Fase |
|---|---|---|
V1__esquema_inicial.sql | Todo el DDL anterior: tablas, restricciones e índices. | F2 |
V2__datos_maestros.sql | Los 4 locales, las categorías y el usuario administrador inicial. Datos que el sistema necesita para funcionar. | F2 |
V3__outbox_e_idempotencia.sql | Se separa para que el histórico refleje cuándo se añadió la mensajería. | F5 |
V4__indice_busqueda_texto.sql | El índice GIN, añadido tras medir que la búsqueda recorría la tabla entera. | F3 |
V5__historico_pedido.sql | Auditoría de transiciones (RN-12). | F3 |
R__vista_ventas_diarias.sql | Repetible (prefijo R): se reejecuta cuando cambia su contenido. Ideal para vistas y funciones. | F6 |
afterMigrate.sql | Solo 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
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/confirmarporque 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
GETnunca modifica nada y es cacheable.POSTcrea o ejecuta; no es idempotente salvo con clave.PUTreemplaza el recurso completo; es idempotente.PATCHmodifica parcialmente. Aquí solo para el cambio de estado.DELETEes idempotente: borrar dos veces devuelve204las 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-Idpara correlacionar con los logs. - Los
POSTque mueven dinero aceptanIdempotency-Key. - Ningún endpoint devuelve entidades del dominio: siempre DTO explícitos.
4.5 Tabla completa de endpoints
| Método y ruta | Quién | Qué hace | Éxito | Errores posibles |
|---|---|---|---|---|
GET /api/v1/productos | Pú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/productos | ADMIN | 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/registro | Público | Crea la cuenta de cliente. | 201 |
400 · 409 correo ya registrado |
POST /api/v1/auth/login | Público | Devuelve token de acceso y de refresco. | 200 |
400 · 401 credenciales · 429 demasiados intentos |
POST /api/v1/auth/refresh | Con refresh | Renueva el token de acceso y rota el de refresco. | 200 |
401 caducado o revocado |
POST /api/v1/pedidos | CLIENTE | Crea un pedido en borrador. Acepta Idempotency-Key. |
201 + Location |
400 · 401 · 422 SKU inválido o retirado |
GET /api/v1/pedidos | CLIENTE / 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}/confirmacion | Propietario | 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}/cancelacion | Propietario / ADMIN | Cancela y libera reservas. Idempotente por naturaleza. | 200 |
404 · 409 transición no permitida |
PATCH /api/v1/pedidos/{id}/estado | STAFF del local / ADMIN | Avanza el estado (preparación, listo, entregado). | 200 |
403 otro local · 404 · 409 transición inválida |
GET /api/v1/locales | Público | Lista de locales activos con dirección. | 200 |
— |
GET /api/v1/locales/{id}/cola | STAFF del local / ADMIN | Pedidos pendientes de preparar, por antigüedad. | 200 |
401 · 403 local ajeno · 404 |
GET /api/v1/inventario | STAFF / ADMIN | Existencias, reservas y disponible por local. | 200 |
401 · 403 |
POST /api/v1/inventario/ajustes | ADMIN | 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/ventas | ADMIN | Pedidos e importe por local y día en un rango. | 200 |
400 rango > 92 días · 403 |
GET /actuator/health | Interno | Estado del servicio y sus dependencias. | 200 / 503 |
— |
GET /v3/api-docs y /swagger-ui.html | Pú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
}
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ámetro | Valores | Por defecto | Notas |
|---|---|---|---|
page | Entero ≥ 0 | 0 | Paginación por desplazamiento, válida para el catálogo (500 productos). |
size | 1 a 100 | 20 | Por encima de 100 se devuelve 400, no se recorta en silencio: recortar sin avisar produce clientes que creen tener todos los datos. |
sort | campo,asc|desc | nombre,asc | Lista blanca de campos ordenables. Nunca se concatena el valor en el SQL. |
q | Texto, 2 a 60 caracteres | — | Búsqueda sin acentos ni mayúsculas sobre nombre y descripción. |
cursor | Cadena opaca en Base64 | — | Solo 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 respuesta | No | Se añade sin más. Los clientes deben ignorar lo que no conocen (regla escrita en docs/api.md). |
| Añadir un endpoint | No | Directo. |
| Añadir un parámetro opcional | No | Con valor por defecto igual al comportamiento anterior. |
| Renombrar un campo | Sí | Se 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) | Sí | Campo nuevo junto al viejo. Nunca cambiar el tipo en sitio. |
| Añadir un valor a un enumerado | Depende | Rompe a los clientes que hacen switch exhaustivo. Se documenta desde el principio que los enumerados pueden crecer. |
| Hacer obligatorio un campo opcional | Sí | Exige versión nueva. |
| Cambiar un código de estado | Sí | Exige 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ódigo | Cuándo se usa aquí | Error frecuente |
|---|---|---|
400 | La petición está mal formada o incumple validaciones de formato. | Usarlo para conflictos de negocio, que son 409 o 422. |
401 | No hay token o no es válido. | Confundirlo con 403: 401 es «no sé quién eres». |
402 | Pago rechazado por la pasarela. | Poco habitual, pero es exactamente su semántica. |
403 | Sé quién eres y no puedes. | Usarlo para recursos ajenos, revelando que existen. Preferimos 404. |
404 | No existe, o existe pero no es tuyo. | Devolver 200 con cuerpo vacío. |
409 | Conflicto con el estado actual: sin stock, transición inválida, versión obsoleta. | Devolver 500 ante una OptimisticLockException. |
422 | Sintaxis correcta, semántica imposible: SKU inexistente, clave de idempotencia reutilizada. | Mezclarlo con 400 sin criterio; elige uno y sé coherente. |
429 | Demasiados intentos de login. | Olvidar la cabecera Retry-After. |
500 | Fallo no previsto. | Incluir la traza en el cuerpo: filtra rutas, versiones y estructura interna. |
503 | Dependencia 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]
/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.
| Fase | Qué existe al terminar | Horas | Módulos a releer |
|---|---|---|---|
| F1 · Esqueleto | Proyecto que arranca, contenedores, salud y CI en verde. | 4–6 h | 04, 09 |
| F2 · Dominio y datos | Modelo con reglas, persistencia, Flyway y tests con base de datos real. | 10–14 h | 01, 05, 06, 07 |
| F3 · API | REST completa con validación, errores, paginación y OpenAPI. | 8–12 h | 04, 07 |
| F4 · Seguridad | Autenticación con JWT, roles y autorización por recurso probada. | 6–8 h | 10, 10.4 |
| F5 · Eventos y caché | Outbox, Kafka, consumidor idempotente, tarea programada y Redis. | 10–14 h | 08, 03, 06.9 |
| F6 · Operación | Contenedores afinados, Kubernetes, métricas, trazas, panel y carga. | 8–12 h | 09, 08.9 |
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 cloneseguido dedocker compose up -d --waitdeja el sistema en pie sin intervención manual. curl localhost:8080/actuator/healthdevuelve{"status":"UP"}con los componentes de base de datos, Redis y Kafka en verde../mvnw verifypasa en limpio, y el badge de CI está en verde en el README.- Parar la base de datos hace que
/actuator/health/readinesspase aDOWN. 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
}
}
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=0y ejecútalo en bucle: si es intermitente, no está resuelto). - Ningún
@Entityni@Autowiredaparece dentro de un paquetedominio. - 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.shrecorre 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=1000o unsortno permitido devuelve400, 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 detecty 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.mdtiene números de antes y después de un cambio concreto, no una promesa.
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
| Nivel | Qué prueba | Herramientas | Cuántos | Tiempo | Qué 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). |
6.2 Umbrales de cobertura y de mutación
| Métrica | Umbral | Ámbito | Qué significa de verdad |
|---|---|---|---|
| Cobertura de líneas | ≥ 80% | Global | Que no hay áreas enteras sin tocar. No dice nada sobre la calidad de las aserciones. |
| Cobertura de líneas | ≥ 90% | Paquetes dominio | Ahí vive el valor; cubrirlo es barato porque los tests son rápidos. |
| Cobertura de ramas | ≥ 70% | Global | Más informativa que la de líneas: obliga a probar los dos lados de cada condición. |
| Puntuación de mutación | ≥ 60% | dominio | La 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 CI | Total | Por encima, se deja de ejecutar en local y la red de seguridad desaparece. |
| Tests intermitentes | Cero tolerancia | Total | Un 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>
> 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();
}
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
| Herramienta | Qué detecta | Dónde | ¿Rompe el build? |
|---|---|---|---|
Compilador con -Xlint:all | Lo que ya sabe Java y casi nadie escucha. | Local y CI | Sí, con -Werror si te atreves |
| Error Prone | Errores reales: comparar con ==, formatos incorrectos, resultados ignorados. | Compilación | Sí, los de severidad alta |
| SpotBugs | Posibles NullPointerException, recursos sin cerrar, concurrencia sospechosa. | CI | Sí, categorías High |
| Spotless | Formato. Elimina los comentarios de revisión sobre espacios. | Local (auto) y CI | Sí |
| OWASP Dependency-Check o Trivy | Vulnerabilidades conocidas en dependencias. | CI y semanal | Sí, a partir de CVSS 7 |
| gitleaks | Secretos en el código o en el historial. | Pre-commit y CI | Sí, siempre |
| SonarQube / SonarCloud | Duplicació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
| Aspecto | Convención del proyecto | Por qué |
|---|---|---|
| Rama principal | main, siempre desplegable y protegida. | Si main puede estar roto, el badge verde no significa nada. |
| Ramas de trabajo | feat/reserva-stock, fix/n-mas-1-lineas, de vida corta (1–3 días). | Las ramas largas producen conflictos y revisiones imposibles. |
| Integración | Pull request aunque trabajes solo, con CI obligatoria. | Te da el punto de revisión y deja constancia del razonamiento. |
| Fusión | Squash 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. |
| Etiquetas | v0.1.0 al cerrar cada fase, con notas de versión. | Demuestra progreso por hitos y permite volver a un punto conocido. |
| Tamaño | Menos 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
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
[](…)
[](…)
[](…)
[](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 README | Pregunta que responde | Error 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
```
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:
- Un GIF de veinte segundos con el recorrido completo en terminal:
docker compose up, la creación del pedido, la confirmación y el evento llegando al consumidor. Grábalo conasciinema(y conviértelo conagg) o con cualquier grabador de pantalla. Es lo que más se mira de todo el README. - Una captura del panel de Grafana con tráfico real durante la prueba de carga. Comunica «esto se puede operar» mejor que un párrafo.
- Una captura de la interfaz de OpenAPI mostrando los endpoints agrupados y algún error documentado.
- Una captura de una traza distribuida que atraviese la petición, la publicación y el consumo. Es la imagen que más impresiona y la que menos gente tiene.
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.
| Bloque | Tiempo | Qué dices | Qué NO dices |
|---|---|---|---|
| 1 · El problema | 25 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 forma | 40 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ícil | 70 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 distinto | 45 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. |
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
| Pieza | Para qué sirve | Tamaño | Señ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 verdad | Un 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 documentada | Comparar 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 libres | Aunque 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
- La foto y el nombre real: un perfil sin cara ni nombre parece abandonado.
- La biografía de una línea: «Backend Java/Spring · Valencia · buscando primer puesto» dice más que cualquier eslogan.
- Los repositorios fijados (hasta seis; usa tres o cuatro).
- La descripción y los temas de cada repositorio fijado.
- El README del perfil, si existe.
- 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
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.
| Formato | Esfuerzo | Qué contar | Dónde |
|---|---|---|---|
| Nota técnica corta (300–600 palabras) | 1 h | Un 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 medida | 4–6 h | Dos 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 lectura | 30 min | Qué te llevas de un capítulo y cómo lo aplicas a tu proyecto. | Repositorio de notas públicas. |
| Charla interna o meetup | 8 h | Veinte 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.
| Paso | Qué hacer | Dificultad |
|---|---|---|
| 1 | Usar y observar. Elige una librería que ya uses (Testcontainers, Flyway, Resilience4j, una de Spring). Lee su código cuando dudes de algo. | Baja |
| 2 | Abrir una incidencia buena. Un caso reproducible mínimo, versiones, comportamiento esperado y obtenido. Esto ya es una contribución valiosa y muy escasa. | Baja |
| 3 | Documentació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 |
| 4 | Un test que falta. Cubrir un caso límite documentado pero no probado. Muy bien recibido y sin riesgo de romper nada. | Media |
| 5 | Un arreglo pequeño etiquetado como good first issue, con test que demuestre el fallo antes y después. | Media |
| 6 | Una 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.mdentero 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 siempre | Aparece a menudo | Diferencial |
|---|---|---|
| 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
| Fuente | Qué leer exactamente | Cuá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 |
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 autor | Para qué sirve | Cuándo |
|---|---|---|
| Effective Java (3.ª ed.) — Joshua Bloch | Noventa 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 otros | El 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 Newland | Có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, Mycroft | Lambdas, 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 autor | Para qué sirve | Cuándo |
|---|---|---|
| High-Performance Java Persistence — Vlad Mihalcea | JPA 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 Kleppmann | El 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 Petrov | Qué 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 autor | Para qué sirve | Cuándo |
|---|---|---|
| Spring in Action (6.ª ed.) — Craig Walls | Recorrido 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 Heckler | Enfoque directo y moderno, orientado a construir. Más corto que el anterior. | Alternativa al anterior |
| Spring Security in Action — Laurentiu Spilca | La ú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 autor | Para qué sirve | Cuándo |
|---|---|---|
| Clean Code — Robert C. Martin | Nombres, 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. Martin | La 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 Vernon | DDD 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 Khononov | La 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 Newman | Descomposició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 Newman | Patrones 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 Nygard | Patrones 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 Ford | Panorama 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 autor | Para qué sirve | Cuándo |
|---|---|---|
| Effective Software Testing — Maurício Aniche | Có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 Khorikov | La 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 Fowler | Catá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 Feathers | Có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 Thomas | Hábitos y actitud profesional. Envejece bien porque habla de oficio, no de tecnología. | Cualquier momento |
| A Philosophy of Software Design — John Ousterhout | Corto 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 autor | Para qué sirve | Cuándo |
|---|---|---|
| Kubernetes: Up and Running — Burns, Beda, Hightower, Evenson | Introducció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 — Google | La parte práctica del anterior: cómo definir SLO de verdad. | Después del anterior |
| Accelerate — Forsgren, Humble, Kim | Las 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 Farley | El 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, Spafford | Novela 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 Pais | Có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
| Recurso | Qué es | Para 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.io | Tutoriales cortos y oficiales de 15–30 minutos por tema. | Todos: son el mejor primer contacto con cada tecnología |
| Baeldung | Recetas 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 Codes | Canales 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 Academy | Gratuita, 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 Spring | Material práctico para tests de integración de verdad. | Fase 2 |
| Katacoda-style / killercoda y Play with Docker | Entornos temporales en el navegador para practicar Kubernetes y Docker sin instalar nada. | Fase 6 |
| Udemy / Coursera / Pluralsight | Cursos 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ándo | Para qué te sirve |
|---|---|---|
| Commit Conf | Madrid, 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. |
| Codemotion | Madrid 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/O | Barcelona, 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 T3chFest | Varias ciudades; T3chFest en Madrid (Universidad Carlos III), con entrada gratuita. | Operación, cultura y temas transversales sin coste. |
| Comunidades en línea | Servidores 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. |
9.6 Plataformas de práctica y cómo elegir qué consumir
| Plataforma | Para qué es buena | Có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. |
| Codewars | Katas cortas y adictivas, con soluciones de la comunidad. | Quince minutos de calentamiento. Compara siempre tu solución con las más votadas. |
| LeetCode | Algoritmos 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. |
| HackerRank | Similar, 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 Code | 25 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 SQLZoo | SQL con soluciones comentadas. | Una hora a la semana mantiene el SQL en forma mejor que cualquier lectura. |
| Coding dojos y katas en grupo | Practicar 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
- 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.
- Un recurso por tema a la vez. Tres libros de arquitectura abiertos a la vez es cero libros de arquitectura leídos.
- 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.
- Prioriza la fuente primaria. Ante la duda, la documentación oficial o la JEP, no el resumen del resumen de un vídeo.
- 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.
- 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.
- 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ón | Qué mide | Coste aprox. | Esfuerzo | Valor 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.
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.