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.
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.
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.
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.
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.
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.
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), so the mapper handles that translation.
The Groovy implementation's FilmJpaMapper uses a syntax that Spotless cannot properly format. Exclude this file from Spotless checks by updating your build.gradle:
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.
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.
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:
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.
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
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
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.
Tests targeting the JPA layer use @DataJpaTest, a Spring slice that loads only JPA infrastructure (repositories, entity manager, datasource) without the full application context.
@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.
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.
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.
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.
@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.
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" } }
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.