This step connects the API to PostgreSQL: entities map the tables, repositories hold the queries, the service defines the transactions and Flyway creates and seeds the schema. Each service owns one schema — catalog here — and nothing else touches it.
flowchart LR
ctl["EventController<br/>VenueController"] --> svc["CatalogService<br/>@Transactional"]
svc --> repo["EventRepository<br/>VenueRepository"]
repo --> jpa["Hibernate (JPA)"]
jpa --> pool["HikariCP<br/>10 connections"]
pool --> db[("PostgreSQL<br/>schema catalog")]
fly["Flyway<br/>V1, V2"] -->|at startup| db
Keep the connection pool in mind: every request that touches the database borrows one of its 10 connections for the duration of its transaction. In lab 2 that number turns out to be the ceiling of the whole service.
1. Entities
An entity is a Java class mapped to a table. These two have no public setters: Event changes only through update(...), so every change goes through one method.
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;}}
Annotation
Meaning
@Table(name = "events", schema = "catalog")
explicit schema: the table belongs to this service
@GeneratedValue(strategy = IDENTITY)
the database generates the id (GENERATED BY DEFAULT AS IDENTITY)
@Enumerated(EnumType.STRING)
stores CONCERT, not 0: reordering the enum cannot corrupt data
@ManyToOne(fetch = LAZY)
the venue is loaded only when needed — or eagerly with join fetch in a query
2. Repositories
Spring Data generates the implementation of these interfaces. The custom queries are written in JPQL (queries over entities, not tables):
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);}
Without join fetch, listing 20 events runs 1 query for the events and then 20 more, one per venue, when EventOut.of touches event.getVenue(). With join fetch e.venue, it is a single SQL JOIN. N+1 is invisible with one user and devastating under load: 21 round trips instead of 1, each one holding a pooled connection.
A Page<Event> result costs two queries: the page itself (... ORDER BY starts_at LIMIT 20 OFFSET ?) and a count(*) over every matching row, so the client can show "page 1 of 1,001". On 200,000 events without an index, both are full scans — the listing you will measure in lab 2, cache in lab 5 and index in lab 6.
3. The service layer
CatalogService is where transactions begin and end. Read methods use @Transactional(readOnly = true): Hibernate skips dirty checking, and in lab 6 the same flag routes reads to a replica.
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");}}
Business rules that live here:
an unknown event or venue id → 404 Not Found;
creating or updating an event with a venue that does not exist → 400 Bad Request;
updateEvent does not call save: the entity is managed inside the transaction and Hibernate writes the UPDATE at commit (dirty checking).
4. Migrations with Flyway
The schema is code: versioned SQL files in src/main/resources/db/migration, applied in order, once, at startup. Flyway records what it ran in catalog.flyway_schema_history, so every environment — your laptop, CI, Kubernetes — converges to the same 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;
Rules of the game:
Name: V<version>__<description>.sql (two underscores). Versions only grow.
Never edit a migration that already ran somewhere: Flyway checks a checksum and refuses to start. To change the schema, add V3__....
ddl-auto: validate makes Hibernate check that the entities match the tables, but never change them: one owner for the schema.
The seed uses generate_series: 200,000 rows in about a second, identical on every machine — so everybody's measurements are comparable.
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)
Property
Why
${DB_HOST:localhost} …
the connection comes from the environment variables of lab 0; the defaults work from your IDE
hikari.maximum-pool-size: ${DB_POOL_SIZE:10}
the pool size, configurable without rebuilding — you will change it in lab 2
flyway.schemas: catalog
Flyway creates the schema and keeps its history table there
open-in-view: false
no lazy loading during JSON serialization: the transaction ends in the service
tomcat.mbeanregistry.enabled
publishes tomcat.threads.busy, used in lab 2
health.probes.enabled
/actuator/health/liveness and /readiness, used by Compose now and Kubernetes in lab 10
Think first: why does the count(*) query matter for performance?
It reads every matching row (20,000 per city) just to produce one number, on every page request. Options: cache it (lab 5), estimate it, drop it and return only "has next page" (Spring's Slice), or make it cheap with an index (lab 6). Many large sites show "1,000+ results" for exactly this reason.
In the interview
Name the access patterns before the tables: "the home page lists upcoming events by city, sorted by date, 20 at a time; the event page reads one event by id". The schema and, later, the indexes follow from those queries — not the other way around.
6. Checkpoint: does it compile?
If you have JDK 21 and Maven installed (lab 0), compile the service now, before Docker enters the picture:
[INFO] Building catalog 1.0.0
[INFO] BUILD SUCCESS
[INFO] Total time: 4.142 s
and a services/catalog/target/app.jar of about 60 MB (the application plus all its libraries). A COMPILATION ERROR names the file and line: compare that file with the full listing above. The service cannot run yet — it needs PostgreSQL, which the next step provides.