Skip to main content

JPA repository usage

Complete Code
The end result of the code developed in this document can be found in the GitHub monorepo springboot-demo-projects, commit(s) JPA Repository Usage (spring_java), JPA Repository Usage (spring_kotlin), JPA Repository Usage (spring_groovy)

In the previous document we set up build-time entity generation from the Sakila schema. The JPA entities exist now, but nothing in the application uses them yet. This document wires them into the hexagonal architecture so the app can query the database, convert results to domain models, and handle the "not found" case cleanly.

No new dependencies are being added here. Everything we need (Spring Data JPA, H2, MapStruct / ModelMapper) was already pulled in during the reverse engineering setup.

New files

Files to Create/Modify
File Tree
├── ...
└── src
├── main
│ ├── java
│ │ └── dev
│ │ └── pollito
│ │ └── spring_java
│ │ ├── config
│ │ │ └── web
│ │ │ └── ControllerAdvice.java
│ │ └── sakila
│ │ └── film
│ │ ├── adapter
│ │ │ └── out
│ │ │ └── jpa
│ │ │ ├── FilmJpaMapper.java
│ │ │ ├── FilmJpaRepository.java
│ │ │ └── FilmRepositoryImpl.java
│ │ └── domain
│ │ ├── port
│ │ │ └── out
│ │ │ └── FilmRepository.java
│ │ └── service
│ │ └── FilmUseCasesImpl.java
│ └── resources
│ ├── application-dev.yaml
│ └── sakila-data.sql
└── test
├── java
│ └── dev
│ └── pollito
│ └── spring_java
│ ├── config
│ │ └── web
│ │ └── ControllerAdviceMockMvcTest.java
│ └── sakila
│ └── film
│ ├── adapter
│ │ └── out
│ │ └── jpa
│ │ └── FilmRepositoryImplDataJpaTest.java
│ └── domain
│ └── service
│ └── FilmUseCasesImplTest.java
└── resources
├── application-test.yaml
├── sakila-data.sql
└── sakila-schema.sql

The highlighted additions span both src/main and src/test. On the main side you're creating the secondary port, its implementation, the JPA repository, the entity-to-domain mapper, and configuring the dev profile to talk to an in-memory H2 database. On the test side you're adding a test profile, reduced SQL seed data, an integration test for the JPA layer, and updating existing tests to account for the new port.

Configure the dev profile

Each module has an application-dev.yaml that activates when the dev Spring profile is active. This is where you tell Spring Boot how to connect to the database, how to initialize it, and how JPA should behave.

resources/application-dev.yaml
spring:
application:
name: spring_java
datasource:
url: jdbc:h2:mem:sakila;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
driver-class-name: org.h2.Driver
username: sa
password:
sql:
init:
mode: always
schema-locations: classpath:sakila-schema.sql
data-locations: classpath:sakila-data.sql
jpa:
database-platform: org.hibernate.dialect.H2Dialect
hibernate:
ddl-auto: none
show-sql: true
properties:
hibernate:
format_sql: true
h2:
console:
enabled: true
path: /h2-console
settings:
web-allow-others: false
management:
otlp:
metrics:
export:
enabled: false

A few things to notice:

  • In-memory H2 (jdbc:h2:mem:sakila) means no external database to install or manage for local development.
  • ddl-auto: none is deliberate. The schema is fully controlled by sakila-schema.sql, not by Hibernate. Hibernate doesn't create or alter tables.
  • sql.init.mode: always tells Spring to run both the schema and data SQL scripts on every startup, so the database is always in a known state.
  • The H2 console is enabled at /h2-console for quick ad-hoc queries during development.

Wire the secondary port

Define the port out interface

The domain layer declares what it needs without knowing JPA exists. FilmRepository is that contract: give me a film by ID, return a domain model.

java/dev/pollito/spring_java/sakila/film/domain/port/out/FilmRepository.java
package dev.pollito.spring_java.sakila.film.domain.port.out;

import dev.pollito.spring_java.sakila.film.domain.model.Film;

public interface FilmRepository {
Film getFilm(Integer id);
}

Create the JPA repository

FilmJpaRepository is the secondary adapter. Spring Data JPA generates the full implementation at runtime from this minimal interface. No custom query methods needed, the inherited findById is enough.

java/dev/pollito/spring_java/sakila/film/adapter/out/jpa/FilmJpaRepository.java
package dev.pollito.spring_java.sakila.film.adapter.out.jpa;

import dev.pollito.spring_java.sakila.generated.entity.Film;
import org.springframework.data.jpa.repository.JpaRepository;

public interface FilmJpaRepository extends JpaRepository<Film, Integer> {}

The Film type parameter here is the generated JPA entity (dev.pollito.{module}.sakila.generated.entity.Film), not the domain model. That distinction matters and is exactly why the mapper exists.

Map entities to domain models

The mapper converts JPA entities into domain models. Field names don't match one-to-one (filmIdid, languageByLanguageId.namelanguage, releaseYear as LocalDateInteger year, and others), so the mapper handles that translation.

java/dev/pollito/spring_java/sakila/film/adapter/out/jpa/FilmJpaMapper.java
package dev.pollito.spring_java.sakila.film.adapter.out.jpa;

import static org.mapstruct.MappingConstants.ComponentModel.SPRING;

import dev.pollito.spring_java.common.util.EnumUtils;
import dev.pollito.spring_java.sakila.film.domain.model.Film;
import dev.pollito.spring_java.sakila.film.domain.model.FilmLanguage;
import dev.pollito.spring_java.sakila.film.domain.model.FilmRating;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;

@Mapper(
componentModel = SPRING,
imports = {
EnumUtils.class,
FilmLanguage.class,
FilmRating.class,
OffsetDateTime.class,
ZoneOffset.class
})
public interface FilmJpaMapper {

@Mapping(target = "id", source = "filmId")
@Mapping(
target = "language",
expression =
"java(source.getLanguageByLanguageId() != null && source.getLanguageByLanguageId().getName() != null ? EnumUtils.fromValue(FilmLanguage.class, source.getLanguageByLanguageId().getName()) : null)")
@Mapping(
target = "originalLanguage",
expression =
"java(source.getLanguageByOriginalLanguageId() != null && source.getLanguageByOriginalLanguageId().getName() != null ? EnumUtils.fromValue(FilmLanguage.class, source.getLanguageByOriginalLanguageId().getName()) : null)")
@Mapping(
target = "releaseYear",
expression =
"java(source.getReleaseYear() != null ? source.getReleaseYear().getYear() : null)")
@Mapping(
target = "rating",
expression =
"java(source.getRating() != null ? EnumUtils.fromValue(FilmRating.class, source.getRating()) : null)")
@Mapping(
target = "lastUpdate",
expression =
"java(source.getLastUpdate() != null ? source.getLastUpdate().atOffset(ZoneOffset.UTC) : null)")
Film map(dev.pollito.spring_java.sakila.generated.entity.Film source);
}

Implement the port

FilmRepositoryImpl is a @Service that implements FilmRepository and orchestrates the repository and mapper.

java/dev/pollito/spring_java/sakila/film/adapter/out/jpa/FilmRepositoryImpl.java
package dev.pollito.spring_java.sakila.film.adapter.out.jpa;

import dev.pollito.spring_java.sakila.film.domain.model.Film;
import dev.pollito.spring_java.sakila.film.domain.port.out.FilmRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class FilmRepositoryImpl implements FilmRepository {
private final FilmJpaRepository repository;
private final FilmJpaMapper mapper;

@Override
public Film getFilm(Integer id) {
return mapper.map(repository.findById(id).orElseThrow());
}
}

The key line is repository.findById(id).orElseThrow(). When the requested film doesn't exist, Optional.orElseThrow() raises a NoSuchElementException. The implementation doesn't catch it. It lets the exception propagate up the call stack, where the web layer decides how to represent it.

Use the secondary port

java/dev/pollito/spring_java/sakila/film/domain/service/FilmUseCasesImpl.java
package dev.pollito.spring_java.sakila.film.domain.service;

import dev.pollito.spring_java.sakila.film.domain.model.Film;
import dev.pollito.spring_java.sakila.film.domain.port.in.FilmUseCases;
import dev.pollito.spring_java.sakila.film.domain.port.out.FilmRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class FilmUseCasesImpl implements FilmUseCases {
private final FilmRepository repository;

@Override
public Film getFilm(Integer id) {
return repository.getFilm(id);
}
}

Handle the "not found" case

Rather than catching NoSuchElementException inside the adapter (which would drag HTTP concerns into the domain layer), each module's ControllerAdvice handles it globally and maps it to an HTTP 404 NOT_FOUND:

java/dev/pollito/spring_java/config/web/ControllerAdvice.java (new NoSuchElementException handler)
@ExceptionHandler(NoSuchElementException.class)
public ResponseEntity<Error> handle(NoSuchElementException e) {
return buildProblemDetail(e, NOT_FOUND);
}

This is the same ControllerAdvice from the error handling setup, with one new @ExceptionHandler added. The secondary port stays clean of HTTP logic, and the error response format stays consistent across the entire API.

Test the Integration

application-test.yaml

A separate test profile configures the database for tests. The important difference from the dev profile: sql.init.mode: never. Scripts aren't loaded on startup. Instead, each test class loads them explicitly via @Sql, which gives you fine-grained control over test data.

resources/application-test.yaml
spring:
datasource:
url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1
driver-class-name: org.h2.Driver
username: sa
password:
jpa:
database-platform: org.hibernate.dialect.H2Dialect
hibernate:
ddl-auto: none
show-sql: true
sql:
init:
mode: never

Both src/test/resources/ contains its own copies of sakila-schema.sql and sakila-data.sql. The schema file is identical to the one in main/resources. The data file is intentionally much smaller (around 238 lines vs ~47,400 in the full dataset). Since @DataJpaTest runs these scripts before every test class that declares @Sql, loading the full dataset on every test run would be needlessly slow.

Integration test — @DataJpaTest

Tests targeting the JPA layer use @DataJpaTest, a Spring slice that loads only JPA infrastructure (repositories, entity manager, datasource) without the full application context.

java/dev/pollito/spring_java/sakila/film/adapter/out/jpa/FilmRepositoryImplDataJpaTest.java
package dev.pollito.spring_java.sakila.film.adapter.out.jpa;

import static org.junit.jupiter.api.Assertions.*;
import static org.springframework.test.context.jdbc.Sql.ExecutionPhase.BEFORE_TEST_CLASS;

import dev.pollito.spring_java.sakila.film.domain.port.out.FilmRepository;
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.context.annotation.Import;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.jdbc.Sql;

@DataJpaTest
@ActiveProfiles("test")
@Import({FilmRepositoryImpl.class, FilmJpaMapperImpl.class})
@Sql(
scripts = {"/sakila-schema.sql", "/sakila-data.sql"},
executionPhase = BEFORE_TEST_CLASS)
class FilmRepositoryImplDataJpaTest {

@SuppressWarnings("unused")
@Autowired
private FilmRepository repository;

@Test
void getFilmReturnsADomainModel() {
assertEquals(1, repository.getFilm(1).getId());
}
}

A few things worth noting:

  • @DataJpaTest loads only the JPA slice. No web layer, no full context.
  • @ActiveProfiles("test") picks up application-test.yaml with sql.init.mode: never.
  • @Import(...) manually imports FilmRepositoryImpl and the mapper. @DataJpaTest doesn't component-scan outside the JPA slice, so anything beyond repositories needs explicit importing.
  • @Sql(executionPhase = BEFORE_TEST_CLASS) runs the schema and data scripts once before all methods in the class, not before each individual test.
  • The test autowires FilmRepository (the interface), not the concrete implementation, staying true to the port abstraction.

The @Import list differs by module: Java and Kotlin import FilmJpaMapperImpl (the MapStruct-generated class), while Groovy imports FilmJpaMapper directly plus ModelMapperConfig to supply the ModelMapper bean.

Unit test — Mocked secondary port

FilmUseCasesImpl (the primary port implementation) is tested in isolation by mocking FilmRepository. These tests have zero database involvement. They verify that the primary port correctly delegates to the secondary port and returns the result.

java/dev/pollito/spring_java/sakila/film/domain/service/FilmUseCasesImplTest.java
package dev.pollito.spring_java.sakila.film.domain.service;

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

import dev.pollito.spring_java.sakila.film.domain.model.Film;
import dev.pollito.spring_java.sakila.film.domain.port.out.FilmRepository;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

@ExtendWith(MockitoExtension.class)
class FilmUseCasesImplTest {
@InjectMocks private FilmUseCasesImpl filmUseCases;
@Mock private FilmRepository repository;

@Test
void getFilmReturnsADomainModel() {
when(repository.getFilm(anyInt())).thenReturn(mock(Film.class));
assertNotNull(filmUseCases.getFilm(1));
}
}

Each module uses its native mocking approach:

ModuleFrameworkMock Style
JavaJUnit 5 + Mockito@Mock, when(...).thenReturn(...)
KotlinJUnit 5 + MockK@MockK, every { ... } returns ...
GroovySpockMock(), >>

ControllerAdvice test update

The existing ControllerAdvice test gets a new test case for NoSuchElementException404 NOT_FOUND. The structure is the same parameterized test from before, just with one more entry in the data set.

java/dev/pollito/spring_java/config/web/ControllerAdviceMockMvcTest.java
package dev.pollito.spring_java.config.web;

import static dev.pollito.spring_java.test.util.MockMvcResultMatchers.hasErrorFields;
import static dev.pollito.spring_java.test.util.MockMvcResultMatchers.hasStandardApiResponseFields;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static org.springframework.http.HttpMethod.GET;
import static org.springframework.http.HttpStatus.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.ConstraintViolationException;
import java.util.NoSuchElementException;
import java.util.Set;
import java.util.stream.Stream;
import org.jspecify.annotations.NonNull;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.springframework.core.MethodParameter;
import org.springframework.http.HttpStatus;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.resource.NoResourceFoundException;

class ControllerAdviceMockMvcTest {

private MockMvc mockMvc;
private final HttpServletRequest request = mock(HttpServletRequest.class);

@RestController
@RequestMapping("/fake")
private static class FakeController {

@GetMapping("/not-found")
@SuppressWarnings("unused")
public void throwNoResourceFoundException() throws NoResourceFoundException {
throw new NoResourceFoundException(GET, "/fake", "no-resource-found");
}

@GetMapping("/error")
@SuppressWarnings("unused")
public void throwException() throws Exception {
throw new Exception("Test exception");
}

@GetMapping("/bad-request")
@SuppressWarnings("unused")
public void throwConstraintViolationException() {
throw new ConstraintViolationException("Constraint violation", Set.of());
}

@GetMapping("/method-arg-not-valid")
@SuppressWarnings({"unused"})
public void throwMethodArgumentNotValidException() throws Exception {
throw new MethodArgumentNotValidException(
new MethodParameter(
FakeController.class.getMethod("throwMethodArgumentNotValidException"), -1),
mock(BindingResult.class));
}

@GetMapping("/no-such-element")
@SuppressWarnings("unused")
public void throwNoSuchElementException() {
throw new NoSuchElementException("No such element");
}
}

@BeforeEach
void setUp() {
mockMvc =
standaloneSetup(new FakeController())
.setControllerAdvice(new ControllerAdvice(request))
.build();
}

static @NonNull Stream<Arguments> testCases() {
return Stream.of(
Arguments.of("/fake/not-found", NOT_FOUND),
Arguments.of("/fake/error", INTERNAL_SERVER_ERROR),
Arguments.of("/fake/bad-request", BAD_REQUEST),
Arguments.of("/fake/method-arg-not-valid", BAD_REQUEST),
Arguments.of("/fake/no-such-element", NOT_FOUND));
}

@ParameterizedTest
@MethodSource("testCases")
void exceptionHandlingReturnsCorrectStatus(String path, @NonNull HttpStatus expectedStatus)
throws Exception {
when(request.getRequestURI()).thenReturn(path);
mockMvc
.perform(get(path))
.andExpect(status().is(expectedStatus.value()))
.andExpect(hasStandardApiResponseFields(path, expectedStatus))
.andExpect(hasErrorFields(expectedStatus));
}
}

The complete flow

With everything wired up, here's what happens when a request hits the application. The project follows hexagonal architecture. JPA sits entirely outside the domain layer, reachable only through a port interface:

Scroll to zoom • Drag corner to resize

Build and run the application with the dev profile, then hit the endpoint:

Terminal
curl -s http://localhost:8080/api/films/42 | jq
{
"instance": "/api/films/42",
"status": 200,
"timestamp": "2026-04-01T17:33:52.486385928+01:00",
"trace": "cc4f4b4afbc7b3f280bcdb14dc960e83",
"data": {
"title": "ARTIST COLDBLOODED",
"language": "English",
"rentalDuration": 5,
"rentalRate": 2.99,
"replacementCost": 10.99,
"id": 42,
"lastUpdate": "2006-02-15T05:03:42Z",
"description": "A Stunning Reflection of a Robot And a Moose who must Challenge a Woman in California",
"releaseYear": 2006,
"rating": "NC-17",
"length": 170,
"originalLanguage": null,
"specialFeatures": "Trailers,Behind the Scenes"
}
}

A note on FilmUseCasesImpl

FilmUseCasesImpl does very little: it receives a film ID, calls the secondary port, and returns the result. No business logic, no transformation. Just pass-through delegation.

In the context of hexagonal architecture, that's perfectly fine. The primary port implementation is the domain's orchestration point. The domain doesn't need to know whether the data comes from a relational database, a third-party API, or an in-memory cache. If tomorrow you replace the JPA adapter with a REST client that calls an external service, the domain remains completely untouched. The same goes for the inbound side: swapping REST controllers for SOAP, GraphQL, or a message consumer requires no changes to the domain layer.

In practice, though, it's common to come across codebases where the domain directly injects a JpaRepository, a WebClient, or whatever the outbound adapter happens to use. That shortcut works, and it eliminates the ceremony of defining port interfaces and their implementations. The cost only becomes apparent when a swap is needed: the adapter's details have leaked into the domain, and untangling them requires touching code that was supposed to be insulated from that kind of change.

Whether the indirection is worth the overhead is a judgment call. Hexagonal architecture makes the boundary explicit; skipping it trades long-term flexibility for short-term simplicity.