Uso de repositorios JPA
En el documento anterior configuramos la generación de entidades en tiempo de build desde el esquema Sakila. Las entidades JPA existen ahora, pero nada en la aplicación las usa todavía. Este documento las conecta en la arquitectura hexagonal para que la app pueda consultar la base de datos, convertir resultados a modelos de dominio, y manejar el caso "no encontrado" limpiamente.
No hay nuevas dependencias siendo agregadas acá. Todo lo que necesitamos (Spring Data JPA, H2, MapStruct / ModelMapper) ya fue incorporado durante la configuración de ingeniería inversa.
Archivos nuevos
- Java
- Kotlin
- Groovy
Las adiciones resaltadas abarcan tanto src/main como src/test. Del lado de main estás creando el puerto secundario, su implementación, el repository JPA, el mapper entidad-a-dominio, y configurando el perfil dev para hablar con una base de datos H2 in-memory. Del lado test estás agregando un perfil de test, datos SQL de prueba reducidos, un integration test para la capa JPA, y actualizando tests existentes para contabilizar el nuevo puerto.
Configurá el perfil dev
Cada módulo tiene un application-dev.yaml que se activa cuando el perfil Spring dev está activo. Este es el lugar dónde le decís a Spring Boot cómo conectarse a la base de datos, cómo inicializarla, y cómo JPA debería comportarse.
- Java
- Kotlin
- Groovy
El modelo de coroutines de Kotlin no funciona bien con OpenSessionInView, que depende de thread-local storage. El filtro bindea una Session de Hibernate al thread actual y la mantiene abierta por todo el request, pero las coroutines pueden resumir en threads distintos. Configurar jpa.open-in-view: false asegura que la Session esté scoped al llamado del repository, no al thread del request. Ver el anti-patrón open-session-in-view para el razonamiento completo.
Hay algunos detalles que vale la pena notar:
- H2 in-memory (
jdbc:h2:mem:sakila) significa que no necesitás instalar ni manejar una base de datos externa para desarrollo local. ddl-auto: nonees deliberado. El esquema está completamente controlado porsakila-schema.sql, no por Hibernate. Hibernate no crea ni altera tablas.sql.init.mode: alwaysle dice a Spring que corra ambos scripts de esquema y data SQL en cada startup, entonces la base de datos siempre está en un estado conocido.- La consola H2 está habilitada en
/h2-consolepara consultas ad-hoc rápidas durante desarrollo.
Conectá el puerto secundario
Definí la interfaz del puerto
La capa de dominio declara qué necesita sin saber que JPA existe. FilmRepository es ese contrato: dame una película por ID, devolvé un modelo de dominio.
- 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)
}
Creá el repository JPA
FilmJpaRepository es el adaptador secundario. Spring Data JPA genera la implementación completa en runtime desde esta interfaz mínima. No hay métodos de consulta personalizados necesarios, el findById heredado es suficiente.
- 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> {}
El parámetro de tipo Film acá es la entidad JPA generada (dev.pollito.{module}.generated.entity.Film), no el modelo de dominio. Esa distinción importa y es exactamente por qué el mapper existe.
Mapeá entidades a modelos de dominio
El mapper convierte entidades JPA en modelos de dominio. Los nombres de campos no coinciden uno-a-uno (filmId → id, languageByLanguageId.name → language, releaseYear como LocalDate → Integer año), entonces el mapper maneja esa traducción.
- Java
- Kotlin
- Groovy
Implementá el puerto
FilmRepositoryImpl es un @Service que implementa FilmRepository y orquesta el repository y el 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())
}
La línea clave es repository.findById(id).orElseThrow(). Cuando la película solicitada no existe, Optional.orElseThrow() levanta una NoSuchElementException. La implementación no la atrapa. La deja propagarse por la call stack, dónde la capa web decide cómo representarla.
Hacé uso del puerto secundario
- 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)
}
Manejá el caso "no encontrado"
En lugar de atrapar NoSuchElementException dentro del adaptador (que arrastraría preocupaciones HTTP a la capa de dominio), el ControllerAdvice de cada módulo lo maneja globalmente y lo mapea a una 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)
}
Este es el mismo ControllerAdvice del setup de manejo de errors, con un nuevo @ExceptionHandler agregado. El puerto secundario se mantiene limpio de lógica HTTP, y el formato de respuesta de error se mantiene consistente a través de toda la API.
Probá la integración
application-test.yaml
Un perfil de test separado configura la base de datos para tests. La diferencia importante del perfil dev: sql.init.mode: never. Los scripts no se cargan en startup. En su lugar, cada clase de test los carga explícitamente vía @Sql, que te da control granular sobre datos de test.
- 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
Ambos src/test/resources/ contienen sus propias copias de sakila-schema.sql y sakila-data.sql. El archivo de esquema es idéntico al de main/resources. El archivo de data es intencionalmente mucho más pequeño (alrededor de 238 líneas vs. ~47,400 en el dataset completo). Ya que @DataJpaTest corre estos scripts antes de cada clase de test que declara @Sql, cargar el dataset completo en cada corrida de tests sería innecesariamente lento.
Integration test — @DataJpaTest
Los tests apuntando a la capa JPA usan @DataJpaTest, un Spring slice que carga solo infraestructura JPA (repositories, entity manager, datasource) sin el contexto completo de la aplicación.
- Java
- Kotlin
- Groovy
Hay algunos detalles que vale la pena notar:
@DataJpaTestcarga solo el slice JPA. No hay capa web, no hay contexto completo.@ActiveProfiles("test")levantaapplication-test.yamlconsql.init.mode: never.@Import(...)manualmente importaFilmRepositoryImply el mapper.@DataJpaTestno hace component-scan fuera del slice JPA, entonces cualquier cosa más allá de los repositories necesita importación explícita.@Sql(executionPhase = BEFORE_TEST_CLASS)corre el esquema y los scripts de data una sola vez antes de todos los métodos en la clase, no antes de cada test individual.- El test inyecta
FilmRepository(la interfaz), no la implementación concreta, manteniéndose leal a la abstracción del puerto.
La lista @Import difiere por módulo: Java y Kotlin importan FilmJpaMapperImpl (la clase generada por MapStruct), mientras que Groovy importa FilmJpaMapper directamente más ModelMapperConfig para suministrar el bean ModelMapper.
Unit test — Mocked secondary port
FilmUseCasesImpl (la implementación del puerto primario) se prueba en aislamiento mockeando FilmRepository. Estos tests no tienen ningún involucramiento de base de datos. Verifican que el puerto primario correctamente delega al puerto secundario y devuelve el resultado.
- Java
- Kotlin
- Groovy
Cada módulo usa su enfoque de mocking nativo:
| Módulo | Framework | Estilo de Mock |
|---|---|---|
| Java | JUnit 5 + Mockito | @Mock, when(...).thenReturn(...) |
| Kotlin | JUnit 5 + MockK | @MockK, every { ... } returns ... |
| Groovy | Spock | Mock(), >> stubbing |
Actualización de test ControllerAdvice
El test ControllerAdvice existente obtiene un nuevo test case para NoSuchElementException → 404 NOT_FOUND. La estructura es el mismo test parametrizado de antes, solo con una entrada más en el dataset.
- Java
- Kotlin
- Groovy
El flujo completo
Con todo conectado, acá está qué pasa cuando un request le pega a la aplicación. El proyecto sigue la arquitectura hexagonal. JPA se sienta enteramente afuera de la capa de dominio, reachable solo a través de una interfaz de puerto:
Buildeá y corré la aplicación con el perfil dev, entonces le pegas al endpoint:
Una nota sobre FilmUseCasesImpl
FilmUseCasesImpl hace muy poco: recibe un ID de película, llama al puerto secundario y devuelve el resultado. Sin lógica de negocio, sin transformaciones. Solo delegación de paso.
En el contexto de la arquitectura hexagonal, eso está perfectamente bien. La implementación del puerto primario es el punto de orquestación del dominio. El dominio no necesita saber si los datos vienen de una base de datos relacional, una API de terceros, o un caché en memoria. Si mañana reemplazás el adaptador JPA con un cliente REST que llama a un servicio externo, el dominio permanece completamente intacto. Lo mismo aplica del lado de entrada: cambiar controladores REST por SOAP, GraphQL, o un consumidor de mensajes no requiere ningún cambio en la capa de dominio.
En la práctica, sin embargo, es común encontrarse con codebases donde el dominio inyecta directamente un JpaRepository, un WebClient, o lo que sea que use el adaptador de salida. Ese atajo funciona, y elimina la ceremonia de definir interfaces de puertos y sus implementaciones. El costo solo se hace evidente cuando se necesita un cambio: los detalles del adaptador se filtraron al dominio, y desenredarlos requiere tocar código que se suponía estaba aislado de ese tipo de cambio.
Si la indirección vale la pena o no es una decisión del desarrollador. La arquitectura hexagonal hace el límite explícito; saltársela cambia la flexibilidad a largo plazo por simplicidad a corto plazo.