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.
packageboxoffice.catalog;importjakarta.persistence.Column;importjakarta.persistence.Entity;importjakarta.persistence.GeneratedValue;importjakarta.persistence.GenerationType;importjakarta.persistence.Id;importjakarta.persistence.Table;@Entity@Table(name="venues",schema="catalog")publicclassVenue{@Id@GeneratedValue(strategy=GenerationType.IDENTITY)privateLongid;@Column(nullable=false)privateStringname;@Column(nullable=false)privateStringcity;@Column(nullable=false)privateintcapacity;protectedVenue(){// required by JPA}publicVenue(Stringname,Stringcity,intcapacity){this.name=name;this.city=city;this.capacity=capacity;}publicLonggetId(){returnid;}publicStringgetName(){returnname;}publicStringgetCity(){returncity;}publicintgetCapacity(){returncapacity;}}
packageboxoffice.catalog;importjakarta.persistence.Column;importjakarta.persistence.Entity;importjakarta.persistence.EnumType;importjakarta.persistence.Enumerated;importjakarta.persistence.FetchType;importjakarta.persistence.GeneratedValue;importjakarta.persistence.GenerationType;importjakarta.persistence.Id;importjakarta.persistence.JoinColumn;importjakarta.persistence.ManyToOne;importjakarta.persistence.Table;importjava.math.BigDecimal;importjava.time.OffsetDateTime;@Entity@Table(name="events",schema="catalog")publicclassEvent{@Id@GeneratedValue(strategy=GenerationType.IDENTITY)privateLongid;@Column(nullable=false)privateStringtitle;@Enumerated(EnumType.STRING)@Column(nullable=false)privateCategorycategory;@ManyToOne(fetch=FetchType.LAZY,optional=false)@JoinColumn(name="venue_id")privateVenuevenue;@Column(name="starts_at",nullable=false)privateOffsetDateTimestartsAt;@Column(name="price_from",nullable=false)privateBigDecimalpriceFrom;protectedEvent(){// required by JPA}publicEvent(Stringtitle,Categorycategory,Venuevenue,OffsetDateTimestartsAt,BigDecimalpriceFrom){update(title,category,venue,startsAt,priceFrom);}publicvoidupdate(Stringtitle,Categorycategory,Venuevenue,OffsetDateTimestartsAt,BigDecimalpriceFrom){this.title=title;this.category=category;this.venue=venue;this.startsAt=startsAt;this.priceFrom=priceFrom;}publicLonggetId(){returnid;}publicStringgetTitle(){returntitle;}publicCategorygetCategory(){returncategory;}publicVenuegetVenue(){returnvenue;}publicOffsetDateTimegetStartsAt(){returnstartsAt;}publicBigDecimalgetPriceFrom(){returnpriceFrom;}}
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):
packageboxoffice.catalog;importjava.time.OffsetDateTime;importjava.util.Optional;importorg.springframework.data.domain.Page;importorg.springframework.data.domain.Pageable;importorg.springframework.data.jpa.repository.JpaRepository;importorg.springframework.data.jpa.repository.Query;publicinterfaceEventRepositoryextendsJpaRepository<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(Longid);// 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(OffsetDateTimefrom,Pageablepageable);// 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(Stringcity,OffsetDateTimefrom,Pageablepageable);}
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.
packageboxoffice.catalog;importjava.time.OffsetDateTime;importjava.util.List;importorg.springframework.data.domain.PageRequest;importorg.springframework.http.HttpStatus;importorg.springframework.stereotype.Service;importorg.springframework.transaction.annotation.Transactional;importorg.springframework.web.server.ResponseStatusException;@ServicepublicclassCatalogService{privatefinalEventRepositoryevents;privatefinalVenueRepositoryvenues;publicCatalogService(EventRepositoryevents,VenueRepositoryvenues){this.events=events;this.venues=venues;}// ---- events -------------------------------------------------------------@Transactional(readOnly=true)publicEventOutfindEvent(Longid){returnevents.findWithVenue(id).map(EventOut::of).orElseThrow(()->notFound("event",id));}@Transactional(readOnly=true)publicPageOut<EventOut>upcomingEvents(Stringcity,intpage,intsize){varpageable=PageRequest.of(page,size);varnow=OffsetDateTime.now();varresult=(city==null||city.isBlank())?events.findUpcoming(now,pageable):events.findUpcomingInCity(city,now,pageable);returnPageOut.of(result.map(EventOut::of));}@TransactionalpublicEventOutcreateEvent(EventInin){varevent=newEvent(in.title(),in.category(),venue(in.venueId()),in.startsAt(),in.priceFrom());returnEventOut.of(events.save(event));}@TransactionalpublicEventOutupdateEvent(Longid,EventInin){varevent=events.findById(id).orElseThrow(()->notFound("event",id));event.update(in.title(),in.category(),venue(in.venueId()),in.startsAt(),in.priceFrom());returnEventOut.of(event);// dirty checking: JPA writes the UPDATE at commit}@TransactionalpublicvoiddeleteEvent(Longid){if(!events.existsById(id)){thrownotFound("event",id);}events.deleteById(id);}// ---- venues -------------------------------------------------------------@Transactional(readOnly=true)publicList<VenueOut>allVenues(){returnvenues.findAll().stream().map(VenueOut::of).toList();}@Transactional(readOnly=true)publicVenueOutfindVenue(Longid){returnvenues.findById(id).map(VenueOut::of).orElseThrow(()->notFound("venue",id));}@TransactionalpublicVenueOutcreateVenue(VenueInin){returnVenueOut.of(venues.save(newVenue(in.name(),in.city(),in.capacity())));}// ---- helpers ------------------------------------------------------------privateVenuevenue(Longid){returnvenues.findById(id).orElseThrow(()->newResponseStatusException(HttpStatus.BAD_REQUEST,"venue "+id+" does not exist"));}privatestaticResponseStatusExceptionnotFound(Stringwhat,Longid){returnnewResponseStatusException(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.
-- Flyway runs this once, inside the schema "catalog" (spring.flyway.schemas).CREATETABLEcatalog.venues(idBIGINTGENERATEDBYDEFAULTASIDENTITYPRIMARYKEY,nameVARCHAR(200)NOTNULL,cityVARCHAR(100)NOTNULL,capacityINTEGERNOTNULLCHECK(capacity>0));CREATETABLEcatalog.events(idBIGINTGENERATEDBYDEFAULTASIDENTITYPRIMARYKEY,titleVARCHAR(200)NOTNULL,categoryVARCHAR(20)NOTNULLCHECK(categoryIN('CONCERT','SPORTS','FESTIVAL','THEATER')),venue_idBIGINTNOTNULLREFERENCEScatalog.venues(id),starts_atTIMESTAMPTZNOTNULL,price_fromNUMERIC(10,2)NOTNULLCHECK(price_from>=0));-- No secondary indexes on purpose: lab 6 measures the listing query before and after adding them.
-- 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).INSERTINTOcatalog.venues(name,city,capacity)SELECTv.kind||' '||c.city,c.city,CASEv.kindWHEN'Stadium'THEN60000WHEN'Arena'THEN15000WHEN'Hall'THEN4000WHEN'Theater'THEN1200ELSE300ENDFROM(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'))ASc(n,city)CROSSJOIN(VALUES(1,'Stadium'),(2,'Arena'),(3,'Hall'),(4,'Theater'),(5,'Club'))ASv(n,kind)ORDERBYc.n,v.n;-- event 1 is the flash-sale show of the course: 60,000 seats at Stadium São Paulo (venue 1)INSERTINTOcatalog.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);INSERTINTOcatalog.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)*25FROMgenerate_series(2,200000)ASg;ANALYZEcatalog.venues;ANALYZEcatalog.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.
server:port:8082tomcat:mbeanregistry:enabled:true# exposes tomcat.threads.* metrics (Little's Law in lab 2)spring:application:name:catalogdatasource: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 itjpa:open-in-view:falsehibernate:ddl-auto:validate# Flyway changes the schema; Hibernate only checks itmvc:problemdetails:enabled:true# errors as RFC 9457 application/problem+jsonmanagement:endpoints:web:exposure:include:health, info, metricsendpoint:health:probes:enabled:true# /actuator/health/liveness and /readinessspringdoc: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:
[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.