Skip to content

c. Persistence

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.

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

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

The N+1 query problem

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.

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

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.

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;

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.

5. Configuration

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)
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:

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

You should see, at the end:

[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.

Next: 3. Container.