a. Overview
In this lab
- Build
catalog, the first BoxOffice service: a REST API for events and venues with Spring Boot 4, PostgreSQL, Flyway and an OpenAPI document. - Package it as a container image and run it with its database using Docker Compose.
- Measure it with a k6 smoke test: the latency of each endpoint when the service is not under load.
Every ticketing site starts with the same page: what is on, where, and when? In BoxOffice that is the job of catalog. It is the most read-heavy service of the system β during a flash sale, millions of fans refresh the event page while only a handful of administrators ever change it β and that is why it will be the guinea pig for load balancing (lab 3), caching (lab 5) and indexing (lab 6). Today you build the simplest version that works, the monolith-first step of the scaling journey.
Before you start
| Time | 3β4 hours |
| You need | the tools of lab 0; Docker with at least 4 GB of memory; the first build downloads ~100 MB of Maven dependencies |
| Directory | every command on these pages runs from the root of your boxoffice/ repository unless it starts with cd |
Check the tools:
docker compose version # Docker Compose v2.x (or newer)
java -version # 21 or newer
mvn -v # Apache Maven 3.9+
k6 version # k6 v1.0+
uv --version # uv (runs the Python helper scripts)
Start state β the end of lab 0:
π boxoffice/
βββ .gitignore
βββ π load/
β βββ hello.js
βββ π results/
β βββ lab0.md
βββ π scripts/
βββ check-env.sh
Catch up
Skipped or broke lab 0? Download boxoffice-after-lab-0.zip, unzip it and cd boxoffice (then git init -b main if you want version control). Every file of this lab is shown in full on the next pages, and the same files are in this lab's code/ folder (for example code/docker-compose.yaml).
Slides
Click the slides and use β β to move through them; F goes full screen and P opens the presenter view, with the notes. Open the slides in a new tab
What you will build
sequenceDiagram
autonumber
actor Fan
participant C as catalog :8082<br/>(Spring Boot)
participant DB as db :5432<br/>(PostgreSQL, schema catalog)
Fan->>C: GET /catalog/events?city=Recife
C->>DB: SELECT events JOIN venues WHERE city = ? ORDER BY starts_at LIMIT 20
DB-->>C: 20 rows
C->>DB: SELECT count(*) ... (total for the page links)
DB-->>C: 20000
C-->>Fan: 200 {items: [...], page: 0, totalItems: 20000} The service follows a classic three-layer structure β controller (HTTP, validation, DTOs) β service (business rules, transactions) β repository (SQL through JPA) β with one rule that matters for everything that follows: the API never exposes the database entities, only records (EventOut, VenueOut) that you control.
| Method | Path | Body | Answer |
|---|---|---|---|
GET | /catalog/events?city=&page=&size= | β | 200 page of upcoming events, soonest first |
GET | /catalog/events/{id} | β | 200 event with its venue, or 404 |
POST | /catalog/events | EventIn | 201 + Location header, or 400 |
PUT | /catalog/events/{id} | EventIn | 200, 400 or 404 |
DELETE | /catalog/events/{id} | β | 204 or 404 |
GET | /catalog/venues Β· /catalog/venues/{id} | β | 200 venues |
POST | /catalog/venues | VenueIn | 201 |
GET | /actuator/health Β· /catalog/openapi Β· /swagger-ui.html | β | health, OpenAPI document, interactive docs |
The database starts with 50 venues in 10 cities and 200,000 events (one per hour from March 2027), generated by a Flyway migration1. Event 1 is the course's flash-sale show: BoxOffice Fest β Stadium Night, at a 60,000-seat stadium. A realistic volume of data is deliberate: a query that is instant on 10 rows can take tens of milliseconds on 200,000, and that is exactly what you will measure.
Theory connection
- In the story of the course,
catalogis the "one service, one database" of chapters 1 and 2: stages 0 and 1 of the scaling journey, and the system whose capacity class 2 estimates and lab 2 measures. Its 1 CPU and 10 database connections are the numbers that lab 2 turns into a knee. - Class 5, Compute & services: stateless app servers, API styles (REST, resources, status codes, versioning).
- Class 6, Databases: relational data models, schemas and migrations.
Steps
-
Create the Spring Boot project, design the endpoints, the request/response records and the controllers.
-
JPA entities and repositories, the service layer with transactions, and the Flyway migrations that create and seed the schema.
-
A multi-stage Dockerfile, the first
docker-compose.yaml, and the service running with PostgreSQL.
When the three steps are done, your repository looks like this:
π boxoffice/
βββ docker-compose.yaml
βββ π load/
β βββ hello.js
β βββ smoke.js
βββ π results/
β βββ lab0.md
β βββ lab1.md
βββ π services/
βββ π catalog/
βββ Dockerfile
βββ .dockerignore
βββ pom.xml
βββ π src/main/java/boxoffice/catalog/
β βββ CatalogApplication.java
β βββ Category.java
β βββ Event.java Venue.java
β βββ EventRepository.java VenueRepository.java
β βββ CatalogService.java
β βββ EventController.java VenueController.java
β βββ EventIn.java EventOut.java VenueIn.java VenueOut.java PageOut.java
βββ π src/main/resources/
βββ application.yaml
βββ π db/migration/
βββ V1__create_catalog.sql
βββ V2__seed_catalog.sql
Measure it: the unloaded baseline
Before asking "how much load can it take?" (lab 2), record how fast each endpoint is with a single user. That is the service time: the best latency you will ever see, and the reference for every comparison later.
Save the smoke test as load/smoke.js:
With the stack running (step 3):
http_req_duration{name:GET /catalog/events?city}
β 'p(95)<500' p(95)=42.18ms
http_req_duration{name:GET /catalog/events/{id}}
β 'p(95)<200' p(95)=11.15ms
http_req_failed
β 'rate==0' rate=0.00%
β TOTAL RESULTS
β detail is 200
β list is 200
β list has 20 items
http_req_duration.....................: avg=19.33ms med=23.94ms p(95)=37.74ms p(99)=72.02ms max=87.26ms
{ name:GET /catalog/events?city }...: avg=32.04ms med=30.17ms p(95)=42.18ms p(99)=71.38ms max=87.26ms
{ name:GET /catalog/events/{id} }...: avg=6.62ms med=4.69ms p(95)=11.15ms p(99)=40.19ms max=73.71ms
http_reqs.............................: 112 3.678382/s
The numbers above come from a run on a laptop; yours will differ. Two things should stand out anyway: the listing is clearly slower than the detail, about 2 to 6 times (it joins two tables, sorts and counts thousands of rows; the detail is a primary-key lookup), and p99 is far from the median even with a single user (JIT compilation, garbage collection, the first connections of the pool).
Record the results in results/lab1.md:
# Lab 1 β catalog, unloaded (1 VU, 30 s)
| Endpoint | p50 (ms) | p95 (ms) | p99 (ms) | max (ms) |
|------------------------------|---------:|---------:|---------:|---------:|
| GET /catalog/events/{id} | | | | |
| GET /catalog/events?city=... | | | | |
- Startup time ("Started CatalogApplication in ... seconds"): ____ s
- Image size (`docker image ls boxoffice-catalog`): ____ MB
- Seed time (Flyway "Successfully applied 2 migrations ... execution time"): ____ s
- Why is the listing slower than the detail? ____
Think first: run the smoke test twice. Why is the second run faster?
The first requests after a start hit a cold JVM (classes loading, the JIT still interpreting bytecode), an empty connection pool and PostgreSQL pages not yet in its buffer cache. After a few hundred requests everything is warm. Load tests therefore always have a warm-up, and you should never compare a cold run with a warm one.
If your numbers differ
Absolute values depend on your CPU, on the memory given to Docker and on what else is running (close the browser tabs and IDEs you do not need). What must hold everywhere: every check passes, the detail is clearly faster than the listing (about 2Γ or more), and the second run is faster than the first. If the detail p95 is above ~100 ms with one user, Docker is probably short of CPU or memory (docker stats).
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
Bind for 0.0.0.0:5432 failed: port is already allocated | a local PostgreSQL is running | stop it (brew services stop postgresql, sudo systemctl stop postgresql) or map "15432:5432" in docker-compose.yaml |
build fails in mvn β¦ package with Could not resolve dependencies | no internet, proxy, or a broken download | check the connection and run docker compose build --no-cache catalog |
catalog stays starting/unhealthy | it is still migrating/starting (the first start seeds 200,000 events), or it crashed | docker compose logs catalog; look for Started CatalogApplication or the first ERROR |
Connection to localhost:5432 refused in the catalog logs | DB_HOST is not db inside Compose | use the docker-compose.yaml of this page unchanged |
Validate failed: Migrations have failed validation | an applied migration was edited | restore the file; on your laptop only, docker compose down -v resets the database |
curl: (7) Failed to connect to localhost port 8082 | the container is not up yet, or port not published | docker compose ps; wait for healthy |
?city=SΓ£o Paulo returns 400 or nothing | the space and the Γ£ must be URL-encoded | use ?city=S%C3%A3o%20Paulo (k6 does it with encodeURIComponent) |
exec format error on Apple Silicon | an image built for another architecture | all images used here are multi-arch; remove any platform: line and rebuild |
Best practices in this lab
- Configure through environment variables with safe defaults β
DB_POOL_SIZE: ${DB_POOL_SIZE:-10}indocker-compose.yamlandapplication.yamllets lab 2 change the pool without a rebuild; the formula behind the number is in Queueing. - Fix the resources of every container β
cpus: "1.0"andmemory: 768Mmake one replica's capacity a measurable number (Numbers β servers). - Version the schema as migrations β Flyway's
V1__create_catalog.sqlandV2__seed_catalog.sqlrebuild the same database on every machine; a schema changed by hand drifts. - Expose a readiness endpoint and let the platform use it β the Compose
healthcheckcalls/actuator/health/readiness; load balancers rely on the same signal (Load balancing β health checks). - Write the latency requirement as a test threshold β
load/smoke.jsfails when a p95 passes its limit (Requirements).
Clean up
docker compose down # stop and remove the containers, keep the database volume
# docker compose down -v # ...and also erase the database (Flyway seeds it again next time)
Keep the repository as it is: lab 2 measures this same service.
Deliverable
-
services/catalogbuilds and runs withdocker compose up --build; Flyway appliesV1andV2. - Every endpoint of the table answers as described (try the
400and404cases too β the body must beapplication/problem+json2). - The OpenAPI document is served at
http://localhost:8082/catalog/openapi. -
load/smoke.jspasses all thresholds;results/lab1.mdis filled in. - Repository tagged
lab1; quiz answered.
End state
π boxoffice/
βββ docker-compose.yaml # new: db + catalog
βββ load/smoke.js # new
βββ results/lab1.md # your measurements
βββ π services/catalog/ # new: the whole service
βββ Dockerfile .dockerignore pom.xml
βββ src/main/java/boxoffice/catalog/*.java (14 classes)
βββ src/main/resources/application.yaml
βββ src/main/resources/db/migration/V1__create_catalog.sql, V2__seed_catalog.sql
Running: db (PostgreSQL 17, schema catalog with 50 venues and 200,000 events) and catalog on port 8082.
Next: Lab 2 β Measure first.
-
Spring Boot reference, Data Access β Database Initialization with Flyway.Β β©