Across ten modules we have built Ribalta's electric bike network layer by layer. First an endpoint that returned four in-memory stations; then Spring's container and its beans; the REST contract with its DTOs and its errors; persistence on PostgreSQL with Flyway; security with JWT and per-datum rules; the tests; operability; deployment; and finally observability, judgement and cleanliness. Every lesson added a piece and almost always looked towards the next one.

We have never looked at CicloUrbana whole. This lesson does: the complete architecture in one diagram, the real repository structure, the journey of a single request from the load balancer to the metric — citing at every step where it was studied —, the map of what each module built, the design decisions with their alternatives and trade-offs, the final pom.xml and configuration, how to get the project running from scratch and where to take it next. If you have followed the course, you will recognise everything here; what is new is seeing it all together.

Contents

  1. The final architecture
  2. The complete repository
  3. The journey of a request: POST /api/v1/rentals
  4. Course map: what each module built
  5. Design decisions and their alternatives
  6. The final pom.xml
  7. The final configuration
  8. Getting the project running from scratch
  9. An end-to-end walkthrough with .http requests
  10. Final go-live checklist
  11. Where to take this project next
  12. Common Mistakes and Tips
  13. Exercises

  1. The final architecture

flowchart TB
    subgraph CLI["Clients"]
        M["Mobile app<br/>Ribalta citizens"]
        P["Council panel<br/>operators and administration"]
    end

    subgraph EDGE["Edge"]
        ALB["ALB / Ingress<br/>TLS, rate limiting · 08-03, 08-04"]
    end

    subgraph APP["CicloUrbana · 3 replicas · 07-04, 08-04"]
        F["Filter chain<br/>TraceFilter · JwtAuthenticationFilter · 03-06, 05-04"]
        C["REST controllers<br/>DTOs and validation · 03-02, 03-05"]
        S["Services<br/>@Transactional · @PreAuthorize · 04-07, 05-05"]
        R["Repositories<br/>Spring Data JPA · 04-05"]
        T["Tasks and asynchrony<br/>@Scheduled · @Async · 07-03"]
        AC["Actuator<br/>probes, metrics · 07-01, 09-03"]
        F --> C --> S --> R
        S --> T
    end

    subgraph DAT["Data"]
        DB[("PostgreSQL 16<br/>schema with Flyway · 04-08")]
        RD[("Redis<br/>distributed cache · 09-02")]
    end

    subgraph EXT["External services"]
        PG["Payment gateway<br/>RestClient + Resilience4j · 07-06"]
    end

    subgraph OBS["Observability · module 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 --> DB
    S --> RD
    T --> PG
    AC -.->|"scrape /actuator/prometheus"| PR
    APP -.->|"JSON logs to stdout"| LO
    APP -.->|"OTLP"| OT

Four observations about the drawing, because a diagram without a reading is decoration.

The application is a single deployable. The five blocks inside are not services: they are layers and modules within the same process. It is the modular monolith from 07-05, and the arrows between them are method calls, not network calls.

The dependencies point inwards and downwards. Filters → controllers → services → repositories → database. No arrow goes up. It is the dependency rule from 10-01, verified automatically with the ArchUnit rules from 10-03.

Observability is a sidecar, not a layer. It is not on the request path: the application publishes metrics, logs and traces, and others collect them. If Prometheus goes down, CicloUrbana keeps working.

The only thing that can take the service down is PostgreSQL. The Redis cache degrades to Caffeine, the gateway has a circuit breaker and a deferred-charge fallback, and the rest is optional. That asymmetry is deliberate and is why the readiness probe does query the database and the liveness one does not.

  1. The complete repository

ciclourbana/
├── .github/workflows/
│   ├── ci.yml                      # build, test, JaCoCo, Spotless · 08-05
│   └── cd.yml                      # image, Trivy, deploy to pre and prod · 08-05
├── helm/ciclourbana/
│   ├── Chart.yaml · values.yaml · values-pre.yaml · values-prod.yaml
│   └── templates/                  # deployment, service, ingress, hpa, job-migration · 08-04
├── observability/                  # prometheus.yml, loki.yml, tempo.yml,
│                                   # otel-collector.yml, Grafana dashboards · 09-04, 09-06
├── load/peak-hour-load.js          # k6 scenario · 09-01
├── requests/ciclourbana.http       # end-to-end collection · section 9
├── src/main/java/com/ciclourbana/
│   ├── CicloUrbanaApplication.java          # scan root · 01-04
│   ├── common/                              # cross-cutting
│   │   ├── CommonConfig.java                # Clock, RestClient.Builder · 02-01
│   │   ├── AuditableEntity.java             # createdAt/By, updatedAt/By · 04-03
│   │   ├── PageResponse.java                # own Page wrapper · 04-05
│   │   ├── TraceFilter.java                 # traceId in MDC and header · 03-06, 09-06
│   │   ├── GlobalExceptionHandler.java      # ProblemDetail RFC 7807 · 03-06
│   │   ├── CicloUrbanaException.java + hierarchy · 03-06
│   │   └── CicloUrbanaMetrics.java          # business metrics · 09-03
│   ├── stations/                            # Station, Repository, Service,
│   │   └── dto/                             # Controller, Mapper + DTOs · modules 3 and 4
│   ├── bikes/                               # Bike, BikeStatus,
│   │   └── dto/                             # @BikePlate, FleetReviewer
│   ├── rentals/                             # Rental, FareCalculator and the three
│   │   └── dto/                             # fares, FareSelector, RentalExpirer
│   ├── users/                               # User, roles, wallet
│   ├── security/                            # SecurityConfig, JwtService,
│   │                                        # JwtAuthenticationFilter, AuthenticatedUser,
│   │                                        # RentalSecurity, SecurityAudit · module 5
│   ├── payments/                            # PaymentGatewayClient + Resilience4j · 07-06
│   └── config/                              # TaskConfig, AsyncConfig,
│                                            # CacheConfig, OpenApiConfig
├── src/main/resources/
│   ├── application.yml                      # common to every environment · 07-02
│   ├── application-{dev,test,pre,prod}.yml  # only the differences
│   ├── logback-spring.xml                   # JSON to stdout with traceId · 09-05
│   └── db/migration/V1__…V9__…sql           # versioned schema · 04-08
├── src/test/java/com/ciclourbana/           # mirror of the production tree · 06-01
│   ├── …Test.java                           # unit and slice tests (Surefire)
│   ├── …IT.java                             # integration with Testcontainers (Failsafe)
│   ├── IntegrationTestBase.java             # shared context · 06-05
│   └── ArchitectureRulesTest.java           # ArchUnit · 10-03
├── Dockerfile                               # multi-stage, ciclo user · 07-04
├── docker-compose.yml                       # app + PostgreSQL + Redis
├── docker-compose.observability.yml         # Prometheus, Grafana, Loki, Tempo, Collector
├── .dockerignore · .gitignore · .env.example
├── mvnw · mvnw.cmd · .mvn/
└── pom.xml

Two things deserve attention. src/test is an exact mirror of src/main, which lets you reach package-private members without opening up the production class and keeps the tree navigable. And the *Test / *IT split is not cosmetic: Surefire runs the former in ./mvnw test in seconds, and Failsafe runs the latter in ./mvnw verify against a real PostgreSQL.

  1. The journey of a request: POST /api/v1/rentals

Marta opens the app at the "Main Square" station, picks a bike and taps "rent". This is what happens.

sequenceDiagram
    autonumber
    participant M as Mobile app
    participant L as ALB / Ingress
    participant FS as Spring Security filters
    participant DS as DispatcherServlet
    participant CT as RentalController
    participant SV as RentalService (proxy)
    participant DB as PostgreSQL
    participant EV as RentalNotifier
    participant PG as Payment gateway

    M->>L: POST /api/v1/rentals · Bearer …
    L->>FS: TLS terminated · X-Forwarded-*
    FS->>FS: TraceFilter: root span + traceId in MDC
    FS->>FS: JwtAuthenticationFilter: validates signature and exp
    FS->>DS: SecurityContext with AuthenticatedUser
    DS->>CT: binding to StartRentalRequest
    CT->>CT: @Valid: plate, ids, fare
    CT->>SV: start(request)
    SV->>SV: @PreAuthorize: isAuthenticated()
    SV->>DB: BEGIN (READ_COMMITTED)
    SV->>DB: SELECT … FOR UPDATE (best bike)
    SV->>SV: FareSelector: calculates the amount
    SV->>DB: INSERT rental · UPDATE bike
    SV->>DB: COMMIT
    SV-->>CT: RentalResponse (mapped inside the tx)
    CT-->>M: 201 Created · Location · X-Trace-Id
    Note over EV,PG: AFTER_COMMIT, already off the HTTP thread
    SV->>EV: RentalStarted event
    EV->>PG: authorise charge (@Async + circuit breaker)

The step-by-step detail, with its lesson:

# What happens Piece Lesson
1 TLS terminates at the load balancer, which forwards with X-Forwarded-Proto ALB / Ingress 08-03, 08-04
2 The request enters the servlet filter chain SecurityFilterChain 05-01
3 TraceFilter opens the root span and puts traceId/spanId in the MDC TraceFilter 03-06, 09-06
4 JwtAuthenticationFilter validates the HS256 signature and the expiry, and builds the AuthenticatedUser JwtService 05-04
5 AuthorizationFilter checks that the route requires authentication and has it authorizeHttpRequests 05-02
6 The DispatcherServlet resolves the handler and deserialises the body with Jackson @RequestBody 03-02
7 Bean Validation checks the DTO: @NotNull, @Positive, @BikePlate StartRentalRequest 03-04
8 The controller delegates to the service; there is no logic here RentalController 03-03
9 The service's proxy evaluates @PreAuthorize before going in @EnableMethodSecurity 05-05
10 The same proxy opens the transaction: a pool connection, autoCommit=false @Transactional 04-07
11 SELECT … FOR UPDATE reserves the best available bike and serialises the race @Lock(PESSIMISTIC_WRITE) 04-07
12 FareSelector picks the user's FareCalculator and calculates the amount StandardFare and its siblings 02-02
13 INSERT of the rental and UPDATE of the bike by dirty checking Spring Data JPA 04-05
14 The mapping to RentalResponse happens inside the transaction RentalMapper 03-05
15 COMMIT; the connection goes back to the pool HikariCP 04-02
16 @TransactionalEventListener(AFTER_COMMIT) fires the event once committed RentalStarted 04-07
17 The charge leaves on another thread, with the MDC and SecurityContext propagated @Async("mailExecutor") 07-03
18 The gateway call carries traceparent, a timeout and a circuit breaker RestClient + Resilience4j 07-06
19 The controller replies 201 with Location and X-Trace-Id ResponseEntity 03-03
20 CicloUrbanaMetrics increments ciclourbana.rentals.started{fare,station} Micrometer 09-03
21 A JSON line is written to stdout with the traceId, and the span is exported over OTLP Logback + Tracing 09-05, 09-06

What you see when you put it together. Steps 9 and 10 are done by the same proxy, which is why the self-invocation trap takes down the transaction and the permission check at the same time. Step 14 is not a stylistic detail: with open-in-view: false it is the only way for the response to be built without a LazyInitializationException. And steps 16 to 18 are the reason Marta gets her 201 in tens of milliseconds while the charge takes almost two seconds on another thread.

  1. Course map: what each module built

Module What was built Main classes Repository files
1 Introduction The project and its lifecycle CicloUrbanaApplication, DemoStationLoader pom.xml, mvnw, src/main/java, application.properties
2 Core concepts The container, typed configuration and the fare system FareCalculator, StandardFare, StudentFare, FareSelector, FareProperties, NetworkProperties common/CommonConfig.java, application.yml
3 REST The complete public contract StationController, record DTOs, StationMapper, GlobalExceptionHandler, TraceFilter, @BikePlate */dto/, common/, OpenApiConfig
4 Data Real persistence Station, Bike, Rental, AuditableEntity, repositories, PageResponse db/migration/V1…V9, application.yml (Hikari, JPA)
5 Security Authentication and authorisation SecurityConfig, JwtService, JwtAuthenticationFilter, AuthenticatedUser, RentalSecurity, SecurityAudit security/, application-prod.yml
6 Testing The safety net StandardFareTest, StationControllerTest, RentalFullFlowIT, IntegrationTestBase src/test/, JaCoCo and Failsafe in pom.xml
7 Operability Initiative of its own, and packaging RentalExpirer, OccupancyRecalculator, FleetReviewer, RentalNotifier, MdcDecorator, PaymentGatewayClient Dockerfile, docker-compose.yml, V7__shedlock.sql
8 Deployment Automated delivery — .github/workflows/, helm/
9 Performance Seeing and measuring CicloUrbanaMetrics, QueryCounterFilter, StationSummary observability/, load/, logback-spring.xml
10 Judgement The reflection and the automatic rules ArchitectureRulesTest, FareWithOvertimeSurcharge Spotless in pom.xml, docs/decisions/

  1. Design decisions and their alternatives

Every architecture decision buys something and pays something. These are CicloUrbana's six main ones, with what they cost.

Decision Discarded alternative Why Trade-off we accepted
Modular monolith Microservices One team, a coupled domain, local transactions and a stack trace that explains everything Scaling means replicating the whole application, even if only rentals are under load
Stateless JWT Cookie-based session Three replicas with no sticky sessions and no shared store; a mobile app does not handle cookies comfortably A token cannot be revoked before it expires; you need a short expiry and rotating refresh
Flyway ddl-auto: update The schema is reviewed in the pull request, versioned and reproduced identically across the four environments Every entity change requires writing the migration by hand; and renaming costs three deployments
MapStruct ModelMapper or manual mapping unmappedTargetPolicy=ERROR turns the forgotten field into a compilation error, with no runtime cost One more annotation processor and generated code you have to know how to read
Caffeine + Redis Redis alone Caffeine answers in nanoseconds and without the network for anything that tolerates a few seconds of divergence Two cache levels to invalidate; some data can differ by a few seconds between replicas
Testcontainers H2 in PostgreSQL mode Partial indexes, types, functions and locks behave for real as they do in production The integration suite takes minutes and needs Docker on the machine and in the pipeline

And two minor decisions that illustrate the same way of thinking. open-in-view: false buys you that no query is fired during serialisation, and pays with the obligation to map to a DTO inside the transaction — which, incidentally, reinforces a practice we wanted anyway. Deny by default buys you that an oversight produces a visible 403 instead of an open endpoint, and pays with every new route requiring an explicit rule.

None of these six is universally correct. What makes them defensible is that they were taken with the trade-off in plain sight and were written down.

  1. The final pom.xml

The accumulated dependencies, grouped by the module that introduced them. The version column is the important information: only the ones the Spring Boot BOM does not manage carry their own version, and they are exactly the ones you have to review by hand on every upgrade.

Module Dependencies (org.springframework.boot: unless stated) Scope Version
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 Data spring-boot-starter-data-jpa, org.flywaydb:flyway-core, flyway-database-postgresql compile BOM
4 org.postgresql:postgresql runtime BOM
5 Security spring-boot-starter-security compile BOM
5 io.jsonwebtoken:jjwt-api (+ jjwt-impl and jjwt-jackson at runtime) mixed 0.12.6
7 Operations 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 Performance 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 Testing spring-boot-starter-test, spring-security-test, spring-boot-testcontainers, org.testcontainers:postgresql test BOM
10 Judgement com.tngtech.archunit:archunit-junit5 test 1.3.0
1 Development spring-boot-devtools (runtime), spring-boot-configuration-processor optional BOM

And the part of the pom.xml that really deserves careful reading, because it is the part that breaks the build on a concrete problem:

<parent>                                       <!-- Module 1: coherent versions -->
    <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: the forgotten field does not compile (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>   <!-- needed for #userId in SpEL (05-05) -->
        </compilerArgs>
      </configuration>
    </plugin>

    <plugin>   <!-- Failsafe: the *IT classes in verify, not in 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: a map of the red, not a score (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: style is not argued about (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: fails on high severity (05-05) -->
      <groupId>org.owasp</groupId><artifactId>dependency-check-maven</artifactId>
      <version>10.0.4</version>
      <configuration><failBuildOnCVSS>7</failBuildOnCVSS></configuration>
    </plugin>
  </plugins>
</build>

The six plugins are not ornaments: each one breaks the build on a concrete problem — an unmapped field, a failed integration test, unformatted code, a high-severity vulnerability. It is the underlying idea of 10-01: moving the check out of a person's head and into the build.

  1. The final configuration

# application.yml — common to every environment
spring:
  application:
    name: ciclourbana                 # becomes the service.name of the traces (09-06)
  threads:
    virtual:
      enabled: true                   # Java 21 virtual threads (07-03)
  jackson:
    default-property-inclusion: non_null
    deserialization: { fail-on-unknown-properties: false }   # contract tolerance (03-05)
  datasource:
    url: ${DATABASE_URL}              # no default value: mandatory (10-01)
    username: ${DATABASE_USER}
    password: ${DATABASE_PASSWORD}
    hikari:
      pool-name: ciclourbana-pool
      maximum-pool-size: 10           # (cores × 2) + disks (09-01)
      minimum-idle: 10
      connection-timeout: 3000        # fail fast, do not queue
      leak-detection-threshold: 20000
  jpa:
    open-in-view: false               # structural decision from module 4
    hibernate: { ddl-auto: validate }  # the schema is governed by 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 }               # the default scheduler has ONE thread (07-03)
      thread-name-prefix: task-ciclo-
  lifecycle:
    timeout-per-shutdown-phase: 40s   # longer than the await-termination-period values

server:
  shutdown: graceful                  # graceful shutdown (01-05, 07-03)
  forward-headers-strategy: framework # TLS terminates at the load balancer (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                        # separate management port (07-01)
  endpoints:
    web:
      exposure:
        include: health,info,metrics,prometheus,loggers,scheduledtasks
  endpoint:
    health:
      probes: { enabled: true }       # /health/liveness and /health/readiness
      show-details: when_authorized
  tracing:
    sampling: { probability: 1.0 }    # sampling is decided by the Collector, by tail (09-06)
  otlp:
    tracing:
      endpoint: ${OTLP_ENDPOINT:http://otel-collector:4318/v1/traces}
  metrics:
    tags: { application: ciclourbana }

ciclourbana:                          # own properties, validated (02-05)
  city: Ribalta
  fares:
    unlock: 0.50
    price-per-minute: 0.12
    student-price-per-minute: 0.08
  network:
    minimum-capacity: 8
    battery-threshold: 20
    maximum-rental-duration: PT2H
    featured-stations: [Main Square, University]
  rentals:
    expirer:
      interval: PT10M
  jwt:
    secret: ${JWT_SECRET}             # mandatory, never versioned
    expiration: PT15M
    refresh-expiration: P7D

And the per-environment differences, which are the only thing that changes:

Property dev test pre prod
Database Local Docker Testcontainers Pre-production RDS RDS with a replica
flyway.locations + db/demo db/migration db/migration db/migration
logging.level.com.ciclourbana DEBUG INFO INFO INFO
Swagger UI On On On springdoc.api-docs.enabled: false
CORS http://localhost:5173 — https://pre.ribalta.example https://panel.ribalta.example
Cache Caffeine Disabled Redis Redis
tracing.sampling 1.0 locally 0.0 1.0 → Collector 1.0 → Collector with tail sampling
Actuator exposed "*" health With ADMIN health,info,prometheus

  1. Getting the project running from scratch

# 1. Clone and prepare the local secrets
git clone https://github.com/ribalta-council/ciclourbana.git
cd ciclourbana
cp .env.example .env          # fill in DATABASE_PASSWORD and JWT_SECRET
openssl rand -base64 48       # generate a 256+ bit JWT secret

# 2. Bring up the infrastructure (PostgreSQL 16 + Redis)
docker compose up -d
docker compose ps             # wait for the healthcheck to report "healthy"

# 3. Full build: formatting, unit tests, *IT with Testcontainers, coverage
./mvnw verify
open target/site/jacoco/index.html

# 4. Start in development
./mvnw spring-boot:run -Dspring-boot.run.profiles=dev

# 5. Check that it is alive and the schema is up to date
curl -s localhost:8081/actuator/health/readiness | jq
curl -s localhost:8081/actuator/info | jq '.build.version'    # 2.4.0

# 6. (Optional) The full observability stack
docker compose -f docker-compose.observability.yml up -d
# Grafana at http://localhost:3000 · Prometheus 9090 · Tempo 3200

Step 3 deserves a comment: ./mvnw verify starts an ephemeral PostgreSQL with Testcontainers and applies the nine migrations to it before running the *IT classes. If that command passes on your machine, the project is healthy from top to bottom.

  1. An end-to-end walkthrough with .http requests

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

### 1. Registering a Ribalta citizen
POST {{host}}/auth/register
Content-Type: application/json

{ "name": "Marta Serra", "email": "[email protected]",
  "password": "Bicycle-2026!", "fareType": "STANDARD" }

> {% client.test("201 and no sensitive data", () => {
     client.assert(response.status === 201);
     client.assert(response.body.passwordHash === undefined); }); %}

### 2. Login: returns the access token and the refresh token
# @name login
POST {{host}}/auth/login
Content-Type: application/json

{ "email": "[email protected]", "password": "Bicycle-2026!" }

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

### 3. Stations with available bikes (public, paginated)
GET {{host}}/stations?page=0&size=20
Authorization: Bearer {{token}}

### 4. Detail of "Main Square", with its docked bikes
GET {{host}}/stations/1
Authorization: Bearer {{token}}

### 5. Start the rental: 201 with Location and X-Trace-Id
# @name rental
POST {{host}}/rentals
Authorization: Bearer {{token}}
Content-Type: application/json

{ "originStationId": 1, "plate": "RB-0142" }

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

### 6. Finish at "University": calculates the amount and releases the bike
PATCH {{host}}/rentals/{{rentalId}}/finish
Authorization: Bearer {{token}}
Content-Type: application/json

{ "destinationStationId": 4 }

### 7. The rental's invoice
GET {{host}}/rentals/{{rentalId}}
Authorization: Bearer {{token}}

### 8. Security check: another citizen's rental gives 403
GET {{host}}/rentals/9999
Authorization: Bearer {{token}}

And the three checks that close the walkthrough, which are the ones that prove the module 9 observability really works:

# The business metric has moved
curl -s localhost:8081/actuator/metrics/ciclourbana.rentals.started | jq '.measurements'

# The request's log is queryable by its traceId (Loki, 09-05)
# {app="ciclourbana"} | json | traceId = "<the X-Trace-Id from step 5>"

# And the full trace is in Tempo, with the charge span (09-06)
curl -s "http://localhost:3200/api/traces/<traceId>" | jq '.batches | length'

If all three return data, the three signals are connected: the metric detects, the trace locates and the log explains.

  1. Final go-live checklist

# Check Objective verification
1 Zero secrets in the repository and in its history A secret scanner with --log-opts="--all"; rotate whatever is found
2 The prod profile really is activated Look in the startup log for The following 1 profile is active: "prod"
3 ./mvnw verify green, with the *IT classes included The ci.yml pipeline, not anyone's machine
4 Migrations applied and backward compatible flyway:info and the section 5 test with the previous version running
5 Deny by default and per-datum rules An automated test of token-less routes and of crossed identifiers
6 Swagger, console and Actuator closed curl to /swagger-ui.html, /h2-console and /actuator/env: 404 or 401
7 HTTPS enforced with HSTS and a valid certificate curl -I over HTTP: it must reject or redirect
8 Distinct liveness and readiness probes The manifest: livenessProbe does not query the database
9 Resources and HPA sized kubectl describe hpa and the k6 load test against pre
10 JSON logs with traceId, no credentials Search for eyJ, Bearer and a known email in a complete flow: zero
11 Metrics and alerts active A dashboard with real traffic and an alert that fires when you provoke it
12 Rolling back is a timed command Actually run it in pre and note the time
13 PostgreSQL backups restored Restore a backup in a separate environment; an untested backup does not exist
14 No high-severity vulnerabilities dependency-check with failBuildOnCVSS=7 green
15 Professional security review A signed audit and penetration test report

Point 13 did not appear in any lesson and is one of the most expensive to get wrong: a backup that has never been restored is not a backup, it is a hope. And point 15 repeats the warning from module 5: everything built in this course is a teaching starting point and needs professional review before being exposed to the internet.

  1. Where to take this project next

CicloUrbana is finished as course material, not as a product. These seven extensions are ordered by difficulty and each one combines lessons you already know.

# Extension Difficulty What to combine
1 Reports with CSV export of the rental history Low Queries and projections 04-06, keyset pagination 09-01, streamed response and @Async 07-03
2 Operator panel with the internal fleet view Low Separate DTOs 03-05, the OPERATOR role and @PreAuthorize 05-05, security tests 06-04
3 Map integration and proximity search Medium RestClient with a circuit breaker 07-06, geocoding cache 09-02, geospatial index 04-08
4 Advance booking of a bike, expiring after 10 minutes Medium Pessimistic locking and statuses 04-07, a task with ShedLock 07-03, a new migration and invariants in the domain 10-03
5 Push notifications to the phone Medium AFTER_COMMIT events 04-07, a dedicated executor and bulkhead 07-03, fault tolerance 07-06
6 Demand-based dynamic fares at peak times High Another FareCalculator and decorators 02-02, occupancy metrics 09-03, @ConfigurationProperties 02-05, parameterised tests 06-02
7 Extracting billing into its own service Very high The whole of module 7, tracing in place first 09-06, the outbox pattern and sagas, a versioned contract 03-01

About number 7, which is the one most people want to do first: it is last on the list for a reason. Splitting the system into two processes turns a local transaction into a saga with compensations, a stack trace into an investigation across two deployments, and a method call into a network call that can fail. Do it only when you have distributed tracing working and a concrete reason — independent scaling or an independent team — not as a stylistic exercise.

And a recommendation about method: for any of the seven, start by writing the test for the behaviour you want, then the migration if it touches the schema, and leave the controller for last. It is the order that throws the least code away.

Common Mistakes and Tips

Believing the diagram is the architecture. The diagram in section 1 is a view; the real architecture is what the code's dependencies do. That is why the ArchUnit rules exist: they are the only diagram that cannot lie.

Copying the whole pom.xml into a new project. It brings Redis, ShedLock, Resilience4j and Testcontainers into a project that may not need them, and every dependency is maintenance and vulnerability surface. Start with web, data-jpa and test, and add when something hurts.

Always bringing up the entire observability stack locally. Prometheus, Grafana, Loki, Tempo and the Collector consume memory and startup time. Day to day, the application and PostgreSQL are enough; the full stack goes up when you are working on observability.

Forgetting that the .env is not in the repository. It is the first thing that breaks for whoever clones the project for the first time. A versioned .env.example with the keys and without the values saves each new person that half hour.

Tip: walk through section 3 with a debugger. Putting a breakpoint in RentalService.start and climbing the call stack up to the filter is the exercise that consolidates the whole course best: you see, on a single screen, the proxy, the transaction, the SecurityContext and the MDC.

Tip: keep the decision log. The table in section 5 — decision, alternative, reason, trade-off — is the document you will be most grateful for a year from now, and the only one that answers "why is it built like this?".

Tip: measure before extending. Any of the seven extensions in section 11 changes the load profile. Save today's k6 baseline: it will be the reference for knowing whether the extension cost anything.

Exercises

Exercise 1: following the thread of a failure

In production, at 09:12, the mobile app starts receiving 500s on POST /api/v1/rentals. The error body is a ProblemDetail with "code": "INTERNAL_ERROR" and a traceId. Describe, step by step and with the project's concrete tools, how you would investigate the incident from that traceId to the root cause, stating which piece of which module you would use at each step and what you would rule out with each one.

Exercise 2: designing advance booking

Design extension number 4 from section 11: a citizen can book a specific bike for 10 minutes before picking it up; once that time has passed, the booking expires and the bike becomes available again. Specify the model change, the migration, the API contract, the concurrency control, the expiry task, the security rules, the metrics and the tests you would write.

Exercise 3: an architecture review

A neighbouring city's council wants to reuse CicloUrbana. Their numbers: 40 stations (against 4), 300,000 rentals a month, two development teams and a new requirement — real-time integration with the public transport system. Analyse which decisions from section 5 would still be correct, which would need revisiting and in what order you would tackle the adaptation.

Solutions

Solution 1

Step 1 — from the alert to the dashboard (09-04). Before touching the traceId, look at the RED row on the dashboard: is it a spike of errors or a trickle? Did it start with the 09:05 deployment? This distinguishes a systematic failure from a one-off, and decides whether to roll back right away.

Step 2 — the log for that exact request (09-05). In Loki: {app="ciclourbana", environment="prod"} | json | traceId = "...". It returns the complete story of the request, including the stack trace that was not sent to the client. Most incidents are closed right here, with the exception's name and the line.

Step 3 — the span cascade (09-06). In Tempo, the same trace. Here you get an answer the log does not give: where the time went and what failed. A red bar on the gateway span rules out the problem being ours; twenty identical select spans give away a new N+1; a 900 ms uninstrumented gap points at connection waiting or a GC pause.

Step 4 — ruling out resources (09-03). hikaricp.connections.pending, jvm.gc.pause and resilience4j.circuitbreaker.state at the instant of the failure. If pending is at 60, the problem is not the logic: something is holding connections — an HTTP call inside a transaction is suspect number one.

Step 5 — reproduce (06-05). With the cause identified, write the test that reproduces it before fixing anything. If the failure depends on real data, an *IT with Testcontainers and the exact state that triggers it.

Step 6 — decide (08-05). If the failure arrived with the 09:05 deployment, roll back first and diagnose afterwards: time to restore is a DORA metric and rolling back is a command. If it is an external problem, the decision is a business one — degrade, open the circuit breaker, wait — as in the case study in 09-06.

What the exercise teaches: the route goes from the general to the particular and from the cheap to the expensive. The metric costs a glance, the trace a click, the log a query, and the debugger half a morning. Starting with the debugger is the most common mistake.

Solution 2

Model. BikeStatus gains the value RESERVED. A Reservation(id, user, bike, station, createdAt, expiresAt, status) entity appears, with ReservationStatus { ACTIVE, USED, EXPIRED, CANCELLED }. The booking is an aggregate of its own and not a field on Bike, because it has a lifecycle, a history and rules of its own.

Migration V10__reservations.sql. A table with a sequence (allocationSize = 50, never IDENTITY, because of the batching from 09-01), foreign keys to users, bikes and stations, audit columns, a CHECK on the status, an index (expires_at) WHERE status = 'ACTIVE' — partial, a few dozen rows against millions — and a partial unique index that prevents two active bookings for the same user, the same technique as uk_rentals_user_in_progress. The enum of the status column on bikes widens its CHECK: a backward-compatible change.

Contract. POST /api/v1/reservations with { "bikeId": 12 } returns 201 with Location and ReservationResponse(id, plate, station, expiresAt, secondsRemaining). DELETE /api/v1/reservations/{id} cancels (204). And POST /api/v1/rentals optionally accepts a reservationId: if it is present, it consumes the booking instead of looking for a bike.

Concurrency. This is the delicate point. SELECT … FOR UPDATE on the bike with a 3 s timeout, a check that it is still AVAILABLE, and a change to RESERVED in the same transaction. Without the lock, two citizens book the same bike and one gets an unnecessary 409 when the system could have offered them another.

Expiry. ReservationExpirer, sibling of RentalExpirer: @Scheduled(fixedDelayString = "${ciclourbana.reservations.expirer.interval:PT30S}") with @SchedulerLock — three replicas, one execution —, the logic in a public, invocable method so it can be tested, an injected Clock and idempotency: marking an already-expired booking as expired changes nothing.

Security. @PreAuthorize("isAuthenticated()") to create; @PreAuthorize("hasAnyRole('OPERATOR','ADMIN') or @reservationSecurity.isOwner(#reservationId, principal)") to cancel and to query, with a new bean identical in shape to RentalSecurity. The listing filters in the query, never with @PostFilter.

Metrics. A Counter ciclourbana.reservations.created{station} and another ciclourbana.reservations.expired, with the station tag — four values — and never the user identifier. The expiry rate is the interesting business indicator: if it is high, the 10-minute window is either too short or too long.

Tests. A unit test of Reservation.expire() with Clock.fixed and of the exactly-ten-minutes edge case; a @DataJpaTest of the partial unique index — two active bookings for the same user must violate the constraint —; a security slice with two citizens crossing identifiers; and an *IT of the full book → rent → finish flow, plus another of book → wait → check it expired, with the clock under control.

The design decision you have to be able to defend: the booking is an aggregate of its own. Modelling it as two fields on Bike looks cheaper and makes the history impossible, complicates expiry and puts transient state into an entity that ought to be stable.

Solution 3

What remains correct without discussion. Flyway, DTOs, deny by default, @Transactional in the service, Testcontainers, structured logs and observability: these are decisions whose benefit grows with size rather than shrinking. MapStruct goes from optional to clearly worthwhile, because the number of DTOs multiplies.

What needs revisiting, with the reason.

Decision Status with the new numbers What to do
Modular monolith Still correct, though only just 300,000 rentals a month is ~7 a minute on average: trivial for a monolith. What pushes is not the load but two teams; the reasonable answer is Spring Modulith and stricter internal boundaries rather than splitting
Local Caffeine Insufficient With more replicas, 40 stations and changing data, the divergence between local caches becomes noticeable. Redis as the shared level, Caffeine in front for what is immutable
A pool of 10 connections To be recalculated It depends on the new server, not the old one: formula, load test and pending as the signal
15-minute JWT Correct, with caveats With two teams and more clients, you need key management and rotation; and probably an identity provider instead of signing them yourself
stations with no geospatial index Insufficient With 40 stations and proximity search, PostGIS or at least an index on the coordinates
Public transport integration A new decision It is the first genuine case for messaging: real-time timetables do not fit into synchronous requests. Kafka or similar, with the outbox pattern

The order of the adaptation, which is what the exercise is really asking:

  1. Measure with the new data before touching anything. Load 40 stations and 300,000 rentals into pre and run the k6 scenario. Almost every one of the decisions above is settled by data, not opinions.
  2. Indexes and queries. What degrades with volume degrades first, and it is the cheapest thing to fix.
  3. Module boundaries before teams. With two teams, the conflict is not technical but one of code ownership: ArchUnit rules per module and explicit boundaries before dividing the work.
  4. A distributed cache and a resized pool, with the load test as the judge.
  5. The public transport integration last, because it introduces a new technology — messaging — and it is better done on a base that is already stable and measured.

What I would not do: split into microservices from the outset "because it is bigger now". Forty stations and seven rentals a minute are not a scale problem: they are an organisational problem, and that is solved with internal boundaries before separate processes.

Conclusion

CicloUrbana is complete and, for the first time, you have seen it whole. You have the final architecture in a diagram that can be read: a single deployable with internal modules, dependencies that always point inwards, observability as a sidecar rather than a layer, and a single dependency capable of taking the service down — PostgreSQL — which explains why the readiness probe queries it and the liveness one does not. You have the real repository structure, with packages by feature, the test tree as a mirror of the production one, the *Test/*IT split and everything that surrounds the code: Dockerfile, docker-compose, helm/, .github/workflows, observability/ and load/.

And you have the journey of a request told in twenty-one steps, from the load balancer to the metric, with the lesson behind each one. That walkthrough is the best summary of the course because everything appears in it: the filter that opens the span, the JWT that gets validated, the binding and validation of the DTO, the proxy that evaluates the permission and opens the transaction at the same point — hence self-invocation taking both down at once —, the pessimistic lock on the last bike at "Main Square", the mapping inside the transaction that makes open-in-view: false possible, the AFTER_COMMIT that moves the charge off the HTTP thread and the business metric that closes the cycle.

You also have the map of what each module built, the final pom.xml where you can see which versions the BOM manages and which have to be reviewed by hand, the consolidated configuration with the table of the only things that change between environments, the commands to get the project running from scratch and the .http collection that walks through registration, login, browsing, renting, invoicing and the check that one citizen cannot read another's rental — followed by the three queries that prove the metric, the log and the trace are joined by the same traceId.

And you have the two pieces that separate a course project from a product: the table of decisions with their trade-offs, the document that answers "why is it built like this?" a year from now, and the final checklist with its fifteen points, including the one no lesson had mentioned — a backup that has never been restored is not a backup — and the one repeated since module 5: this is a teaching starting point and it needs professional review before being exposed to the internet.

The project does not end here, and that is the idea. The seven extensions in section 11 are ordered by difficulty and each one tells you which lessons to combine, from the report with CSV export to extracting billing into its own service — last on the list, and with good reason. Building any of them on this foundation is the best way to make what you have learnt your own.

There is one lesson left, and it is the only one in the course that is not about CicloUrbana. Because knowing Spring Boot is not having finished a course: it is having begun to be able to read the documentation with judgement, tell an up-to-date resource from a confusing one, plan a major version upgrade without fear and choose what to learn next among twenty possible paths. Resources for Further Learning deals with that: the official documentation and how to read it, the source code as the best source, how to keep up with Spring's release cycle, the Java ecosystem, the books that are genuinely worth it, certifications with an honest assessment, the community, deliberate practice, the natural topics for the next step and a reasoned roadmap for the next six months.

Spring Boot Course

Module 1: Introduction to Spring Boot

Module 2: Spring Boot Core Concepts

Module 3: Building RESTful Web Services

Module 4: Data Access with Spring Boot

Module 5: Security in Spring Boot

Module 6: Testing in Spring Boot

Module 7: Advanced Spring Boot Features

Module 8: Deploying Spring Boot Applications

Module 9: Performance and Monitoring

Module 10: Best Practices and Tips

© Copyright 2026. All rights reserved