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, under the tag persistence-integration.

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
│ │ │ └── advice
│ │ │ └── ControllerAdvice.java
│ │ └── sakila
│ │ └── film
│ │ ├── adapter
│ │ │ └── out
│ │ │ └── jpa
│ │ │ ├── FilmJpaMapper.java
│ │ │ └── FilmJpaRepository.java
│ │ └── domain
│ │ ├── model/...
│ │ └── port
│ │ └── out
│ │ ├── FindByIdPortOut.java
│ │ └── FindByIdPortOutImpl.java
│ └── resources
│ ├── application-dev.yaml
│ ├── ...
│ └── sakila-schema.sql
└── test
├── java
│ └── dev
│ └── pollito
│ └── spring_java
│ ├── SanityCheckSpringBootTest.java
│ ├── config
│ │ └── advice
│ │ └── ControllerAdviceTest.java
│ └── sakila
│ └── film
│ └── domain
│ └── port
│ ├── in
│ │ └── FindByIdPortInImplTest.java
│ └── out
│ └── FindByIdPortOutImplDataJpaTest.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
# ...
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
# ...

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.
  • Kotlin additionally sets jpa.open-in-view: false to disable the open-session-in-view anti-pattern. Java and Groovy leave it at the default (enabled), which is a Spring Boot default worth knowing about.

Wire the Secondary Port

Define the Port Interface

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

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

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

public interface FindByIdPortOut {
Film findById(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.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}.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), 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 dev.pollito.spring_java.config.mapper.MapperSpringConfig;
import dev.pollito.spring_java.sakila.film.domain.model.Film;
import java.time.LocalDate;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.springframework.core.convert.converter.Converter;

@Mapper(config = MapperSpringConfig.class)
public interface FilmJpaMapper
extends Converter<dev.pollito.spring_java.generated.entity.Film, Film> {

@Override
@Mapping(target = "id", source = "filmId")
@Mapping(target = "language", source = "languageByLanguageId.name")
Film convert(dev.pollito.spring_java.generated.entity.Film source);

default Integer mapReleaseYear(LocalDate releaseYear) {
return releaseYear != null ? releaseYear.getYear() : null;
}
}
Groovy Spotless Configuration

The Groovy implementation's FilmJpaMapper uses a syntax that Spotless cannot properly format. Exclude this file from Spotless checks by updating your build.gradle:

build.gradle
// ...
spotless {
groovy {
target 'src/*/groovy/**/*.groovy'
targetExclude 'build/**/*.groovy', '**/FilmJpaMapper.groovy'
importOrder()
removeSemicolons()
greclipse().configFile('greclipse.properties')
}
groovyGradle {
target '*.gradle'
greclipse().configFile('greclipse.properties')
}
}
// ...

This exclusion is necessary because the mapper's closure-based configuration in the configureTypeMap() method doesn't comply with the standard formatting rules that Spotless applies. The file remains functional and follows clean code principles; it's just excluded from automatic formatting to prevent Spotless from rejecting or mishandling it.

Implement the Port

FindByIdPortOutImpl implements FindByIdPortOut and orchestrates the repository and mapper. It's a @Service that lives alongside the port interface.

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

import dev.pollito.spring_java.sakila.film.adapter.out.jpa.FilmJpaMapper;
import dev.pollito.spring_java.sakila.film.adapter.out.jpa.FilmJpaRepository;
import dev.pollito.spring_java.sakila.film.domain.model.Film;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class FindByIdPortOutImpl implements FindByIdPortOut {
private final FilmJpaRepository repository;
private final FilmJpaMapper mapper;

@Override
public Film findById(Integer id) {
return mapper.convert(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.

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/advice/ControllerAdvice.java
// ...
import java.util.NoSuchElementException;
// ...
public class ControllerAdvice {
// ...
@ExceptionHandler(NoSuchElementException.class)
public ResponseEntity<Error> handle(NoSuchElementException e) {
// ...
}
}

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/domain/port/out/FindByIdPortOutImplDataJpaTest.java
package dev.pollito.spring_java.sakila.film.domain.port.out;

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

import dev.pollito.spring_java.sakila.film.adapter.out.jpa.FilmJpaMapperImpl;
import dev.pollito.spring_java.sakila.film.domain.model.Film;
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({FindByIdPortOutImpl.class, FilmJpaMapperImpl.class})
@Sql(
scripts = {"/sakila-schema.sql", "/sakila-data.sql"},
executionPhase = BEFORE_TEST_CLASS)
class FindByIdPortOutImplDataJpaTest {

@SuppressWarnings("unused")
@Autowired
private FindByIdPortOut findByIdPortOut;

@Test
void findByIdFindsAnEntityReturnsADomainModel() {
Integer id = 1;
Film result = findByIdPortOut.findById(id);

assertNotNull(result);
assertEquals(id, result.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 FindByIdPortOutImpl 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 FindByIdPortOut (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

FindByIdPortInImpl (the primary port implementation) is tested in isolation by mocking FindByIdPortOut. 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/port/in/FindByIdPortInImplTest.java
package dev.pollito.spring_java.sakila.film.domain.port.in;

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.FindByIdPortOut;
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 FindByIdPortInImplTest {
@InjectMocks private FindByIdPortInImpl findByIdPortIn;
@Mock private FindByIdPortOut findByIdPortOut;

@Test
void findByIdReturnsADomainModel() {
when(findByIdPortOut.findById(anyInt())).thenReturn(mock(Film.class));
assertNotNull(findByIdPortIn.findById(1));
}
}

Each module uses its native mocking approach:

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

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/advice/ControllerAdviceTest.java
// ...
import java.util.NoSuchElementException;
// ...
class ControllerAdviceTest {
// ...
private static class FakeController {
@GetMapping("/no-such-element")
@SuppressWarnings("unused")
public void throwNoSuchElementException() {
throw new NoSuchElementException("No such element");
}
}
// ...
static @NonNull Stream<Arguments> testCases() {
return Stream.of(
// ...
Arguments.of("/fake/no-such-element", NOT_FOUND));
}
// ...
}

Update the Sanity Check Test

The existing SanityCheckSpringBootTest / SanityCheckSpringBootSpec needs to be updated to use the test profile and load the database schema. Without these changes, the test will fail because the application now depends on the database being initialized.

java/dev/pollito/spring_java/SanityCheckSpringBootTest.java
// ...
import static org.springframework.test.context.jdbc.Sql.ExecutionPhase.BEFORE_TEST_CLASS;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.jdbc.Sql;

@SpringBootTest
@AutoConfigureMockMvc
@ExtendWith(OutputCaptureExtension.class)
@ActiveProfiles("test")
@Sql(
scripts = {"/sakila-schema.sql", "/sakila-data.sql"},
executionPhase = BEFORE_TEST_CLASS)
class SanityCheckSpringBootTest {
// ...
}

The two key additions:

  • @ActiveProfiles("test") activates the application-test.yaml configuration, which sets sql.init.mode: never.
  • @Sql(scripts = [...], executionPhase = BEFORE_TEST_CLASS) explicitly loads the schema and data before the test class runs, ensuring the database is ready.

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/1 | jq
{
"instance": "/api/films/1",
"status": 200,
"timestamp": "2026-03-08T01:49:43.360796324Z",
"trace": "b7639cc6048c55bd18954a6f61c1c818",
"data": {
"description": "A Epic Drama of a Feminist And a Mad Scientist who must Battle a Teacher in The Canadian Rockies",
"id": 1,
"language": "English",
"length": 86,
"rating": "PG",
"releaseYear": 2006,
"title": "ACADEMY DINOSAUR"
}
}

A Note on FindByIdPortInImpl

FindByIdPortInImpl 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 judgement call. Hexagonal architecture makes the boundary explicit; skipping it trades long-term flexibility for short-term simplicity.