Pular para conteúdo

b. API

O primeiro passo desenha o contrato do catalog: quais recursos ele expõe, quais requisições aceita e o que responde. Em uma entrevista, este é o passo 3 do framework (API e modelo de dados); em um time de verdade, é aquilo contra o que os outros times programam, então merece cuidado antes de existir qualquer SQL.

1. Crie o projeto

Crie a pasta services/catalog no seu repositório com a estrutura padrão do Maven:

mkdir -p services/catalog/src/main/java/boxoffice/catalog
mkdir -p services/catalog/src/main/resources/db/migration

Depois acrescente o 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>
Dependência Por quê
spring-boot-starter-webmvc Spring MVC + Tomcat embutido: uma thread por requisição em andamento (você vai contá-las no lab 2)
spring-boot-starter-validation anotações de Bean Validation em records e parâmetros
spring-boot-starter-data-jpa JPA/Hibernate, repositórios, transações, o pool de conexões HikariCP
spring-boot-starter-actuator /actuator/health (usado pelo Compose, pelo Nginx e pelo Kubernetes) e /actuator/metrics
spring-boot-starter-flyway + flyway-database-postgresql migrations versionadas do schema (no Spring Boot 4 cada tecnologia tem o seu starter e módulo de auto-configuração; só o flyway-core não é mais detectado)
postgresql driver JDBC
springdoc-openapi-starter-webmvc-ui (3.x) gera o documento OpenAPI a partir dos controllers, mais o Swagger UI (o springdoc 3 é a linha do Spring Boot 4)

Prefere um gerador?

O start.spring.io produz o mesmo esqueleto (Maven, Java 21, as dependências acima). Escolha o Spring Boot 4.1.x e ajuste o group para boxoffice. Os labs foram escritos e testados com Spring Boot 4.1.1 e Spring Cloud 2025.1.3; uma versão de correção 4.1.x mais nova funciona do mesmo jeito.

O ponto de entrada é a linha de sempre:

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. Desenhe os recursos

Dois recursos, cada um um substantivo com uma URL de coleção e uma URL de item, todos sob o prefixo do serviço /catalog (veja as convenções do 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 : acontece em
    Event --> Category
Decisão Escolha Por quê
Identificadores BIGINT gerado pelo banco simples, compacto, fácil de sortear no k6 (1..200000)
Coleções paginadas (page, size ≤ 50) um GET /events sem limite sobre 200.000 linhas seria uma queda autoinfligida
Listagem padrão próximos eventos, os mais próximos primeiro é o que a página inicial mostra; eventos passados não estão à venda
Dinheiro BigDecimal / NUMERIC(10,2) nunca double para preços
Tempo OffsetDateTime / TIMESTAMPTZ, ISO-8601 no JSON eventos acontecem em fusos horários diferentes
Erros RFC 9457 application/problem+json um formato de erro legível por máquina para todo serviço
Criação 201 Created + Location: /catalog/events/{id} o cliente descobre onde o novo recurso vive

3. Records de requisição e resposta

A API nunca devolve entidades JPA. Records são imutáveis, não têm anotações de persistência e deixam o formato do JSON explícito — quando a tabela muda, a API não muda por acidente.

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
}

Todos ficam em src/main/java/boxoffice/catalog/. As fábricas estáticas EventOut.of(Event) e VenueOut.of(Venue) são o único lugar em que uma entidade vira DTO.

4. Controllers

Controllers só traduzem HTTP em chamadas de método: validam a entrada, chamam o serviço e escolhem o código de status. Nada de SQL, nada de regra de negócio.

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);
    }
}

Como a validação funciona aqui:

  • @Valid @RequestBody EventIn in verifica as anotações do record (@NotBlank, @NotNull, @PositiveOrZero) e responde 400 antes de o método rodar.
  • @Min(0) / @Max(50) em page e size usam a validação de métodos embutida no Spring (Spring Framework 6.1+): ?size=5000 é rejeitado com 400 em vez de carregar 5.000 linhas.
  • Com spring.mvc.problemdetails.enabled: true (próximo passo), os dois casos voltam como application/problem+json.

O que um cliente vê

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"}

Na entrevista

Ao esboçar a API, diga três coisas em voz alta: os recursos e verbos (GET /events?city=&page=), a estratégia de paginação (offset agora; keyset — WHERE starts_at > :last — quando os usuários rolam fundo, porque OFFSET 100000 ainda lê 100.000 linhas) e o modelo de erros. Entrevistadores prestam atenção em se você limita toda coleção.

Armadilhas

  • Devolver a entidade: um venue lazy serializado fora de uma transação lança LazyInitializationException ou, pior, uma coluna nova vaza para os clientes (um hash de senha, um preço de custo).
  • Listas sem limite: todo endpoint de coleção precisa de um tamanho máximo de página.
  • 200 para tudo: códigos de status fazem parte do contrato; proxies, caches e checks do k6 dependem deles.

Os controllers dependem do CatalogService, que você escreve no próximo passo junto com as entidades e o banco: 2. Persistência.