Skip to content
SparkTime, home
Java From Zero to Job-Ready
Lessons
1 · Java Foundations
2 · Object-Oriented Java
3 · Core Java
4 · Collections and Generics
5 · Functional Java
6 · The JVM and Concurrency
7 · Tools and Craft
8 · Databases and JDBC
9 · Web and Spring
10 · Persistence with JPA
11 · Secure, Tested, Production-Ready
12 · Interviews and Readiness
Lesson 34 of 41

Lesson 34 · Persistence with JPA

JPA and Hibernate: Entities, the Persistence Context, Spring Data and Flyway Migrations

Replace the in-memory repository with a real PostgreSQL database using JPA, Hibernate and Spring Data: map an entity, manage the schema with Flyway migrations, understand the persistence context and dirty checking, write queries, and read the SQL Hibernate actually generates, including a real failure only PostgreSQL revealed.

On this page

Key takeaways

  • JPA is a specification, Hibernate is the implementation, and Spring Data JPA generates repository classes from interfaces on top of them. An ORM maps entities to tables so that you write less JDBC, and it does not remove the need to understand the SQL.
  • A Flyway migration is a versioned SQL file that creates or changes the schema in a repeatable, reviewable way. Hibernate only validates that the entities match the tables, and ddl-auto update or create must never manage a real schema.
  • Inside a transaction, Hibernate tracks the managed entities in the persistence context and writes changes back at commit (dirty checking), issuing an UPDATE for only the columns that changed.
  • Spring Data derives queries from method names or takes JPQL from @Query, and supports paging and sorting. A query with optional null parameters failed on PostgreSQL, but not on an in-memory database, until an explicit cast was added.
  • Test repositories against the real database engine, with the real migrations, and read the SQL log. Both findings in this article came from doing that.

In article 28 you wrote JDBC by hand: SQL strings, parameter binding, a loop over a ResultSet and manual mapping for every query. It is precise, and for a growing application it is also repetitive and error-prone. Object-relational mapping (ORM) removes much of that work by mapping Java objects to table rows. In this article the Job Application Tracker gets a real PostgreSQL database through the standard Java stack: JPA, Hibernate and Spring Data JPA. You will see that the controller and the rules in the service barely change, you will see exactly which SQL Hibernate generates, and you will meet a failure that only a real PostgreSQL produced.

What you will learn

#
  • What JPA, Hibernate and Spring Data JPA are and how they relate.
  • How to map an entity and manage the schema with Flyway migrations.
  • How the persistence context and dirty checking work.
  • How to write repository queries, and how to test them against a real database.
  • How to read the SQL that Hibernate generates.

The problem and the layers

#

Objects and tables model data differently. Objects have references, inheritance and collections. Tables have rows, foreign keys and joins. Mapping between them by hand is the object-relational impedance mismatch, and an ORM does the routine part for you. Three names are often confused, and they are three layers:

Name

What it is

JPA (Jakarta Persistence)

A specification: the annotations (@Entity, @Id, ...) and the API (EntityManager, JPQL). It is only interfaces and rules. This course's stack implements version 3.2

Hibernate ORM

The implementation of JPA that does the work: it turns entity changes into SQL, manages caching and lazy loading. Spring Boot 4.1.1 uses Hibernate ORM 7.4.5

Spring Data JPA

A layer on top that generates repository classes from interfaces, so you rarely touch the EntityManager. Here it is version 4.1.1

Older style. Before Spring Boot 3, JPA's annotations lived in the javax.persistence package. Since the move to Jakarta EE, they are in jakarta.persistence, so older tutorials need that change. In older code you will also see repositories written by hand with an injected EntityManager, and applications that let Hibernate create the tables with ddl-auto=update, which the schema section below explains is unsafe.

An ORM saves effort but does not remove the need to know SQL. Every call becomes SQL, and the performance and correctness of your application depend on it. This article keeps showing you the SQL on purpose.

Setting up

#

The project is version 33 with the following changes. The starters bring JPA with Hibernate and Spring Data (spring-boot-starter-data-jpa) and Flyway (spring-boot-starter-flyway, plus its PostgreSQL support). The PostgreSQL driver is runtime scope. For tests there is the data-JPA test starter and an embedded PostgreSQL server, whose binaries are pinned to version 18.6.0 with a BOM, as in Project 2 (article 29):

Diff
--- a/pom.xml+++ b/pom.xml@@ -20,5 +20,19 @@   <properties>     <java.version>21</java.version>+    <postgres.binaries.version>18.6.0</postgres.binaries.version>   </properties>++  <dependencyManagement>+    <dependencies>+      <!-- Makes the embedded test database a PostgreSQL 18 server -->+      <dependency>+        <groupId>io.zonky.test.postgres</groupId>+        <artifactId>embedded-postgres-binaries-bom</artifactId>+        <version>${postgres.binaries.version}</version>+        <type>pom</type>+        <scope>import</scope>+      </dependency>+    </dependencies>+  </dependencyManagement>    <dependencies>@@ -31,8 +45,36 @@       <artifactId>spring-boot-starter-validation</artifactId>     </dependency>+    <dependency>+      <groupId>org.springframework.boot</groupId>+      <artifactId>spring-boot-starter-data-jpa</artifactId>+    </dependency>+    <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>      <dependency>       <groupId>org.springframework.boot</groupId>       <artifactId>spring-boot-starter-webmvc-test</artifactId>+      <scope>test</scope>+    </dependency>+    <dependency>+      <groupId>org.springframework.boot</groupId>+      <artifactId>spring-boot-starter-data-jpa-test</artifactId>+      <scope>test</scope>+    </dependency>+    <dependency>+      <groupId>io.zonky.test</groupId>+      <artifactId>embedded-postgres</artifactId>+      <version>2.2.2</version>       <scope>test</scope>     </dependency>

(The junit and Mockito libraries still come from the Spring Boot test starters. Spring Boot manages the versions of the PostgreSQL driver, Hibernate, Flyway and the others, which is why most of these dependencies have no version.)

The application configuration adds the database settings. The password has no default, so the application refuses to start without one, which is the safe failure (article 32):

Code
tracker.default-page-size=20tracker.max-page-size=50# The database. The address and user can be overridden, and the password must come from the environment.spring.datasource.url=${DB_URL:jdbc:postgresql://localhost:5432/jobtracker}spring.datasource.username=${DB_USER:jobtracker}spring.datasource.password=${DB_PASSWORD}# Flyway creates and changes the tables. Hibernate only checks that the entities match them.spring.jpa.hibernate.ddl-auto=validatespring.jpa.open-in-view=false

Spring Boot creates a DataSource backed by HikariCP, the connection pool from article 28, because it finds the driver and the URL. You do not write a line of JDBC setup. Two settings deserve explanation:

  • spring.jpa.hibernate.ddl-auto=validate tells Hibernate to check at start-up that the entities match the existing tables and fail if they do not, and never to change the schema. The other values are none, update, create and create-drop. The last three change the database on their own, which is convenient in a demonstration and dangerous with real data: update cannot rename or drop columns safely, cannot be reviewed, and behaves differently in different environments. That is why a migration tool owns the schema.
  • spring.jpa.open-in-view=false switches off a Spring Boot default that keeps the persistence session open for the whole web request, including while the response is rendered. It hides lazy-loading problems during development and holds a database connection longer than needed. Turning it off forces data access to stay inside the service layer, where the transactions are.

The schema belongs to Flyway

#

Flyway applies versioned migrations: SQL files named V<version>__<description>.sql, run in order exactly once, recorded in a history table (flyway_schema_history). Every environment, from a developer's laptop to production, ends up with the same schema because it ran the same files. The first migration creates the table:

SQL
CREATE TABLE job_applications (    id         bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,    company    varchar(100)  NOT NULL,    position   varchar(100)  NOT NULL,    status     varchar(20)   NOT NULL               CHECK (status IN ('WISHLIST', 'APPLIED', 'INTERVIEWING', 'OFFER', 'REJECTED', 'WITHDRAWN')),    applied_on date,    job_url    varchar(500),    notes      varchar(2000),    created_at timestamptz   NOT NULL,    updated_at timestamptz   NOT NULL);CREATE INDEX idx_job_applications_status ON job_applications (status);

Notice how the constraints from article 27 appear: NOT NULL, a CHECK that lists the valid statuses, an identity column and an index on status, which the list endpoint will filter by. When the application starts, Flyway looks at the history table and applies any migrations that have not yet run. This is the real log from the first start-up against an empty database:

Code
org.flywaydb.core.FlywayExecutor         : Database: jdbc:postgresql://localhost:54329/jobtracker (PostgreSQL 18.6)o.f.core.internal.command.DbMigrate      : Current version of schema "public": << Empty Schema >>o.f.core.internal.command.DbMigrate      : Migrating schema "public" to version "1 - create job applications"o.f.core.internal.command.DbMigrate      : Successfully applied 1 migration to schema "public", now at version v1 (execution time 00:00.057s)

The rules of migrations: never edit a migration that has been applied (Flyway checks a checksum and refuses), and add a new file for every change (V2__add_location.sql). Treat them as part of the code, reviewed in pull requests. Flyway will also refuse to run against a non-empty schema that has no history table, which protects you from pointing it at the wrong database.

The entity

#

An entity is a class mapped to a table. This is the JobApplication class from article 33, with annotations added and its setters for the id and the public no-argument constructor removed:

Java
// File: src/main/java/com/example/jobtracker/JobApplication.javapackage com.example.jobtracker;import jakarta.persistence.Column;import jakarta.persistence.Entity;import jakarta.persistence.EnumType;import jakarta.persistence.Enumerated;import jakarta.persistence.GeneratedValue;import jakarta.persistence.GenerationType;import jakarta.persistence.Id;import jakarta.persistence.Table;import java.time.Instant;import java.time.LocalDate;/** The domain object, now also a JPA entity: one row of the job_applications table. */@Entity@Table(name = "job_applications")public class JobApplication {    @Id    @GeneratedValue(strategy = GenerationType.IDENTITY)    private Long id;    @Column(nullable = false, length = 100)    private String company;    @Column(nullable = false, length = 100)    private String position;    @Enumerated(EnumType.STRING)    @Column(nullable = false, length = 20)    private ApplicationStatus status;    private LocalDate appliedOn;    @Column(length = 500)    private String jobUrl;    @Column(length = 2000)    private String notes;    @Column(nullable = false, updatable = false)    private Instant createdAt;    @Column(nullable = false)    private Instant updatedAt;    protected JobApplication() {                 // required by JPA    }    public JobApplication(String company, String position) {        this.company = company;        this.position = position;    }    public Long getId() {        return id;    }    public String getCompany() {        return company;    }    public void setCompany(String company) {        this.company = company;    }    public String getPosition() {        return position;    }    public void setPosition(String position) {        this.position = position;    }    public ApplicationStatus getStatus() {        return status;    }    public void setStatus(ApplicationStatus status) {        this.status = status;    }    public LocalDate getAppliedOn() {        return appliedOn;    }    public void setAppliedOn(LocalDate appliedOn) {        this.appliedOn = appliedOn;    }    public String getJobUrl() {        return jobUrl;    }    public void setJobUrl(String jobUrl) {        this.jobUrl = jobUrl;    }    public String getNotes() {        return notes;    }    public void setNotes(String notes) {        this.notes = notes;    }    public Instant getCreatedAt() {        return createdAt;    }    public void setCreatedAt(Instant createdAt) {        this.createdAt = createdAt;    }    public Instant getUpdatedAt() {        return updatedAt;    }    public void setUpdatedAt(Instant updatedAt) {        this.updatedAt = updatedAt;    }}

What each part means:

Part

Meaning

@Entity, @Table(name = "job_applications")

This class is mapped to this table

@Id

The primary key

@GeneratedValue(strategy = GenerationType.IDENTITY)

The database generates the id (the identity column). Hibernate leaves the id out of the INSERT and reads it back. The alternative, SEQUENCE, asks a database sequence for ids first, and lets Hibernate batch inserts

@Column(nullable = false, length = 100)

Column details. They document intent, and with ddl-auto=validate Hibernate checks them against the table

@Enumerated(EnumType.STRING)

Store the enum's name ('APPLIED') in a text column. The default, ORDINAL, stores the position (0, 1, 2...), so inserting or reordering a constant silently changes the meaning of every stored row. Always use STRING

LocalDate, Instant

Mapped natively to date and timestamp with time zone. There is no need for the old java.sql.Date

The no-argument protected constructor

JPA needs one to create instances when reading rows. Keeping it protected stops application code from using it

Names: Spring Boot configures Hibernate with a naming strategy that converts Java camelCase to SQL snake_case, so appliedOn becomes applied_on and createdAt becomes created_at without any @Column(name = ...). The table name is set explicitly because the class name would give job_application.

Entities and equals/hashCode. Article 7's contract matters here. An entity gets its id only when it is saved, so an equals based on the id breaks while the id is null, and one based on all fields breaks when a field changes. This course's entity does not override them, so two different Java objects for the same row are not equal. That is a safe default for the code in this project. If you must override them, base them on a business key that never changes, or follow the pattern recommended in the Hibernate documentation.

An entity is also not an API type. As article 33 argued, the controller keeps returning the JobApplicationResponse record, never this class: it avoids mass assignment, it keeps database structure out of the API, and (next article) it avoids serialising lazy relationships.

The persistence context

#

This is the central idea of JPA, and the cause of most confusion. Inside a transaction, Hibernate keeps a persistence context (the "session"): the set of entities it currently manages, each tracked by its id. An entity is in one of four states:

State

Meaning

Transient

A new object, new JobApplication(...). Hibernate knows nothing about it

Managed

Attached to the persistence context, so Hibernate tracks it. It comes from save on a new object, from findById, or from a query

Detached

Was managed, but the transaction (and context) ended. Changes to it are no longer tracked

Removed

Marked for deletion at the next flush

Three consequences make JPA different from JDBC:

  1. Dirty checking. At the end of the transaction (the flush), Hibernate compares each managed entity with a snapshot taken when it was loaded and writes UPDATE statements for those that changed. You do not have to call save to persist a change to a managed entity. (The service still calls save for clarity, which does nothing extra here.)
  2. The first-level cache. Within one persistence context, loading the same id twice returns the same Java object and runs the query once.
  3. Writes are delayed. save on a new entity with an IDENTITY id inserts immediately, because the id is needed, but updates and deletes wait until the flush, so Hibernate can order and combine them.

The transaction is what defines the context's lifetime. In the service, @Transactional on the class opens a transaction when a public method is called and commits when it returns, and rolls back if it throws an unchecked exception (and readOnly = true on reads tells Hibernate it can skip dirty checking). This is the declarative form of the setAutoCommit(false) / commit / rollback pattern from article 28. The next article covers it in more depth.

The repository

#

With Spring Data JPA you write an interface, and Spring generates the implementation at start-up. Extending JpaRepository<JobApplication, Long> gives you save, findById, findAll, delete, count, paging and more, for free. This is what the Spring Data rename in article 33 was preparing for: the names save, findById and deleteById are the same, so the service needed only small changes.

Java
// File: src/main/java/com/example/jobtracker/JobApplicationRepository.javapackage com.example.jobtracker;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;import org.springframework.data.repository.query.Param;/** * There is no implementation class. Spring Data creates one at start-up from this interface. * save, findById, findAll, deleteById and many more come from JpaRepository. */public interface JobApplicationRepository extends JpaRepository<JobApplication, Long> {    /**     * Both filters are optional: a null status or a null company means "do not filter on it".     * The cast tells PostgreSQL that the company parameter is text even when it is null.     */    @Query("""            select a from JobApplication a            where (:status is null or a.status = :status)              and (:company is null or lower(a.company) like lower(concat('%', cast(:company as string), '%')))            """)    Page<JobApplication> search(@Param("status") ApplicationStatus status,                                @Param("company") String company,                                Pageable pageable);}

There are three ways to add queries beyond the built-in ones:

Technique

Example

Use when

Derived query from the method name

List<JobApplication> findByStatus(ApplicationStatus s), long countByStatus(...), findByCompanyContainingIgnoreCase(String c)

Simple conditions. Spring parses findBy, And, Or, Containing, IgnoreCase, OrderBy, and so on

JPQL with @Query

The search method above

Anything more complex or optional. JPQL queries the entities (JobApplication, a.company), not tables

Native SQL with @Query(nativeQuery = true)

Plain SQL

A database-specific feature, at the cost of portability

Notes on search:

  • Pageable carries the page number, size and sort, and returning Page<T> gives you the content plus the total number of items and pages. Spring Data runs a second count query when it needs the total.
  • Named parameters (:status) with @Param bind safely: it is a PreparedStatement underneath, so there is no SQL injection (article 28).
  • The two optional filters use the pattern :x is null or ..., so one method serves every combination.

The failure that only PostgreSQL showed

#

The first version of that query did not have the cast. It read lower(concat('%', :company, '%')). Against a real PostgreSQL 18.6 server, two of the four repository tests failed:

Code
ERROR:  function lower(bytea) does not existHINT:  No function matches the given name and argument types. You might need to add explicit type casts.The SQL Hibernate sent: select ja1_0.id,ja1_0.applied_on,ja1_0.company,ja1_0.created_at,ja1_0.job_url,ja1_0.notes,ja1_0.position,ja1_0.status,ja1_0.updated_at from job_applications ja1_0 where (? is null or ja1_0.status=?) and (? is null or lower(ja1_0.company) like lower(('%'||?||'%')) escape '') order by ja1_0.id fetch first ? rows only

Read the SQL that Hibernate sent. When company is null, the driver binds an untyped null to the ?, and PostgreSQL has to guess its type. Inside lower(('%'||?||'%')) it guessed bytea (binary data), and lower does not exist for bytea. The error is raised even though the or would have skipped that part at run time, because PostgreSQL has to resolve every function call in the statement before it runs anything. The fix is the explicit cast(:company as string), which Hibernate sends as cast(? as varchar), telling PostgreSQL the type up front. You can see it in the SQL log below.

The lesson matters more than the detail. An in-memory database such as H2 would very likely have accepted the original query, and the bug would have appeared in production on the first request without a company filter. Because the repository tests ran against the real database engine, the failure appeared on the developer's machine, with a clear message. Use the database you deploy to, and run the real migrations in the tests.

The service changes

#

The service keeps the same public methods and the same rules. These are the differences from the in-memory version, and they are all about storage:

Diff
--- a/src/main/java/com/example/jobtracker/JobApplicationService.java+++ b/src/main/java/com/example/jobtracker/JobApplicationService.java@@ -3,11 +3,13 @@ import java.time.Clock; import java.time.Instant;-import java.util.Comparator;-import java.util.List;-import java.util.Locale;+import org.springframework.data.domain.Page;+import org.springframework.data.domain.PageRequest;+import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service;+import org.springframework.transaction.annotation.Transactional; -/** The rules of the application. It knows nothing about HTTP and nothing about how data is stored. */+/** The rules of the application. It knows nothing about HTTP, and now storage is a database. */ @Service+@Transactional public class JobApplicationService {     private final JobApplicationRepository repository;@@ -23,5 +25,5 @@     /** A new application starts as APPLIED if the user says when they applied, otherwise as WISHLIST. */     public JobApplicationResponse create(JobApplicationRequest request) {-        JobApplication application = new JobApplication();+        JobApplication application = new JobApplication(request.company(), request.position());         copyFields(request, application);         application.setStatus(request.appliedOn() == null ? ApplicationStatus.WISHLIST : ApplicationStatus.APPLIED);@@ -32,27 +34,21 @@     } +    @Transactional(readOnly = true)     public JobApplicationResponse get(Long id) {         return JobApplicationResponse.from(find(id));     } -    /** Filters by status and by part of the company name, then returns one page, oldest first. */+    /** Filters by status and by part of the company name in the database, and returns one page, oldest first. */+    @Transactional(readOnly = true)     public PageResponse<JobApplicationResponse> list(ApplicationStatus status, String company, Integer page, Integer size) {         int pageSize = size == null ? settings.defaultPageSize() : Math.min(size, settings.maxPageSize());         int pageNumber = page == null ? 0 : page;-        String needle = company == null ? null : company.toLowerCase(Locale.ROOT);+        String companyFilter = company == null || company.isBlank() ? null : company.strip(); -        List<JobApplication> matching = repository.findAll().stream()-                .filter(a -> status == null || a.getStatus() == status)-                .filter(a -> needle == null || a.getCompany().toLowerCase(Locale.ROOT).contains(needle))-                .sorted(Comparator.comparing(JobApplication::getId))-                .toList();+        Page<JobApplication> result = repository.search(status, companyFilter,+                PageRequest.of(pageNumber, pageSize, Sort.by("id"))); -        List<JobApplicationResponse> items = matching.stream()-                .skip((long) pageNumber * pageSize)-                .limit(pageSize)-                .map(JobApplicationResponse::from)-                .toList();-        int totalPages = (int) Math.ceil(matching.size() / (double) pageSize);-        return new PageResponse<>(items, pageNumber, pageSize, matching.size(), totalPages);+        return new PageResponse<>(result.getContent().stream().map(JobApplicationResponse::from).toList(),+                pageNumber, pageSize, result.getTotalElements(), result.getTotalPages());     } @@ -76,6 +72,5 @@      public void delete(Long id) {-        find(id);                                   // throws if it does not exist-        repository.deleteById(id);+        repository.delete(find(id));                // throws if it does not exist     } 
  • @Transactional on the class (and readOnly = true on the reads) defines the transaction boundaries.
  • list no longer filters in Java: it calls repository.search with a PageRequest, so the database does the filtering, sorting and paging, exactly the improvement the review of article 33 asked for. The page size is still clamped to the maximum.
  • Creation uses the entity's constructor.
  • delete loads the entity first, so a missing id is still a 404.

The controller, DTOs, validation and exception handler are unchanged. That is the point of the layers.

Testing

#

The service tests change shape, because the service no longer holds the filtering logic. They use a Mockito mock of the repository and test only what the service owns: the default status, normalising input, the allowed status moves, passing the right filters and PageRequest to the repository, clamping the page size, and delete behaviour. They run in under a second with no Spring and no database:

Java
// File: src/test/java/com/example/jobtracker/JobApplicationServiceTest.javapackage com.example.jobtracker;import static org.junit.jupiter.api.Assertions.assertEquals;import static org.junit.jupiter.api.Assertions.assertThrows;import static org.mockito.ArgumentMatchers.any;import static org.mockito.ArgumentMatchers.eq;import static org.mockito.BDDMockito.given;import static org.mockito.Mockito.never;import static org.mockito.Mockito.verify;import java.time.Clock;import java.time.Instant;import java.time.LocalDate;import java.time.ZoneOffset;import java.util.List;import java.util.Optional;import org.junit.jupiter.api.BeforeEach;import org.junit.jupiter.api.Test;import org.junit.jupiter.api.extension.ExtendWith;import org.mockito.Mock;import org.mockito.junit.jupiter.MockitoExtension;import org.springframework.data.domain.Page;import org.springframework.data.domain.PageImpl;import org.springframework.data.domain.PageRequest;import org.springframework.data.domain.Sort;/** The rules are tested alone: the repository is a mock, so no database is involved. */@ExtendWith(MockitoExtension.class)class JobApplicationServiceTest {    private static final Clock CLOCK = Clock.fixed(Instant.parse("2026-03-15T10:00:00Z"), ZoneOffset.UTC);    @Mock    JobApplicationRepository repository;    private JobApplicationService service;    @BeforeEach    void setUp() {        service = new JobApplicationService(repository, CLOCK, new TrackerSettings(2, 3));    }    private static JobApplicationRequest request(String company, LocalDate appliedOn) {        return new JobApplicationRequest(company, "Java Developer", appliedOn, null, null);    }    private static JobApplication stored(ApplicationStatus status) {        JobApplication application = new JobApplication("Acme", "Java Developer");        application.setStatus(status);        application.setCreatedAt(Instant.parse("2026-03-01T00:00:00Z"));        application.setUpdatedAt(Instant.parse("2026-03-01T00:00:00Z"));        return application;    }    @Test    void anApplicationWithoutAnAppliedDateStartsAsWishlist() {        given(repository.save(any(JobApplication.class))).willAnswer(call -> call.getArgument(0));        JobApplicationResponse created = service.create(request("  Acme ", null));        assertEquals(ApplicationStatus.WISHLIST, created.status());        assertEquals("Acme", created.company());        assertEquals(Instant.parse("2026-03-15T10:00:00Z"), created.createdAt());    }    @Test    void anApplicationWithAnAppliedDateStartsAsApplied() {        given(repository.save(any(JobApplication.class))).willAnswer(call -> call.getArgument(0));        JobApplicationResponse created = service.create(request("Acme", LocalDate.of(2026, 3, 1)));        assertEquals(ApplicationStatus.APPLIED, created.status());    }    @Test    void gettingAnUnknownIdThrows() {        given(repository.findById(99L)).willReturn(Optional.empty());        assertThrows(ApplicationNotFoundException.class, () -> service.get(99L));    }    @Test    void listPassesTheFiltersAndAPageRequestToTheRepository() {        Page<JobApplication> page = new PageImpl<>(List.of(stored(ApplicationStatus.APPLIED)), PageRequest.of(1, 2), 5);        given(repository.search(ApplicationStatus.APPLIED, "acme", PageRequest.of(1, 2, Sort.by("id")))).willReturn(page);        PageResponse<JobApplicationResponse> result = service.list(ApplicationStatus.APPLIED, " acme ", 1, 2);        assertEquals(1, result.items().size());        assertEquals(5, result.totalItems());        assertEquals(3, result.totalPages());    }    @Test    void listUsesTheDefaultSizeAndLimitsTheMaximum() {        given(repository.search(eq(null), eq(null), any())).willReturn(Page.empty());        service.list(null, "  ", null, null);        service.list(null, null, 0, 100);        verify(repository).search(null, null, PageRequest.of(0, 2, Sort.by("id")));    // the default size        verify(repository).search(null, null, PageRequest.of(0, 3, Sort.by("id")));    // 100 reduced to 3    }    @Test    void replaceChangesTheFieldsButNotTheStatus() {        JobApplication existing = stored(ApplicationStatus.APPLIED);        given(repository.findById(1L)).willReturn(Optional.of(existing));        given(repository.save(existing)).willReturn(existing);        JobApplicationResponse replaced = service.replace(1L, request("  Acme Ltd ", null));        assertEquals("Acme Ltd", replaced.company());        assertEquals(ApplicationStatus.APPLIED, replaced.status());        assertEquals(Instant.parse("2026-03-15T10:00:00Z"), replaced.updatedAt());    }    @Test    void statusMovesOnlyAlongAllowedPaths() {        JobApplication existing = stored(ApplicationStatus.WISHLIST);        given(repository.findById(1L)).willReturn(Optional.of(existing));        given(repository.save(existing)).willReturn(existing);        service.changeStatus(1L, ApplicationStatus.APPLIED);        service.changeStatus(1L, ApplicationStatus.INTERVIEWING);        JobApplicationResponse offer = service.changeStatus(1L, ApplicationStatus.OFFER);        assertEquals(ApplicationStatus.OFFER, offer.status());    }    @Test    void aForbiddenMoveIsRejectedAndNothingIsSaved() {        given(repository.findById(1L)).willReturn(Optional.of(stored(ApplicationStatus.REJECTED)));        InvalidStatusChangeException e = assertThrows(InvalidStatusChangeException.class,                () -> service.changeStatus(1L, ApplicationStatus.APPLIED));        assertEquals("An application cannot move from REJECTED to APPLIED because REJECTED is final", e.getMessage());        verify(repository, never()).save(any());    }    @Test    void deleteRemovesTheApplicationAndAMissingOneThrows() {        JobApplication existing = stored(ApplicationStatus.WISHLIST);        given(repository.findById(1L)).willReturn(Optional.of(existing));        given(repository.findById(2L)).willReturn(Optional.empty());        service.delete(1L);        verify(repository).delete(existing);        assertThrows(ApplicationNotFoundException.class, () -> service.delete(2L));    }}

What used to be tested in the service, filtering and paging, is now the repository's responsibility, so it is tested where it happens, against PostgreSQL. @DataJpaTest is a slice test: it starts only the JPA part of the application (entities, repositories, a DataSource, Flyway) and runs each test in a transaction that is rolled back afterwards. By default it would replace your database with an embedded in-memory one, so @AutoConfigureTestDatabase(replace = NONE) switches that off, and a @DynamicPropertySource points the DataSource at a real PostgreSQL server started for the test class:

Java
// File: src/test/java/com/example/jobtracker/JobApplicationRepositoryTest.javapackage com.example.jobtracker;import static org.junit.jupiter.api.Assertions.assertEquals;import static org.junit.jupiter.api.Assertions.assertNotNull;import io.zonky.test.db.postgres.embedded.EmbeddedPostgres;import java.io.IOException;import java.time.Instant;import java.time.LocalDate;import java.util.List;import org.junit.jupiter.api.AfterAll;import org.junit.jupiter.api.BeforeAll;import org.junit.jupiter.api.Test;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;import org.springframework.data.domain.Page;import org.springframework.data.domain.PageRequest;import org.springframework.data.domain.Sort;import org.springframework.jdbc.core.JdbcTemplate;import org.springframework.test.context.DynamicPropertyRegistry;import org.springframework.test.context.DynamicPropertySource;/** * Tests the repository against a real PostgreSQL server, with the real Flyway migration applied. * Each test runs in a transaction that is rolled back afterwards. */@DataJpaTest@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)    // do not swap in another databaseclass JobApplicationRepositoryTest {    private static EmbeddedPostgres postgres;    @BeforeAll    static void startDatabase() throws IOException {        postgres = EmbeddedPostgres.start();    }    @AfterAll    static void stopDatabase() throws IOException {        postgres.close();    }    @DynamicPropertySource    static void databaseProperties(DynamicPropertyRegistry registry) {        registry.add("spring.datasource.url", () -> postgres.getJdbcUrl("postgres", "postgres"));        registry.add("spring.datasource.username", () -> "postgres");        registry.add("spring.datasource.password", () -> "postgres");    }    @Autowired    JobApplicationRepository repository;    @Autowired    JdbcTemplate jdbc;    private JobApplication save(String company, ApplicationStatus status) {        JobApplication application = new JobApplication(company, "Java Developer");        application.setStatus(status);        application.setCreatedAt(Instant.parse("2026-03-15T10:00:00Z"));        application.setUpdatedAt(Instant.parse("2026-03-15T10:00:00Z"));        return repository.saveAndFlush(application);    }    @Test    void savingAssignsAnIdAndTheRowCanBeReadBack() {        JobApplication saved = save("Acme", ApplicationStatus.APPLIED);        saved.setAppliedOn(LocalDate.of(2026, 3, 1));        repository.flush();        JobApplication found = repository.findById(saved.getId()).orElseThrow();        assertNotNull(found.getId());        assertEquals("Acme", found.getCompany());        assertEquals(LocalDate.of(2026, 3, 1), found.getAppliedOn());        assertEquals(Instant.parse("2026-03-15T10:00:00Z"), found.getCreatedAt());    }    @Test    void theStatusIsStoredAsItsName() {        JobApplication saved = save("Acme", ApplicationStatus.INTERVIEWING);        String stored = jdbc.queryForObject("select status from job_applications where id = ?", String.class, saved.getId());        assertEquals("INTERVIEWING", stored);    }    @Test    void searchWithNoFiltersReturnsEverythingInPages() {        for (int i = 1; i <= 5; i++) {            save("Company " + i, ApplicationStatus.WISHLIST);        }        Page<JobApplication> firstPage = repository.search(null, null, PageRequest.of(0, 2, Sort.by("id")));        Page<JobApplication> lastPage = repository.search(null, null, PageRequest.of(2, 2, Sort.by("id")));        assertEquals(5, firstPage.getTotalElements());        assertEquals(3, firstPage.getTotalPages());        assertEquals(List.of("Company 1", "Company 2"), firstPage.getContent().stream().map(JobApplication::getCompany).toList());        assertEquals(List.of("Company 5"), lastPage.getContent().stream().map(JobApplication::getCompany).toList());    }    @Test    void searchFiltersByStatusAndByCompanyIgnoringCase() {        save("Acme Corp", ApplicationStatus.WISHLIST);        save("Globex", ApplicationStatus.APPLIED);        save("ACME Labs", ApplicationStatus.APPLIED);        List<String> applied = repository.search(ApplicationStatus.APPLIED, null, PageRequest.of(0, 10, Sort.by("id")))                .map(JobApplication::getCompany).getContent();        List<String> acme = repository.search(null, "acme", PageRequest.of(0, 10, Sort.by("id")))                .map(JobApplication::getCompany).getContent();        List<String> both = repository.search(ApplicationStatus.APPLIED, "acme", PageRequest.of(0, 10, Sort.by("id")))                .map(JobApplication::getCompany).getContent();        assertEquals(List.of("Globex", "ACME Labs"), applied);        assertEquals(List.of("Acme Corp", "ACME Labs"), acme);        assertEquals(List.of("ACME Labs"), both);    }}

The tests check that saving assigns an id and a row can be read back, that the enum is stored as its name (a direct JdbcTemplate query looks at the real column), that paging works with no filters, and that the combinations of the two filters behave, including the case-insensitive company match. The migration runs first, then Hibernate validates the mapping, so a mismatch between the entity and the SQL fails the tests too.

The whole build reports Tests run: 21, Failures: 0, Errors: 0, Skipped: 0: 8 for the controller (unchanged from article 33), 9 for the service and 4 for the repository. The repository test class is slow, on the order of tens of seconds on the machine used here, because it starts a PostgreSQL server and a Spring context. That is the cost of testing against the real thing, and the reason to keep the number of such tests modest and the rest of the logic in fast unit tests. A common alternative is Testcontainers, which runs the same PostgreSQL version in Docker. It needs Docker, which was not available where this course was verified, so it was not run here.

The SQL that Hibernate generates

#

You can have Hibernate print each statement with spring.jpa.show-sql=true (or log the category org.hibernate.SQL at debug level, which goes through your logging configuration and is better for real use). This is the real output for the requests in the session below, in order: two creations, a read, a filtered list, a status change, and a delete.

Code
Hibernate: insert into job_applications (applied_on,company,created_at,job_url,notes,position,status,updated_at) values (?,?,?,?,?,?,?,?)Hibernate: insert into job_applications (applied_on,company,created_at,job_url,notes,position,status,updated_at) values (?,?,?,?,?,?,?,?)Hibernate: select ja1_0.id,ja1_0.applied_on,ja1_0.company,ja1_0.created_at,ja1_0.job_url,ja1_0.notes,ja1_0.position,ja1_0.status,ja1_0.updated_at from job_applications ja1_0 where ja1_0.id=?Hibernate: select ja1_0.id,ja1_0.applied_on,ja1_0.company,ja1_0.created_at,ja1_0.job_url,ja1_0.notes,ja1_0.position,ja1_0.status,ja1_0.updated_at from job_applications ja1_0 where (? is null or ja1_0.status=?) and (? is null or lower(ja1_0.company) like lower(('%'||cast(? as varchar)||'%')) escape '') order by ja1_0.id fetch first ? rows onlyHibernate: select ja1_0.id,ja1_0.applied_on,ja1_0.company,ja1_0.created_at,ja1_0.job_url,ja1_0.notes,ja1_0.position,ja1_0.status,ja1_0.updated_at from job_applications ja1_0 where ja1_0.id=?Hibernate: update job_applications set applied_on=?,company=?,job_url=?,notes=?,position=?,status=?,updated_at=? where id=?Hibernate: select ja1_0.id,ja1_0.applied_on,ja1_0.company,ja1_0.created_at,ja1_0.job_url,ja1_0.notes,ja1_0.position,ja1_0.status,ja1_0.updated_at from job_applications ja1_0 where ja1_0.id=?Hibernate: delete from job_applications where id=?

Read it closely, because this is where the ORM stops being magic:

  1. Create: insert into job_applications (...) values (?,...) with no id column, because the database generates it. Two creations, two inserts.
  2. Read by id: select ... from job_applications where id=?. The aliases ja1_0 are generated.
  3. The filtered list: one select with the two optional filters, cast(? as varchar), order by id and fetch first ? rows only for paging. There is no count query here because the page was not full: Spring Data skips the count when the first page already holds fewer rows than the page size.
  4. Status change: a select to load the entity (in find), then a single update that lists applied_on, company, job_url, notes, position, status, updated_at. It does not include created_at, because the entity says updatable = false for it. Hibernate noticed that status and updated_at had changed, and wrote the update when the transaction committed.
  5. Delete: a select to load the entity, then delete from job_applications where id=?.

The generated SQL is correct and efficient for these cases. The cases to watch for, and the next article's subject, are associations and loops that issue many selects.

The API still works, now with a database

#

Here is the real session against the packaged jar and a PostgreSQL 18.6 server (this run used port 18082, and <now> replaces timestamps, as before). Two applications are created, one is read, the list is filtered in the database, one status is changed and one application is deleted:

Code
$ curl -i -X POST http://localhost:18082/api/applications -H 'Content-Type: application/json' -d '{"company":"Acme","position":"Java Developer"}'HTTP/1.1 201 Location: http://localhost:18082/api/applications/1{"id":1,"company":"Acme","position":"Java Developer","status":"WISHLIST","appliedOn":null,"jobUrl":null,"notes":null,"createdAt":"<now>","updatedAt":"<now>"}$ curl -i -X POST http://localhost:18082/api/applications -H 'Content-Type: application/json' -d '{"company":"Globex","position":"Backend Engineer","appliedOn":"2026-09-20"}'HTTP/1.1 201 Location: http://localhost:18082/api/applications/2{"id":2,"company":"Globex","position":"Backend Engineer","status":"APPLIED","appliedOn":"2026-09-20","jobUrl":null,"notes":null,"createdAt":"<now>","updatedAt":"<now>"}$ curl -i http://localhost:18082/api/applications/1HTTP/1.1 200 {"id":1,"company":"Acme","position":"Java Developer","status":"WISHLIST","appliedOn":null,"jobUrl":null,"notes":null,"createdAt":"<now>","updatedAt":"<now>"}$ curl -i 'http://localhost:18082/api/applications?status=APPLIED&company=glo&size=5'HTTP/1.1 200 {"items":[{"id":2,"company":"Globex","position":"Backend Engineer","status":"APPLIED","appliedOn":"2026-09-20","jobUrl":null,"notes":null,"createdAt":"<now>","updatedAt":"<now>"}],"page":0,"size":5,"totalItems":1,"totalPages":1}$ curl -i -X POST http://localhost:18082/api/applications/1/status -H 'Content-Type: application/json' -d '{"status":"APPLIED"}'HTTP/1.1 200 {"id":1,"company":"Acme","position":"Java Developer","status":"APPLIED","appliedOn":null,"jobUrl":null,"notes":null,"createdAt":"<now>","updatedAt":"<now>"}$ curl -i -X DELETE http://localhost:18082/api/applications/2HTTP/1.1 204 

Then the application was stopped and started again, and the first application is still there, because the data lives in the database and not in the application's memory:

Code
$ curl -i http://localhost:18082/api/applications/1HTTP/1.1 200 {"id":1,"company":"Acme","position":"Java Developer","status":"APPLIED","appliedOn":null,"jobUrl":null,"notes":null,"createdAt":"<now>","updatedAt":"<now>"}

The responses have exactly the same shape as before, which is the proof that the layering worked: the front end of the API did not change.

Things to know before the next article

#
  • Do not return entities from controllers. Use DTOs.
  • findById returns an Optional, and getReferenceById returns a lazy proxy, which matters in the next article.
  • A repository method call outside a transaction runs in its own short transaction (Spring Data repositories are transactional by default), but the returned entity is then detached.
  • @Transactional works through a proxy, so it applies when another bean calls the method. A method calling another method on this bypasses it. The next article shows this.
  • Never let ddl-auto change a real database, and never edit an applied migration.
  • Look at the SQL whenever performance or behaviour surprises you. show-sql is the first tool.
  • Use STRING for enums, and Instant or OffsetDateTime for times.

Review: what is still limited

#

Limitation

Where it is addressed

One table, no relationships

Article 35 adds interviews and the associations that bring lazy loading and the N+1 problem

Two users editing the same record at once: the last write wins silently

Optimistic locking with @Version, article 35

No authentication, no ownership of the data

Article 36

The tests with a database are slow

Keep them few and focused, and test logic without a database

The database password comes from an environment variable

Article 38, secrets and containers

The migration creates the CHECK list of statuses by hand

Adding a status needs a new migration. That is the price of a database-level guarantee

Common mistakes

#
  • Using ddl-auto=update (or create) against a real database, instead of migrations.
  • Editing an old migration instead of adding a new one.
  • @Enumerated(EnumType.ORDINAL), or no annotation, which means ordinal.
  • Returning entities as JSON, exposing structure and risking lazy-loading errors and infinite loops.
  • Testing against a different database from the one you deploy to.
  • Forgetting that changes to a managed entity are saved automatically at commit, even if you never call save, and being surprised by an unexpected update.
  • Treating the ORM as a black box, never reading the SQL.
  • Putting @Transactional on a private method or calling it from the same class, so the proxy never applies it.
  • Entity equals and hashCode based on a generated id that is null before saving.
  • Loading whole tables with findAll() and filtering in Java.

Exercises

#

Exercise 1

Add a column

#

Add an optional location (up to 100 characters) to the table and the entity. Which files change, and what must you not do?

Exercise 2: Derived query

#

Write a derived query method that returns the applications with a given status ordered by appliedOn descending, and one that counts them.

Exercise 3: Predict the SQL

#

In changeStatus, the code does application.setStatus(next) and then repository.save(application). How many UPDATE statements run, and when?

Exercise 4: Why not update?

#

A teammate says ddl-auto=update is fine because "it adds columns for me". Give three reasons against it.

Exercise 5: The null parameter

#

Explain in your own words why the original query failed on PostgreSQL, and why an H2-based test would not have found it.

Show solutionHide solution

Add a new migration, for example V2__add_location.sql containing ALTER TABLE job_applications ADD COLUMN location varchar(100); (a real PostgreSQL run of this statement on an existing row leaves the new column empty, so existing rows stay valid), then add @Column(length = 100) private String location; with a getter and setter to JobApplication, and carry the field through the request, response and copyFields as in article 33. Do not edit V1: Flyway has already applied it, records its checksum, and would refuse to start. With ddl-auto=validate, the application also fails to start if you add the field to the entity but forget the migration, which is the early warning you want.

Exercise 2

Show solutionHide solution
Java
List<JobApplication> findByStatusOrderByAppliedOnDesc(ApplicationStatus status);long countByStatus(ApplicationStatus status);

Spring Data parses findBy, the property Status, OrderBy, the property AppliedOn and Desc from the name, and fails at start-up if a property does not exist, so a typo is caught immediately.

Exercise 3

Show solutionHide solution

One UPDATE, and it runs at the flush, at the end of the transaction (when the service method returns), not at the call to save. The entity is already managed, because find loaded it inside the same transaction, so save does nothing extra. Hibernate's dirty check finds that status and updated_at differ from the loaded snapshot and writes one UPDATE with the changed columns, as the SQL log showed.

Exercise 4

Show solutionHide solution

(1) It cannot be reviewed or versioned: the change happens as a side effect of starting the application, so there is no file in code review and no record of what ran. (2) It is incomplete and unsafe: it adds columns and tables but does not drop or rename them, cannot migrate existing data, and can produce a schema that differs between environments. (3) It needs powerful database permissions at runtime and can change a production schema when someone deploys, unexpectedly. Versioned migrations are reviewed, repeatable, and run the same everywhere.

Exercise 5

Show solutionHide solution

When the company filter was null, the driver sent an untyped null for the ? that sits inside lower('%' || ? || '%'). PostgreSQL must decide the parameter's type while it analyses the statement, and with no other information it chose bytea, for which lower does not exist, so the statement was rejected even though the or would have skipped that part at run time. H2 infers and converts types more leniently and, in its PostgreSQL compatibility mode, differs in these details, so the query would probably have run. The bug would have surfaced in production on the first request without a company filter. Testing on the real engine found it immediately.

Where to go next

#

Sources & further reading

  1. docs.spring.io
  2. docs.spring.io
  3. docs.hibernate.org
  4. documentation.red-gate.com
  • Guide · Technology

    Heaps and Priority Queues

  • Guide · Technology

    Dynamic Programming Practice: From 1D to 2D

  • Course · Technology

    DSA From Zero to Job-Ready: A Practical Guide with Java