b. API
The first step designs the contract of catalog: which resources it exposes, which requests it accepts and what it answers. In an interview this is step 3 of the framework (API and data model); in a real team it is what other teams code against, so it deserves care before any SQL exists.
1. Create the project
Create the folder services/catalog in your repository with the standard Maven layout:
mkdir -p services/catalog/src/main/java/boxoffice/catalog
mkdir -p services/catalog/src/main/resources/db/migration
Then add the pom.xml:
| Dependency | Why |
|---|---|
spring-boot-starter-webmvc | Spring MVC + embedded Tomcat: one thread per in-flight request (you will count them in lab 2) |
spring-boot-starter-validation | Bean Validation annotations on records and parameters |
spring-boot-starter-data-jpa | JPA/Hibernate, repositories, transactions, the HikariCP connection pool |
spring-boot-starter-actuator | /actuator/health (used by Compose, Nginx and Kubernetes) and /actuator/metrics |
spring-boot-starter-flyway + flyway-database-postgresql | versioned schema migrations (in Spring Boot 4 each technology has its own starter and auto-configuration module; flyway-core alone is no longer picked up) |
postgresql | JDBC driver |
springdoc-openapi-starter-webmvc-ui (3.x) | generates the OpenAPI document from the controllers, plus Swagger UI (springdoc 3 is the line for Spring Boot 4) |
Prefer a generator?
start.spring.io produces the same skeleton (Maven, Java 21, the dependencies above). Choose Spring Boot 4.1.x, then set the group to boxoffice. The labs are written and tested with Spring Boot 4.1.1 and Spring Cloud 2025.1.3; a newer 4.1.x patch release works the same way.
The entry point is the usual one-liner:
package boxoffice.catalog;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class CatalogApplication {
public static void main(String[] args) {
SpringApplication.run(CatalogApplication.class, args);
}
}
2. Design the resources
Two resources, each a noun with a collection URL and an item URL, all under the service prefix /catalog (see the conventions of lab 0):
classDiagram
class Venue {
Long id
String name
String city
int capacity
}
class Event {
Long id
String title
Category category
OffsetDateTime startsAt
BigDecimal priceFrom
}
class Category {
<<enumeration>>
CONCERT
SPORTS
FESTIVAL
THEATER
}
Event "*" --> "1" Venue : takes place at
Event --> Category | Decision | Choice | Why |
|---|---|---|
| Identifiers | BIGINT generated by the database | simple, compact, easy to pick at random in k6 (1..200000) |
| Collections | paginated (page, size ≤ 50) | an unbounded GET /events on 200,000 rows would be a self-inflicted outage |
| Default listing | upcoming events, soonest first | it is what the home page shows; past events are not for sale |
| Money | BigDecimal / NUMERIC(10,2) | never double for prices |
| Time | OffsetDateTime / TIMESTAMPTZ, ISO-8601 in JSON | events happen in different time zones |
| Errors | RFC 9457 application/problem+json | one machine-readable error shape for every service |
| Create | 201 Created + Location: /catalog/events/{id} | the client learns where the new resource lives |
3. Request and response records
The API never returns JPA entities. Records are immutable, have no persistence annotations and make the JSON shape explicit — when the table changes, the API does not change by accident.
package boxoffice.catalog;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
/** What a client sends to create or update an event. */
public record EventIn(
@NotBlank @Size(max = 200) String title,
@NotNull Category category,
@NotNull Long venueId,
@NotNull OffsetDateTime startsAt,
@NotNull @PositiveOrZero BigDecimal priceFrom) {
}
package boxoffice.catalog;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
/** What the API returns: never the JPA entity itself. */
public record EventOut(
Long id,
String title,
Category category,
OffsetDateTime startsAt,
BigDecimal priceFrom,
VenueOut venue) {
static EventOut of(Event e) {
return new EventOut(e.getId(), e.getTitle(), e.getCategory(), e.getStartsAt(),
e.getPriceFrom(), VenueOut.of(e.getVenue()));
}
}
package boxoffice.catalog;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
public record VenueIn(
@NotBlank @Size(max = 200) String name,
@NotBlank @Size(max = 100) String city,
@Positive int capacity) {
}
package boxoffice.catalog;
import java.util.List;
import org.springframework.data.domain.Page;
/** A stable JSON shape for paged results (Spring's Page is not meant to be serialized). */
public record PageOut<T>(List<T> items, int page, int size, long totalItems, int totalPages) {
static <T> PageOut<T> of(Page<T> p) {
return new PageOut<>(p.getContent(), p.getNumber(), p.getSize(), p.getTotalElements(), p.getTotalPages());
}
}
All of them live in src/main/java/boxoffice/catalog/. The static factories EventOut.of(Event) and VenueOut.of(Venue) are the only place where an entity becomes a DTO.
4. Controllers
Controllers only translate HTTP into method calls: they validate the input, call the service and choose the status code. No SQL, no business rule.
How validation works here:
@Valid @RequestBody EventIn inchecks the annotations of the record (@NotBlank,@NotNull,@PositiveOrZero) and answers 400 before the method runs.@Min(0)/@Max(50)onpageandsizeuse Spring's built-in method validation (Spring Framework 6.1+):?size=5000is rejected with 400 instead of loading 5,000 rows.- With
spring.mvc.problemdetails.enabled: true(next step), both cases come back asapplication/problem+json.
What a client sees
In the interview
When you sketch the API, say three things out loud: the resources and verbs (GET /events?city=&page=), the pagination strategy (offset now; keyset — WHERE starts_at > :last — when users scroll deep, because OFFSET 100000 still reads 100,000 rows) and the error model. Interviewers listen for whether you bound every collection.
Pitfalls
- Returning the entity: a lazy
venueserialized outside a transaction throwsLazyInitializationException, or worse, a new column leaks to clients (a password hash, a cost price). - Unbounded lists: every collection endpoint needs a maximum page size.
200for everything: status codes are part of the contract; proxies, caches and k6 checks rely on them.
The controllers depend on CatalogService, which you write in the next step together with the entities and the database: 2. Persistence.