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
- 21File I/O and Serialization
- 22Debugging: Errors, Stack Traces, the Debugger and Logging
- 23Maven: What It Does and How to Use It
- 24Git for Java Projects: Commits, Branches, Merging and Pull Requests
- 25Testing with JUnit and Mockito: Unit Tests, Mocks and Their Limits
- 26Project 1: Build an Expense Tracker CLI in Plain Java
8 · Databases and JDBC
9 · Web and Spring
10 · Persistence with JPA
11 · Secure, Tested, Production-Ready
- 36Security Fundamentals and Spring Security: Passwords, JWT, Access Control, CORS and Secrets
- 37Testing Spring Boot Applications: Slice Tests, MockMvc, Real Databases and the Test Pyramid
- 38Production Practices: Configuration, Logging, Docker and Health Checks for Spring Boot
- 39Project 3: The Job Application Tracker API, Assembled and Reviewed
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.
For
Learners who know SQL, JDBC and the layered Spring Boot API and want to persist objects with the standard Java persistence stack.
Level
Before you start
SQL and JDBC (articles 27 and 28), the layered API (article 33), Spring Boot configuration (article 32). Verified with Spring Boot 4.1.1: Hibernate ORM 7.4.5, Spring Data JPA 4.1.1, Jakarta Persistence 3.2, Flyway 12.4.0 and PostgreSQL 18.6, on JDK 21 and JDK 25.
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 ( |
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 |
Older style. Before Spring Boot 3, JPA's annotations lived in the
javax.persistencepackage. Since the move to Jakarta EE, they are injakarta.persistence, so older tutorials need that change. In older code you will also see repositories written by hand with an injectedEntityManager, and applications that let Hibernate create the tables withddl-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):
--- 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):
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=falseSpring 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=validatetells 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 arenone,update,createandcreate-drop. The last three change the database on their own, which is convenient in a demonstration and dangerous with real data:updatecannot 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=falseswitches 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:
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:
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:
// 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 |
|---|---|
| This class is mapped to this table |
| The primary key |
| The database generates the id (the identity column). Hibernate leaves the id out of the |
| Column details. They document intent, and with |
| Store the enum's name ( |
| Mapped natively to |
The no-argument | JPA needs one to create instances when reading rows. Keeping 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 anequalsbased 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, |
Managed | Attached to the persistence context, so Hibernate tracks it. It comes from |
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:
- 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
UPDATEstatements for those that changed. You do not have to callsaveto persist a change to a managed entity. (The service still callssavefor clarity, which does nothing extra here.) - The first-level cache. Within one persistence context, loading the same id twice returns the same Java object and runs the query once.
- Writes are delayed.
saveon a new entity with anIDENTITYid 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.
// 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 |
| Simple conditions. Spring parses |
JPQL with | The | Anything more complex or optional. JPQL queries the entities ( |
Native SQL with | Plain SQL | A database-specific feature, at the cost of portability |
Notes on search:
Pageablecarries the page number, size and sort, and returningPage<T>gives you the content plus the total number of items and pages. Spring Data runs a secondcountquery when it needs the total.- Named parameters (
:status) with@Parambind safely: it is aPreparedStatementunderneath, 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:
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 onlyRead 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:
--- 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 } @Transactionalon the class (andreadOnly = trueon the reads) defines the transaction boundaries.listno longer filters in Java: it callsrepository.searchwith aPageRequest, 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.
deleteloads the entity first, so a missing id is still a404.
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:
// 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:
// 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.
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:
- Create:
insert into job_applications (...) values (?,...)with noidcolumn, because the database generates it. Two creations, two inserts. - Read by id:
select ... from job_applications where id=?. The aliasesja1_0are generated. - The filtered list: one
selectwith the two optional filters,cast(? as varchar),order by idandfetch first ? rows onlyfor paging. There is nocountquery here because the page was not full: Spring Data skips the count when the first page already holds fewer rows than the page size. - Status change: a
selectto load the entity (infind), then a singleupdatethat listsapplied_on, company, job_url, notes, position, status, updated_at. It does not includecreated_at, because the entity saysupdatable = falsefor it. Hibernate noticed thatstatusandupdated_athad changed, and wrote the update when the transaction committed. - Delete: a
selectto load the entity, thendelete 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:
$ 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:
$ 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.
findByIdreturns anOptional, andgetReferenceByIdreturns 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.
@Transactionalworks through a proxy, so it applies when another bean calls the method. A method calling another method onthisbypasses it. The next article shows this.- Never let
ddl-autochange a real database, and never edit an applied migration. - Look at the SQL whenever performance or behaviour surprises you.
show-sqlis the first tool. - Use
STRINGfor enums, andInstantorOffsetDateTimefor 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 |
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 | Adding a status needs a new migration. That is the price of a database-level guarantee |
Common mistakes
#- Using
ddl-auto=update(orcreate) 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
@Transactionalon a private method or calling it from the same class, so the proxy never applies it. - Entity
equalsandhashCodebased 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
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
#- Next: Relationships, Lazy Loading, N+1 and Transactions adds interviews to applications, and the performance problems that associations bring.
- Then: Security Fundamentals and Spring Security protects the API.
- For test slices in more depth: Testing Spring Boot Applications.
Sources & further reading
- Technology
- Software Engineering
- #Flyway
- #Hibernate
- #Java
- #Java backend
- #Java interview
- #JPA
- #learn Java
- #PostgreSQL
- #Spring Boot
- #Spring Data JPA
- #SQL
Connected lessons
Linked from 7 guides
Other pages in the library that point here.
Keep reading
More in Technology →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

