La lección anterior cerró con una pregunta distinta a todas las que habíamos hecho hasta ahora: CicloUrbana está construida, probada, desplegada y observada, pero ¿está bien hecha? Durante nueve módulos hemos tomado decisiones sin detenernos demasiado a justificarlas como categoría: pusimos @Transactional en el servicio, hicimos los DTOs record, dejamos open-in-view: false, denegamos por defecto en la cadena de filtros. Cada una tenía su razón en su momento, dispersa en la lección donde apareció.
Esta lección las recoge y las ordena. No como una lista de mandamientos —eso sería inútil y probablemente dañino—, sino como un catálogo de decisiones justificadas: para cada práctica, qué problema resuelve, cómo se ve en el código de la red de Ribalta, dónde la estudiamos y —esto es lo que separa un profesional de alguien que copia recetas— en qué circunstancias tiene sentido no aplicarla. Al final tendrás una lista de comprobación de treinta y ocho puntos que puedes llevarte a cualquier proyecto Spring Boot y usarla el día antes de un despliegue.
Contenido
- Una práctica no es un dogma
- Estructura y diseño
- Configuración
- Inyección y beans
- La API
- Datos y persistencia
- Seguridad
- Pruebas
- Operación
- La lista de comprobación de una aplicación lista para producción
- Cuándo romper la regla
- Errores Comunes y Consejos
- Ejercicios
- Una práctica no es un dogma
Antes del catálogo, un aviso sobre cómo leerlo.
Una «buena práctica» es la respuesta que suele ser correcta a un problema recurrente, en un contexto determinado. Las tres palabras importan. Si se olvida el contexto, la práctica se convierte en superstición: gente que crea una interfaz por cada servicio porque «así se hace», sin que exista ni vaya a existir una segunda implementación. Si se olvida el problema, se pierde la capacidad de saber cuándo la práctica ya no aporta nada.
Por eso cada apartado de esta lección tiene siempre la misma forma:
| Elemento | Qué responde |
|---|---|
| La práctica | Qué se hace |
| El porqué | Qué problema concreto evita |
| En CicloUrbana | Dónde se ve en el código de Ribalta |
| Dónde se estudió | La lección que lo desarrolla |
Y hay un apartado 11 dedicado exclusivamente a lo contrario: cuándo romper la regla con criterio. Una regla que no puedes justificar no es una regla que dominas, es una que has memorizado.
Un último marco general antes de empezar. Casi todas las prácticas de esta lección se derivan de tres principios de fondo:
flowchart TB
P1["Hacer explícito lo implícito<br/>DTOs, propiedades validadas,<br/>migraciones versionadas"]
P2["Fallar pronto y ruidosamente<br/>arranque, compilación, pruebas<br/>antes que producción"]
P3["Separar lo que cambia<br/>por razones distintas<br/>dominio / contrato / infraestructura"]
P1 --- P2 --- P3
Si alguna vez dudas ante una decisión que este catálogo no cubre, pregúntate cuál de los tres principios se respeta mejor con cada opción. Suele bastar.
- Estructura y diseño
2.1. Organiza por funcionalidad, no por capa técnica
La práctica. Los paquetes de primer nivel son áreas del negocio (estaciones, bicicletas, alquileres, usuarios), no tipos técnicos (controller, service, repository, model).
El porqué. Un cambio real casi nunca es «tocar todos los controladores»: es «añadir un campo a las estaciones», y eso toca controlador, servicio, repositorio, DTO y mapeador. Con paquetes por capa, ese cambio se reparte por cinco carpetas lejanas; con paquetes por funcionalidad, cabe en una. Además, los paquetes por funcionalidad permiten usar la visibilidad de paquete de Java como una frontera real, cosa que los paquetes por capa hacen imposible: si todos los servicios están juntos, todos son visibles entre sí.
❌ Por capa ✅ Por funcionalidad
com.ciclourbana com.ciclourbana
├── controller ├── CicloUrbanaApplication.java
│ ├── EstacionController ├── estaciones
│ ├── BicicletaController │ ├── Estacion.java
│ └── AlquilerController │ ├── EstacionRepositorio.java
├── service │ ├── EstacionService.java
│ ├── EstacionService │ ├── EstacionController.java
│ └── ... │ ├── EstacionMapper.java
├── repository │ └── dto/
└── model ├── bicicletas
├── alquileres
├── usuarios
├── seguridad
└── comunEn CicloUrbana. Es la estructura que arrastramos desde Entendiendo la Estructura del Proyecto: com.ciclourbana.alquileres contiene Alquiler, AlquilerRepositorio, AlquilerService, AlquilerController, AlquilerMapper, las tarifas y el subpaquete dto. com.ciclourbana.comun guarda lo transversal —PaginaResponse, EntidadAuditable, el Clock, FiltroTraza, ManejadorGlobalExcepciones—.
2.2. El monolito modular por defecto
La práctica. Un solo desplegable con fronteras internas fuertes, hasta que exista una razón concreta para partirlo.
El porqué. En Spring Boot y Microservicios lo vimos con nombres: dividir el sistema cambia llamadas a métodos por llamadas de red, transacciones locales por sagas, una traza de pila por una investigación entre tres equipos. Todo eso son costes reales que solo se pagan bien cuando compran algo —escalado independiente, despliegue independiente, equipos independientes— que el proyecto necesita de verdad.
En CicloUrbana. Los cuatro paquetes de negocio son módulos con su propio servicio como fachada. AlquilerService no consulta EstacionRepositorio directamente; pasa por EstacionService o reacciona a eventos. Esa disciplina es la que hace que extraer la facturación a un servicio propio sea, el día que haga falta, un trabajo de días y no de meses.
2.3. La regla de dependencia entre capas
La práctica. El flujo de dependencias es unidireccional: Controlador → Servicio → Repositorio. Nunca al revés, y nunca saltándose el intermedio.
El porqué. Un repositorio que llama a un servicio crea ciclos —los que hacían fallar el arranque en Inyección de Dependencias— y hace imposible razonar sobre el orden de las cosas. Un controlador que llama al repositorio se salta la transacción, las reglas de negocio y las comprobaciones de seguridad de método de Seguridad a Nivel de Método, que viven precisamente en el servicio.
flowchart LR
C["EstacionController<br/>HTTP, DTOs, códigos"] --> S["EstacionService<br/>@Transactional, @PreAuthorize,<br/>reglas de negocio"]
S --> R["EstacionRepositorio<br/>consultas"]
R --> BD[(PostgreSQL)]
C -.->|"❌ nunca"| R
R -.->|"❌ nunca"| S
En CicloUrbana. EstacionController.crear llama a estacionService.crear(...) y nunca a estacionRepositorio.save(...). En Consejos para Escribir Código Limpio veremos cómo convertir esta regla en una prueba automática con ArchUnit, de modo que dejar de cumplirla ponga la construcción en rojo.
2.4. El dominio no depende del framework
La práctica. Las clases que expresan reglas de negocio no importan org.springframework.* ni jakarta.servlet.*.
El porqué. El dominio es la parte del código que más vive y menos cambia; el framework es la que más cambia. Si CalculadoraTarifa importara HttpServletRequest, la regla de las tarifas de Ribalta quedaría atada a que la petición sea HTTP. Y hay un beneficio inmediato: una clase sin framework se prueba en un milisegundo, que es la razón de que TarifaEstandarTest de Introducción a las Pruebas no levante ningún contexto.
// ✅ Dominio puro: se instancia con new y se prueba sin Spring
public interface CalculadoraTarifa {
BigDecimal calcular(Duration duracion);
default String nombre() { return getClass().getSimpleName(); }
}La anotación @Component sobre TarifaEstandar es la excepción tolerada y consciente: es un metadato que no cambia el comportamiento de la clase y no impide instanciarla con new en una prueba.
- Configuración
3.1. @ConfigurationProperties validadas, no @Value disperso
La práctica. Los grupos de propiedades se enlazan a un record validado; @Value queda para casos verdaderamente sueltos.
El porqué. Cinco @Value repartidos por cuatro clases son cinco lugares donde un error de escritura no da error de compilación, cinco valores sin validar y ninguna documentación de qué configura la aplicación. Un record con prefijo es un contrato: sale en /actuator/configprops, lo autocompleta el IDE y falla al arrancar si algo no cuadra.
// ❌ Así no
@Service
public class TarifaEstandar {
@Value("${ciclourbana.tarifa.desbloqueo}") private BigDecimal desbloqueo;
@Value("${ciclourbana.tarifa.precio-minuto}") private BigDecimal precioMinuto;
}// ✅ Así sí
@Validated
@ConfigurationProperties(prefix = "ciclourbana.tarifa")
public record TarifasProperties(
@NotNull @DecimalMin("0.00") BigDecimal desbloqueo,
@NotNull @DecimalMin("0.01") BigDecimal precioMinuto,
@NotNull @DecimalMin("0.00") BigDecimal precioMinutoEstudiante) {}Dónde se estudió. Propiedades de Spring Boot, con TarifasProperties y RedProperties.
3.2. Un artefacto para todos los entornos
La práctica. El mismo JAR y la misma imagen viajan de dev a pre y a prod. Lo que cambia es el entorno, nunca el binario.
El porqué. Recompilar por entorno significa desplegar en producción un artefacto que nadie probó. Es el segundo de los doce factores y la razón de que los perfiles existan.
En CicloUrbana. application.yml con lo común y application-dev/test/pre/prod.yml con las diferencias, activados por SPRING_PROFILES_ACTIVE (Perfiles de Spring Boot). El perfil nunca se hornea en la imagen Docker.
3.3. Los secretos fuera del repositorio, siempre
La práctica. Contraseña de PostgreSQL, secreto de firma del JWT y clave de la pasarela llegan por variable de entorno o gestor de secretos. Nunca por un fichero versionado, ni «temporalmente».
El porqué. Un secreto que entra en el historial de Git sigue ahí aunque el siguiente commit lo borre. Borrarlo no basta: hay que rotarlo.
# application-prod.yml — versionado, sin un solo valor secreto
spring:
datasource:
url: ${URL_BASE_DATOS}
username: ${USUARIO_BASE_DATOS}
password: ${CLAVE_BASE_DATOS}
ciclourbana:
jwt:
secreto: ${JWT_SECRETO}3.4. Sin valor por defecto para lo obligatorio
La práctica. Una propiedad que debe venir del entorno no lleva valor por defecto. Si falta, la aplicación no arranca.
El porqué. Es el principio de fallar pronto aplicado a la configuración. Un ${JWT_SECRETO:cambiame} arranca perfectamente en producción y firma tokens con un secreto público; sin valor por defecto, el despliegue falla en el arranque, la sonda de disponibilidad no pasa y el despliegue se revierte solo.
| Tipo de propiedad | ¿Valor por defecto? | Ejemplo |
|---|---|---|
| Secreto | Nunca | ${JWT_SECRETO} |
| Dirección de un servicio externo | Solo la de desarrollo local | ${OTLP_ENDPOINT:http://localhost:4318/v1/traces} |
| Parámetro de negocio | Sí, el valor de Ribalta | capacidad-minima: 8 |
| Cadencia de una tarea | Sí, el intervalo razonable | ${...caducador.intervalo:PT10M} |
- Inyección y beans
4.1. Inyección por constructor, campos final, sin @Autowired
La práctica. Todas las dependencias entran por el constructor, se guardan en campos final y no se anota @Autowired cuando hay un solo constructor.
El porqué. De los cinco argumentos de Inyección de Dependencias, dos son decisivos: la clase se puede construir en una prueba con un new, sin reflexión ni contexto; y el constructor que crece duele visualmente, lo que convierte el exceso de dependencias en un problema visible en lugar de en quince @Autowired que nadie cuenta.
// ❌ Así no: no permite final, no se construye en un test, oculta el crecimiento
@Service
public class AlquilerService {
@Autowired private AlquilerRepositorio alquilerRepositorio;
@Autowired private BicicletaRepositorio bicicletaRepositorio;
@Autowired private SelectorTarifa selectorTarifa;
}// ✅ Así sí
@Service
public class AlquilerService {
private final AlquilerRepositorio alquilerRepositorio;
private final BicicletaRepositorio bicicletaRepositorio;
private final SelectorTarifa selectorTarifa;
private final Clock reloj;
public AlquilerService(AlquilerRepositorio alquilerRepositorio,
BicicletaRepositorio bicicletaRepositorio,
SelectorTarifa selectorTarifa,
Clock reloj) {
this.alquilerRepositorio = alquilerRepositorio;
this.bicicletaRepositorio = bicicletaRepositorio;
this.selectorTarifa = selectorTarifa;
this.reloj = reloj;
}
}4.2. Beans sin estado mutable
La práctica. Un bean singleton no guarda estado que cambie entre peticiones.
El porqué. Todos los hilos comparten la misma instancia. Un campo mutable en un @Service es una condición de carrera esperando su turno, y el fallo aparece bajo carga, en producción, y es irreproducible en local.
En CicloUrbana. El estado por petición vive en el MDC o en los argumentos; el estado compartido, en la base de datos o en la caché. SelectorTarifa guarda un Map inmutable construido en el constructor: es estado, pero no es mutable.
4.3. No abuses de @Profile
La práctica. @Profile responde a «¿dónde estoy?». Cuando la pregunta real es «¿está activada esta capacidad?», la respuesta es una propiedad y @ConditionalOnProperty.
El porqué. Con @Profile proliferando, activar una función en preproducción obliga a inventar perfiles combinados y el código acaba sabiendo dónde vive, que es exactamente lo contrario de los doce factores.
// ❌ El bean sabe dónde vive
@Bean @Profile({"dev", "test", "pre"})
ServicioCorreo servicioCorreoSimulado() { ... }
// ✅ El bean depende de una capacidad
@Bean
@ConditionalOnProperty(name = "ciclourbana.correo.modo", havingValue = "simulado",
matchIfMissing = true)
ServicioCorreo servicioCorreoSimulado() { ... }
- La API
5.1. DTOs siempre, sin excepciones «solo en este endpoint»
La práctica. Ninguna entidad JPA cruza la frontera HTTP, ni de entrada ni de salida.
El porqué. Los cuatro fallos de DTOs y Mapeo entre Capas: fuga silenciosa de datos —el contrasenaHash o el DNI de un ciudadano de Ribalta—, acoplamiento invisible que convierte un renombrado en una rotura de la app móvil, referencias circulares al serializar relaciones y la imposibilidad de exponer un campo calculado como bicicletasDisponibles. A los que se suma el técnico de Transacciones: con open-in-view: false, una entidad que sale del servicio con relaciones perezosas es una LazyInitializationException garantizada.
Un DTO es una lista de inclusiones; @JsonIgnore es una lista de exclusiones, y las listas de exclusiones fallan por omisión.
5.2. Versiona el contrato y valida en el borde
La práctica. Todas las rutas cuelgan de /api/v1/..., y toda entrada se valida con Bean Validation en el DTO, con @Valid en el controlador.
El porqué. El prefijo de versión no cuesta nada hoy y es la única forma de introducir un cambio incompatible mañana sin romper a la app instalada en el móvil de los ciudadanos. Y validar en el borde significa que el servicio nunca recibe datos inválidos: la comprobación ocurre una vez, en un sitio, declarativamente, en lugar de repartida en if por toda la lógica.
5.3. Errores uniformes con ProblemDetail
La práctica. Todos los errores salen con el mismo formato RFC 7807, generados en un único @RestControllerAdvice.
El porqué. Un cliente que tiene que interpretar cinco formas distintas de error acaba haciendo if (respuesta.contiene("no existe")). Un formato único con un código propio y un identificador de traza convierte el soporte en una consulta: el ciudadano lee el identificador en su pantalla y el operador encuentra la petición exacta en los logs.
En CicloUrbana. ManejadorGlobalExcepciones traduce la jerarquía de CicloUrbanaException a ProblemDetail, con el traceId unificado de Trazabilidad Distribuida.
5.4. Códigos de estado correctos y paginación obligatoria
La práctica. 201 con cabecera Location al crear, 204 al borrar, 404 si no existe, 409 en conflicto, 422 si la regla de negocio no se cumple. Y ninguna colección se devuelve sin paginar.
El porqué. Los códigos correctos permiten a los clientes, a los proxies y a las métricas de Monitoreo con Actuator distinguir un error del cliente de uno del servidor sin leer el cuerpo. Y un findAll() sin Pageable funciona perfectamente con las cuatro estaciones de Ribalta y tumba la aplicación el día que haya cuatrocientas mil filas de alquileres: es una bomba de relojería que se arma sola con el tiempo.
// ❌ Funciona hoy, revienta con el volumen
@GetMapping public List<AlquilerResponse> listar() { ... }
// ✅ El límite forma parte del contrato
@GetMapping public PaginaResponse<AlquilerResponse> listar(
@PageableDefault(size = 20) Pageable paginacion) { ... }
- Datos y persistencia
6.1. La transacción vive en el servicio
La práctica. @Transactional(readOnly = true) en la clase de servicio, @Transactional explícito en los métodos que escriben. Nunca en el controlador.
El porqué. El caso de uso es la unidad que debe ser atómica: iniciar un alquiler marca la bicicleta y crea el registro, o no hace ninguna de las dos cosas. Un método de repositorio es demasiado pequeño para eso; un controlador es demasiado grande y mantendría la conexión abierta durante la serialización JSON.
Y el patrón readOnly en la clase tiene una virtud cultural: escribir se convierte en una decisión consciente. Un método que se olvida de anotarse no escribe por accidente.
6.2. open-in-view: false, LAZY por defecto, mapeo dentro de la transacción
La práctica. Las tres van juntas y se refuerzan.
El porqué. open-in-view: true —el valor por defecto de Spring Boot, y por eso hay que desactivarlo explícitamente— mantiene el EntityManager abierto durante la serialización, lo que oculta los N+1 detrás de una capa donde nadie los busca y retiene una conexión del pool más tiempo del necesario. Desactivarlo convierte «el servicio devuelve DTOs» de buena práctica en requisito técnico, que es justo lo que queremos.
6.3. El esquema es código: Flyway y ddl-auto: validate
La práctica. El esquema se define en migraciones versionadas; Hibernate solo valida que la base de datos coincide con las entidades.
El porqué. ddl-auto: update no borra columnas, no renombra, no rellena datos y no deja constancia de qué SQL ejecutó. Es imposible revisar en una pull request y es imposible revertir. Con Flyway, cada cambio de esquema es un fichero con nombre, número y checksum, y el historial vive en flyway_schema_history.
En CicloUrbana. V1__crear_esquema_inicial.sql a V9__contract_eliminar_capacidad.sql, más las repetibles R__. Es lo que se construyó en Migraciones de Esquema con Flyway.
6.4. Migraciones compatibles hacia atrás
La práctica. Una migración debe funcionar con la versión de la aplicación que está corriendo ahora y con la que se va a desplegar.
El porqué. Durante un despliegue sin cortes conviven dos versiones. Si V9 borra la columna capacidad y aún hay instancias de la versión anterior que la leen, esas instancias fallan. La solución es el patrón expand/contract: primero se añade lo nuevo y se escriben ambas columnas (V8__expand_plazas_totales.sql), luego se despliega la aplicación que solo usa la nueva, y después se borra la vieja (V9__contract_eliminar_capacidad.sql), en un despliegue distinto.
| Operación | ¿Compatible? | Cómo hacerla segura |
|---|---|---|
| Añadir una columna con valor por defecto | Sí | Directa |
Añadir una columna NOT NULL sin defecto |
No | Añadir con defecto, rellenar, luego endurecer |
| Renombrar una columna | No | Expand/contract en dos despliegues |
| Borrar una columna | No | Solo después de que ninguna versión viva la use |
| Crear un índice en una tabla grande | Bloquea escrituras | CREATE INDEX CONCURRENTLY |
- Seguridad
7.1. Denegar por defecto
La práctica. La cadena de filtros termina con anyRequest().denyAll(), no con permitAll() ni con nada.
El porqué. Con denegación por defecto, olvidar una regla produce un 403 visible en la primera prueba. Con permiso por defecto, olvidar una regla produce un endpoint abierto que nadie descubre hasta que alguien lo aprovecha. El coste del error es asimétrico, así que el valor por defecto debe serlo también.
7.2. Las reglas van de específica a general
La práctica. El orden de authorizeHttpRequests importa: gana la primera que coincide.
// ❌ La segunda regla nunca se evalúa: la primera ya casó
.requestMatchers("/api/v1/estaciones/**").permitAll()
.requestMatchers("/api/v1/estaciones/*/mantenimiento").hasRole("OPERARIO")
// ✅ Lo específico primero
.requestMatchers("/api/v1/estaciones/*/mantenimiento").hasRole("OPERARIO")
.requestMatchers(HttpMethod.GET, "/api/v1/estaciones/**").permitAll()
.anyRequest().denyAll()7.3. Seguridad también a nivel de método
La práctica. Las reglas por URL son la barrera perimetral; las que dependen del dato viven en el servicio, con @PreAuthorize.
El porqué. «Solo tus propios alquileres» no es una propiedad de la ruta. Y una regla en el servicio se aplica a todos los puntos de entrada, incluidos los que aún no existen: la tarea programada, el consumidor de mensajes o el endpoint que alguien añada dentro de seis meses.
@PreAuthorize("hasAnyRole('OPERARIO','ADMIN') or "
+ "@seguridadAlquileres.esPropietario(#idAlquiler, principal)")
@Transactional
public AlquilerResponse finalizar(Long idAlquiler, FinalizarAlquilerRequest peticion) { ... }7.4. Mínimo privilegio y nunca registrar credenciales
La práctica. Cada rol tiene lo justo, y ningún log contiene una contraseña, un token o una cabecera Authorization, ni siquiera en DEBUG.
El porqué. Los logs se copian a sistemas de agregación, se envían a terceros y se conservan años. Un token en un log es una credencial circulando durante meses antes de que alguien lo note.
- Pruebas
8.1. La pirámide, y la unidad no levanta el contexto
La práctica. Muchas pruebas unitarias rápidas, algunas rodajas, pocas de integración, poquísimas de extremo a extremo.
El porqué. Una suite de cinco segundos se ejecuta en cada guardado; una de veinticinco minutos deja de ejecutarse, y una suite que no se ejecuta no protege de nada. @SpringBootTest para probar una fórmula de tarifa multiplica por mil el tiempo y no detecta ni un fallo más.
// ❌ Contexto entero para comprobar una multiplicación
@SpringBootTest
class TarifaEstandarTest {
@Autowired TarifaEstandar tarifa;
@Test void calcula() { ... } // 4 segundos
}
// ✅ Sin Spring
class TarifaEstandarTest {
@Test void cobraDesbloqueoMasDoceCentimosPorMinuto() {
assertThat(new TarifaEstandar().calcular(Duration.ofMinutes(30)))
.isEqualByComparingTo("4.10"); // 0,8 ms
}
}8.2. Rápidas y deterministas
La práctica. Ninguna prueba depende del reloj del sistema, del orden de ejecución ni de datos que dejó otra prueba.
El porqué. Una prueba intermitente es peor que ninguna: entrena al equipo a reintentar en lugar de investigar, y esa costumbre acaba ignorando también los fallos reales.
En CicloUrbana. El Clock inyectado en AlquilerService, ServicioJwt y CaducadorAlquileres no es elegancia: es lo que permite Clock.fixed(Instant.parse("2026-09-01T23:00:00Z"), ZoneOffset.UTC) y afirmar sobre un instante exacto.
8.3. Testcontainers para lo que depende de la base de datos real
La práctica. Las pruebas que verifican migraciones, índices, restricciones o SQL nativo levantan un PostgreSQL real y efímero, no H2.
El porqué. H2 en modo compatibilidad no es PostgreSQL: difiere en tipos, en funciones, en el comportamiento de los índices parciales y en el de los bloqueos. Una prueba que pasa en H2 y falla en producción es exactamente el fallo que las pruebas debían evitar.
Y la contrapartida honesta: son lentas. Por eso viven en clases *IT ejecutadas por Failsafe en ./mvnw verify, separadas del ciclo corto de ./mvnw test.
- Operación
9.1. Sondas de salud diferenciadas
La práctica. liveness responde «el proceso está sano»; readiness responde «puedo recibir tráfico». No son lo mismo.
El porqué. Confundirlas produce dos fallos opuestos y ambos graves. Si liveness comprueba la base de datos, un corte de PostgreSQL hace que Kubernetes reinicie todas las réplicas de una aplicación que estaba perfectamente sana, convirtiendo una degradación en una caída. Si readiness no comprueba nada, el balanceador manda tráfico a una instancia que aún no ha terminado de arrancar.
9.2. Logs estructurados a stdout
La práctica. JSON por la salida estándar, con el traceId en cada línea. Ni ficheros ni rotación gestionada por la aplicación.
El porqué. En un contenedor, escribir a un fichero es escribir en un disco efímero que desaparece con el pod. Y un log en JSON es consultable: | json | traceId = "..." devuelve la historia completa de una petición; un log en texto libre obliga a expresiones regulares frágiles.
9.3. Métricas de negocio, no solo técnicas
La práctica. Además de latencia y memoria, se miden los hechos del negocio: alquileres iniciados por tarifa, finalizados, ocupación de estaciones.
El porqué. Las métricas técnicas dicen que la aplicación funciona; las de negocio dicen que sirve. Un despliegue que no rompe nada pero deja los alquileres iniciados a cero es un fallo que ninguna métrica de JVM detecta.
Con la regla que las hace viables: cardinalidad baja en las etiquetas. MetricasCicloUrbana etiqueta por tarifa y por estacion —tres y cuatro valores—, nunca por idUsuario.
9.4. Apagado ordenado y despliegue reversible
La práctica. server.shutdown: graceful, ejecutores que esperan a terminar, y una estrategia de despliegue que permite volver atrás en minutos.
El porqué. Un SIGKILL en mitad de una petición deja al ciudadano con un error y, si había una transacción a medias, con un estado que hay que reconciliar. Y sobre lo segundo: la métrica DORA que más se descuida es el tiempo de restauración, y la forma más barata de mejorarlo es que revertir sea un botón y no una investigación.
- La lista de comprobación de una aplicación lista para producción
Esta es la tabla que puedes llevarte a cualquier proyecto. Se recorre entera antes del primer despliegue a producción, y después una vez por trimestre.
| # | Área | Comprobación | Lección |
|---|---|---|---|
| 1 | Estructura | Paquetes por funcionalidad, no por capa | 01-04 |
| 2 | Estructura | La clase principal está en el paquete raíz | 01-04 |
| 3 | Estructura | Ningún controlador accede a un repositorio | 10-03 |
| 4 | Estructura | El dominio no importa clases del framework web | 02-02 |
| 5 | Configuración | Cero secretos en el repositorio y en su historial | 07-02 |
| 6 | Configuración | Un solo artefacto para todos los entornos | 07-02 |
| 7 | Configuración | Propiedades agrupadas en @ConfigurationProperties validadas |
02-05 |
| 8 | Configuración | Lo obligatorio no tiene valor por defecto | 02-05 |
| 9 | Configuración | El perfil se activa por entorno y está verificado en el log de arranque | 07-02 |
| 10 | Beans | Inyección por constructor y campos final en todo el proyecto |
02-02 |
| 11 | Beans | Ningún singleton con estado mutable | 02-03 |
| 12 | API | DTOs de entrada y salida en todos los endpoints | 03-05 |
| 13 | API | Rutas versionadas (/api/v1/...) |
03-01 |
| 14 | API | @Valid en todos los cuerpos de petición |
03-04 |
| 15 | API | Errores uniformes con ProblemDetail y sin traza de pila |
03-06 |
| 16 | API | Códigos de estado correctos, con Location en las creaciones |
03-03 |
| 17 | API | Ninguna colección se devuelve sin paginar | 04-05 |
| 18 | API | La documentación OpenAPI está cerrada en producción | 03-07 |
| 19 | Datos | @Transactional en el servicio, readOnly en las lecturas |
04-07 |
| 20 | Datos | open-in-view: false |
04-02 |
| 21 | Datos | Todas las asociaciones son LAZY |
04-04 |
| 22 | Datos | Flyway gobierna el esquema y ddl-auto: validate |
04-08 |
| 23 | Datos | Las migraciones pendientes son compatibles hacia atrás | 04-08 |
| 24 | Datos | El pool está dimensionado con criterio, no «por si acaso» | 09-01 |
| 25 | Seguridad | anyRequest().denyAll() cierra cada cadena |
05-02 |
| 26 | Seguridad | Reglas ordenadas de específica a general, revisadas una a una | 05-02 |
| 27 | Seguridad | Reglas por dato con @PreAuthorize en cada recurso de usuario |
05-05 |
| 28 | Seguridad | Contraseñas con BCrypt y JWT corto, rotable y sin datos sensibles | 05-04 |
| 29 | Seguridad | Ningún log contiene tokens, contraseñas ni datos personales | 09-05 |
| 30 | Pruebas | La suite rápida termina en segundos y se ejecuta en cada cambio | 06-01 |
| 31 | Pruebas | Cada regla de seguridad tiene su prueba automática | 06-04 |
| 32 | Pruebas | Lo que depende de PostgreSQL se prueba con Testcontainers | 06-05 |
| 33 | Operación | Sondas liveness y readiness diferenciadas y enganchadas al orquestador |
07-01 |
| 34 | Operación | Logs en JSON a stdout, con traceId en cada línea |
09-05 |
| 35 | Operación | Métricas de negocio publicadas, con etiquetas de baja cardinalidad | 09-03 |
| 36 | Operación | Apagado ordenado configurado y coherente con el plazo del orquestador | 07-03 |
| 37 | Operación | Alertas sobre síntomas —latencia, errores— y no sobre causas | 09-04 |
| 38 | Entrega | Revertir la versión anterior es un comando, no una investigación | 08-05 |
- Cuándo romper la regla
Este apartado es el que da valor a los diez anteriores. Cada una de estas prácticas tiene un contexto donde deja de ser la mejor opción, y saber cuál es la diferencia entre aplicarlas y entenderlas.
| Práctica | Se puede romper cuando... | Lo que hay que hacer a cambio |
|---|---|---|
| DTOs siempre | Un microservicio interno cuyo único cliente eres tú y cuyo «dominio» es literalmente el contrato | Documentarlo; y en cuanto haya un segundo cliente, introducir el DTO |
| Interfaz para cada servicio | Casi siempre: una interfaz con una sola implementación y sin frontera de módulo es ceremonia | Nada. Es la regla que más se aplica sin pensar |
| Paginación obligatoria | La colección tiene un tamaño acotado por diseño (los cuatro estados de una bicicleta) | Asegurarse de que la cota es estructural, no «hoy son pocos» |
| Transacción solo en el servicio | Un importador que necesita una transacción por fila | TransactionTemplate, con el motivo comentado |
Nunca @PostFilter |
Colección pequeña, acotada y no paginada | Verificar que sigue siéndolo dentro de un año |
Todo LAZY |
Una relación @ManyToOne que siempre se necesita y siempre es una fila |
Medir; casi siempre es mejor un @EntityGraph puntual |
| Pirámide de pruebas | Una capa que es puro cableado y donde la prueba de integración es la única útil | No convertirlo en la norma: es el origen del cono de helado |
| Monolito modular | Una parte con un perfil de escalado radicalmente distinto o un requisito de aislamiento | Tener trazabilidad distribuida antes de partir |
| Un artefacto para todos los entornos | Nunca. Esta no se rompe | — |
| Secretos fuera del repositorio | Nunca. Esta tampoco | — |
Las dos últimas filas son deliberadas: hay reglas que no tienen excepción defendible, y conviene tener claro cuáles son. Todo lo demás es un compromiso, y un compromiso se toma con los costes a la vista.
La forma honesta de romper una regla tiene tres pasos: nombrar la regla que se está rompiendo, decir qué se gana, y decir qué se pierde y cómo se compensa. Un comentario de tres líneas encima del código, o una entrada en el registro de decisiones del proyecto. Lo que no vale es romperla sin darse cuenta.
Errores Comunes y Consejos
Aplicar una práctica sin entender qué problema resuelve. Es el error de fondo de esta lección. Se manifiesta en interfaces con una implementación, en @Profile para todo, en pruebas que solo existen para subir la cobertura y en una capa de mapeadores que mapea record idénticos.
Convertir la lista de comprobación en un trámite. Marcar 38 casillas sin verificar ninguna es peor que no tener lista, porque produce la sensación de haberlo revisado. Cada punto necesita una comprobación objetiva: no «creo que Swagger está cerrado», sino curl contra /swagger-ui.html en producción.
Confundir «funciona» con «está bien». ddl-auto: update funciona, exponer entidades funciona, @Autowired en campos funciona. Todas las prácticas de esta lección se refieren a lo que pasa después: al sexto mes, con el volumen real, con tres personas más tocando el código.
Introducir todas las prácticas de golpe en un proyecto existente. Un cambio masivo es irrevisable y arriesgado. El orden útil en un proyecto heredado: primero lo que evita daño irreversible (secretos, denegación por defecto, migraciones), luego lo que da red de seguridad (pruebas), y por último lo estructural (paquetes, DTOs), fichero a fichero y aprovechando los cambios que ya haya que hacer.
Consejo: escribe el porqué en el repositorio, no en la cabeza. Un fichero docs/decisiones/ con una entrada corta por decisión —contexto, opciones, elección, consecuencias— vale más que cualquier documentación generada. Dentro de un año, la pregunta no será «qué hace esto» sino «por qué se hizo así», y la respuesta suele haberse perdido.
Consejo: convierte en automático todo lo que se pueda. Una práctica que depende de que alguien se acuerde en una revisión se incumple tarde o temprano. unmappedTargetPolicy=ERROR en MapStruct, ddl-auto: validate, denyAll() al final, failBuildOnCVSS en Dependency-Check y las reglas de ArchUnit de 10-03 son la misma idea: mover la comprobación de la cabeza de una persona a la construcción.
Consejo: una práctica que no puedes explicar en dos frases no la dominas. Prueba con open-in-view: false, con readOnly = true o con el orden de las reglas de seguridad. Si la explicación no sale, vuelve a la lección correspondiente: es más rentable que memorizar la regla.
Ejercicios
Ejercicio 1: auditar un servicio con la lista de comprobación
Un equipo te pasa esta clase de otro proyecto Spring Boot para revisión. Identifica todas las prácticas de esta lección que incumple, agrupadas por área, e indica para cada una qué problema concreto causará y en qué momento.
package com.ejemplo.service;
@Service
public class PedidoService {
@Autowired private PedidoRepository pedidoRepository;
@Autowired private ClienteRepository clienteRepository;
@Value("${pasarela.clave:clave-de-pruebas}") private String clavePasarela;
private int pedidosProcesados = 0;
@GetMapping("/pedidos")
public List<Pedido> listar() {
return pedidoRepository.findAll();
}
public Pedido crear(Pedido pedido) {
Cliente cliente = clienteRepository.findById(pedido.getClienteId()).get();
pedido.setCliente(cliente);
pedidosProcesados++;
log.info("Creando pedido para {} con clave {}", cliente.getEmail(), clavePasarela);
return pedidoRepository.save(pedido);
}
}Ejercicio 2: justificar una excepción
El ayuntamiento de Ribalta pide un endpoint interno GET /api/v1/interno/estaciones/completo que devuelve, para el panel de operarios, toda la información de una estación: sus campos, sus bicicletas con el código de anclaje interno, las últimas incidencias y los contadores de auditoría. Un compañero propone devolver directamente la entidad Estacion porque «es interno y ahorra tres clases».
Argumenta si la excepción es defendible. Si crees que no, propón la alternativa concreta. Si crees que sí en algún caso, di bajo qué condiciones y qué habría que hacer a cambio.
Ejercicio 3: la lista de comprobación aplicada a Ribalta
Redacta el informe de las ocho comprobaciones de la tabla del apartado 10 que considerarías más críticas para el primer despliegue de CicloUrbana a producción, ordenadas por criticidad. Para cada una indica: cómo se verifica de forma objetiva (un comando, una consulta, una prueba) y qué harías si la comprobación falla la noche anterior al despliegue.
Soluciones
Solución 1
Área de estructura y capas.
- Un
@GetMappingdentro de un@Service. La clase mezcla dos capas: es controlador y servicio a la vez. Consecuencia: no hay dónde poner la transacción sin que abarque la serialización, y no se puede probar la lógica sinMockMvc. Cuándo duele: en cuanto haya un segundo punto de entrada. - El paquete es
com.ejemplo.service, organización por capa. Consecuencia: cada cambio funcional toca cuatro carpetas y la visibilidad de paquete deja de servir como frontera.
Área de beans e inyección.
@Autowireden campos. No permitefinal, obliga a reflexión para construir la clase en una prueba y oculta el crecimiento de dependencias.private int pedidosProcesados: estado mutable en un singleton. Consecuencia: condición de carrera, el contador pierde incrementos bajo concurrencia y el fallo es irreproducible en local. Si de verdad se quiere ese número, es unCounterde Micrometer.
Área de configuración y seguridad.
@Valuesuelto para un secreto. Debería ser un@ConfigurationPropertiesvalidado.- El secreto tiene valor por defecto (
clave-de-pruebas). Consecuencia: la aplicación arranca en producción sin la clave real y falla al primer cobro, en lugar de fallar en el arranque. - Se registra el secreto en el log, y además el correo del cliente. Consecuencia: una credencial y un dato personal viajando a la agregación de logs y conservados durante años. Es el fallo más grave de la clase.
Área de API y datos.
- Devuelve la entidad
Pedidoy la recibe como parámetro. Fuga de datos en la salida y mass assignment en la entrada: el cliente puede fijar cualquier campo, incluido elido el estado. findAll()sin paginar. Funciona con 50 pedidos y tumba la JVM con 500 000.- Ninguna anotación transaccional.
crearhace dos operaciones de escritura que se confirman por separado; si la segunda falla, queda un estado inconsistente sin ningún error visible. .get()sobre unOptional. Si el cliente no existe,NoSuchElementExceptiony un500con traza en lugar de un404conProblemDetail.pedido.setCliente(cliente)con la entidad recibida del cliente HTTP, que es una entidad no gestionada mezclada con una gestionada: comportamiento impredecible al persistir.
El patrón de fondo: ninguno de estos doce problemas produce un error hoy. Todos producen un error dentro de unos meses, y varios de ellos de forma silenciosa. Esa es la definición operativa de deuda técnica.
Solución 2
La excepción no es defendible tal como está planteada, y el motivo principal no es el que suele darse.
El argumento habitual contra exponer entidades es la fuga de datos, y aquí el compañero lo neutraliza diciendo que el endpoint es interno. Pero hay tres razones que siguen en pie:
1. «Interno» no significa «sin clientes». El panel de operarios es un cliente, lo mantiene otro equipo y se despliega por su cuenta. En cuanto alguien renombre capacidad a plazasTotales en la entidad —exactamente lo que hicimos en V8__expand_plazas_totales.sql— el panel deja de mostrar la capacidad, sin ningún error de compilación en ninguno de los dos lados.
2. El problema técnico es insalvable. Con open-in-view: false, devolver Estacion con sus bicicletas, sus incidencias y su auditoría produce LazyInitializationException en la serialización, salvo que se carguen todas las relaciones dentro de la transacción. Y si se cargan todas, hemos construido un agregado sin límite: la respuesta crece con el histórico de incidencias sin ninguna cota.
3. La entidad lleva campos que no son datos. @Version, los campos de EntidadAuditable y las relaciones bidireccionales existen por razones de persistencia, no de contrato. Publicarlos invita al cliente a interpretarlos.
La alternativa concreta, que además cuesta menos de lo que parece:
// Vista interna, en el paquete de operaciones, con su propia ruta y su propio rol
public record EstacionOperarioResponse(
Long id, String nombre, String direccion, int capacidad,
UbicacionResponse ubicacion,
int anclajesLibres, boolean estaLlena,
List<BicicletaOperarioResumen> bicicletas, // con codigoAnclajeInterno
List<IncidenciaResumen> incidenciasAbiertas, // acotado: solo las abiertas
Instant ultimaRevision) {}Tres decisiones dentro de la alternativa: las incidencias que se anidan son solo las abiertas, que están acotadas por diseño, mientras que el histórico completo se consulta paginado en /api/v1/interno/estaciones/{id}/incidencias; es una clase distinta de la pública, no la misma con campos condicionales, porque un if (esOperario) dentro de un mapeador falla el día que alguien invierta la condición; y la ruta y el rol son distintos, de modo que la protección no depende de que nadie se equivoque al mapear.
¿Hay algún caso donde sí sería defendible? Uno, y muy acotado: un endpoint de diagnóstico bajo /actuator, protegido por rol ADMIN, cuyo propósito explícito sea volcar el estado interno para depurar, documentado como no estable y excluido de la documentación pública. Eso no es una API: es una herramienta. Y aun así, con la comprobación previa de que no publica ningún dato personal.
Solución 3
| Orden | Comprobación | Verificación objetiva | Si falla la noche anterior |
|---|---|---|---|
| 1 | Cero secretos en el repositorio y su historial (#5) | gitleaks detect --log-opts="--all" sobre el historial completo, no solo la última versión |
Se para el despliegue. Rotar todos los secretos encontrados antes de nada; borrarlos del código no basta |
| 2 | El perfil prod se activa de verdad (#9) |
Arrancar con la configuración real y buscar en el log The following 1 profile is active: "prod". Si dice falling back to default, todo lo demás de esta tabla es falso |
Se para: sin perfil activo, la aplicación corre con la configuración de desarrollo, incluidos Swagger abierto y H2 |
| 3 | anyRequest().denyAll() y reglas revisadas (#25, #26) |
Prueba automatizada que lanza peticiones sin token a una lista de rutas conocidas y a rutas inventadas: todas deben dar 401 o 403, ninguna 200 |
Se para. Es el único fallo de esta lista que expone datos de terceros de forma inmediata |
| 4 | Reglas por dato con @PreAuthorize (#27) |
Prueba de integración con dos ciudadanos reales cruzando identificadores en alquileres e incidencias: todo 403 |
Se para si afecta a datos personales; el IDOR es el fallo más explotado de las APIs |
| 5 | Flyway gobierna el esquema y ddl-auto: validate (#22, #23) |
flyway:info contra una copia del esquema de producción, y revisar que las migraciones pendientes son compatibles hacia atrás |
Se pospone el despliegue: una migración incompatible durante un despliegue progresivo rompe las instancias de la versión anterior |
| 6 | Sondas diferenciadas y enganchadas (#33) | curl a /actuator/health/liveness y /readiness; y comprobar en el manifiesto que livenessProbe no consulta la base de datos |
Se puede desplegar con vigilancia manual, pero se corrige de inmediato: una sonda mal configurada convierte una degradación en una caída total |
| 7 | Revertir es un comando (#38) | Ejecutar de verdad el rollback en preproducción y cronometrarlo | Se puede desplegar, pero solo en horario de baja actividad y con alguien disponible |
| 8 | Ningún log con credenciales ni datos personales (#29) | Ejecutar un flujo completo en preproducción y buscar en los logs eyJ, Bearer, contrasena y un correo conocido: cero resultados |
Se corrige antes; no bloquea si el sistema de logs aún no exporta a terceros, pero se arregla en el despliegue siguiente |
El criterio de ordenación, que es el fondo del ejercicio: primero lo que compromete todo el sistema de golpe (un secreto filtrado, la configuración de desarrollo en producción), después lo que compromete datos de terceros (denegación por defecto, acceso cruzado), luego lo que compromete la integridad de los datos (migraciones), y por último lo que compromete la capacidad de reaccionar (sondas, reversión, logs). Es la misma escala que usamos en 05-05, aplicada ahora a todo el sistema y no solo a la seguridad.
Y una observación que conviene interiorizar: de los ocho puntos, seis se verifican ejecutando algo, no leyendo código. Una lista de comprobación cuyos puntos se marcan por inspección visual es una lista de buenas intenciones.
Conclusión
Las decisiones que hemos ido tomando durante nueve módulos tienen ahora una forma reconocible. Sabes que una buena práctica es la respuesta que suele ser correcta a un problema recurrente en un contexto concreto, y que las tres palabras importan: sin el problema, la práctica es superstición; sin el contexto, es un dogma. Y sabes que casi todas se derivan de tres principios de fondo —hacer explícito lo implícito, fallar pronto y ruidosamente, separar lo que cambia por razones distintas— que sirven de brújula ante decisiones que ningún catálogo cubre.
Tienes el catálogo completo agrupado por área. Estructura: paquetes por funcionalidad, monolito modular con fronteras reales, la regla de dependencia controlador → servicio → repositorio que nunca se invierte, y un dominio que no importa el framework porque eso es lo que lo hace duradero y comprobable en milisegundos. Configuración: @ConfigurationProperties validadas frente a @Value disperso, un solo artefacto para todos los entornos, secretos siempre fuera del repositorio y sin valor por defecto para lo obligatorio. Beans: constructor, final, sin @Autowired, sin estado mutable y sin @Profile para lo que en realidad es una capacidad. API: DTOs sin excepciones, versionado, validación en el borde, ProblemDetail uniforme, códigos correctos y paginación obligatoria. Datos: la transacción en el servicio con readOnly por defecto, open-in-view: false con LAZY y mapeo dentro de la transacción como un solo paquete de decisiones, Flyway con validate y migraciones compatibles hacia atrás. Seguridad: denegar por defecto, orden de específica a general, reglas por dato en el servicio y jamás una credencial en un log. Pruebas: la pirámide, la unidad sin contexto, el determinismo del Clock inyectado y Testcontainers para lo que H2 no puede validar. Operación: sondas diferenciadas, logs estructurados a stdout, métricas de negocio con cardinalidad controlada, apagado ordenado y despliegue reversible.
Y tienes las dos piezas que convierten el catálogo en una herramienta de trabajo: la lista de treinta y ocho comprobaciones con su lección de referencia, pensada para recorrerse antes de un despliegue y una vez por trimestre; y el apartado de cuándo romper la regla, con la tabla de excepciones defendibles, las dos que no tienen excepción —un artefacto para todos los entornos y los secretos fuera del repositorio— y la forma honesta de romper cualquier otra: nombrar la regla, decir qué se gana y decir qué se pierde y cómo se compensa.
Todo este catálogo está escrito en positivo: lo que conviene hacer. Pero la mayoría de nosotros no aprendemos así. Aprendemos cuando algo falla, y las prácticas de esta lección son en realidad la cicatriz de errores concretos que alguien cometió antes: la anotación que no hizo nada, el endpoint que quedó abierto por el orden de dos líneas, la excepción capturada que se llevó por delante el rollback, la tarea programada que se ejecutó tres veces al escalar. La lección siguiente, Errores Comunes y Cómo Evitarlos, recorre ese reverso: un catálogo de fallos reales con su síntoma, su causa, cómo se diagnostica y el código corregido —desde la clase principal en el paquete equivocado hasta la caché que esconde una consulta mal escrita—, con la trampa del proxy tratada por fin de una vez y en un solo sitio, y una tabla de diagnóstico rápido que va del síntoma a la lección donde está la respuesta.
Curso de Spring Boot
Módulo 1: Introducción a Spring Boot
- ¿Qué es Spring Boot?
- Configuración de tu Entorno de Desarrollo
- Creando tu Primera Aplicación Spring Boot
- Entendiendo la Estructura del Proyecto
- El Arranque y el Ciclo de Vida de la Aplicación
Módulo 2: Conceptos Básicos de Spring Boot
- Anotaciones de Spring Boot
- Inyección de Dependencias en Spring Boot
- Ámbito y Ciclo de Vida de los Beans
- Configuración de Spring Boot
- Propiedades de Spring Boot
- Autoconfiguración y Starters por Dentro
Módulo 3: Construyendo Servicios Web RESTful
- Introducción a los Servicios Web RESTful
- Creando Controladores REST
- Manejo de Métodos HTTP
- Validación de Datos de Entrada
- DTOs y Mapeo entre Capas
- Manejo de Excepciones en REST
- Documentar la API con OpenAPI
Módulo 4: Acceso a Datos con Spring Boot
- Introducción a Spring Data JPA
- Configuración de Fuentes de Datos
- Creación de Entidades JPA
- Relaciones entre Entidades
- Uso de Repositorios de Spring Data
- Métodos de Consulta en Spring Data JPA
- Transacciones y Gestión de la Persistencia
- Migraciones de Esquema con Flyway
Módulo 5: Seguridad en Spring Boot
- Introducción a Spring Security
- Configuración de Spring Security
- Autenticación y Autorización de Usuarios
- Implementación de Autenticación JWT
- Seguridad a Nivel de Método y Endurecimiento de la API
Módulo 6: Pruebas en Spring Boot
- Introducción a las Pruebas
- Pruebas Unitarias con JUnit
- Simulación con Mockito
- Pruebas de Integración
- Pruebas con Testcontainers
Módulo 7: Funciones Avanzadas de Spring Boot
- Spring Boot Actuator
- Perfiles de Spring Boot
- Tareas Programadas y Ejecución Asíncrona
- Spring Boot con Docker
- Spring Boot y Microservicios
- Comunicación entre Servicios y Tolerancia a Fallos
Módulo 8: Despliegue de Aplicaciones Spring Boot
- Introducción al Despliegue
- Desplegando en Heroku
- Desplegando en AWS
- Desplegando en Kubernetes
- Integración y Entrega Continua
Módulo 9: Rendimiento y Monitoreo
- Ajuste de Rendimiento
- Caché con Spring Cache
- Monitoreo con Spring Boot Actuator
- Uso de Prometheus y Grafana
- Gestión de Registros y Logs
- Trazabilidad Distribuida
