Skip to content

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:

services/catalog/pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>4.1.1</version>
        <relativePath/>
    </parent>

    <groupId>boxoffice</groupId>
    <artifactId>catalog</artifactId>
    <version>1.0.0</version>
    <name>catalog</name>
    <description>BoxOffice catalog: events and venues</description>

    <properties>
        <java.version>21</java.version>
        <springdoc.version>3.1.1</springdoc.version>
    </properties>

    <dependencies>
        <!-- REST API (Spring MVC on Tomcat) -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webmvc</artifactId>
        </dependency>
        <!-- Bean Validation (@NotBlank, @Positive, ...) -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
        <!-- JPA + Hibernate -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <!-- health, metrics -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>
        <!-- schema migrations (Boot 4: the starter brings the auto-configuration) -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-flyway</artifactId>
        </dependency>
        <dependency>
            <groupId>org.flywaydb</groupId>
            <artifactId>flyway-database-postgresql</artifactId>
        </dependency>
        <dependency>
            <groupId>org.postgresql</groupId>
            <artifactId>postgresql</artifactId>
            <scope>runtime</scope>
        </dependency>
        <!-- OpenAPI document + Swagger UI -->
        <dependency>
            <groupId>org.springdoc</groupId>
            <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
            <version>${springdoc.version}</version>
        </dependency>
    </dependencies>

    <build>
        <finalName>app</finalName>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>
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:

services/catalog/src/main/java/boxoffice/catalog/CatalogApplication.java
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.

services/catalog/src/main/java/boxoffice/catalog/EventIn.java
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) {
}
services/catalog/src/main/java/boxoffice/catalog/EventOut.java
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()));
    }
}
services/catalog/src/main/java/boxoffice/catalog/VenueIn.java
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) {
}
services/catalog/src/main/java/boxoffice/catalog/VenueOut.java
package boxoffice.catalog;

public record VenueOut(Long id, String name, String city, int capacity) {

    static VenueOut of(Venue v) {
        return new VenueOut(v.getId(), v.getName(), v.getCity(), v.getCapacity());
    }
}
services/catalog/src/main/java/boxoffice/catalog/PageOut.java
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());
    }
}
services/catalog/src/main/java/boxoffice/catalog/Category.java
package boxoffice.catalog;

public enum Category {
    CONCERT, SPORTS, FESTIVAL, THEATER
}

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.

services/catalog/src/main/java/boxoffice/catalog/EventController.java
package boxoffice.catalog;

import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import java.net.URI;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/catalog/events")
public class EventController {

    private final CatalogService service;

    public EventController(CatalogService service) {
        this.service = service;
    }

    @GetMapping
    public PageOut<EventOut> list(
            @RequestParam(required = false) String city,
            @RequestParam(defaultValue = "0") @Min(0) int page,
            @RequestParam(defaultValue = "20") @Min(1) @Max(50) int size) {
        return service.upcomingEvents(city, page, size);
    }

    @GetMapping("/{id}")
    public EventOut get(@PathVariable Long id) {
        return service.findEvent(id);
    }

    @PostMapping
    public ResponseEntity<EventOut> create(@Valid @RequestBody EventIn in) {
        var out = service.createEvent(in);
        return ResponseEntity.created(URI.create("/catalog/events/" + out.id())).body(out);
    }

    @PutMapping("/{id}")
    public EventOut update(@PathVariable Long id, @Valid @RequestBody EventIn in) {
        return service.updateEvent(id, in);
    }

    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void delete(@PathVariable Long id) {
        service.deleteEvent(id);
    }
}
services/catalog/src/main/java/boxoffice/catalog/VenueController.java
package boxoffice.catalog;

import jakarta.validation.Valid;
import java.net.URI;
import java.util.List;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/catalog/venues")
public class VenueController {

    private final CatalogService service;

    public VenueController(CatalogService service) {
        this.service = service;
    }

    @GetMapping
    public List<VenueOut> list() {
        return service.allVenues();
    }

    @GetMapping("/{id}")
    public VenueOut get(@PathVariable Long id) {
        return service.findVenue(id);
    }

    @PostMapping
    public ResponseEntity<VenueOut> create(@Valid @RequestBody VenueIn in) {
        var out = service.createVenue(in);
        return ResponseEntity.created(URI.create("/catalog/venues/" + out.id())).body(out);
    }
}

How validation works here:

  • @Valid @RequestBody EventIn in checks the annotations of the record (@NotBlank, @NotNull, @PositiveOrZero) and answers 400 before the method runs.
  • @Min(0) / @Max(50) on page and size use Spring's built-in method validation (Spring Framework 6.1+): ?size=5000 is rejected with 400 instead of loading 5,000 rows.
  • With spring.mvc.problemdetails.enabled: true (next step), both cases come back as application/problem+json.

What a client sees

GET /catalog/events?size=500 HTTP/1.1

HTTP/1.1 400 Bad Request
Content-Type: application/problem+json

{"detail":"Validation failure","instance":"/catalog/events",
 "status":400,"title":"Bad Request"}

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 venue serialized outside a transaction throws LazyInitializationException, or worse, a new column leaks to clients (a password hash, a cost price).
  • Unbounded lists: every collection endpoint needs a maximum page size.
  • 200 for 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.