Saltar al contenido principal

Uso de repositorios JPA

Código Completo
El resultado final del código desarrollado en este documento se puede encontrar en el monorepo de GitHub springboot-demo-projects, bajo el tag persistence-integration.

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

Archivos a Crear/Modificar
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

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.

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
# ...

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: none es deliberado. El esquema está completamente controlado por sakila-schema.sql, no por Hibernate. Hibernate no crea ni altera tablas.
  • sql.init.mode: always le 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-console para consultas ad-hoc rápidas durante desarrollo.
  • Kotlin además setea jpa.open-in-view: false para deshabilitar el open-session-in-view anti-pattern. Java y Groovy lo dejan en el default (habilitado), que es un default de Spring Boot que conviene conocer.

Conectá el Puerto Secundario

Definí la Interfaz del Puerto

La capa de dominio declara qué necesita sin saber que JPA existe. FindByIdPortOut es ese contrato: dame una película por ID, devolvé un modelo de dominio.

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);
}

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/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> {}

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.

Mapá Entidades a Modelos de Dominio

El mapper convierte entidades JPA en modelos de dominio. Los nombres de campos no coinciden uno-a-uno (filmIdid, languageByLanguageId.namelanguage, releaseYear como LocalDateInteger año), entonces el mapper maneja esa traducción.

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;
}
}
Configuración Groovy Spotless

La implementación de Groovy de FilmJpaMapper usa una sintaxis que Spotless no puede formatear apropiadamente. Excluí este archivo de chequeos Spotless actualizando tu 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')
}
}
// ...

Esta exclusión es necesaria porque la configuración basada en closures del mapper en el método configureTypeMap() no cumple con las reglas de formateo estándar que Spotless aplica. El archivo permanece funcional y sigue principios de código limpio; solo es excluido de formateo automático para prevenir que Spotless lo rechace o lo maneje mal.

Implementá el Puerto

FindByIdPortOutImpl implementa FindByIdPortOut y orquesta el repository y el mapper. Es un @Service que vive junto a la interfaz del puerto.

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());
}
}

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.

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

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.

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

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/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());
}
}

Hay algunos detalles que vale la pena notar:

  • @DataJpaTest carga solo el slice JPA. No hay capa web, no hay contexto completo.
  • @ActiveProfiles("test") levanta application-test.yaml con sql.init.mode: never.
  • @Import(...) manualmente importa FindByIdPortOutImpl y el mapper. @DataJpaTest no 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 FindByIdPortOut (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

FindByIdPortInImpl (la implementación del puerto primario) se prueba en aislamiento mockeando FindByIdPortOut. 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/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));
}
}

Cada módulo usa su enfoque de mocking nativo:

MóduloFrameworkEstilo de Mock
JavaJUnit 5 + Mockito@Mock, when(...).thenReturn(...)
KotlinJUnit 5 + MockK@MockK, every { ... } returns ...
GroovySpockMock(), >> stubbing

Actualización de Test ControllerAdvice

El test ControllerAdvice existente obtiene un nuevo test case para NoSuchElementException404 NOT_FOUND. La estructura es el mismo test parametrizado de antes, solo con una entrada más en el dataset.

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));
}
// ...
}

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:

Scroll to zoom • Drag corner to resize

Buildeá y corré la aplicación con el perfil dev, entonces le pegas al 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"
}
}

Una Nota Sobre FindByIdPortInImpl

FindByIdPortInImpl 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.