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, en el/los commit(s) JPA Repository Usage (spring_java), JPA Repository Usage (spring_kotlin), JPA Repository Usage (spring_groovy).

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
│ │ │ └── web
│ │ │ └── ControllerAdvice.java
│ │ └── sakila
│ │ └── film
│ │ ├── adapter
│ │ │ └── out
│ │ │ └── jpa
│ │ │ ├── FilmJpaMapper.java
│ │ │ ├── FilmJpaRepository.java
│ │ │ └── FilmRepositoryImpl.java
│ │ └── domain
│ │ ├── port
│ │ │ └── out
│ │ │ └── FilmRepository.java
│ │ └── service
│ │ └── FilmUseCasesImpl.java
│ └── resources
│ ├── application-dev.yaml
│ └── sakila-data.sql
└── test
├── java
│ └── dev
│ └── pollito
│ └── spring_java
│ ├── config
│ │ └── web
│ │ └── ControllerAdviceMockMvcTest.java
│ └── sakila
│ └── film
│ ├── adapter
│ │ └── out
│ │ └── jpa
│ │ └── FilmRepositoryImplDataJpaTest.java
│ └── domain
│ └── service
│ └── FilmUseCasesImplTest.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
spring:
application:
name: spring_java
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
management:
otlp:
metrics:
export:
enabled: 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.

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

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

Mapeá 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 static org.mapstruct.MappingConstants.ComponentModel.SPRING;

import dev.pollito.spring_java.common.util.EnumUtils;
import dev.pollito.spring_java.sakila.film.domain.model.Film;
import dev.pollito.spring_java.sakila.film.domain.model.FilmLanguage;
import dev.pollito.spring_java.sakila.film.domain.model.FilmRating;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;

@Mapper(
componentModel = SPRING,
imports = {
EnumUtils.class,
FilmLanguage.class,
FilmRating.class,
OffsetDateTime.class,
ZoneOffset.class
})
public interface FilmJpaMapper {

@Mapping(target = "id", source = "filmId")
@Mapping(
target = "language",
expression =
"java(source.getLanguageByLanguageId() != null && source.getLanguageByLanguageId().getName() != null ? EnumUtils.fromValue(FilmLanguage.class, source.getLanguageByLanguageId().getName()) : null)")
@Mapping(
target = "originalLanguage",
expression =
"java(source.getLanguageByOriginalLanguageId() != null && source.getLanguageByOriginalLanguageId().getName() != null ? EnumUtils.fromValue(FilmLanguage.class, source.getLanguageByOriginalLanguageId().getName()) : null)")
@Mapping(
target = "releaseYear",
expression =
"java(source.getReleaseYear() != null ? source.getReleaseYear().getYear() : null)")
@Mapping(
target = "rating",
expression =
"java(source.getRating() != null ? EnumUtils.fromValue(FilmRating.class, source.getRating()) : null)")
@Mapping(
target = "lastUpdate",
expression =
"java(source.getLastUpdate() != null ? source.getLastUpdate().atOffset(ZoneOffset.UTC) : null)")
Film map(dev.pollito.spring_java.sakila.generated.entity.Film source);
}

Implementá el puerto

FilmRepositoryImpl es un @Service que implementa FilmRepository y orquesta el repository y el mapper.

java/dev/pollito/spring_java/sakila/film/adapter/out/jpa/FilmRepositoryImpl.java
package dev.pollito.spring_java.sakila.film.adapter.out.jpa;

import dev.pollito.spring_java.sakila.film.domain.model.Film;
import dev.pollito.spring_java.sakila.film.domain.port.out.FilmRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class FilmRepositoryImpl implements FilmRepository {
private final FilmJpaRepository repository;
private final FilmJpaMapper mapper;

@Override
public Film getFilm(Integer id) {
return 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/dev/pollito/spring_java/sakila/film/domain/service/FilmUseCasesImpl.java
package dev.pollito.spring_java.sakila.film.domain.service;

import dev.pollito.spring_java.sakila.film.domain.model.Film;
import dev.pollito.spring_java.sakila.film.domain.port.in.FilmUseCases;
import dev.pollito.spring_java.sakila.film.domain.port.out.FilmRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class FilmUseCasesImpl implements FilmUseCases {
private final FilmRepository repository;

@Override
public Film getFilm(Integer id) {
return 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/dev/pollito/spring_java/config/web/ControllerAdvice.java (new NoSuchElementException handler)
@ExceptionHandler(NoSuchElementException.class)
public ResponseEntity<Error> handle(NoSuchElementException e) {
return buildProblemDetail(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.

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/adapter/out/jpa/FilmRepositoryImplDataJpaTest.java
package dev.pollito.spring_java.sakila.film.adapter.out.jpa;

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

import dev.pollito.spring_java.sakila.film.domain.port.out.FilmRepository;
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({FilmRepositoryImpl.class, FilmJpaMapperImpl.class})
@Sql(
scripts = {"/sakila-schema.sql", "/sakila-data.sql"},
executionPhase = BEFORE_TEST_CLASS)
class FilmRepositoryImplDataJpaTest {

@SuppressWarnings("unused")
@Autowired
private FilmRepository repository;

@Test
void getFilmReturnsADomainModel() {
assertEquals(1, repository.getFilm(1).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 FilmRepositoryImpl 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 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/dev/pollito/spring_java/sakila/film/domain/service/FilmUseCasesImplTest.java
package dev.pollito.spring_java.sakila.film.domain.service;

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.FilmRepository;
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 FilmUseCasesImplTest {
@InjectMocks private FilmUseCasesImpl filmUseCases;
@Mock private FilmRepository repository;

@Test
void getFilmReturnsADomainModel() {
when(repository.getFilm(anyInt())).thenReturn(mock(Film.class));
assertNotNull(filmUseCases.getFilm(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/web/ControllerAdviceMockMvcTest.java
package dev.pollito.spring_java.config.web;

import static dev.pollito.spring_java.test.util.MockMvcResultMatchers.hasErrorFields;
import static dev.pollito.spring_java.test.util.MockMvcResultMatchers.hasStandardApiResponseFields;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static org.springframework.http.HttpMethod.GET;
import static org.springframework.http.HttpStatus.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.ConstraintViolationException;
import java.util.NoSuchElementException;
import java.util.Set;
import java.util.stream.Stream;
import org.jspecify.annotations.NonNull;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.springframework.core.MethodParameter;
import org.springframework.http.HttpStatus;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.resource.NoResourceFoundException;

class ControllerAdviceMockMvcTest {

private MockMvc mockMvc;
private final HttpServletRequest request = mock(HttpServletRequest.class);

@RestController
@RequestMapping("/fake")
private static class FakeController {

@GetMapping("/not-found")
@SuppressWarnings("unused")
public void throwNoResourceFoundException() throws NoResourceFoundException {
throw new NoResourceFoundException(GET, "/fake", "no-resource-found");
}

@GetMapping("/error")
@SuppressWarnings("unused")
public void throwException() throws Exception {
throw new Exception("Test exception");
}

@GetMapping("/bad-request")
@SuppressWarnings("unused")
public void throwConstraintViolationException() {
throw new ConstraintViolationException("Constraint violation", Set.of());
}

@GetMapping("/method-arg-not-valid")
@SuppressWarnings({"unused"})
public void throwMethodArgumentNotValidException() throws Exception {
throw new MethodArgumentNotValidException(
new MethodParameter(
FakeController.class.getMethod("throwMethodArgumentNotValidException"), -1),
mock(BindingResult.class));
}

@GetMapping("/no-such-element")
@SuppressWarnings("unused")
public void throwNoSuchElementException() {
throw new NoSuchElementException("No such element");
}
}

@BeforeEach
void setUp() {
mockMvc =
standaloneSetup(new FakeController())
.setControllerAdvice(new ControllerAdvice(request))
.build();
}

static @NonNull Stream<Arguments> testCases() {
return Stream.of(
Arguments.of("/fake/not-found", NOT_FOUND),
Arguments.of("/fake/error", INTERNAL_SERVER_ERROR),
Arguments.of("/fake/bad-request", BAD_REQUEST),
Arguments.of("/fake/method-arg-not-valid", BAD_REQUEST),
Arguments.of("/fake/no-such-element", NOT_FOUND));
}

@ParameterizedTest
@MethodSource("testCases")
void exceptionHandlingReturnsCorrectStatus(String path, @NonNull HttpStatus expectedStatus)
throws Exception {
when(request.getRequestURI()).thenReturn(path);
mockMvc
.perform(get(path))
.andExpect(status().is(expectedStatus.value()))
.andExpect(hasStandardApiResponseFields(path, expectedStatus))
.andExpect(hasErrorFields(expectedStatus));
}
}

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/42 | jq
{
"instance": "/api/films/42",
"status": 200,
"timestamp": "2026-04-01T17:33:52.486385928+01:00",
"trace": "cc4f4b4afbc7b3f280bcdb14dc960e83",
"data": {
"title": "ARTIST COLDBLOODED",
"language": "English",
"rentalDuration": 5,
"rentalRate": 2.99,
"replacementCost": 10.99,
"id": 42,
"lastUpdate": "2006-02-15T05:03:42Z",
"description": "A Stunning Reflection of a Robot And a Moose who must Challenge a Woman in California",
"releaseYear": 2006,
"rating": "NC-17",
"length": 170,
"originalLanguage": null,
"specialFeatures": "Trailers,Behind the Scenes"
}
}

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.