Skip to content

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/ at the start of lab 1
πŸ“ boxoffice/
β”œβ”€β”€ Git .gitignore
β”œβ”€β”€ πŸ“ load/
β”‚   └── JavaScript hello.js
β”œβ”€β”€ πŸ“ results/
β”‚   └── Markdown lab0.md
└── πŸ“ scripts/
    └── GNU Bash 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, catalog is 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

  • 1. API


    Create the Spring Boot project, design the endpoints, the request/response records and the controllers.

  • 2. Persistence


    JPA entities and repositories, the service layer with transactions, and the Flyway migrations that create and seed the schema.

  • 3. Container


    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/
πŸ“ boxoffice/
β”œβ”€β”€ YAML docker-compose.yaml
β”œβ”€β”€ πŸ“ load/
β”‚   β”œβ”€β”€ JavaScript hello.js
β”‚   └── JavaScript smoke.js
β”œβ”€β”€ πŸ“ results/
β”‚   β”œβ”€β”€ Markdown lab0.md
β”‚   └── Markdown lab1.md
└── πŸ“ services/
    └── πŸ“ catalog/
        β”œβ”€β”€ Docker 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/
            β”œβ”€β”€ YAML 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:

load/smoke.js
// Lab 1: smoke test. One user, 30 seconds: does every endpoint answer, and how fast
// when the service is NOT under load?   k6 run load/smoke.js
import http from 'k6/http';
import { check, sleep } from 'k6';

const BASE_URL = __ENV.BASE_URL || 'http://localhost:8082';
const CITIES = ['SΓ£o Paulo', 'Rio de Janeiro', 'Belo Horizonte', 'Porto Alegre', 'Curitiba',
  'Salvador', 'Recife', 'Fortaleza', 'BrasΓ­lia', 'Manaus'];

export const options = {
  vus: 1,
  duration: '30s',
  summaryTrendStats: ['avg', 'med', 'p(95)', 'p(99)', 'max'],
  thresholds: {
    http_req_failed: ['rate==0'],
    // one threshold per endpoint makes k6 print one latency line per endpoint
    'http_req_duration{name:GET /catalog/events/{id}}': ['p(95)<200'],
    'http_req_duration{name:GET /catalog/events?city}': ['p(95)<500'],
  },
};

export default function () {
  const id = 1 + Math.floor(Math.random() * 200000);
  const detail = http.get(`${BASE_URL}/catalog/events/${id}`,
    { tags: { name: 'GET /catalog/events/{id}' } });
  check(detail, { 'detail is 200': (r) => r.status === 200 });

  const city = CITIES[Math.floor(Math.random() * CITIES.length)];
  const list = http.get(`${BASE_URL}/catalog/events?city=${encodeURIComponent(city)}&page=0&size=20`,
    { tags: { name: 'GET /catalog/events?city' } });
  check(list, {
    'list is 200': (r) => r.status === 200,
    'list has 20 items': (r) => r.json('items').length === 20,
  });

  sleep(0.5);   // think time: a real user does not click non-stop
}

With the stack running (step 3):

k6 run load/smoke.js β–ˆ THRESHOLDS

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:

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} in docker-compose.yaml and application.yaml lets 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" and memory: 768M make one replica's capacity a measurable number (Numbers β€” servers).
  • Version the schema as migrations β€” Flyway's V1__create_catalog.sql and V2__seed_catalog.sql rebuild the same database on every machine; a schema changed by hand drifts.
  • Expose a readiness endpoint and let the platform use it β€” the Compose healthcheck calls /actuator/health/readiness; load balancers rely on the same signal (Load balancing β€” health checks).
  • Write the latency requirement as a test threshold β€” load/smoke.js fails 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/catalog builds and runs with docker compose up --build; Flyway applies V1 and V2.
  • Every endpoint of the table answers as described (try the 400 and 404 cases too β€” the body must be application/problem+json2).
  • The OpenAPI document is served at http://localhost:8082/catalog/openapi.
  • load/smoke.js passes all thresholds; results/lab1.md is filled in.
  • Repository tagged lab1; quiz answered.

End state

added in lab 1
πŸ“ boxoffice/
β”œβ”€β”€ YAML docker-compose.yaml                      # new: db + catalog
β”œβ”€β”€ JavaScript load/smoke.js                            # new
β”œβ”€β”€ Markdown results/lab1.md                          # your measurements
└── πŸ“ services/catalog/                        # new: the whole service
    β”œβ”€β”€ Docker Dockerfile  .dockerignore  pom.xml
    β”œβ”€β”€  src/main/java/boxoffice/catalog/*.java   (14 classes)
    β”œβ”€β”€ YAML 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.