JPA repository usage
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
- Java
- Kotlin
- Groovy
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.
- Java
- Kotlin
- Groovy
Kotlin's coroutine model doesn't play well with OpenSessionInView, which relies on thread-local storage. The filter binds a Hibernate Session to the current thread and keeps it open for the entire request, but coroutines can resume on different threads. Setting jpa.open-in-view: false ensures the Session is scoped to the repository call, not the request thread. See the open-session-in-view anti-pattern for the full rationale.
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: noneis deliberate. The schema is fully controlled bysakila-schema.sql, not by Hibernate. Hibernate doesn't create or alter tables.sql.init.mode: alwaystells 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-consolefor 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
- Kotlin
- Groovy
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);
}
package dev.pollito.spring_kotlin.sakila.film.domain.port.out
import dev.pollito.spring_kotlin.sakila.film.domain.model.Film
interface FilmRepository {
fun getFilm(id: Int): Film
}
package dev.pollito.spring_groovy.sakila.film.domain.port.out
import dev.pollito.spring_groovy.sakila.film.domain.model.Film
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
- Kotlin
- Groovy
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> {}
package dev.pollito.spring_kotlin.sakila.film.adapter.out.jpa
import dev.pollito.spring_kotlin.sakila.generated.entity.Film
import org.springframework.data.jpa.repository.JpaRepository
interface FilmJpaRepository : JpaRepository<Film, Int>
package dev.pollito.spring_groovy.sakila.film.adapter.out.jpa
import dev.pollito.spring_groovy.sakila.generated.entity.Film
import org.springframework.data.jpa.repository.JpaRepository
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 (filmId → id, languageByLanguageId.name → language, releaseYear as LocalDate → Integer year, and others), so the mapper handles that translation.
- Java
- Kotlin
- Groovy
Implement the port
FilmRepositoryImpl is a @Service that implements FilmRepository and orchestrates the repository and mapper.
- Java
- Kotlin
- Groovy
package dev.pollito.spring_kotlin.sakila.film.adapter.out.jpa
import dev.pollito.spring_kotlin.sakila.film.domain.model.Film
import dev.pollito.spring_kotlin.sakila.film.domain.port.out.FilmRepository
import org.springframework.stereotype.Service
@Service
class FilmRepositoryImpl(
private val repository: FilmJpaRepository,
private val mapper: FilmJpaMapper,
) : FilmRepository {
override fun getFilm(id: Int): Film = 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
- Kotlin
- Groovy
package dev.pollito.spring_kotlin.sakila.film.domain.service
import dev.pollito.spring_kotlin.sakila.film.domain.model.Film
import dev.pollito.spring_kotlin.sakila.film.domain.port.`in`.FilmUseCases
import dev.pollito.spring_kotlin.sakila.film.domain.port.out.FilmRepository
import org.springframework.stereotype.Service
@Service
class FilmUseCasesImpl(private val repository: FilmRepository) : FilmUseCases {
override fun getFilm(id: Int): Film = 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
- Kotlin
- Groovy
@ExceptionHandler(NoSuchElementException.class)
public ResponseEntity<Error> handle(NoSuchElementException e) {
return buildProblemDetail(e, NOT_FOUND);
}
@ExceptionHandler(NoSuchElementException::class)
fun handle(e: NoSuchElementException): ResponseEntity<Error> {
return buildErrorResponse(e, NOT_FOUND)
}
@ExceptionHandler(NoSuchElementException)
ResponseEntity<Error> handle(NoSuchElementException e) {
buildErrorResponse(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.
- Java
- Kotlin
- Groovy
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
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
- Kotlin
- Groovy
A few things worth noting:
@DataJpaTestloads only the JPA slice. No web layer, no full context.@ActiveProfiles("test")picks upapplication-test.yamlwithsql.init.mode: never.@Import(...)manually importsFilmRepositoryImpland the mapper.@DataJpaTestdoesn'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
- Kotlin
- Groovy
Each module uses its native mocking approach:
| Module | Framework | Mock Style |
|---|---|---|
| Java | JUnit 5 + Mockito | @Mock, when(...).thenReturn(...) |
| Kotlin | JUnit 5 + MockK | @MockK, every { ... } returns ... |
| Groovy | Spock | Mock(), >> |
ControllerAdvice test update
The existing ControllerAdvice test gets a new test case for NoSuchElementException → 404 NOT_FOUND. The structure is the same parameterized test from before, just with one more entry in the data set.
- Java
- Kotlin
- Groovy
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:
Build and run the application with the dev profile, then hit the endpoint:
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.