Pular para conteúdo

c. Persistência

Este passo conecta a API ao PostgreSQL: entidades mapeiam as tabelas, repositórios guardam as consultas, o serviço define as transações e o Flyway cria e popula o schema. Cada serviço é dono de um schema — aqui, catalog — e nada mais mexe nele.

flowchart LR
    ctl["EventController<br/>VenueController"] --> svc["CatalogService<br/>@Transactional"]
    svc --> repo["EventRepository<br/>VenueRepository"]
    repo --> jpa["Hibernate (JPA)"]
    jpa --> pool["HikariCP<br/>10 conexões"]
    pool --> db[("PostgreSQL<br/>schema catalog")]
    fly["Flyway<br/>V1, V2"] -->|na inicialização| db

Guarde o pool de conexões na cabeça: toda requisição que toca o banco pega emprestada uma das suas 10 conexões durante a sua transação. No lab 2 esse número vira o teto do serviço inteiro.

1. Entidades

Uma entidade é uma classe Java mapeada para uma tabela. Estas duas não têm setters públicos: Event só muda por update(...), então toda mudança passa por um único método.

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

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;

@Entity
@Table(name = "venues", schema = "catalog")
public class Venue {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String name;

    @Column(nullable = false)
    private String city;

    @Column(nullable = false)
    private int capacity;

    protected Venue() {
        // required by JPA
    }

    public Venue(String name, String city, int capacity) {
        this.name = name;
        this.city = city;
        this.capacity = capacity;
    }

    public Long getId() { return id; }
    public String getName() { return name; }
    public String getCity() { return city; }
    public int getCapacity() { return capacity; }
}
services/catalog/src/main/java/boxoffice/catalog/Event.java
package boxoffice.catalog;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.OffsetDateTime;

@Entity
@Table(name = "events", schema = "catalog")
public class Event {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String title;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private Category category;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "venue_id")
    private Venue venue;

    @Column(name = "starts_at", nullable = false)
    private OffsetDateTime startsAt;

    @Column(name = "price_from", nullable = false)
    private BigDecimal priceFrom;

    protected Event() {
        // required by JPA
    }

    public Event(String title, Category category, Venue venue, OffsetDateTime startsAt, BigDecimal priceFrom) {
        update(title, category, venue, startsAt, priceFrom);
    }

    public void update(String title, Category category, Venue venue, OffsetDateTime startsAt, BigDecimal priceFrom) {
        this.title = title;
        this.category = category;
        this.venue = venue;
        this.startsAt = startsAt;
        this.priceFrom = priceFrom;
    }

    public Long getId() { return id; }
    public String getTitle() { return title; }
    public Category getCategory() { return category; }
    public Venue getVenue() { return venue; }
    public OffsetDateTime getStartsAt() { return startsAt; }
    public BigDecimal getPriceFrom() { return priceFrom; }
}
Anotação Significado
@Table(name = "events", schema = "catalog") schema explícito: a tabela pertence a este serviço
@GeneratedValue(strategy = IDENTITY) o banco gera o id (GENERATED BY DEFAULT AS IDENTITY)
@Enumerated(EnumType.STRING) grava CONCERT, não 0: reordenar o enum não corrompe dados
@ManyToOne(fetch = LAZY) o local só é carregado quando preciso — ou de imediato com join fetch em uma consulta

2. Repositórios

O Spring Data gera a implementação destas interfaces. As consultas próprias são escritas em JPQL (consultas sobre entidades, não tabelas):

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

import java.time.OffsetDateTime;
import java.util.Optional;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;

public interface EventRepository extends JpaRepository<Event, Long> {

    // one query: the event and its venue (no lazy-loading surprise later)
    @Query("select e from Event e join fetch e.venue where e.id = :id")
    Optional<Event> findWithVenue(Long id);

    // upcoming events, soonest first; Page also runs the count query
    @Query(value = """
            select e from Event e join fetch e.venue
            where e.startsAt >= :from
            order by e.startsAt, e.id""",
           countQuery = "select count(e) from Event e where e.startsAt >= :from")
    Page<Event> findUpcoming(OffsetDateTime from, Pageable pageable);

    // the same, filtered by the venue's city
    @Query(value = """
            select e from Event e join fetch e.venue v
            where v.city = :city and e.startsAt >= :from
            order by e.startsAt, e.id""",
           countQuery = """
            select count(e) from Event e join e.venue v
            where v.city = :city and e.startsAt >= :from""")
    Page<Event> findUpcomingInCity(String city, OffsetDateTime from, Pageable pageable);
}
services/catalog/src/main/java/boxoffice/catalog/VenueRepository.java
package boxoffice.catalog;

import org.springframework.data.jpa.repository.JpaRepository;

public interface VenueRepository extends JpaRepository<Venue, Long> {
}

O problema das N+1 consultas

Sem join fetch, listar 20 eventos executa 1 consulta para os eventos e mais 20, uma por local, quando EventOut.of toca event.getVenue(). Com join fetch e.venue, é um único JOIN em SQL. O N+1 é invisível com um usuário e devastador sob carga: 21 idas e voltas em vez de 1, cada uma segurando uma conexão do pool.

Um resultado Page<Event> custa duas consultas: a página em si (... ORDER BY starts_at LIMIT 20 OFFSET ?) e um count(*) sobre todas as linhas que casam, para o cliente mostrar "página 1 de 1.001". Em 200.000 eventos sem índice, as duas são varreduras completas — a listagem que você vai medir no lab 2, colocar em cache no lab 5 e indexar no lab 6.

3. A camada de serviço

O CatalogService é onde as transações começam e terminam. Métodos de leitura usam @Transactional(readOnly = true): o Hibernate pula o dirty checking e, no lab 6, a mesma marcação encaminha as leituras para uma réplica.

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

import java.time.OffsetDateTime;
import java.util.List;
import org.springframework.data.domain.PageRequest;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;

@Service
public class CatalogService {

    private final EventRepository events;
    private final VenueRepository venues;

    public CatalogService(EventRepository events, VenueRepository venues) {
        this.events = events;
        this.venues = venues;
    }

    // ---- events -------------------------------------------------------------

    @Transactional(readOnly = true)
    public EventOut findEvent(Long id) {
        return events.findWithVenue(id)
                .map(EventOut::of)
                .orElseThrow(() -> notFound("event", id));
    }

    @Transactional(readOnly = true)
    public PageOut<EventOut> upcomingEvents(String city, int page, int size) {
        var pageable = PageRequest.of(page, size);
        var now = OffsetDateTime.now();
        var result = (city == null || city.isBlank())
                ? events.findUpcoming(now, pageable)
                : events.findUpcomingInCity(city, now, pageable);
        return PageOut.of(result.map(EventOut::of));
    }

    @Transactional
    public EventOut createEvent(EventIn in) {
        var event = new Event(in.title(), in.category(), venue(in.venueId()), in.startsAt(), in.priceFrom());
        return EventOut.of(events.save(event));
    }

    @Transactional
    public EventOut updateEvent(Long id, EventIn in) {
        var event = events.findById(id).orElseThrow(() -> notFound("event", id));
        event.update(in.title(), in.category(), venue(in.venueId()), in.startsAt(), in.priceFrom());
        return EventOut.of(event);   // dirty checking: JPA writes the UPDATE at commit
    }

    @Transactional
    public void deleteEvent(Long id) {
        if (!events.existsById(id)) {
            throw notFound("event", id);
        }
        events.deleteById(id);
    }

    // ---- venues -------------------------------------------------------------

    @Transactional(readOnly = true)
    public List<VenueOut> allVenues() {
        return venues.findAll().stream().map(VenueOut::of).toList();
    }

    @Transactional(readOnly = true)
    public VenueOut findVenue(Long id) {
        return venues.findById(id).map(VenueOut::of).orElseThrow(() -> notFound("venue", id));
    }

    @Transactional
    public VenueOut createVenue(VenueIn in) {
        return VenueOut.of(venues.save(new Venue(in.name(), in.city(), in.capacity())));
    }

    // ---- helpers ------------------------------------------------------------

    private Venue venue(Long id) {
        return venues.findById(id).orElseThrow(() ->
                new ResponseStatusException(HttpStatus.BAD_REQUEST, "venue " + id + " does not exist"));
    }

    private static ResponseStatusException notFound(String what, Long id) {
        return new ResponseStatusException(HttpStatus.NOT_FOUND, what + " " + id + " not found");
    }
}

Regras de negócio que vivem aqui:

  • um id de evento ou local desconhecido → 404 Not Found;
  • criar ou atualizar um evento com um local que não existe → 400 Bad Request;
  • updateEvent não chama save: a entidade está gerenciada dentro da transação e o Hibernate escreve o UPDATE no commit (dirty checking).

4. Migrations com Flyway

O schema é código: arquivos SQL versionados em src/main/resources/db/migration, aplicados em ordem, uma vez, na inicialização. O Flyway registra o que executou em catalog.flyway_schema_history, então todo ambiente — o seu notebook, a CI, o Kubernetes — converge para o mesmo schema.

services/catalog/src/main/resources/db/migration/V1__create_catalog.sql
-- Flyway runs this once, inside the schema "catalog" (spring.flyway.schemas).
CREATE TABLE catalog.venues (
    id        BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    name      VARCHAR(200) NOT NULL,
    city      VARCHAR(100) NOT NULL,
    capacity  INTEGER      NOT NULL CHECK (capacity > 0)
);

CREATE TABLE catalog.events (
    id          BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    title       VARCHAR(200)  NOT NULL,
    category    VARCHAR(20)   NOT NULL CHECK (category IN ('CONCERT', 'SPORTS', 'FESTIVAL', 'THEATER')),
    venue_id    BIGINT        NOT NULL REFERENCES catalog.venues (id),
    starts_at   TIMESTAMPTZ   NOT NULL,
    price_from  NUMERIC(10,2) NOT NULL CHECK (price_from >= 0)
);
-- No secondary indexes on purpose: lab 6 measures the listing query before and after adding them.
services/catalog/src/main/resources/db/migration/V2__seed_catalog.sql
-- Demo data: 10 cities x 5 venues, and 200,000 events (one per hour from March 2027).
-- generate_series makes the seed deterministic and fast (a few seconds).
INSERT INTO catalog.venues (name, city, capacity)
SELECT v.kind || ' ' || c.city,
       c.city,
       CASE v.kind WHEN 'Stadium' THEN 60000 WHEN 'Arena' THEN 15000 WHEN 'Hall' THEN 4000
                   WHEN 'Theater' THEN 1200 ELSE 300 END
FROM (VALUES (1, 'São Paulo'), (2, 'Rio de Janeiro'), (3, 'Belo Horizonte'), (4, 'Porto Alegre'),
             (5, 'Curitiba'), (6, 'Salvador'), (7, 'Recife'), (8, 'Fortaleza'), (9, 'Brasília'),
             (10, 'Manaus')) AS c(n, city)
CROSS JOIN (VALUES (1, 'Stadium'), (2, 'Arena'), (3, 'Hall'), (4, 'Theater'), (5, 'Club')) AS v(n, kind)
ORDER BY c.n, v.n;

-- event 1 is the flash-sale show of the course: 60,000 seats at Stadium São Paulo (venue 1)
INSERT INTO catalog.events (title, category, venue_id, starts_at, price_from)
VALUES ('BoxOffice Fest — Stadium Night', 'FESTIVAL', 1, TIMESTAMPTZ '2027-06-12 20:00:00-03', 250.00);

INSERT INTO catalog.events (title, category, venue_id, starts_at, price_from)
SELECT (ARRAY['Concert', 'Match', 'Festival', 'Play'])[1 + g % 4] || ' #' || g,
       (ARRAY['CONCERT', 'SPORTS', 'FESTIVAL', 'THEATER'])[1 + g % 4],
       1 + (g * 7) % 50,
       TIMESTAMPTZ '2027-03-01 20:00:00-03' + g * INTERVAL '1 hour',
       50 + (g % 20) * 25
FROM generate_series(2, 200000) AS g;

ANALYZE catalog.venues;
ANALYZE catalog.events;

Regras do jogo:

  • Nome: V<versão>__<descrição>.sql (dois sublinhados). As versões só crescem.
  • Nunca edite uma migration que já rodou em algum lugar: o Flyway confere um checksum e se recusa a iniciar. Para mudar o schema, acrescente V3__....
  • ddl-auto: validate faz o Hibernate verificar que as entidades batem com as tabelas, mas nunca alterá-las: um único dono para o schema.
  • A carga inicial usa generate_series: 200.000 linhas em cerca de um segundo, idênticas em toda máquina — então as medições de todos são comparáveis.

5. Configuração

services/catalog/src/main/resources/application.yaml
server:
  port: 8082
  tomcat:
    mbeanregistry:
      enabled: true          # exposes tomcat.threads.* metrics (Little's Law in lab 2)

spring:
  application:
    name: catalog
  datasource:
    url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:boxoffice}
    username: ${DB_USER:boxoffice}
    password: ${DB_PASSWORD:boxoffice}
    hikari:
      maximum-pool-size: ${DB_POOL_SIZE:10}
  flyway:
    schemas: catalog         # Flyway creates the schema and owns it
  jpa:
    open-in-view: false
    hibernate:
      ddl-auto: validate     # Flyway changes the schema; Hibernate only checks it
  mvc:
    problemdetails:
      enabled: true          # errors as RFC 9457 application/problem+json

management:
  endpoints:
    web:
      exposure:
        include: health, info, metrics
  endpoint:
    health:
      probes:
        enabled: true        # /actuator/health/liveness and /readiness

springdoc:
  api-docs:
    path: /catalog/openapi   # OpenAPI document under the service prefix (Swagger UI: /swagger-ui.html)
Propriedade Por quê
${DB_HOST:localhost} … a conexão vem das variáveis de ambiente do lab 0; os padrões funcionam a partir da sua IDE
hikari.maximum-pool-size: ${DB_POOL_SIZE:10} o tamanho do pool, configurável sem recompilar — você vai mudá-lo no lab 2
flyway.schemas: catalog o Flyway cria o schema e mantém nele a sua tabela de histórico
open-in-view: false nada de lazy loading durante a serialização do JSON: a transação termina no serviço
tomcat.mbeanregistry.enabled publica tomcat.threads.busy, usado no lab 2
health.probes.enabled /actuator/health/liveness e /readiness, usados pelo Compose agora e pelo Kubernetes no lab 10
Pense antes: por que a consulta count(*) importa para o desempenho?

Ela lê todas as linhas que casam (20.000 por cidade) só para produzir um número, a cada requisição de página. Opções: colocá-la em cache (lab 5), estimá-la, descartá-la e devolver só "tem próxima página" (o Slice do Spring), ou torná-la barata com um índice (lab 6). Muitos sites grandes mostram "1.000+ resultados" exatamente por isso.

Na entrevista

Nomeie os padrões de acesso antes das tabelas: "a página inicial lista os próximos eventos por cidade, ordenados por data, 20 por vez; a página do evento lê um evento pelo id". O schema e, depois, os índices decorrem dessas consultas — não o contrário.

6. Ponto de controle: compila?

Se você tem o JDK 21 e o Maven instalados (lab 0), compile o serviço agora, antes de o Docker entrar em cena:

cd services/catalog
mvn -B -DskipTests package
cd ../..

Você deve ver, no fim:

[INFO] Building catalog 1.0.0
[INFO] BUILD SUCCESS
[INFO] Total time:  4.142 s

e um services/catalog/target/app.jar de cerca de 60 MB (a aplicação mais todas as suas bibliotecas). Um COMPILATION ERROR aponta o arquivo e a linha: compare esse arquivo com a listagem completa acima. O serviço ainda não roda — precisa do PostgreSQL, que o próximo passo fornece.

Próximo: 3. Container.