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 35 · Persistence with JPA
JPA Relationships, Lazy Loading, the N+1 Problem, Transactions and Optimistic Locking
Add interviews to job applications and meet the real problems of JPA: relationship mapping, lazy loading and its exception, the N+1 query problem measured and fixed, what @Transactional does and does not do, and optimistic locking that stops lost updates, all tested on PostgreSQL.
For
Learners who have a working JPA repository and need to understand the relationship, performance and concurrency problems that appear in real applications and in interviews.
Level
Before you start
JPA and Spring Data basics (article 34), SQL joins and transactions (article 27) and concurrency concepts (article 19). Verified with Spring Boot 4.1.1, Hibernate ORM 7.4.5 and PostgreSQL 18.6 on JDK 21 and JDK 25.
On this page
Key takeaways
- A bidirectional OneToMany/ManyToOne relationship has one owning side (the one with the foreign key, ManyToOne) and one inverse side (mappedBy). Keep both sides in step with helper methods, and make associations lazy.
- Lazy loading fetches related data only when it is touched, and only while the session is open. Reading a lazy collection after the transaction ended throws LazyInitializationException.
- The N+1 problem is one query for a list plus one more query for each row's association. It was measured here as 6 statements for 5 rows, and fixed to 1 with a join fetch or to 2 with batch fetching.
- @Transactional works through a proxy. A call from inside the same class bypasses it, and by default only unchecked exceptions roll back. Both were proven with tests against the real database.
- Optimistic locking uses a version column: an update only succeeds if the version is unchanged. Return the new version to the client, and flush before building the response, or a lagging version causes false conflicts.
Article 34 mapped one table. Real data has relationships: a job application has several interviews. Relationships are where JPA stops being simple, and where most of its famous problems live: lazy-loading errors, the N+1 query problem, transactions that do not behave as expected, and two users overwriting each other's changes. These are also among the most common topics in Java backend interviews. This article adds interviews to the Job Application Tracker and demonstrates each problem on a real PostgreSQL, with measured numbers.
What you will learn
#- How to map a one-to-many relationship, and what the owning side is.
- What lazy loading is, and why it throws an exception.
- How to detect, measure and fix the N+1 problem.
- What
@Transactionalreally does, including the cases where it silently does nothing. - How optimistic locking prevents lost updates.
The new schema
#An application has many interviews, and each interview belongs to one application. In the database that is a foreign key in the child table (interviews.application_id). The new migration also adds a version column to applications, used for optimistic locking later in this article. Remember the rule from article 34: a new file, never an edit of V1.
-- Optimistic locking: every update increments the version, and an update fails if someone else changed the row first.ALTER TABLE job_applications ADD COLUMN version bigint NOT NULL DEFAULT 0;CREATE TABLE interviews ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, application_id bigint NOT NULL REFERENCES job_applications (id), scheduled_at timestamptz NOT NULL, type varchar(20) NOT NULL CHECK (type IN ('PHONE', 'TECHNICAL', 'ONSITE')), notes varchar(2000));-- PostgreSQL does not index foreign keys automatically, and we will always look interviews up by application.CREATE INDEX idx_interviews_application_id ON interviews (application_id);The comment about the index is worth noticing: PostgreSQL does not create an index for a foreign key. Without one, "the interviews of application 7" would scan the whole table.
Mapping a relationship
#The many side: Interview
#// File: src/main/java/com/example/jobtracker/Interview.javapackage com.example.jobtracker;import jakarta.persistence.Column;import jakarta.persistence.Entity;import jakarta.persistence.EnumType;import jakarta.persistence.Enumerated;import jakarta.persistence.FetchType;import jakarta.persistence.GeneratedValue;import jakarta.persistence.GenerationType;import jakarta.persistence.Id;import jakarta.persistence.JoinColumn;import jakarta.persistence.ManyToOne;import jakarta.persistence.Table;import java.time.Instant;/** One interview for a job application. This side owns the foreign key column. */@Entity@Table(name = "interviews")public class Interview { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @ManyToOne(fetch = FetchType.LAZY, optional = false) @JoinColumn(name = "application_id") private JobApplication application; @Column(nullable = false) private Instant scheduledAt; @Enumerated(EnumType.STRING) @Column(nullable = false, length = 20) private InterviewType type; @Column(length = 2000) private String notes; protected Interview() { } public Interview(Instant scheduledAt, InterviewType type, String notes) { this.scheduledAt = scheduledAt; this.type = type; this.notes = notes; } public Long getId() { return id; } public JobApplication getApplication() { return application; } /** Called by JobApplication.addInterview, so that both sides of the relationship agree. */ void setApplication(JobApplication application) { this.application = application; } public Instant getScheduledAt() { return scheduledAt; } public InterviewType getType() { return type; } public String getNotes() { return notes; }}The one side: JobApplication
#// File: src/main/java/com/example/jobtracker/JobApplication.javapackage com.example.jobtracker;import jakarta.persistence.CascadeType;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.OneToMany;import jakarta.persistence.OrderBy;import jakarta.persistence.Table;import jakarta.persistence.Version;import java.time.Instant;import java.time.LocalDate;import java.util.ArrayList;import java.util.Collections;import java.util.List;import org.hibernate.annotations.BatchSize;/** 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; /** Hibernate adds one to this on every update and refuses to overwrite a row that someone else changed. */ @Version private long version; @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; /** * The inverse side of the relationship: the interviews table holds the foreign key. * Interviews are saved and deleted with their application, and removing one from the list deletes it. * They are loaded lazily, and in batches of up to 20 applications at a time. */ @OneToMany(mappedBy = "application", cascade = CascadeType.ALL, orphanRemoval = true) @OrderBy("scheduledAt") @BatchSize(size = 20) private List<Interview> interviews = new ArrayList<>(); protected JobApplication() { // required by JPA } public JobApplication(String company, String position) { this.company = company; this.position = position; } public Long getId() { return id; } public long getVersion() { return version; } 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; } /** A read-only view: changes must go through addInterview and removeInterview. */ public List<Interview> getInterviews() { return Collections.unmodifiableList(interviews); } public void addInterview(Interview interview) { interviews.add(interview); interview.setApplication(this); // keep both sides of the relationship in step } public boolean removeInterview(Interview interview) { return interviews.remove(interview); // orphanRemoval deletes the row }}This is the densest part of JPA, so take it in pieces.
Owning side and inverse side. In a bidirectional relationship, exactly one side is responsible for the foreign key. Here it is Interview.application, annotated @ManyToOne with a @JoinColumn: it owns the relationship, and its value is what Hibernate writes into application_id. The other side, JobApplication.interviews, is the inverse side and says mappedBy = "application": "I am the mirror of the application field over there". Changing only the inverse side does not change the database. That is why the entity has addInterview, which sets both: the list and interview.setApplication(this). Always modify bidirectional relationships through helper methods like that, and expose the collection read-only (the getter returns an unmodifiable view), so no caller can desynchronise the two sides.
The relationship types:
Annotation | Meaning | Example |
|---|---|---|
| Many rows here point at one there. The foreign key lives here | Many interviews, one application |
| The reverse view of that. Usually | One application, many interviews |
| One row matches exactly one row | A user and their profile |
| Many on both sides, through a join table | Applications and tags |
For many-to-many, prefer an explicit join entity (a class for the join table) when the relationship has its own data or when you need control, because @ManyToMany hides the join table and becomes limiting quickly. Avoid a bidirectional relationship unless you really navigate both ways, since each one adds synchronisation work.
Cascading and orphans. cascade = CascadeType.ALL means that persisting, merging or removing an application does the same to its interviews: adding a new Interview to a managed application's list is enough to insert it. orphanRemoval = true means that an interview removed from the list is deleted from the database. Use cascade only for a true parent-owns-child relationship like this one. Never cascade deletion across relationships where the other side has its own life (deleting an interview must not delete the application).
Ordering and fetching. @OrderBy("scheduledAt") sorts the collection when loaded. fetch = FetchType.LAZY on the @ManyToOne is explicit because the JPA default for @ManyToOne and @OneToOne is EAGER, which loads the related entity every time. The default for @OneToMany is already LAZY. Make every association lazy and fetch deliberately where needed. @BatchSize is a Hibernate annotation explained under N+1.
Lazy loading
#A lazy association is not loaded when the entity is. Hibernate puts a placeholder (a proxy or a special collection) in the field, and runs the query the first time you touch it. This avoids loading data you never use. It only works while the session is open, which means inside the transaction. Touch it afterwards and:
org.hibernate.LazyInitializationException: Cannot lazily initialize collection of role'com.example.jobtracker.JobApplication.interviews' with key '1' (no session)That is the real message from the test below, which loads an application in one transaction, lets the transaction end, and then reads getInterviews() on the detached entity:
package com.example.jobtracker;import static org.junit.jupiter.api.Assertions.assertEquals;import static org.junit.jupiter.api.Assertions.assertThrows;import static org.junit.jupiter.api.Assertions.assertTrue;import jakarta.persistence.EntityManager;import jakarta.persistence.EntityManagerFactory;import java.time.Instant;import java.util.List;import org.hibernate.LazyInitializationException;import org.hibernate.SessionFactory;import org.hibernate.stat.Statistics;import org.junit.jupiter.api.BeforeEach;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.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;import org.springframework.transaction.PlatformTransactionManager;import org.springframework.transaction.support.TransactionTemplate;/** Relationships, lazy loading and the N+1 problem, observed on a real PostgreSQL with Hibernate's statistics. */@DataJpaTest(properties = "spring.jpa.properties.hibernate.generate_statistics=true")@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)class RelationshipTest { @DynamicPropertySource static void database(DynamicPropertyRegistry registry) { TestPostgres.register(registry); } @Autowired JobApplicationRepository repository; @Autowired EntityManager entityManager; @Autowired EntityManagerFactory entityManagerFactory; @Autowired JdbcTemplate jdbc; @Autowired PlatformTransactionManager transactionManager; private Statistics statistics; @BeforeEach void startCountingStatements() { statistics = entityManagerFactory.unwrap(SessionFactory.class).getStatistics(); } private JobApplication application(String company, int interviews) { JobApplication application = new JobApplication(company, "Java Developer"); application.setStatus(ApplicationStatus.APPLIED); application.setCreatedAt(Instant.parse("2026-03-15T10:00:00Z")); application.setUpdatedAt(Instant.parse("2026-03-15T10:00:00Z")); for (int i = 1; i <= interviews; i++) { application.addInterview(new Interview(Instant.parse("2026-04-0" + i + "T09:00:00Z"), InterviewType.PHONE, null)); } return application; } /** Five applications with two interviews each, written to the database and then forgotten by the session. */ private void fiveApplicationsWithTwoInterviews() { for (int i = 1; i <= 5; i++) { repository.save(application("Company " + i, 2)); } entityManager.flush(); entityManager.clear(); statistics.clear(); } @Test void interviewsAreSavedAndLoadedWithTheirApplication() { JobApplication saved = repository.saveAndFlush(application("Acme", 2)); entityManager.clear(); JobApplication loaded = repository.findById(saved.getId()).orElseThrow(); assertEquals(2, loaded.getInterviews().size()); assertEquals(Instant.parse("2026-04-01T09:00:00Z"), loaded.getInterviews().get(0).getScheduledAt()); assertEquals(loaded, loaded.getInterviews().get(0).getApplication()); } @Test void removingAnInterviewFromTheListDeletesItsRow() { JobApplication saved = repository.saveAndFlush(application("Acme", 2)); saved.removeInterview(saved.getInterviews().get(0)); entityManager.flush(); Integer rows = jdbc.queryForObject("select count(*) from interviews where application_id = ?", Integer.class, saved.getId()); assertEquals(1, rows); } @Test void deletingAnApplicationDeletesItsInterviews() { JobApplication saved = repository.saveAndFlush(application("Acme", 2)); repository.delete(saved); entityManager.flush(); assertEquals(0, jdbc.queryForObject("select count(*) from interviews", Integer.class)); } @Test void theNPlusOneProblemAppearsWhenEachInterviewLoadsItsOwnApplication() { fiveApplicationsWithTwoInterviews(); List<Interview> interviews = entityManager.createQuery("select i from Interview i order by i.id", Interview.class) .getResultList(); List<String> companies = interviews.stream().map(i -> i.getApplication().getCompany()).toList(); assertEquals(10, companies.size()); assertEquals(6, statistics.getPrepareStatementCount()); // 1 for the interviews, then 1 for each of the 5 companies } @Test void aJoinFetchOnTheOwningSideLoadsEverythingInOneStatement() { fiveApplicationsWithTwoInterviews(); List<Interview> interviews = entityManager.createQuery( "select i from Interview i join fetch i.application order by i.id", Interview.class).getResultList(); List<String> companies = interviews.stream().map(i -> i.getApplication().getCompany()).toList(); assertEquals(10, companies.size()); assertEquals(1, statistics.getPrepareStatementCount()); } @Test void batchFetchingTurnsTheNPlusOneIntoTwoStatements() { fiveApplicationsWithTwoInterviews(); // @BatchSize(size = 20) is active List<JobApplication> page = repository.search(null, null, PageRequest.of(0, 10, Sort.by("id"))).getContent(); int interviews = page.stream().mapToInt(a -> a.getInterviews().size()).sum(); assertEquals(10, interviews); assertEquals(2, statistics.getPrepareStatementCount()); // 1 for the page, 1 for all the interviews } @Test void aJoinFetchLoadsEverythingInOneStatement() { fiveApplicationsWithTwoInterviews(); List<JobApplication> all = entityManager.createQuery( "select distinct a from JobApplication a left join fetch a.interviews order by a.id", JobApplication.class) .getResultList(); int interviews = all.stream().mapToInt(a -> a.getInterviews().size()).sum(); assertEquals(10, interviews); assertEquals(1, statistics.getPrepareStatementCount()); } @Test void readingALazyCollectionAfterTheTransactionEndsFails() { TransactionTemplate transaction = new TransactionTemplate(transactionManager); // This test class is itself transactional, so run the work in separate transactions that really commit. transaction.setPropagationBehavior(org.springframework.transaction.TransactionDefinition.PROPAGATION_REQUIRES_NEW); Long id = transaction.execute(status -> repository.saveAndFlush(application("Acme", 2)).getId()); try { JobApplication detached = transaction.execute(status -> repository.findById(id).orElseThrow()); LazyInitializationException e = assertThrows(LazyInitializationException.class, () -> detached.getInterviews().size()); assertTrue(e.getMessage().contains("could not initialize proxy - no session") || e.getMessage().contains("Cannot lazily initialize collection"), e.getMessage()); } finally { transaction.executeWithoutResult(status -> repository.deleteById(id)); } }}(The whole class is shown because it carries all of this article's relationship and N+1 tests. The one for this section is the last, readingALazyCollectionAfterTheTransactionEndsFails.)
This is also why JobApplicationResponse.from carries a warning comment: it reads the interviews, so it must run inside the service's transaction, before the entity is detached. The common wrong "fixes" are FetchType.EAGER (which hides the problem and loads too much everywhere) and keeping the session open for the whole web request with spring.jpa.open-in-view=true. The right fixes are to map to a DTO inside the transaction (as this project does), or to load what you need with a query designed for it (join fetch or an entity graph).
The N+1 problem
#Here is the most famous JPA performance problem. You load a list of N parents with one query. Then you touch a lazy association on each parent, and Hibernate runs one more query for each: 1 + N queries. For 5 rows it is invisible. For 500 rows per request it is the reason a page takes seconds.
The RelationshipTest creates 5 applications with 2 interviews each, clears the session, and counts the statements Hibernate sends, using its statistics (hibernate.generate_statistics). It runs on the real PostgreSQL, and the numbers are assertions in tests that pass:
Scenario | Statements | Why |
|---|---|---|
Load all interviews, then read each one's | 6 | 1 for the 10 interviews, then 1 for each of the 5 distinct applications |
The same, with | 1 | One join query loads both |
Load a page of applications, then read each one's | 6 | 1 for the applications, then 1 for each of the 5 collections. (Measured by removing the annotation and running the same test: it reported 6.) |
The same, with | 2 | 1 for the applications, and 1 for all their interviews |
| 1 | One join query. The result has the 10 interviews attached to their 5 applications |
How to find it: look at the SQL log (spring.jpa.show-sql=true), count statements per request, and write a test that asserts the statement count, as above. An N+1 does not show up in a functional test, because the results are correct. It only shows up as a count, and an assertion keeps it from coming back.
The fixes, and when to use which:
Fix | How | Notes |
|---|---|---|
Join fetch |
| One statement. Best for a single entity or a short, unpaged result. Combined with paging on a collection it is dangerous: the database cannot limit parent rows when a join multiplies them, so Hibernate may have to load everything and trim it in memory. Do not page a collection fetch join without checking what SQL is sent |
Batch fetching |
| Turns N queries into N/size. Works with paging. This is what the project uses for the list endpoint |
A DTO projection | A query that selects only the columns needed, or a count, straight into a record | Avoids loading entities when you only need a summary |
Do not return the association | List responses show a count, and the detail response shows the list | Often the best API design |
The list endpoint returns each application with its interviews nested. Here is the real SQL that one GET /api/applications request sent, for 3 applications:
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,ja1_0.version 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 i1_0.application_id,i1_0.id,i1_0.notes,i1_0.scheduled_at,i1_0.type from interviews i1_0 where i1_0.application_id = any (?) order by i1_0.scheduled_atTwo statements: one for the page of applications, and one for all of their interviews, with where i1_0.application_id = any (?): that is batch fetching, a single query for every application on the page, however many there are (up to 20 in a batch).
The service
#The service gets three new methods for interviews and a stale-version check. The DIFF below shows what changed from article 34:
--- a/src/main/java/com/example/jobtracker/JobApplicationService.java+++ b/src/main/java/com/example/jobtracker/JobApplicationService.java@@ -3,4 +3,5 @@ import java.time.Clock; import java.time.Instant;+import java.util.List; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest;@@ -56,7 +57,10 @@ public JobApplicationResponse replace(Long id, JobApplicationRequest request) { JobApplication application = find(id);+ if (request.version() != null && request.version() != application.getVersion()) {+ throw new StaleVersionException(id, request.version(), application.getVersion());+ } copyFields(request, application); application.setUpdatedAt(Instant.now(clock));- return JobApplicationResponse.from(repository.save(application));+ return JobApplicationResponse.from(repository.saveAndFlush(application)); // flush, so the response has the new version } @@ -68,5 +72,27 @@ application.setStatus(next); application.setUpdatedAt(Instant.now(clock));- return JobApplicationResponse.from(repository.save(application));+ return JobApplicationResponse.from(repository.saveAndFlush(application));+ }++ public InterviewResponse addInterview(Long applicationId, InterviewRequest request) {+ JobApplication application = find(applicationId);+ Interview interview = new Interview(request.scheduledAt(), request.type(), request.notes());+ application.addInterview(interview);+ repository.flush(); // run the INSERT now, so that the interview has its id+ return InterviewResponse.from(interview);+ }++ @Transactional(readOnly = true)+ public List<InterviewResponse> listInterviews(Long applicationId) {+ return find(applicationId).getInterviews().stream().map(InterviewResponse::from).toList();+ }++ public void deleteInterview(Long applicationId, Long interviewId) {+ JobApplication application = find(applicationId);+ Interview interview = application.getInterviews().stream()+ .filter(i -> i.getId().equals(interviewId))+ .findFirst()+ .orElseThrow(() -> new InterviewNotFoundException(applicationId, interviewId));+ application.removeInterview(interview); // orphanRemoval deletes the row when the transaction commits } Notice repository.flush() in addInterview: an IDENTITY id is generated by the INSERT, so the code forces the insert to run now to get the interview's id for the response. And note saveAndFlush in replace and changeStatus, which has a story, told in the optimistic locking section.
The controller, the exception handler and the DTOs change in small ways (new endpoints for interviews, new conflict responses, version and interviews in the response):
--- a/src/main/java/com/example/jobtracker/JobApplicationController.java+++ b/src/main/java/com/example/jobtracker/JobApplicationController.java@@ -4,4 +4,5 @@ import jakarta.validation.constraints.Min; import java.net.URI;+import java.util.List; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity;@@ -65,3 +66,24 @@ service.delete(id); }++ @PostMapping("/{id}/interviews")+ public ResponseEntity<InterviewResponse> addInterview(@PathVariable Long id,+ @Valid @RequestBody InterviewRequest request,+ UriComponentsBuilder uriBuilder) {+ InterviewResponse created = service.addInterview(id, request);+ URI location = uriBuilder.path("/api/applications/{id}/interviews/{interviewId}")+ .buildAndExpand(id, created.id()).toUri();+ return ResponseEntity.created(location).body(created);+ }++ @GetMapping("/{id}/interviews")+ public List<InterviewResponse> listInterviews(@PathVariable Long id) {+ return service.listInterviews(id);+ }++ @DeleteMapping("/{id}/interviews/{interviewId}")+ @ResponseStatus(HttpStatus.NO_CONTENT)+ public void deleteInterview(@PathVariable Long id, @PathVariable Long interviewId) {+ service.deleteInterview(id, interviewId);+ } }--- a/src/main/java/com/example/jobtracker/ApiExceptionHandler.java+++ b/src/main/java/com/example/jobtracker/ApiExceptionHandler.java@@ -5,4 +5,5 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory;+import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus;@@ -28,4 +29,28 @@ ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage()); problem.setTitle("Application not found");+ return problem;+ }++ @ExceptionHandler(InterviewNotFoundException.class)+ ProblemDetail interviewNotFound(InterviewNotFoundException e) {+ ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage());+ problem.setTitle("Interview not found");+ return problem;+ }++ /** The client edited an old copy (it sent a version number that is no longer current). */+ @ExceptionHandler(StaleVersionException.class)+ ProblemDetail staleVersion(StaleVersionException e) {+ ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, e.getMessage());+ problem.setTitle("Edit conflict");+ return problem;+ }++ /** Two requests changed the same row at the same moment, and the database refused the second one. */+ @ExceptionHandler(OptimisticLockingFailureException.class)+ ProblemDetail concurrentUpdate(OptimisticLockingFailureException e) {+ ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT,+ "The application was changed by another request at the same time. Read it again and repeat your change.");+ problem.setTitle("Edit conflict"); return problem; }// File: src/main/java/com/example/jobtracker/InterviewRequest.javapackage com.example.jobtracker;import jakarta.validation.constraints.NotNull;import jakarta.validation.constraints.Size;import java.time.Instant;public record InterviewRequest( @NotNull Instant scheduledAt, @NotNull InterviewType type, @Size(max = 2000) String notes) {}// File: src/main/java/com/example/jobtracker/InterviewResponse.javapackage com.example.jobtracker;import java.time.Instant;public record InterviewResponse(Long id, Instant scheduledAt, InterviewType type, String notes) { static InterviewResponse from(Interview i) { return new InterviewResponse(i.getId(), i.getScheduledAt(), i.getType(), i.getNotes()); }}--- a/src/main/java/com/example/jobtracker/JobApplicationRequest.java+++ b/src/main/java/com/example/jobtracker/JobApplicationRequest.java@@ -6,5 +6,8 @@ import java.time.LocalDate; -/** What a client sends to create or replace an application. The status is deliberately not part of it. */+/**+ * What a client sends to create or replace an application. The status is deliberately not part of it.+ * When replacing, the client sends back the version it read, so that a stale edit can be detected.+ */ public record JobApplicationRequest( @NotBlank @Size(max = 100) String company,@@ -12,4 +15,5 @@ @PastOrPresent LocalDate appliedOn, @Size(max = 500) String jobUrl,- @Size(max = 2000) String notes) {+ @Size(max = 2000) String notes,+ Long version) { }--- a/src/main/java/com/example/jobtracker/JobApplicationResponse.java+++ b/src/main/java/com/example/jobtracker/JobApplicationResponse.java@@ -3,8 +3,10 @@ import java.time.Instant; import java.time.LocalDate;+import java.util.List; /** What the API returns. It is a separate type from JobApplication, so the stored shape can change freely. */ public record JobApplicationResponse( Long id,+ long version, String company, String position,@@ -14,9 +16,12 @@ String notes, Instant createdAt,- Instant updatedAt) {+ Instant updatedAt,+ List<InterviewResponse> interviews) { + /** Reads the interviews, so it must be called while the transaction (and persistence context) is open. */ static JobApplicationResponse from(JobApplication a) {- return new JobApplicationResponse(a.getId(), a.getCompany(), a.getPosition(), a.getStatus(),- a.getAppliedOn(), a.getJobUrl(), a.getNotes(), a.getCreatedAt(), a.getUpdatedAt());+ return new JobApplicationResponse(a.getId(), a.getVersion(), a.getCompany(), a.getPosition(), a.getStatus(),+ a.getAppliedOn(), a.getJobUrl(), a.getNotes(), a.getCreatedAt(), a.getUpdatedAt(),+ a.getInterviews().stream().map(InterviewResponse::from).toList()); } }// File: src/main/java/com/example/jobtracker/StaleVersionException.javapackage com.example.jobtracker;/** The client edited an old copy: the application has been changed since the client read it. */public class StaleVersionException extends RuntimeException { public StaleVersionException(Long id, long sentVersion, long currentVersion) { super("Application " + id + " was changed by someone else: you sent version " + sentVersion + " but the current version is " + currentVersion + ". Read it again and repeat your change."); }}// File: src/main/java/com/example/jobtracker/InterviewNotFoundException.javapackage com.example.jobtracker;public class InterviewNotFoundException extends RuntimeException { public InterviewNotFoundException(Long applicationId, Long interviewId) { super("Application " + applicationId + " has no interview with id " + interviewId); }}What @Transactional really does
#You used it in article 34 without opening it up. A transaction gives you atomicity (all or nothing) and, with JPA, defines the lifetime of the persistence context. Spring implements @Transactional with a proxy: when another bean calls your method, the call goes through a wrapper that begins a transaction, calls your code, and commits (or rolls back). That design has consequences, and each of these is proven by a test in TransactionTest, running against the real database and with the usual test-level transaction switched off, since it would hide the effects:
package com.example.jobtracker;import static org.junit.jupiter.api.Assertions.assertEquals;import static org.junit.jupiter.api.Assertions.assertFalse;import static org.junit.jupiter.api.Assertions.assertThrows;import static org.junit.jupiter.api.Assertions.assertTrue;import java.io.IOException;import java.time.Instant;import org.junit.jupiter.api.AfterEach;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.context.annotation.Import;import org.springframework.jdbc.core.JdbcTemplate;import org.springframework.orm.ObjectOptimisticLockingFailureException;import org.springframework.test.context.DynamicPropertyRegistry;import org.springframework.test.context.DynamicPropertySource;import org.springframework.transaction.PlatformTransactionManager;import org.springframework.transaction.annotation.Propagation;import org.springframework.transaction.annotation.Transactional;import org.springframework.transaction.support.TransactionTemplate;/** * How transactions and optimistic locking really behave. A @DataJpaTest is normally wrapped in a transaction that * is rolled back, which would hide these effects, so these tests switch that off and clean up after themselves. */@DataJpaTest@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)@Transactional(propagation = Propagation.NOT_SUPPORTED)@Import(TransactionProbe.class)class TransactionTest { @DynamicPropertySource static void database(DynamicPropertyRegistry registry) { TestPostgres.register(registry); } @Autowired TransactionProbe probe; @Autowired JobApplicationRepository repository; @Autowired JdbcTemplate jdbc; @Autowired PlatformTransactionManager transactionManager; @AfterEach void cleanUp() { jdbc.update("delete from interviews"); jdbc.update("delete from job_applications"); } private int rows() { return jdbc.queryForObject("select count(*) from job_applications", Integer.class); } @Test void aTransactionalMethodCalledThroughTheProxyRunsInATransaction() { assertTrue(probe.isTransactionActive()); } @Test void aTransactionalMethodCalledFromInsideTheSameClassGetsNoTransaction() { assertFalse(probe.callsItsOwnTransactionalMethod()); // the proxy is bypassed, so the annotation is ignored } @Test void anUncheckedExceptionRollsTheTransactionBack() { assertThrows(IllegalStateException.class, probe::saveThenFailWithUncheckedException); assertEquals(0, rows()); } @Test void aCheckedExceptionDoesNotRollBackByDefault() { assertThrows(IOException.class, probe::saveThenFailWithCheckedException); assertEquals(1, rows()); // the row was committed even though the method failed } @Test void rollbackForMakesACheckedExceptionRollBackToo() { assertThrows(IOException.class, probe::saveThenFailWithCheckedExceptionAndRollbackForAll); assertEquals(0, rows()); } @Test void aSecondEditOfAnOldCopyIsRejectedByTheVersionColumn() { TransactionTemplate transaction = new TransactionTemplate(transactionManager); JobApplication created = new JobApplication("Acme", "Java Developer"); created.setStatus(ApplicationStatus.WISHLIST); created.setCreatedAt(Instant.parse("2026-03-15T10:00:00Z")); created.setUpdatedAt(Instant.parse("2026-03-15T10:00:00Z")); Long id = transaction.execute(status -> repository.saveAndFlush(created).getId()); // Two users read the same row. Each now holds a detached copy with version 0. JobApplication firstUser = transaction.execute(status -> repository.findById(id).orElseThrow()); JobApplication secondUser = transaction.execute(status -> repository.findById(id).orElseThrow()); assertEquals(0, firstUser.getVersion()); assertEquals(0, secondUser.getVersion()); firstUser.setNotes("changed by the first user"); JobApplication saved = transaction.execute(status -> repository.save(firstUser)); assertEquals(1, saved.getVersion()); // the update succeeded and the version went up secondUser.setNotes("changed by the second user, who did not see the first change"); assertThrows(ObjectOptimisticLockingFailureException.class, () -> transaction.execute(status -> repository.save(secondUser))); assertEquals("changed by the first user", jdbc.queryForObject("select notes from job_applications where id = ?", String.class, id)); }}The test uses a small bean written only for it:
package com.example.jobtracker;import java.io.IOException;import java.time.Instant;import org.springframework.stereotype.Service;import org.springframework.transaction.annotation.Transactional;import org.springframework.transaction.support.TransactionSynchronizationManager;/** A small bean used only by tests, to show how @Transactional really behaves. */@Serviceclass TransactionProbe { private final JobApplicationRepository repository; TransactionProbe(JobApplicationRepository repository) { this.repository = repository; } @Transactional public boolean isTransactionActive() { return TransactionSynchronizationManager.isActualTransactionActive(); } /** Calls its own annotated method directly. The call does not pass through Spring's proxy. */ public boolean callsItsOwnTransactionalMethod() { return isTransactionActive(); } private JobApplication newApplication(String company) { JobApplication application = new JobApplication(company, "Java Developer"); application.setStatus(ApplicationStatus.WISHLIST); application.setCreatedAt(Instant.parse("2026-03-15T10:00:00Z")); application.setUpdatedAt(Instant.parse("2026-03-15T10:00:00Z")); return application; } @Transactional public void saveThenFailWithUncheckedException() { repository.save(newApplication("unchecked")); throw new IllegalStateException("something went wrong"); } @Transactional public void saveThenFailWithCheckedException() throws IOException { repository.save(newApplication("checked")); throw new IOException("disk full"); } @Transactional(rollbackFor = Exception.class) public void saveThenFailWithCheckedExceptionAndRollbackForAll() throws IOException { repository.save(newApplication("checked with rollbackFor")); throw new IOException("disk full"); }}- A call through the proxy runs in a transaction (
aTransactionalMethodCalledThroughTheProxyRunsInATransaction). - A call from inside the same class does not (
...FromInsideTheSameClassGetsNoTransaction).callsItsOwnTransactionalMethodcallsisTransactionActive()onthis, which is the real object, not the proxy, so the annotation never applies. This is the self-invocation pitfall, and it is a favourite interview question. If a method needs its own transaction settings, call it from another bean. - An unchecked exception rolls back: the save is undone (
anUncheckedExceptionRollsTheTransactionBack: zero rows). - A checked exception does NOT roll back by default:
saveThenFailWithCheckedExceptionthrowsIOException, and one row is still in the table. The method failed and committed anyway. (aCheckedExceptionDoesNotRollBackByDefault.) This is a historical rule of Spring and Java EE, and it surprises people. rollbackFor = Exception.classfixes that (rollbackForMakesACheckedExceptionRollBackToo: zero rows).
More to know:
Setting | Meaning |
|---|---|
| What to do when a transaction already exists. |
| A hint that the transaction does not write. Hibernate can skip dirty checking, and the connection can be marked read-only |
| The isolation level from article 27. The default is the database's, Read Committed on PostgreSQL |
| Which exceptions roll back |
Rules of thumb: put @Transactional on service methods (the unit of business work), not on controllers or repositories; keep transactions short, with no remote calls or user waiting inside; remember that it applies only to public methods called from outside the bean; and know that the annotation on a class applies to all its public methods.
Optimistic locking
#Article 19 and article 27 described the lost update: two users read the same data, both change it, and the second save silently overwrites the first. There are two defences. Pessimistic locking locks the row when it is read (select ... for update), so others wait. It is safe, but it holds locks and does not suit long user interactions. Optimistic locking assumes conflicts are rare, lets everyone read freely, and detects a conflict at write time. JPA implements it with a version column:
- The entity has a
@Versionfield (seeJobApplication, and theversioncolumn in the migration). - Every
UPDATE(andDELETE) includes the version in itsWHEREclause and increments it:... where id=? and version=?. You can see it in the real SQL below:delete from job_applications where id=? and version=?. - If another transaction changed the row first, the version no longer matches, no row is updated, and Hibernate throws an exception that Spring reports as
ObjectOptimisticLockingFailureException.
This test lets two "users" read the same application, saves the first user's change, and then tries the second's (it is the last test in the TransactionTest listing above):
Row was already updated or deleted by another transaction for entity [com.example.jobtracker.JobApplication with id '4']That message is the real exception text, and the assertions around it check that the first user's change survived and the second was rejected, not silently applied. The row's version went from 0 to 1 on the first save.
That protects the database against two simultaneous requests. For two users working across different HTTP requests, minutes apart, the client has to send back the version it saw, which is why the API now returns version in every response and accepts it in the PUT body. The service compares it to the current one and throws StaleVersionException, which the handler turns into 409 Conflict. Here is the real HTTP session, in which two clients edit application 1 starting from version 0:
$ curl -i -X PUT http://localhost:18083/api/applications/1 -H 'Content-Type: application/json' -d '{"company":"Acme Corp","position":"Java Developer","appliedOn":"2026-09-01","version":0}'HTTP/1.1 200 {"id":1,"version":1,"company":"Acme Corp","position":"Java Developer","status":"APPLIED","appliedOn":"2026-09-01","jobUrl":null,"notes":null,"createdAt":"<now>","updatedAt":"<now>","interviews":[{"id":1,"scheduledAt":"2026-10-10T09:00:00Z","type":"PHONE","notes":null},{"id":2,"scheduledAt":"2026-10-17T13:30:00Z","type":"TECHNICAL","notes":"Pair programming"}]}$ curl -i -X PUT http://localhost:18083/api/applications/1 -H 'Content-Type: application/json' -d '{"company":"Acme Inc","position":"Java Developer","appliedOn":"2026-09-01","version":0}'HTTP/1.1 409 {"detail":"Application 1 was changed by someone else: you sent version 0 but the current version is 1. Read it again and repeat your change.","instance":"/api/applications/1","status":409,"title":"Edit conflict"}$ curl -i -X PUT http://localhost:18083/api/applications/1 -H 'Content-Type: application/json' -d '{"company":"Acme Inc","position":"Java Developer","appliedOn":"2026-09-01","version":1}'HTTP/1.1 200 {"id":1,"version":2,"company":"Acme Inc","position":"Java Developer","status":"APPLIED","appliedOn":"2026-09-01","jobUrl":null,"notes":null,"createdAt":"<now>","updatedAt":"<now>","interviews":[{"id":1,"scheduledAt":"2026-10-10T09:00:00Z","type":"PHONE","notes":null},{"id":2,"scheduledAt":"2026-10-17T13:30:00Z","type":"TECHNICAL","notes":"Pair programming"}]}Read it as two clients. The first PUT (version 0) succeeded and the response shows version 1. The second client, still holding the old copy (version 0), was refused with a 409 whose message says what happened and what to do. The third request, made after reading the new version, succeeded and returned version 2. (Many APIs carry the version in an ETag header and an If-Match request header instead. The idea is the same.)
The bug that real HTTP testing found
#The first version of the service used repository.save(application) in replace and changeStatus, and the session above showed the first PUT returning version 0, although the database had moved to 1. The cause: Hibernate increments the version when it flushes, at the end of the transaction, but the response object had already been built from the entity before that. The client would have received the old version, and its next edit would have been rejected as stale although nobody else had touched the row: a false conflict.
The fix is saveAndFlush, which flushes before the response is built, so that the returned version is the current one. The test class JobApplicationServiceIntegrationTest pins it down, and it was checked by temporarily putting the old code back and watching the tests fail (one failure and one error), then restoring the fix:
package com.example.jobtracker;import static org.junit.jupiter.api.Assertions.assertEquals;import static org.junit.jupiter.api.Assertions.assertThrows;import java.time.LocalDate;import org.junit.jupiter.api.AfterEach;import org.junit.jupiter.api.Test;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.boot.test.context.SpringBootTest;import org.springframework.jdbc.core.JdbcTemplate;import org.springframework.test.context.DynamicPropertyRegistry;import org.springframework.test.context.DynamicPropertySource;/** The service with the real repository, real transactions and a real PostgreSQL: the layers working together. */@SpringBootTestclass JobApplicationServiceIntegrationTest { @DynamicPropertySource static void database(DynamicPropertyRegistry registry) { TestPostgres.register(registry); } @Autowired JobApplicationService service; @Autowired JdbcTemplate jdbc; @AfterEach void cleanUp() { jdbc.update("delete from interviews"); jdbc.update("delete from job_applications"); } private static JobApplicationRequest request(String company, Long version) { return new JobApplicationRequest(company, "Java Developer", LocalDate.of(2026, 3, 1), null, null, version); } @Test void everyUpdateReturnsTheNewVersionSoTheClientCanUseItNext() { JobApplicationResponse created = service.create(request("Acme", null)); assertEquals(0, created.version()); JobApplicationResponse first = service.replace(created.id(), request("Acme Corp", created.version())); JobApplicationResponse second = service.replace(created.id(), request("Acme Inc", first.version())); assertEquals(1, first.version()); assertEquals(2, second.version()); } @Test void anOldVersionIsRejectedAndTheStoredDataIsNotOverwritten() { JobApplicationResponse created = service.create(request("Acme", null)); service.replace(created.id(), request("Acme Corp", 0L)); assertThrows(StaleVersionException.class, () -> service.replace(created.id(), request("Overwrite", 0L))); assertEquals("Acme Corp", service.get(created.id()).company()); } @Test void aStatusChangeAlsoReturnsTheNewVersion() { JobApplicationResponse created = service.create(request("Acme", null)); JobApplicationResponse moved = service.changeStatus(created.id(), ApplicationStatus.INTERVIEWING); assertEquals(1, moved.version()); } @Test void deletingAnApplicationRemovesItsInterviewsToo() { JobApplicationResponse created = service.create(request("Acme", null)); service.addInterview(created.id(), new InterviewRequest(java.time.Instant.parse("2026-04-01T09:00:00Z"), InterviewType.PHONE, null)); assertEquals(1, service.listInterviews(created.id()).size()); service.delete(created.id()); assertEquals(0, jdbc.queryForObject("select count(*) from interviews", Integer.class)); }}This is a good example of why unit tests with mocks were not enough: the bug needed a real database, a real flush and the real version column.
Everything else in the session
#These are the other requests of the real run (JDK 21, PostgreSQL 18.6, <now> replaces timestamps, port 18083): three applications, interviews added to two of them, a request for an unknown application, and then the nested list from the SQL above:
$ curl -i -X POST http://localhost:18083/api/applications -H 'Content-Type: application/json' -d '{"company":"Acme","position":"Java Developer","appliedOn":"2026-09-01"}'HTTP/1.1 201 Location: http://localhost:18083/api/applications/1{"id":1,"version":0,"company":"Acme","position":"Java Developer","status":"APPLIED","appliedOn":"2026-09-01","jobUrl":null,"notes":null,"createdAt":"<now>","updatedAt":"<now>","interviews":[]}$ curl -i -X POST http://localhost:18083/api/applications -H 'Content-Type: application/json' -d '{"company":"Globex","position":"Backend Engineer","appliedOn":"2026-09-02"}'HTTP/1.1 201 Location: http://localhost:18083/api/applications/2{"id":2,"version":0,"company":"Globex","position":"Backend Engineer","status":"APPLIED","appliedOn":"2026-09-02","jobUrl":null,"notes":null,"createdAt":"<now>","updatedAt":"<now>","interviews":[]}$ curl -i -X POST http://localhost:18083/api/applications -H 'Content-Type: application/json' -d '{"company":"Initech","position":"Platform Engineer","appliedOn":"2026-09-03"}'HTTP/1.1 201 Location: http://localhost:18083/api/applications/3{"id":3,"version":0,"company":"Initech","position":"Platform Engineer","status":"APPLIED","appliedOn":"2026-09-03","jobUrl":null,"notes":null,"createdAt":"<now>","updatedAt":"<now>","interviews":[]}$ curl -i -X POST http://localhost:18083/api/applications/1/interviews -H 'Content-Type: application/json' -d '{"scheduledAt":"2026-10-10T09:00:00Z","type":"PHONE"}'HTTP/1.1 201 Location: http://localhost:18083/api/applications/1/interviews/1{"id":1,"scheduledAt":"2026-10-10T09:00:00Z","type":"PHONE","notes":null}$ curl -i -X POST http://localhost:18083/api/applications/1/interviews -H 'Content-Type: application/json' -d '{"scheduledAt":"2026-10-17T13:30:00Z","type":"TECHNICAL","notes":"Pair programming"}'HTTP/1.1 201 Location: http://localhost:18083/api/applications/1/interviews/2{"id":2,"scheduledAt":"2026-10-17T13:30:00Z","type":"TECHNICAL","notes":"Pair programming"}$ curl -i -X POST http://localhost:18083/api/applications/2/interviews -H 'Content-Type: application/json' -d '{"scheduledAt":"2026-10-12T10:00:00Z","type":"ONSITE"}'HTTP/1.1 201 Location: http://localhost:18083/api/applications/2/interviews/3{"id":3,"scheduledAt":"2026-10-12T10:00:00Z","type":"ONSITE","notes":null}$ curl -i -X POST http://localhost:18083/api/applications/9/interviews -H 'Content-Type: application/json' -d '{"scheduledAt":"2026-10-12T10:00:00Z","type":"ONSITE"}'HTTP/1.1 404 {"detail":"No job application with id 9","instance":"/api/applications/9/interviews","status":404,"title":"Application not found"}$ curl -i http://localhost:18083/api/applicationsHTTP/1.1 200 {"items":[{"id":1,"version":0,"company":"Acme","position":"Java Developer","status":"APPLIED","appliedOn":"2026-09-01","jobUrl":null,"notes":null,"createdAt":"<now>","updatedAt":"<now>","interviews":[{"id":1,"scheduledAt":"2026-10-10T09:00:00Z","type":"PHONE","notes":null},{"id":2,"scheduledAt":"2026-10-17T13:30:00Z","type":"TECHNICAL","notes":"Pair programming"}]},{"id":2,"version":0,"company":"Globex","position":"Backend Engineer","status":"APPLIED","appliedOn":"2026-09-02","jobUrl":null,"notes":null,"createdAt":"<now>","updatedAt":"<now>","interviews":[{"id":3,"scheduledAt":"2026-10-12T10:00:00Z","type":"ONSITE","notes":null}]},{"id":3,"version":0,"company":"Initech","position":"Platform Engineer","status":"APPLIED","appliedOn":"2026-09-03","jobUrl":null,"notes":null,"createdAt":"<now>","updatedAt":"<now>","interviews":[]}],"page":0,"size":20,"totalItems":3,"totalPages":1}Finally, deleting application 1 removes its interviews first, because of cascade and orphanRemoval. This is the real SQL for that single request, followed by the response of a later request for its interviews:
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,ja1_0.version from job_applications ja1_0 where ja1_0.id=?Hibernate: select i1_0.application_id,i1_0.id,i1_0.notes,i1_0.scheduled_at,i1_0.type from interviews i1_0 where i1_0.application_id=? order by i1_0.scheduled_atHibernate: delete from interviews where id=?Hibernate: delete from interviews where id=?Hibernate: delete from job_applications where id=? and version=?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,ja1_0.version from job_applications ja1_0 where ja1_0.id=?$ curl -i -X DELETE http://localhost:18083/api/applications/1HTTP/1.1 204 $ curl -i http://localhost:18083/api/applications/1/interviewsHTTP/1.1 404 {"detail":"No job application with id 1","instance":"/api/applications/1/interviews","status":404,"title":"Application not found"}Notice what the SQL tells you: Hibernate loads the application, loads its interviews, deletes them one by one (two delete statements for two interviews), and then deletes the application with the version check. Cascading removal through an entity collection issues one statement per child. For a parent with thousands of children you would use a bulk statement instead. The final select belongs to the next request, which is a lookup that now finds nothing and gives 404.
Test results
#mvn clean verify runs 45 tests, with no failures, on JDK 21 and JDK 25: 11 for the controller, 12 for the service with a mocked repository, 4 for the service with the real database, 4 for the repository, 8 for relationships and N+1, and 6 for transactions and locking. The database tests share one PostgreSQL server for the whole run through a small helper (TestPostgres), because starting one for every test class cost tens of seconds each. That is one of the practical costs of testing against a real database, and sharing the server is the standard way to pay it only once.
Review: what is still limited
#Limitation | Where it is addressed |
|---|---|
No authentication, and any caller can read or change anything | |
Deleting a parent with many children issues one | A bulk delete for large sets. Fine here |
The list nests every interview, so a very large number per application would make responses large | Return a count in lists and fetch interviews separately |
| Both are needed: one for slow edits, one for simultaneous ones |
Statement counts are checked in tests only for the paths the tests cover | Add a count test for every list endpoint that nests data |
Common mistakes
#- Making associations
EAGERto avoidLazyInitializationException, which only moves the problem into slow queries. - Returning entities from controllers, so lazy collections are touched during JSON serialisation.
- Not keeping both sides of a bidirectional relationship in sync.
- Not noticing N+1: results are correct, so no test fails. Count statements.
- Fetch-joining a collection together with paging without checking the generated SQL.
- Believing
@Transactionalworks onprivatemethods or on calls from the same class. - Expecting a checked exception to roll back.
- Making transactions long: remote calls, file uploads or waiting for a user inside them.
- Cascading
REMOVEto something that is not owned by the parent. - Building the response before the flush, so the version (or a generated value) is stale.
- Ignoring concurrent edits because "it works on my machine" with one user.
Exercises
#Exercise 1
Owning side
#You write interview.setApplication(app) but forget app.getInterviews().add(interview), then save the interview. What ends up in the database, and what is wrong with the in-memory object graph?
Exercise 2: Find the N+1
#A teammate's endpoint loads 200 applications and returns each with the company of its latest interview through application.getInterviews().get(0).getCompany(). Describe the symptom, how to prove it, and two fixes.
Exercise 3: Will it roll back?
#For each case say whether the save is rolled back: (a) @Transactional method throws IllegalArgumentException, (b) it throws IOException, (c) it catches the exception inside and returns normally, (d) it calls another @Transactional method of the same class that throws RuntimeException, and the first method does not catch it.
Exercise 4: The conflict
#Two users open application 5 (version 3). User A saves a change, and then user B saves. Describe exactly what each request does at the database level and what B sees.
Exercise 5: Choose the lock
#A booking system lets the user hold an item for 15 minutes while they pay. Would you use optimistic or pessimistic locking, and why?
Show solutionHide solution
The database is correct: the foreign key lives on the owning side (Interview.application), and that is what Hibernate writes, so application_id is set. But the in-memory object graph is inconsistent: the application's interviews list does not contain the new interview, so code that reads the list in the same session (for example building a response) will not see it until the application is reloaded. That is exactly why addInterview updates both sides.
Exercise 2
Show solutionHide solution
The symptom is an endpoint that returns correct data but is slow, and gets slower with more rows: 1 query for the applications plus up to 200 for the interviews (an N+1). Prove it with show-sql or a statement-count test like the ones in RelationshipTest. Fixes: batch fetching (@BatchSize on the collection) reduces it to 2 statements, or a join fetch / entity graph gets everything in one query, taking care with paging. A third option is a query that selects only what the response needs, such as the company of the latest interview, straight into a DTO, so no interview entities are loaded at all.
Exercise 3
Show solutionHide solution
(a) Yes, an unchecked exception rolls back by default. (b) No, a checked exception does not roll back unless rollbackFor says so (the IOException test above). (c) No: the exception never leaves the method, so Spring sees a normal return and commits. (d) No transaction is applied to the inner method at all, because the call is from within the same class (self-invocation, so no proxy). But the exception still propagates out of the outer method, and since the outer method is @Transactional and the exception is unchecked, the outer transaction rolls back. If the outer method is not transactional, nothing is rolled back.
Exercise 4
Show solutionHide solution
Both requests load the row at version 3. A's UPDATE is ... where id=5 and version=3, it matches, changes the row and sets the version to 4. B's UPDATE is ... where id=5 and version=3: no row matches any more (the version is 4), so zero rows are updated, Hibernate detects that and throws the optimistic-locking exception, and B's transaction rolls back. B sees an error (409 Conflict in this API) and must reload (now version 4), reapply the edit, and try again. A's change is never overwritten.
Exercise 5
Show solutionHide solution
For a 15-minute payment hold, you should not keep a database lock open for the whole time, because a transaction (and its row lock) held across a user interaction ties up connections and blocks others, and a user who walks away would block the item. Use an application-level reservation: a short transaction that marks the item as reserved with an expiry time (a held_until column), protected by a short lock or an optimistic version check so that two users cannot both reserve it, and then a separate short transaction on payment. Pessimistic locking suits short critical sections inside one request, and optimistic locking suits conflicts that are rare.
Where to go next
#- Next: Security Fundamentals and Spring Security puts authentication and authorisation in front of this API.
- Then: Testing Spring Boot Applications organises the slice, integration and API tests you have seen into a test strategy.
- For interviews: Interview Concepts, Part 2 turns the N+1, lazy loading, transaction and locking material into answers.
Sources & further reading
- Technology
- Software Engineering
- #Hibernate
- #Java
- #Java backend
- #Java interview
- #JPA
- #lazy loading
- #learn Java
- #N+1 problem
- #optimistic locking
- #Spring Boot
- #SQL
- #transactions
Connected lessons
References
Linked from 4 guides
Other pages in the library that point here.
Keep reading
More in Technology →Guide · Technology
Relational Databases and Data Modeling for System Design
Guide · Technology
Heaps and Priority Queues
Course · Technology
DSA From Zero to Job-Ready: A Practical Guide with Java

