Durante diez módulos hemos construido la red de bicicletas eléctricas de Ribalta capa por capa. Primero un endpoint que devolvía cuatro estaciones en memoria; después el contenedor de Spring y sus beans; el contrato REST con sus DTOs y sus errores; la persistencia sobre PostgreSQL con Flyway; la seguridad con JWT y reglas por dato; las pruebas; la operabilidad; el despliegue; y por fin la observabilidad, el criterio y la limpieza. Cada lección añadió una pieza y miró casi siempre hacia la siguiente.

Nunca hemos mirado CicloUrbana entera. Esta lección lo hace: la arquitectura completa en un diagrama, la estructura real del repositorio, el viaje de una sola petición desde el balanceador hasta la métrica —citando en cada paso dónde se estudió—, el mapa de qué construyó cada módulo, las decisiones de diseño con sus alternativas y sus contrapartidas, el pom.xml y la configuración finales, cómo poner el proyecto en marcha desde cero y hacia dónde seguir llevándolo. Si has seguido el curso, aquí vas a reconocerlo todo; lo nuevo es verlo junto.

Contenido

  1. La arquitectura final
  2. El repositorio completo
  3. El viaje de una petición: POST /api/v1/alquileres
  4. Mapa del curso: qué construyó cada módulo
  5. Decisiones de diseño y sus alternativas
  6. El pom.xml final
  7. La configuración final
  8. Poner en marcha el proyecto desde cero
  9. Recorrido de extremo a extremo con peticiones .http
  10. Lista de comprobación final de puesta en producción
  11. Hacia dónde seguir con este proyecto
  12. Errores Comunes y Consejos
  13. Ejercicios

  1. La arquitectura final

flowchart TB
    subgraph CLI["Clientes"]
        M["App móvil<br/>ciudadanos de Ribalta"]
        P["Panel del ayuntamiento<br/>operarios y administración"]
    end

    subgraph BORDE["Borde"]
        ALB["ALB / Ingress<br/>TLS, límite de tasa · 08-03, 08-04"]
    end

    subgraph APP["CicloUrbana · 3 réplicas · 07-04, 08-04"]
        F["Cadena de filtros<br/>FiltroTraza · FiltroAutenticacionJwt · 03-06, 05-04"]
        C["Controladores REST<br/>DTOs y validación · 03-02, 03-05"]
        S["Servicios<br/>@Transactional · @PreAuthorize · 04-07, 05-05"]
        R["Repositorios<br/>Spring Data JPA · 04-05"]
        T["Tareas y asincronía<br/>@Scheduled · @Async · 07-03"]
        AC["Actuator<br/>sondas, métricas · 07-01, 09-03"]
        F --> C --> S --> R
        S --> T
    end

    subgraph DAT["Datos"]
        BD[("PostgreSQL 16<br/>esquema con Flyway · 04-08")]
        RD[("Redis<br/>caché distribuida · 09-02")]
    end

    subgraph EXT["Servicios externos"]
        PG["Pasarela de pagos<br/>RestClient + Resilience4j · 07-06"]
    end

    subgraph OBS["Observabilidad · módulo 9"]
        PR["Prometheus"]
        LO["Loki"]
        TE["Tempo"]
        OT["OTel Collector"]
        GR["Grafana"]
        OT --> TE
        PR --> GR
        LO --> GR
        TE --> GR
    end

    M --> ALB
    P --> ALB
    ALB --> F
    R --> BD
    S --> RD
    T --> PG
    AC -.->|"scrape /actuator/prometheus"| PR
    APP -.->|"logs JSON a stdout"| LO
    APP -.->|"OTLP"| OT

Cuatro observaciones sobre el dibujo, porque un diagrama sin lectura es decoración.

La aplicación es un solo desplegable. Los cinco bloques de dentro no son servicios: son capas y módulos dentro del mismo proceso. Es el monolito modular de 07-05, y las flechas entre ellos son llamadas a métodos, no llamadas de red.

Las dependencias apuntan hacia dentro y hacia abajo. Filtros → controladores → servicios → repositorios → base de datos. Ninguna flecha sube. Es la regla de dependencia de 10-01, verificada automáticamente con las reglas de ArchUnit de 10-03.

La observabilidad es un lateral, no una capa. No está en el camino de la petición: la aplicación publica métricas, logs y trazas, y otros los recogen. Si Prometheus cae, CicloUrbana sigue funcionando.

Lo único que puede tumbar el servicio es PostgreSQL. La caché de Redis degrada a Caffeine, la pasarela tiene cortacircuitos y respaldo de cobro diferido, y el resto es opcional. Esa asimetría es deliberada y está detrás de que la sonda readiness sí consulte la base de datos y la de liveness no.

  1. El repositorio completo

ciclourbana/
├── .github/workflows/
│   ├── ci.yml                      # compilar, probar, JaCoCo, Spotless · 08-05
│   └── cd.yml                      # imagen, Trivy, despliegue a pre y prod · 08-05
├── helm/ciclourbana/
│   ├── Chart.yaml · values.yaml · values-pre.yaml · values-prod.yaml
│   └── templates/                  # deployment, service, ingress, hpa, job-migracion · 08-04
├── observabilidad/                 # prometheus.yml, loki.yml, tempo.yml,
│                                   # otel-collector.yml, tableros de Grafana · 09-04, 09-06
├── carga/carga-hora-punta.js       # escenario de k6 · 09-01
├── peticiones/ciclourbana.http     # colección de extremo a extremo · apartado 9
├── src/main/java/com/ciclourbana/
│   ├── CicloUrbanaApplication.java          # raíz del escaneo · 01-04
│   ├── comun/                               # transversal
│   │   ├── ConfiguracionComun.java          # Clock, RestClient.Builder · 02-01
│   │   ├── EntidadAuditable.java            # createdAt/By, updatedAt/By · 04-03
│   │   ├── PaginaResponse.java              # envoltorio propio de Page · 04-05
│   │   ├── FiltroTraza.java                 # traceId en MDC y cabecera · 03-06, 09-06
│   │   ├── ManejadorGlobalExcepciones.java  # ProblemDetail RFC 7807 · 03-06
│   │   ├── CicloUrbanaException.java + jerarquía · 03-06
│   │   └── MetricasCicloUrbana.java         # métricas de negocio · 09-03
│   ├── estaciones/                          # Estacion, Repositorio, Service,
│   │   └── dto/                             # Controller, Mapper + DTOs · módulos 3 y 4
│   ├── bicicletas/                          # Bicicleta, EstadoBicicleta,
│   │   └── dto/                             # @MatriculaBicicleta, RevisorFlota
│   ├── alquileres/                          # Alquiler, CalculadoraTarifa y las tres
│   │   └── dto/                             # tarifas, SelectorTarifa, CaducadorAlquileres
│   ├── usuarios/                            # Usuario, roles, monedero
│   ├── seguridad/                           # ConfiguracionSeguridad, ServicioJwt,
│   │                                        # FiltroAutenticacionJwt, UsuarioAutenticado,
│   │                                        # SeguridadAlquileres, AuditoriaSeguridad · módulo 5
│   ├── pagos/                               # ClientePasarelaPagos + Resilience4j · 07-06
│   └── config/                              # ConfiguracionTareas, ConfiguracionAsincronia,
│                                            # ConfiguracionCache, ConfiguracionOpenApi
├── src/main/resources/
│   ├── application.yml                      # común a todos los entornos · 07-02
│   ├── application-{dev,test,pre,prod}.yml  # solo las diferencias
│   ├── logback-spring.xml                   # JSON a stdout con traceId · 09-05
│   └── db/migration/V1__…V9__…sql           # esquema versionado · 04-08
├── src/test/java/com/ciclourbana/           # espejo del árbol de producción · 06-01
│   ├── …Test.java                           # unitarias y rodajas (Surefire)
│   ├── …IT.java                             # integración con Testcontainers (Failsafe)
│   ├── PruebaIntegracionBase.java           # contexto compartido · 06-05
│   └── ReglasArquitecturaTest.java          # ArchUnit · 10-03
├── Dockerfile                               # multietapa, usuario ciclo · 07-04
├── docker-compose.yml                       # app + PostgreSQL + Redis
├── docker-compose.observabilidad.yml        # Prometheus, Grafana, Loki, Tempo, Collector
├── .dockerignore · .gitignore · .env.ejemplo
├── mvnw · mvnw.cmd · .mvn/
└── pom.xml

Dos cosas merecen atención. src/test es un espejo exacto de src/main, lo que permite acceder a miembros con visibilidad de paquete sin abrir la clase de producción y hace el árbol navegable. Y la separación *Test / *IT no es cosmética: Surefire ejecuta los primeros en ./mvnw test en segundos, y Failsafe los segundos en ./mvnw verify con PostgreSQL real.

  1. El viaje de una petición: POST /api/v1/alquileres

Marta abre la aplicación en la estación «Plaza Mayor», elige una bicicleta y pulsa «alquilar». Esto es lo que ocurre.

sequenceDiagram
    autonumber
    participant M as App móvil
    participant L as ALB / Ingress
    participant FS as Filtros de Spring Security
    participant DS as DispatcherServlet
    participant CT as AlquilerController
    participant SV as AlquilerService (proxy)
    participant BD as PostgreSQL
    participant EV as NotificadorAlquiler
    participant PG as Pasarela de pagos

    M->>L: POST /api/v1/alquileres · Bearer …
    L->>FS: TLS terminado · X-Forwarded-*
    FS->>FS: FiltroTraza: span raíz + traceId en MDC
    FS->>FS: FiltroAutenticacionJwt: valida firma y exp
    FS->>DS: SecurityContext con UsuarioAutenticado
    DS->>CT: binding a IniciarAlquilerRequest
    CT->>CT: @Valid: matrícula, ids, tarifa
    CT->>SV: iniciar(peticion)
    SV->>SV: @PreAuthorize: isAuthenticated()
    SV->>BD: BEGIN (READ_COMMITTED)
    SV->>BD: SELECT … FOR UPDATE (mejor bicicleta)
    SV->>SV: SelectorTarifa: calcula el importe
    SV->>BD: INSERT alquiler · UPDATE bicicleta
    SV->>BD: COMMIT
    SV-->>CT: AlquilerResponse (mapeado dentro de la tx)
    CT-->>M: 201 Created · Location · X-Trace-Id
    Note over EV,PG: AFTER_COMMIT, ya fuera del hilo HTTP
    SV->>EV: evento AlquilerIniciado
    EV->>PG: autorizar cobro (@Async + cortacircuitos)

El detalle paso a paso, con su lección:

# Qué ocurre Pieza Lección
1 TLS termina en el balanceador, que reenvía con X-Forwarded-Proto ALB / Ingress 08-03, 08-04
2 La petición entra en la cadena de filtros de servlet SecurityFilterChain 05-01
3 FiltroTraza abre el span raíz y pone traceId/spanId en el MDC FiltroTraza 03-06, 09-06
4 FiltroAutenticacionJwt valida la firma HS256 y la caducidad, y construye el UsuarioAutenticado ServicioJwt 05-04
5 AuthorizationFilter comprueba que la ruta exige autenticación y la tiene authorizeHttpRequests 05-02
6 El DispatcherServlet resuelve el handler y deserializa el cuerpo con Jackson @RequestBody 03-02
7 Bean Validation comprueba el DTO: @NotNull, @Positive, @MatriculaBicicleta IniciarAlquilerRequest 03-04
8 El controlador delega en el servicio; no hay lógica aquí AlquilerController 03-03
9 El proxy del servicio evalúa @PreAuthorize antes de entrar @EnableMethodSecurity 05-05
10 El mismo proxy abre la transacción: conexión del pool, autoCommit=false @Transactional 04-07
11 SELECT … FOR UPDATE reserva la mejor bicicleta disponible y serializa la carrera @Lock(PESSIMISTIC_WRITE) 04-07
12 SelectorTarifa elige la CalculadoraTarifa del usuario y calcula el importe TarifaEstandar y hermanas 02-02
13 INSERT del alquiler y UPDATE de la bicicleta por dirty checking Spring Data JPA 04-05
14 El mapeo a AlquilerResponse ocurre dentro de la transacción AlquilerMapper 03-05
15 COMMIT; la conexión vuelve al pool HikariCP 04-02
16 @TransactionalEventListener(AFTER_COMMIT) dispara el evento ya confirmado AlquilerIniciado 04-07
17 El cobro sale en otro hilo, con MDC y SecurityContext propagados @Async("ejecutorCorreo") 07-03
18 La llamada a la pasarela lleva traceparent, tiempo de espera y cortacircuitos RestClient + Resilience4j 07-06
19 El controlador responde 201 con Location y X-Trace-Id ResponseEntity 03-03
20 MetricasCicloUrbana incrementa ciclourbana.alquileres.iniciados{tarifa,estacion} Micrometer 09-03
21 Se escribe una línea JSON a stdout con el traceId, y el span se exporta por OTLP Logback + Tracing 09-05, 09-06

Lo que se ve al juntarlo. Los pasos 9 y 10 los hace el mismo proxy, y por eso la trampa de la autoinvocación se lleva por delante la transacción y la comprobación de permisos a la vez. El paso 14 no es un detalle de estilo: con open-in-view: false es la única forma de que la respuesta se construya sin LazyInitializationException. Y los pasos 16 a 18 son la razón de que Marta reciba su 201 en decenas de milisegundos mientras el cobro tarda casi dos segundos en otro hilo.

  1. Mapa del curso: qué construyó cada módulo

Módulo Qué se construyó Clases principales Ficheros del repositorio
1 Introducción El proyecto y su ciclo de vida CicloUrbanaApplication, CargadorEstacionesDemo pom.xml, mvnw, src/main/java, application.properties
2 Conceptos básicos El contenedor, la configuración tipada y el sistema de tarifas CalculadoraTarifa, TarifaEstandar, TarifaEstudiante, SelectorTarifa, TarifasProperties, RedProperties comun/ConfiguracionComun.java, application.yml
3 REST El contrato público completo EstacionController, DTOs record, EstacionMapper, ManejadorGlobalExcepciones, FiltroTraza, @MatriculaBicicleta */dto/, comun/, ConfiguracionOpenApi
4 Datos La persistencia real Estacion, Bicicleta, Alquiler, EntidadAuditable, repositorios, PaginaResponse db/migration/V1…V9, application.yml (Hikari, JPA)
5 Seguridad Autenticación y autorización ConfiguracionSeguridad, ServicioJwt, FiltroAutenticacionJwt, UsuarioAutenticado, SeguridadAlquileres, AuditoriaSeguridad seguridad/, application-prod.yml
6 Pruebas La red de seguridad TarifaEstandarTest, EstacionControllerTest, AlquilerFlujoCompletoIT, PruebaIntegracionBase src/test/, JaCoCo y Failsafe en pom.xml
7 Operabilidad Iniciativa propia y empaquetado CaducadorAlquileres, RecalculadorOcupacion, RevisorFlota, NotificadorAlquiler, DecoradorMdc, ClientePasarelaPagos Dockerfile, docker-compose.yml, V7__shedlock.sql
8 Despliegue La entrega automática — .github/workflows/, helm/
9 Rendimiento Ver y medir MetricasCicloUrbana, FiltroContadorConsultas, EstacionResumen observabilidad/, carga/, logback-spring.xml
10 Criterio La reflexión y las reglas automáticas ReglasArquitecturaTest, TarifaConRecargoPorExceso Spotless en pom.xml, docs/decisiones/

  1. Decisiones de diseño y sus alternativas

Toda decisión de arquitectura compra algo y paga algo. Estas son las seis principales de CicloUrbana, con lo que costaron.

Decisión Alternativa descartada Por qué Contrapartida que asumimos
Monolito modular Microservicios Un equipo, un dominio acoplado, transacciones locales y una traza de pila que lo explica todo Escalar significa replicar la aplicación entera, aunque solo los alquileres tengan carga
JWT sin estado Sesión con cookie Tres réplicas sin sesión pegajosa ni almacén compartido; el móvil no maneja cookies con comodidad Un token no se puede revocar antes de que caduque; hace falta caducidad corta y refresco rotatorio
Flyway ddl-auto: update El esquema se revisa en la pull request, se versiona y se reproduce igual en los cuatro entornos Cada cambio de entidad exige escribir la migración a mano; y renombrar cuesta tres despliegues
MapStruct ModelMapper o mapeo manual unmappedTargetPolicy=ERROR convierte el campo olvidado en error de compilación, sin coste en ejecución Un procesador de anotaciones más y código generado que hay que saber leer
Caffeine + Redis Solo Redis Caffeine responde en nanosegundos y sin red para lo que tolera divergencia de segundos Dos niveles de caché que invalidar; hay datos que pueden diferir unos segundos entre réplicas
Testcontainers H2 en modo PostgreSQL Índices parciales, tipos, funciones y bloqueos se comportan de verdad como en producción La suite de integración tarda minutos y necesita Docker en la máquina y en la canalización

Y dos decisiones menores que ilustran el mismo modo de pensar. open-in-view: false compra que ninguna consulta se dispare durante la serialización, y paga con la obligación de mapear a DTO dentro de la transacción —lo que, de paso, refuerza una práctica que queríamos igualmente—. La denegación por defecto compra que un olvido produzca un 403 visible en vez de un endpoint abierto, y paga con que cada ruta nueva exija una regla explícita.

Ninguna de estas seis es universalmente correcta. Lo que las hace defendibles es que se tomaron con la contrapartida a la vista y quedaron escritas.

  1. El pom.xml final

Las dependencias acumuladas, agrupadas por el módulo que las introdujo. La columna de versión es la información importante: solo llevan versión propia las que el BOM de Spring Boot no gestiona, y son exactamente las que hay que revisar a mano en cada actualización.

Módulo Dependencias (org.springframework.boot: salvo indicación) Ámbito Versión
3 REST spring-boot-starter-web, spring-boot-starter-validation compile BOM
3 org.springdoc:springdoc-openapi-starter-webmvc-ui compile 2.6.0
3 org.mapstruct:mapstruct compile 1.6.3
4 Datos spring-boot-starter-data-jpa, org.flywaydb:flyway-core, flyway-database-postgresql compile BOM
4 org.postgresql:postgresql runtime BOM
5 Seguridad spring-boot-starter-security compile BOM
5 io.jsonwebtoken:jjwt-api (+ jjwt-impl y jjwt-jackson en runtime) mixto 0.12.6
7 Operación spring-boot-starter-actuator, spring-boot-starter-aop compile BOM
7 net.javacrumbs.shedlock:shedlock-spring + shedlock-provider-jdbc-template compile 5.16.0
7 io.github.resilience4j:resilience4j-spring-boot3 compile 2.2.0
9 Rendimiento spring-boot-starter-cache, com.github.ben-manes.caffeine:caffeine, spring-boot-starter-data-redis compile BOM
9 io.micrometer:micrometer-registry-prometheus runtime BOM
9 io.micrometer:micrometer-tracing-bridge-otel, io.opentelemetry:opentelemetry-exporter-otlp compile BOM
9 net.logstash.logback:logstash-logback-encoder compile 8.0
6 Pruebas spring-boot-starter-test, spring-security-test, spring-boot-testcontainers, org.testcontainers:postgresql test BOM
10 Criterio com.tngtech.archunit:archunit-junit5 test 1.3.0
1 Desarrollo spring-boot-devtools (runtime), spring-boot-configuration-processor optional BOM

Y la parte del pom.xml que de verdad hay que leer con atención, porque es la que rompe la construcción ante un problema concreto:

<parent>                                       <!-- Módulo 1: versiones coherentes -->
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.3.5</version>
</parent>

<groupId>com.ciclourbana</groupId>
<artifactId>ciclourbana</artifactId>
<version>2.4.0</version>
<properties><java.version>21</java.version></properties>

<build>
  <plugins>
    <plugin><groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-maven-plugin</artifactId></plugin>

    <plugin>   <!-- MapStruct: el campo olvidado no compila (03-05) -->
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-compiler-plugin</artifactId>
      <configuration>
        <annotationProcessorPaths>
          <path><groupId>org.mapstruct</groupId>
                <artifactId>mapstruct-processor</artifactId>
                <version>${mapstruct.version}</version></path>
        </annotationProcessorPaths>
        <compilerArgs>
          <arg>-Amapstruct.unmappedTargetPolicy=ERROR</arg>
          <arg>-Amapstruct.defaultComponentModel=spring</arg>
          <arg>-parameters</arg>   <!-- necesario para #idUsuario en SpEL (05-05) -->
        </compilerArgs>
      </configuration>
    </plugin>

    <plugin>   <!-- Failsafe: las *IT en verify, no en test (06-01) -->
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-failsafe-plugin</artifactId>
      <executions><execution>
        <goals><goal>integration-test</goal><goal>verify</goal></goals>
      </execution></executions>
    </plugin>

    <plugin>   <!-- JaCoCo: mapa del rojo, no puntuación (06-01) -->
      <groupId>org.jacoco</groupId><artifactId>jacoco-maven-plugin</artifactId>
      <version>0.8.12</version>
      <executions>
        <execution><goals><goal>prepare-agent</goal></goals></execution>
        <execution><phase>verify</phase><goals><goal>report</goal></goals></execution>
      </executions>
    </plugin>

    <plugin>   <!-- Spotless: el estilo no se discute (10-03) -->
      <groupId>com.diffplug.spotless</groupId>
      <artifactId>spotless-maven-plugin</artifactId><version>2.44.0</version>
      <configuration><java><palantirJavaFormat/><removeUnusedImports/></java></configuration>
      <executions><execution><phase>validate</phase>
        <goals><goal>check</goal></goals></execution></executions>
    </plugin>

    <plugin>   <!-- Dependency-Check: falla ante severidad alta (05-05) -->
      <groupId>org.owasp</groupId><artifactId>dependency-check-maven</artifactId>
      <version>10.0.4</version>
      <configuration><failBuildOnCVSS>7</failBuildOnCVSS></configuration>
    </plugin>
  </plugins>
</build>

Los seis plugins no son adorno: cada uno rompe la construcción ante un problema concreto —un campo sin mapear, una prueba de integración fallida, código sin formatear, una vulnerabilidad alta—. Es la idea de fondo de 10-01: mover la comprobación de la cabeza de una persona a la construcción.

  1. La configuración final

# application.yml — común a todos los entornos
spring:
  application:
    name: ciclourbana                 # se convierte en service.name de las trazas (09-06)
  threads:
    virtual:
      enabled: true                   # hilos virtuales de Java 21 (07-03)
  jackson:
    default-property-inclusion: non_null
    deserialization: { fail-on-unknown-properties: false }   # tolerancia del contrato (03-05)
  datasource:
    url: ${URL_BASE_DATOS}            # sin valor por defecto: obligatorio (10-01)
    username: ${USUARIO_BASE_DATOS}
    password: ${CLAVE_BASE_DATOS}
    hikari:
      pool-name: ciclourbana-pool
      maximum-pool-size: 10           # (núcleos × 2) + discos (09-01)
      minimum-idle: 10
      connection-timeout: 3000        # fallar rápido, no encolar
      leak-detection-threshold: 20000
  jpa:
    open-in-view: false               # decisión estructural del módulo 4
    hibernate: { ddl-auto: validate }  # el esquema lo gobierna Flyway
    properties:
      hibernate:
        jdbc.batch_size: 50
        order_inserts: true
        batch_versioned_data: true
  flyway: { enabled: true, locations: classpath:db/migration }
  cache:
    type: caffeine
    caffeine: { spec: "maximumSize=1000,expireAfterWrite=10m,recordStats" }
  task:
    execution:
      pool: { core-size: 8, max-size: 24, queue-capacity: 200 }
      thread-name-prefix: async-ciclo-
      shutdown: { await-termination: true, await-termination-period: 30s }
    scheduling:
      pool: { size: 4 }               # el planificador por defecto tiene UN hilo (07-03)
      thread-name-prefix: tarea-ciclo-
  lifecycle:
    timeout-per-shutdown-phase: 40s   # mayor que los await-termination-period

server:
  shutdown: graceful                  # apagado ordenado (01-05, 07-03)
  forward-headers-strategy: framework # TLS termina en el balanceador (05-05)
  error: { include-stacktrace: never, include-message: never }
  compression:
    enabled: true
    mime-types: application/json,application/problem+json
    min-response-size: 1KB

management:
  server:
    port: 8081                        # puerto de gestión separado (07-01)
  endpoints:
    web:
      exposure:
        include: health,info,metrics,prometheus,loggers,scheduledtasks
  endpoint:
    health:
      probes: { enabled: true }       # /health/liveness y /health/readiness
      show-details: when_authorized
  tracing:
    sampling: { probability: 1.0 }    # el muestreo lo decide el Collector, por cola (09-06)
  otlp:
    tracing:
      endpoint: ${OTLP_ENDPOINT:http://otel-collector:4318/v1/traces}
  metrics:
    tags: { aplicacion: ciclourbana }

ciclourbana:                          # propiedades propias, validadas (02-05)
  ciudad: Ribalta
  tarifa:
    desbloqueo: 0.50
    precio-minuto: 0.12
    precio-minuto-estudiante: 0.08
  red:
    capacidad-minima: 8
    umbral-bateria: 20
    duracion-maxima-alquiler: PT2H
    estaciones-destacadas: [Plaza Mayor, Universidad]
  alquileres:
    caducador:
      intervalo: PT10M
  jwt:
    secreto: ${JWT_SECRETO}           # obligatorio, nunca versionado
    expiracion: PT15M
    expiracion-refresco: P7D

Y las diferencias por entorno, que es lo único que cambia:

Propiedad dev test pre prod
Base de datos Docker local Testcontainers RDS de preproducción RDS con réplica
flyway.locations + db/demo db/migration db/migration db/migration
logging.level.com.ciclourbana DEBUG INFO INFO INFO
Swagger UI Activo Activo Activo springdoc.api-docs.enabled: false
CORS http://localhost:5173 — https://pre.ribalta.es https://panel.ribalta.es
Caché Caffeine Deshabilitada Redis Redis
tracing.sampling 1.0 local 0.0 1.0 → Collector 1.0 → Collector con cola
Actuator expuesto "*" health Con ADMIN health,info,prometheus

  1. Poner en marcha el proyecto desde cero

# 1. Clonar y preparar los secretos locales
git clone https://github.com/ayuntamiento-ribalta/ciclourbana.git
cd ciclourbana
cp .env.ejemplo .env          # rellenar CLAVE_BASE_DATOS y JWT_SECRETO
openssl rand -base64 48       # generar un secreto JWT de 256+ bits

# 2. Levantar la infraestructura (PostgreSQL 16 + Redis)
docker compose up -d
docker compose ps             # esperar a que el healthcheck marque "healthy"

# 3. Construcción completa: formato, unitarias, *IT con Testcontainers, cobertura
./mvnw verify
open target/site/jacoco/index.html

# 4. Arrancar en desarrollo
./mvnw spring-boot:run -Dspring-boot.run.profiles=dev

# 5. Comprobar que está viva y con el esquema al día
curl -s localhost:8081/actuator/health/readiness | jq
curl -s localhost:8081/actuator/info | jq '.build.version'    # 2.4.0

# 6. (Opcional) La pila de observabilidad completa
docker compose -f docker-compose.observabilidad.yml up -d
# Grafana en http://localhost:3000 · Prometheus 9090 · Tempo 3200

El paso 3 merece un comentario: ./mvnw verify levanta un PostgreSQL efímero con Testcontainers y aplica las nueve migraciones sobre él antes de ejecutar las *IT. Si ese comando pasa en tu máquina, el proyecto está sano de arriba abajo.

  1. Recorrido de extremo a extremo con peticiones .http

### peticiones/ciclourbana.http
@host = http://localhost:8080/api/v1

### 1. Registro de una ciudadana de Ribalta
POST {{host}}/auth/registro
Content-Type: application/json

{ "nombre": "Marta Serra", "correo": "[email protected]",
  "contrasena": "Bicicleta-2026!", "tipoTarifa": "ESTANDAR" }

> {% client.test("201 y sin datos sensibles", () => {
     client.assert(response.status === 201);
     client.assert(response.body.contrasenaHash === undefined); }); %}

### 2. Login: devuelve el token de acceso y el de refresco
# @name login
POST {{host}}/auth/login
Content-Type: application/json

{ "correo": "[email protected]", "contrasena": "Bicicleta-2026!" }

> {% client.global.set("token", response.body.tokenAcceso); %}

### 3. Estaciones con bicicletas disponibles (público, paginado)
GET {{host}}/estaciones?pagina=0&tamanio=20
Authorization: Bearer {{token}}

### 4. Detalle de «Plaza Mayor», con sus bicicletas ancladas
GET {{host}}/estaciones/1
Authorization: Bearer {{token}}

### 5. Iniciar el alquiler: 201 con Location y X-Trace-Id
# @name alquiler
POST {{host}}/alquileres
Authorization: Bearer {{token}}
Content-Type: application/json

{ "estacionOrigenId": 1, "matricula": "RB-0142" }

> {% client.global.set("alquilerId", response.body.id);
     client.global.set("traza", response.headers.valueOf("X-Trace-Id")); %}

### 6. Finalizar en «Universidad»: calcula el importe y libera la bicicleta
PATCH {{host}}/alquileres/{{alquilerId}}/finalizar
Authorization: Bearer {{token}}
Content-Type: application/json

{ "estacionDestinoId": 4 }

### 7. La factura del alquiler
GET {{host}}/alquileres/{{alquilerId}}
Authorization: Bearer {{token}}

### 8. Comprobación de seguridad: el alquiler de otro ciudadano da 403
GET {{host}}/alquileres/9999
Authorization: Bearer {{token}}

Y las tres comprobaciones que cierran el recorrido, que son las que demuestran que la observabilidad del módulo 9 funciona de verdad:

# La métrica de negocio se ha movido
curl -s localhost:8081/actuator/metrics/ciclourbana.alquileres.iniciados | jq '.measurements'

# El log de la petición es consultable por su traceId (Loki, 09-05)
# {app="ciclourbana"} | json | traceId = "<el X-Trace-Id del paso 5>"

# Y la traza completa está en Tempo, con el span del cobro (09-06)
curl -s "http://localhost:3200/api/traces/<traceId>" | jq '.batches | length'

Si los tres devuelven datos, las tres señales están conectadas: la métrica detecta, la traza localiza y el log explica.

  1. Lista de comprobación final de puesta en producción

# Comprobación Verificación objetiva
1 Cero secretos en el repositorio y en su historial Detector de secretos con --log-opts="--all"; rotar lo encontrado
2 El perfil prod se activa de verdad Buscar en el log de arranque The following 1 profile is active: "prod"
3 ./mvnw verify en verde, con las *IT incluidas La canalización de ci.yml, no la máquina de nadie
4 Migraciones aplicadas y compatibles hacia atrás flyway:info y la prueba del apartado 5 con la versión anterior corriendo
5 Denegación por defecto y reglas por dato Prueba automatizada de rutas sin token y de identificadores cruzados
6 Swagger, consola y Actuator cerrados curl a /swagger-ui.html, /h2-console y /actuator/env: 404 o 401
7 HTTPS obligatorio con HSTS y certificado válido curl -I sobre HTTP: debe rechazar o redirigir
8 Sondas liveness y readiness diferenciadas El manifiesto: livenessProbe no consulta la base de datos
9 Recursos y HPA dimensionados kubectl describe hpa y la prueba de carga de k6 contra pre
10 Logs en JSON con traceId, sin credenciales Buscar eyJ, Bearer y un correo conocido en un flujo completo: cero
11 Métricas y alertas activas Un panel con tráfico real y una alerta que se dispara al provocarla
12 Revertir es un comando cronometrado Ejecutarlo de verdad en pre y anotar el tiempo
13 Copias de seguridad de PostgreSQL restauradas Restaurar una copia en un entorno aparte; una copia no probada no existe
14 Sin vulnerabilidades altas dependency-check con failBuildOnCVSS=7 en verde
15 Revisión de seguridad profesional Informe firmado de auditoría y prueba de penetración

El punto 13 no apareció en ninguna lección y es de los que más caro salen: una copia de seguridad que nunca se ha restaurado no es una copia de seguridad, es una esperanza. Y el 15 repite la advertencia del módulo 5: todo lo construido en este curso es un punto de partida didáctico y necesita revisión profesional antes de exponerse a Internet.

  1. Hacia dónde seguir con este proyecto

CicloUrbana está terminada como material de curso, no como producto. Estas siete extensiones están ordenadas por dificultad y cada una combina lecciones que ya conoces.

# Extensión Dificultad Qué hay que combinar
1 Informes con exportación a CSV del histórico de alquileres Baja Consultas y proyecciones 04-06, paginación por keyset 09-01, respuesta en flujo y @Async 07-03
2 Panel de operario con la vista interna de la flota Baja DTOs separados 03-05, rol OPERARIO y @PreAuthorize 05-05, pruebas de seguridad 06-04
3 Integración con un mapa y búsqueda por cercanía Media RestClient con cortacircuitos 07-06, caché de geocodificación 09-02, índice geoespacial 04-08
4 Reserva anticipada de bicicleta, con caducidad a los 10 minutos Media Bloqueo pesimista y estados 04-07, tarea con ShedLock 07-03, nueva migración e invariantes en el dominio 10-03
5 Notificaciones push al móvil Media Eventos AFTER_COMMIT 04-07, ejecutor propio y mamparo 07-03, tolerancia a fallos 07-06
6 Tarifas dinámicas por demanda en hora punta Alta Otra CalculadoraTarifa y decoradores 02-02, métricas de ocupación 09-03, @ConfigurationProperties 02-05, pruebas parametrizadas 06-02
7 Extraer la facturación a un servicio propio Muy alta El módulo 7 entero, trazabilidad previa 09-06, patrón outbox y saga, contrato versionado 03-01

Sobre la número 7, que es la que más gente quiere hacer primero: es la última de la lista por una razón. Repartir el sistema en dos procesos convierte una transacción local en una saga con compensaciones, una traza de pila en una investigación entre dos despliegues, y una llamada a método en una llamada de red que puede fallar. Hazla solo cuando tengas trazabilidad distribuida funcionando y una razón concreta —escalado o equipo independiente—, no como ejercicio de estilo.

Y una recomendación sobre el método: para cualquiera de las siete, empieza por escribir la prueba del comportamiento que quieres, sigue por la migración si toca el esquema, y deja el controlador para el final. Es el orden que menos código tira a la basura.

Errores Comunes y Consejos

Creer que el diagrama es la arquitectura. El diagrama del apartado 1 es una vista; la arquitectura real es lo que hacen las dependencias del código. Por eso existen las reglas de ArchUnit: son el único diagrama que no puede mentir.

Copiar el pom.xml entero a un proyecto nuevo. Trae Redis, ShedLock, Resilience4j y Testcontainers a un proyecto que quizá no los necesita, y cada dependencia es superficie de mantenimiento y de vulnerabilidades. Empieza por web, data-jpa y test, y añade cuando duela algo.

Levantar la observabilidad entera en local siempre. Prometheus, Grafana, Loki, Tempo y el Collector consumen memoria y tiempo de arranque. En el día a día basta con la aplicación y PostgreSQL; la pila completa se levanta cuando se está trabajando en observabilidad.

Olvidar que el .env no está en el repositorio. Es lo primero que rompe a quien clona el proyecto por primera vez. Un .env.ejemplo versionado con las claves y sin los valores ahorra esa media hora a cada persona nueva.

Consejo: recorre el apartado 3 con un depurador. Poner un punto de interrupción en AlquilerService.iniciar y subir por la pila de llamadas hasta el filtro es el ejercicio que más consolida todo el curso: se ven, en una sola pantalla, el proxy, la transacción, el SecurityContext y el MDC.

Consejo: guarda el registro de decisiones. La tabla del apartado 5 —decisión, alternativa, motivo, contrapartida— es el documento que más se agradece dentro de un año, y el único que responde a «¿por qué está hecho así?».

Consejo: mide antes de extender. Cualquiera de las siete extensiones del apartado 11 cambia el perfil de carga. Guarda la línea base de k6 de hoy: será la referencia para saber si la extensión ha costado algo.

Ejercicios

Ejercicio 1: seguir el hilo de un fallo

En producción, a las 09:12, la app móvil empieza a recibir 500 en POST /api/v1/alquileres. El cuerpo del error es un ProblemDetail con "code": "ERROR_INTERNO" y un traceId. Describe, paso a paso y con las herramientas concretas del proyecto, cómo investigarías el incidente desde ese traceId hasta la causa raíz, indicando qué pieza de qué módulo usarías en cada paso y qué descartarías con cada una.

Ejercicio 2: diseñar la reserva anticipada

Diseña la extensión número 4 del apartado 11: un ciudadano puede reservar una bicicleta concreta durante 10 minutos antes de recogerla; pasado ese tiempo, la reserva caduca y la bicicleta vuelve a estar disponible. Especifica el cambio en el modelo, la migración, el contrato de la API, el control de concurrencia, la tarea de caducidad, las reglas de seguridad, las métricas y las pruebas que escribirías.

Ejercicio 3: revisión de arquitectura

El ayuntamiento de una ciudad vecina quiere reutilizar CicloUrbana. Sus números: 40 estaciones (frente a 4), 300 000 alquileres al mes, dos equipos de desarrollo y un requisito nuevo —integración en tiempo real con el sistema de transporte público—. Analiza qué decisiones del apartado 5 seguirían siendo correctas, cuáles habría que revisar y en qué orden abordarías la adaptación.

Soluciones

Solución 1

Paso 1 — de la alerta al cuadro de mando (09-04). Antes de tocar el traceId, mirar la fila RED del panel: ¿es un pico de errores o un goteo? ¿empezó con el despliegue de las 09:05? Con esto se distingue un fallo sistemático de uno puntual, y se decide si hay que revertir ya.

Paso 2 — el log de esa petición exacta (09-05). En Loki: {app="ciclourbana", entorno="prod"} | json | traceId = "...". Devuelve la historia completa de la petición, incluida la traza de pila que no se le envió al cliente. La mayoría de los incidentes se cierran aquí, con el nombre de la excepción y la línea.

Paso 3 — la cascada de spans (09-06). En Tempo, la misma traza. Aquí se responde algo que el log no dice: dónde se fue el tiempo y qué falló. Una barra roja en el span de la pasarela descarta que el problema sea nuestro; veinte spans select idénticos delatan un N+1 nuevo; un hueco de 900 ms sin instrumentar apunta a espera de conexión o a una pausa de GC.

Paso 4 — descartar recursos (09-03). hikaricp.connections.pending, jvm.gc.pause y resilience4j.circuitbreaker.state en el instante del fallo. Si pending está en 60, el problema no es la lógica: algo retiene conexiones —una llamada HTTP dentro de una transacción es el sospechoso número uno—.

Paso 5 — reproducir (06-05). Con la causa identificada, escribir la prueba que la reproduce antes de arreglar nada. Si el fallo depende de datos reales, una *IT con Testcontainers y el estado exacto que lo provoca.

Paso 6 — decidir (08-05). Si el fallo llegó con el despliegue de las 09:05, revertir primero y diagnosticar después: el tiempo de restauración es una métrica DORA y la reversión es un comando. Si es un problema externo, la decisión es de negocio —degradar, abrir el cortacircuitos, esperar—, como en el caso práctico de 09-06.

Lo que el ejercicio enseña: el recorrido va de lo general a lo particular y de lo barato a lo caro. La métrica cuesta un vistazo, la traza un clic, el log una consulta, y el depurador media mañana. Empezar por el depurador es el error más común.

Solución 2

Modelo. EstadoBicicleta gana el valor RESERVADA. Aparece la entidad Reserva(id, usuario, bicicleta, estacion, creada, expira, estado) con EstadoReserva { ACTIVA, CONSUMIDA, CADUCADA, CANCELADA }. La reserva es un agregado propio y no un campo de Bicicleta, porque tiene ciclo de vida, histórico y reglas propias.

Migración V10__reservas.sql. Tabla con secuencia (allocationSize = 50, nunca IDENTITY, por los lotes de 09-01), claves ajenas a usuarios, bicicletas y estaciones, columnas de auditoría, CHECK sobre el estado, índice (expira) WHERE estado = 'ACTIVA' —parcial, unas decenas de filas frente a millones— y un índice único parcial que impide dos reservas activas del mismo usuario, la misma técnica del uk_alquileres_usuario_en_curso. El enum de la columna estado de bicicletas amplía su CHECK: cambio compatible hacia atrás.

Contrato. POST /api/v1/reservas con { "bicicletaId": 12 } devuelve 201 con Location y ReservaResponse(id, matricula, estacion, expira, segundosRestantes). DELETE /api/v1/reservas/{id} cancela (204). Y POST /api/v1/alquileres acepta opcionalmente reservaId: si viene, consume la reserva en lugar de buscar bicicleta.

Concurrencia. Es el punto delicado. SELECT … FOR UPDATE sobre la bicicleta con timeout de 3 s, comprobación de que sigue DISPONIBLE, y cambio a RESERVADA en la misma transacción. Sin bloqueo, dos ciudadanos reservan la misma bicicleta y uno se lleva un 409 innecesario cuando el sistema podría haberle ofrecido otra.

Caducidad. CaducadorReservas, hermano de CaducadorAlquileres: @Scheduled(fixedDelayString = "${ciclourbana.reservas.caducador.intervalo:PT30S}") con @SchedulerLock —tres réplicas, una ejecución—, lógica en un método público invocable para poder probarla, Clock inyectado y idempotencia: marcar como caducada una reserva ya caducada no cambia nada.

Seguridad. @PreAuthorize("isAuthenticated()") para crear; @PreAuthorize("hasAnyRole('OPERARIO','ADMIN') or @seguridadReservas.esPropietario(#idReserva, principal)") para cancelar y consultar, con un bean nuevo idéntico en forma a SeguridadAlquileres. El listado filtra en la consulta, nunca con @PostFilter.

Métricas. Un Counter ciclourbana.reservas.creadas{estacion} y otro ciclourbana.reservas.caducadas, con la etiqueta de estación —cuatro valores— y nunca el identificador de usuario. La tasa de caducidad es el indicador de negocio interesante: si es alta, la ventana de 10 minutos es demasiado corta o demasiado larga.

Pruebas. Unitaria de Reserva.caducar() con Clock.fixed y del caso límite de los 10 minutos exactos; @DataJpaTest del índice único parcial —dos reservas activas del mismo usuario deben violar la restricción—; rodaja de seguridad con dos ciudadanos cruzando identificadores; y una *IT del flujo completo reservar → alquilar → finalizar, más otra de reservar → esperar → comprobar que caducó, con el reloj controlado.

La decisión de diseño que hay que saber defender: la reserva es un agregado propio. Modelarla como dos campos en Bicicleta parece más barato y hace imposible el histórico, complica la caducidad y mete estado transitorio en una entidad que debería ser estable.

Solución 3

Lo que sigue siendo correcto sin discusión. Flyway, DTOs, denegación por defecto, @Transactional en el servicio, Testcontainers, logs estructurados y observabilidad: son decisiones cuyo beneficio crece con el tamaño, no decrece. MapStruct pasa de opcional a claramente rentable, porque el número de DTOs se multiplica.

Lo que hay que revisar, con su motivo.

Decisión Estado con los números nuevos Qué hacer
Monolito modular Sigue siendo correcto, y por poco 300 000 alquileres al mes son ~7 por minuto de media: ridículo para un monolito. Lo que empuja no es la carga sino dos equipos; la respuesta razonable es Spring Modulith y fronteras internas más estrictas antes que dividir
Caffeine local Insuficiente Con más réplicas, 40 estaciones y datos que cambian, la divergencia entre cachés locales se nota. Redis como nivel compartido, Caffeine delante para lo inmutable
Pool de 10 conexiones A recalcular Depende del servidor nuevo, no del anterior: fórmula, prueba de carga y pending como señal
JWT de 15 minutos Correcto, con matices Con dos equipos y más clientes, hace falta gestión de claves y rotación; y probablemente un proveedor de identidad en lugar de firmar nosotros
estaciones sin índice geoespacial Insuficiente Con 40 estaciones y búsqueda por cercanía, PostGIS o al menos un índice sobre las coordenadas
Integración con transporte público Decisión nueva Es el primer caso real de mensajería: horarios en tiempo real no encajan en peticiones síncronas. Kafka o similar, con el patrón outbox

El orden de la adaptación, que es lo que el ejercicio pregunta de verdad:

  1. Medir con los datos nuevos antes de tocar nada. Cargar 40 estaciones y 300 000 alquileres en pre y ejecutar el escenario de k6. Casi todas las decisiones anteriores se resuelven con datos, no con opiniones.
  2. Índices y consultas. Lo que se degrada con el volumen se degrada primero, y es lo más barato de arreglar.
  3. Fronteras de módulo antes que equipos. Con dos equipos, el conflicto no es técnico sino de propiedad del código: reglas de ArchUnit por módulo y fronteras explícitas antes de repartir el trabajo.
  4. Caché distribuida y pool redimensionado, con la prueba de carga como juez.
  5. La integración de transporte público, la última, porque introduce una tecnología nueva —mensajería— y conviene hacerlo sobre una base ya estable y medida.

Lo que no haría: dividir en microservicios desde el principio «porque ahora es más grande». Cuarenta estaciones y siete alquileres por minuto no son un problema de escala: son un problema de organización, y ese se resuelve con fronteras internas antes que con procesos separados.

Conclusión

CicloUrbana está completa y, por primera vez, la has visto entera. Tienes la arquitectura final en un diagrama que se lee: un solo desplegable con módulos internos, dependencias que apuntan siempre hacia dentro, la observabilidad como lateral y no como capa, y una única dependencia capaz de tumbar el servicio —PostgreSQL—, lo que explica por qué la sonda readiness la consulta y la de liveness no. Tienes la estructura real del repositorio, con los paquetes por funcionalidad, el árbol de pruebas como espejo del de producción, la separación *Test/*IT y todo lo que rodea al código: Dockerfile, docker-compose, helm/, .github/workflows, observabilidad/ y carga/.

Y tienes el viaje de una petición contado en veintiún pasos, del balanceador a la métrica, con la lección de cada uno. Ese recorrido es el mejor resumen del curso porque en él aparece todo: el filtro que abre el span, el JWT que se valida, el binding y la validación del DTO, el proxy que evalúa el permiso y abre la transacción en el mismo punto —de ahí que la autoinvocación se lleve las dos por delante—, el bloqueo pesimista sobre la última bicicleta de «Plaza Mayor», el mapeo dentro de la transacción que hace posible open-in-view: false, el AFTER_COMMIT que saca el cobro del hilo HTTP y la métrica de negocio que cierra el ciclo.

Tienes también el mapa de qué construyó cada módulo, el pom.xml final donde se ve qué versiones gestiona el BOM y cuáles hay que revisar a mano, la configuración consolidada con la tabla de lo único que cambia entre entornos, los comandos para poner el proyecto en marcha desde cero y la colección .http que recorre registro, login, consulta, alquiler, factura y la comprobación de que un ciudadano no puede leer el alquiler de otro —seguida de las tres consultas que demuestran que la métrica, el log y la traza están unidos por el mismo traceId—.

Y tienes las dos piezas que separan un proyecto de curso de un producto: la tabla de decisiones con sus contrapartidas, que es el documento que responde dentro de un año a «¿por qué está hecho así?», y la lista de comprobación final con sus quince puntos, incluido el que ninguna lección había mencionado —una copia de seguridad que nunca se ha restaurado no es una copia de seguridad— y el que se repite desde el módulo 5: esto es un punto de partida didáctico y necesita revisión profesional antes de exponerse a Internet.

El proyecto no termina aquí, y esa es la idea. Las siete extensiones del apartado 11 están ordenadas por dificultad y cada una te dice qué lecciones combinar, desde el informe con exportación hasta extraer la facturación a un servicio propio —la última de la lista, y con motivo—. Construir cualquiera de ellas sobre esta base es la mejor forma de convertir lo aprendido en algo tuyo.

Queda una última lección, y es la única del curso que no habla de CicloUrbana. Porque saber Spring Boot no es haber terminado un curso: es haber empezado a poder leer la documentación con criterio, distinguir un recurso actualizado de uno que confunde, planificar una actualización de versión mayor sin miedo y elegir qué aprender después entre veinte caminos posibles. Recursos para Aprendizaje Adicional se ocupa de eso: la documentación oficial y cómo leerla, el código fuente como la mejor fuente, cómo mantenerse al día con el ciclo de publicación de Spring, el ecosistema Java, los libros que de verdad valen la pena, las certificaciones con una valoración honesta, la comunidad, la práctica deliberada, los temas naturales para el siguiente paso y una hoja de ruta razonada para los próximos seis meses.

Curso de Spring Boot

Módulo 1: Introducción a Spring Boot

Módulo 2: Conceptos Básicos de Spring Boot

Módulo 3: Construyendo Servicios Web RESTful

Módulo 4: Acceso a Datos con Spring Boot

Módulo 5: Seguridad en Spring Boot

Módulo 6: Pruebas en Spring Boot

Módulo 7: Funciones Avanzadas de Spring Boot

Módulo 8: Despliegue de Aplicaciones Spring Boot

Módulo 9: Rendimiento y Monitoreo

Módulo 10: Mejores Prácticas y Consejos

© Copyright 2026. Todos los derechos reservados