Llevamos dos lecciones escribiendo entidades y relaciones, y hemos ido usando en los ejemplos un estacionRepositorio que todavía no existe. Mientras tanto, EstacionRepositorioEnMemoria sigue ahí desde 02-01, con su ConcurrentHashMap, su AtomicLong y sus seis métodos escritos a mano. Esta lección lo jubila.

Y lo hace de la forma más llamativa posible: borrando código. Spring Data JPA convierte una interfaz vacía en un repositorio completamente funcional, con una veintena de métodos ya implementados, paginación, ordenación y traducción de excepciones. Es probablemente el momento del curso en el que la relación entre esfuerzo y resultado es más favorable. Pero conviene entender qué ocurre por debajo, porque varios de esos métodos heredados tienen una semántica que no es la que su nombre sugiere —save no siempre inserta, getReferenceById no consulta la base de datos— y confundirlas produce errores difíciles de rastrear.

Contenido

  1. La jerarquía de interfaces de Spring Data
  2. Cómo Spring Data crea la implementación
  3. Jubilar EstacionRepositorioEnMemoria
  4. Los métodos heredados y su semántica exacta
  5. findById frente a getReferenceById
  6. save: insertar o fusionar
  7. Optional<T> y el manejo de la ausencia
  8. Paginación y ordenación
  9. Integrar la paginación en la API REST
  10. Repositorios con métodos propios
  11. @Repository y la traducción de excepciones
  12. Example y Specification: una primera mirada
  13. Errores Comunes y Consejos
  14. Ejercicios

  1. La jerarquía de interfaces de Spring Data

graph TD
    R["Repository&lt;T, ID&gt;<br/>marcador, sin métodos"]
    C["CrudRepository&lt;T, ID&gt;<br/>save, findById, delete..."]
    LC["ListCrudRepository&lt;T, ID&gt;<br/>devuelve List en vez de Iterable"]
    P["PagingAndSortingRepository&lt;T, ID&gt;<br/>findAll(Pageable), findAll(Sort)"]
    J["JpaRepository&lt;T, ID&gt;<br/>flush, saveAndFlush, getReferenceById"]
    R --> C
    C --> LC
    R --> P
    LC --> J
    P --> J
Interfaz Qué aporta Cuándo elegirla
Repository<T, ID> Nada: solo marca la interfaz para que Spring Data la detecte Cuando quieres exponer solo los métodos que tú declares
CrudRepository<T, ID> CRUD básico; devuelve Iterable<T> Aplicaciones sencillas, sin paginación
ListCrudRepository<T, ID> Igual, pero devuelve List<T> Casi siempre mejor que CrudRepository
PagingAndSortingRepository<T, ID> findAll(Pageable) y findAll(Sort) Cuando solo necesitas paginar
JpaRepository<T, ID> Todo lo anterior más flush, saveAndFlush, getReferenceById, deleteAllInBatch La opción por defecto con JPA

Qué elegir en CicloUrbana. JpaRepository para casi todo: es lo esperado y no hay coste por métodos que no uses. La alternativa que merece consideración es extender Repository<T, ID> declarando solo los métodos permitidos:

public interface AlquilerRepositorio extends Repository<Alquiler, Long> {
    Alquiler save(Alquiler alquiler);
    Optional<Alquiler> findById(Long id);
    List<Alquiler> findByUsuarioIdOrderByInicioDesc(Long usuarioId);
    // Deliberadamente NO se expone deleteById: los alquileres no se borran
}

Es una decisión de diseño defendible: un histórico de alquileres nunca debe borrarse, y no exponer el método hace imposible el accidente. El precio es escribir a mano las firmas que sí quieres. (Nota sobre ListCrudRepository: es una incorporación de Spring Data 3 que devuelve List<T> donde CrudRepository devolvía Iterable<T>; JpaRepository ya lo extiende.)

  1. Cómo Spring Data crea la implementación

Esta es la pregunta que todo el mundo se hace: ¿quién implementa una interfaz que nadie implementa?

En el arranque, JpaRepositoriesAutoConfiguration (02-06) activa el escaneo de repositorios desde el paquete de @SpringBootApplication. Por cada interfaz que extienda Repository:

  1. JpaRepositoryFactoryBean la analiza y determina la entidad y el tipo de su id.
  2. Crea una instancia de SimpleJpaRepository<T, ID>, la implementación estándar que contiene el código real de save, findById, findAll, etc., escrita sobre el EntityManager.
  3. Envuelve esa instancia en un proxy dinámico de Java que implementa tu interfaz.
  4. Registra el proxy como bean del contexto.
sequenceDiagram
    participant A as Arranque de Spring
    participant F as JpaRepositoryFactoryBean
    participant P as Proxy dinámico
    participant S as SimpleJpaRepository
    participant EM as EntityManager

    A->>F: encontrada EstacionRepositorio
    F->>S: crear SimpleJpaRepository(Estacion.class, em)
    F->>P: crear proxy que implementa EstacionRepositorio
    A->>A: registrar el proxy como bean
    Note over P,S: En ejecución
    P->>S: findById(1L) -> delega
    S->>EM: em.find(Estacion.class, 1L)

Cuando llamas a un método, el proxy decide a quién delegar: si es heredado (findById, save), a SimpleJpaRepository; si es una consulta derivada (findByNombre), analiza el nombre y genera la consulta (04-06); si tiene @Query, ejecuta esa consulta; y si pertenece a una interfaz Custom que tú implementas, a tu implementación (apartado 10).

Este mecanismo de proxies es el mismo de 02-03, llevado al extremo de que no hay objeto envuelto que tú hayas escrito: el proxy es lo único que existe de tu interfaz. Y una consecuencia útil: los repositorios son beans singleton normales, inyectables por constructor como cualquier otro (02-02).

  1. Jubilar EstacionRepositorioEnMemoria

Este es el momento que preparamos desde 02-01. La implementación en memoria era, resumida:

@Repository
public class EstacionRepositorioEnMemoria implements EstacionRepositorio {

    private final Map<Long, Estacion> almacen = new ConcurrentHashMap<>();
    private final AtomicLong secuencia = new AtomicLong(0);

    @Override
    public Estacion guardar(Estacion estacion) {
        Long id = estacion.id() != null ? estacion.id() : secuencia.incrementAndGet();
        almacen.put(id, new Estacion(id, estacion.nombre(), /* ... */));
        return almacen.get(id);
    }

    @Override public Optional<Estacion> buscarPorId(Long id) { /* ... */ }
    @Override public List<Estacion> buscarTodas() { /* ... */ }
    @Override public boolean existePorId(Long id) { /* ... */ }
    @Override public void eliminarPorId(Long id) { /* ... */ }
    @Override public boolean existePorNombre(String nombre) { /* ... */ }
}

Unas ochenta líneas contando el fichero real. Su sustituto completo:

package com.ciclourbana.estaciones;

public interface EstacionRepositorio extends JpaRepository<Estacion, Long> {

    boolean existsByNombre(String nombre);

    List<Estacion> findByActivaTrue();
}

Qué desaparece:

Se elimina Sustituido por
EstacionRepositorioEnMemoria entera SimpleJpaRepository, generado
ConcurrentHashMap y AtomicLong La secuencia de PostgreSQL (04-03)
guardar, buscarPorId, buscarTodas... Métodos heredados de JpaRepository
existePorNombre a mano existsByNombre, consulta derivada (04-06)
CargadorEstacionesDemo Migración de datos con Flyway (04-08)

Qué hay que ajustar en EstacionService. Los nombres de método cambian del castellano de nuestra interfaz propia a los de Spring Data: guardar → save, buscarPorId → findById, buscarTodas → findAll, existePorId → existsById, eliminarPorId → deleteById y existePorNombre → existsByNombre. Es el único coste real del cambio.

Podrías conservar los nombres en castellano declarándolos en la interfaz y anotándolos con @Query, pero no compensa: los nombres de Spring Data son un vocabulario compartido que cualquier desarrollador Java reconoce al instante. Los identificadores del dominio siguen en castellano —Estacion, EstacionService, EstacionRepositorio—; lo que se adopta es la API del framework, igual que se escribe List y no Lista.

Y lo importante: EstacionController no cambia ni una línea. La API REST de Ribalta sigue exactamente igual, con sus trece endpoints, sus DTOs y sus códigos de estado. Es la recompensa de haber aislado el almacenamiento tras una interfaz desde el principio.

  1. Los métodos heredados y su semántica exacta

Método Qué hace exactamente Consultas
save(T) persist si es nueva, merge si tiene id 0-2
saveAll(Iterable<T>) save sobre cada elemento N
saveAndFlush(T) save + flush inmediato 1-2
findById(ID) Optional con la entidad, o vacío. Consulta ya 0-1
getReferenceById(ID) Un proxy perezoso. No consulta 0
findAll() Todas las filas de la tabla 1
findAllById(Iterable<ID>) WHERE id IN (...) 1
existsById(ID) SELECT count(*) ... WHERE id = ? 1
count() SELECT count(*) 1
deleteById(ID) Carga la entidad y la borra 1-2
delete(T) Borra una entidad ya cargada 1
deleteAll() Carga todas y borra una a una 1 + N
deleteAllInBatch() Un solo DELETE FROM tabla 1
flush() Sincroniza el contexto con la base de datos Las pendientes

Tres avisos que evitan sorpresas:

deleteAll() frente a deleteAllInBatch(). El primero carga todas las entidades y emite un DELETE por cada una para poder aplicar cascadas y callbacks: con 100 000 alquileres, 100 001 sentencias. deleteAllInBatch() ejecuta un único DELETE FROM alquileres, pero se salta las cascadas y el contexto de persistencia, que puede quedar con entidades ya inexistentes. Rápido y peligroso.

deleteById ejecuta un SELECT antes del DELETE, porque necesita la entidad para aplicar cascadas. Y si el id no existe, en Spring Data 3 no lanza excepción: simplemente no hace nada. Para devolver el 404 correcto (03-06) hay que comprobar antes con existsById y lanzar RecursoNoEncontradoException.

count() y existsById() no cargan entidades. Son consultas de agregación puras. Preferir existsById(id) a findById(id).isPresent() no es cosmética: la segunda trae todas las columnas de la fila para descartarlas.

  1. findById frente a getReferenceById

Es la diferencia más incomprendida de la API, y la que más rendimiento puede ahorrar.

findById(id) getReferenceById(id)
Consulta la base de datos Sí, inmediatamente No
Devuelve Optional<T> T (un proxy)
Si el id no existe Optional.empty() Falla más tarde, con EntityNotFoundException
Uso típico Leer o modificar la entidad Solo asignarla como clave ajena

El caso donde getReferenceById brilla es exactamente el de CicloUrbana al iniciar un alquiler:

@Transactional
public AlquilerResponse iniciar(IniciarAlquilerRequest peticion) {
    Bicicleta bicicleta = bicicletaRepositorio.findById(peticion.bicicletaId())
            .orElseThrow(() -> new RecursoNoEncontradoException("Bicicleta", peticion.bicicletaId()));

    if (!bicicleta.puedeAlquilarse(redProperties.umbralBateria())) {
        throw new BicicletaNoDisponibleException(bicicleta.getMatricula());
    }

    Alquiler alquiler = new Alquiler();
    // Solo necesitamos el id para la clave ajena: NO hay que cargar el usuario
    alquiler.setUsuario(usuarioRepositorio.getReferenceById(peticion.usuarioId()));
    alquiler.setBicicleta(bicicleta);
    alquiler.setEstacionOrigen(estacionRepositorio.getReferenceById(peticion.estacionOrigenId()));
    alquiler.setInicio(Instant.now());

    bicicleta.setEstado(EstadoBicicleta.EN_USO);
    return mapper.aRespuesta(alquilerRepositorio.save(alquiler));
}

Bicicleta se carga con findById porque hay que leer su estado y su batería y modificar su estado. Usuario y Estacion solo se necesitan para rellenar usuario_id y estacion_origen_id en el INSERT, y para eso basta el id que el proxy ya contiene: nos ahorramos dos SELECT en la operación más frecuente de toda la aplicación.

El riesgo, que hay que conocer: si el usuario no existe, getReferenceById no falla ahí sino más tarde —al acceder a un campo del proxy, o en el commit, donde falla la clave ajena—, y el error llega descolocado respecto a su causa. La regla: usa getReferenceById solo para asignar asociaciones cuyo id ya has validado, por ejemplo porque viene de un usuario autenticado (módulo 5).

  1. save: insertar o fusionar

save() parece un simple «guardar» y hace dos cosas muy distintas según el estado de la entidad (04-01):

graph TD
    A["save(entidad)"] --> B{"¿id es null?"}
    B -->|Sí| C["persist(): INSERT<br/>la entidad pasa a gestionada"]
    B -->|No| D["merge(): SELECT + UPDATE<br/>devuelve una COPIA gestionada"]

La consecuencia práctica más importante está en merge: devuelve una instancia distinta de la que le pasas. La que le pasas sigue separada.

Estacion separada = new Estacion(...);          // con id = 1, viniendo de fuera
Estacion gestionada = estacionRepositorio.save(separada);
separada == gestionada;                         // false
separada.setNombre("Otro nombre");              // NO se guarda: sigue separada
gestionada.setNombre("Otro nombre");            // SÍ se guarda: está gestionada

Usa siempre el valor devuelto por save(). Ignorarlo es una de las causas más comunes de «modifico y no se guarda».

Y el corolario, que ya vimos en 04-01 y desarrollaremos en 04-07: sobre una entidad gestionada no hace falta llamar a save(). Dentro de una transacción, cargar con findById, hacer estacion.setActiva(false) y no llamar a nada más es suficiente y correcto: el dirty checking genera el UPDATE en el commit.

Un matiz de rendimiento en cargas masivas: save() sobre una entidad con id ejecuta un SELECT antes del UPDATE, para poder fusionar. Insertando miles de filas con ids ya asignados, ese SELECT se paga por cada una. Si sabes que la entidad es nueva, entityManager.persist() lo evita; Spring Data lo detecta solo cuando el id es nulo o cuando la entidad implementa Persistable.

  1. Optional<T> y el manejo de la ausencia

Spring Data devuelve Optional<T> en las búsquedas por identificador. Es una decisión de diseño deliberada: hace imposible olvidar el caso «no existe», que con null se olvida constantemente.

El uso correcto en CicloUrbana enlaza directamente con la jerarquía de excepciones de 03-06:

@Transactional(readOnly = true)
public EstacionResponse buscarPorId(Long id) {
    return estacionRepositorio.findById(id)
            .map(mapper::aRespuesta)
            .orElseThrow(() -> new RecursoNoEncontradoException("Estación", id));
}

Se lee de corrido: busca, mapea si está, y si no lanza la excepción que el @RestControllerAdvice convierte en un 404 con ProblemDetail.

Formas incorrectas frecuentes:

findById(id).get();          // MAL: NoSuchElementException -> 500 en vez de 404
findById(id).orElse(null);   // MAL: reintroduce el null que Optional venía a eliminar
// MAL: verboso y equivalente a orElseThrow
Optional<Estacion> opt = findById(id);
if (opt.isEmpty()) throw new RecursoNoEncontradoException("Estación", id);

Métodos útiles de Optional en este contexto: map para transformar, filter para condicionar, orElseThrow para exigir presencia, orElseGet para un valor por defecto calculado y ifPresentOrElse para dos ramas. Y nunca uses Optional como parámetro de método ni como campo de una entidad: está pensado para valores de retorno.

  1. Paginación y ordenación

GET /api/v1/estaciones devuelve hoy las cuatro estaciones de Ribalta. Cuando la red crezca a doscientas, o cuando se listen los alquileres del año, devolverlo todo dejará de ser viable: memoria en el servidor, ancho de banda y un cliente que no puede procesarlo.

Las piezas:

Tipo Qué es
Pageable Petición de página: número, tamaño y ordenación
PageRequest Su implementación: PageRequest.of(0, 20, Sort.by("nombre"))
Sort Ordenación: Sort.by(Sort.Direction.DESC, "capacidad")
Page<T> Resultado con total de elementos y de páginas
Slice<T> Resultado sin total: solo sabe si hay página siguiente
Retorno Consultas SQL Sabe el total Cuándo usarlo
List<T> 1 No Cuando no necesitas metadatos
Slice<T> 1 (pide tamaño + 1 filas) No Desplazamiento infinito, tablas grandes
Page<T> 2 (una de datos y una de count) Sí Tablas con numeración de páginas

La diferencia de coste importa: Page ejecuta una segunda consulta SELECT count(*) que, sobre una tabla de millones de alquileres con filtros complejos, puede ser más cara que la propia consulta de datos. Slice la evita pidiendo una fila de más y comprobando si vino.

En el repositorio no hay que declarar nada: JpaRepository ya hereda findAll(Pageable).

Page<Estacion> pagina = estacionRepositorio.findAll(
        PageRequest.of(0, 20, Sort.by("nombre").ascending()));

pagina.getContent();       // List<Estacion> con las 20 de esta página
pagina.getTotalElements(); // 200      pagina.getTotalPages(); // 10
pagina.getNumber();        // 0        pagina.hasNext();       // true

Y el SQL que genera con PostgreSQL:

select e1_0.id, e1_0.nombre, ... from estaciones e1_0
 order by e1_0.nombre asc
 offset 0 rows fetch first 20 rows only;

select count(e1_0.id) from estaciones e1_0;   -- solo con Page, no con Slice

Aviso sobre las páginas profundas. OFFSET 100000 obliga a la base de datos a leer y descartar cien mil filas antes de devolver veinte. Con tablas grandes, la paginación por cursor —«dame los siguientes a partir de este id»— es mucho más eficiente (09-01).

  1. Integrar la paginación en la API REST

Spring MVC resuelve Pageable automáticamente a partir de los parámetros de consulta, gracias a PageableHandlerMethodArgumentResolver, que Spring Boot registra solo.

@GetMapping
public PaginaResponse<EstacionResponse> listar(
        @PageableDefault(size = 20, sort = "nombre") Pageable pageable) {
    return estacionService.listar(pageable);
}

Ahora la API acepta GET /api/v1/estaciones?page=0&size=20&sort=capacidad,desc.

@PageableDefault es importante: sin él, el tamaño por defecto es 20 pero el cliente puede pedir size=100000 y tumbar el servidor. Limita además el máximo globalmente:

spring:
  data:
    web:
      pageable:
        default-page-size: 20
        max-page-size: 100
        one-indexed-parameters: false   # la primera página es la 0

Por qué no se devuelve Page directamente. Es tentador —funciona y Jackson lo serializa— y es un error. Al arrancar, Spring Boot 3 incluso avisa:

Serializing PageImpl instances as-is is not supported, meaning that there is no
guarantee about the stability of the resulting JSON structure!

Tres motivos concretos: la estructura JSON no es estable —PageImpl es una clase interna de Spring Data cuya serialización ha cambiado entre versiones y puede volver a cambiar, rompiendo a todos los clientes de Ribalta en una actualización de dependencias—; filtra detalles internos, porque el JSON incluye un objeto pageable con paged, unpaged y offset, conceptos del framework ajenos al contrato público; y contradice la disciplina de 03-05, ya que si no exponemos entidades, menos aún clases internas del framework.

La solución es un DTO propio, estable y documentable en OpenAPI (03-07):

package com.ciclourbana.comun.dto;   // DTO de página, estable y documentable

public record PaginaResponse<T>(
        List<T> contenido,
        int pagina,
        int tamano,
        long totalElementos,
        int totalPaginas,
        boolean primera,
        boolean ultima) {

    public static <T> PaginaResponse<T> de(Page<T> page) {
        return new PaginaResponse<>(
                page.getContent(), page.getNumber(), page.getSize(),
                page.getTotalElements(), page.getTotalPages(),
                page.isFirst(), page.isLast());
    }
}

Y en el servicio, con el mapeo dentro de la transacción como exige 04-04:

@Transactional(readOnly = true)
public PaginaResponse<EstacionResponse> listar(Pageable pageable) {
    Page<EstacionResponse> pagina = estacionRepositorio.findAll(pageable)
            .map(mapper::aRespuesta);
    return PaginaResponse.de(pagina);
}

Page.map() transforma el contenido conservando los metadatos: no hay que reconstruir nada a mano.

La respuesta al cliente:

{
  "contenido": [ { "id": 1, "nombre": "Plaza Mayor", "capacidad": 24 },
                 { "id": 2, "nombre": "Estación Norte", "capacidad": 30 } ],
  "pagina": 0, "tamano": 20, "totalElementos": 4, "totalPaginas": 1,
  "primera": true, "ultima": true
}

  1. Repositorios con métodos propios

A veces un método necesita lógica que ni las consultas derivadas ni @Query pueden expresar: acceso directo al EntityManager, construcción dinámica de criterios o llamadas al SQL nativo con procesamiento intermedio. Spring Data lo permite con una convención de nombres muy concreta.

Paso 1: la interfaz con los métodos propios.

package com.ciclourbana.estaciones;

public interface EstacionRepositorioCustom {
    List<Estacion> buscarConFiltros(String nombre, Integer capacidadMinima, Boolean activa);
}

Paso 2: la implementación. El nombre es obligatorio: <NombreInterfaz>Impl.

package com.ciclourbana.estaciones;

public class EstacionRepositorioCustomImpl implements EstacionRepositorioCustom {

    private final EntityManager entityManager;

    public EstacionRepositorioCustomImpl(EntityManager em) { this.entityManager = em; }

    @Override
    public List<Estacion> buscarConFiltros(String nombre, Integer capacidadMinima,
                                           Boolean activa) {
        CriteriaBuilder cb = entityManager.getCriteriaBuilder();
        CriteriaQuery<Estacion> consulta = cb.createQuery(Estacion.class);
        Root<Estacion> raiz = consulta.from(Estacion.class);

        List<Predicate> predicados = new ArrayList<>();
        if (nombre != null && !nombre.isBlank())
            predicados.add(cb.like(cb.lower(raiz.get("nombre")),
                                   "%" + nombre.toLowerCase() + "%"));
        if (capacidadMinima != null)
            predicados.add(cb.greaterThanOrEqualTo(raiz.get("capacidad"), capacidadMinima));
        if (activa != null)
            predicados.add(cb.equal(raiz.get("activa"), activa));

        consulta.where(predicados.toArray(Predicate[]::new))
                .orderBy(cb.asc(raiz.get("nombre")));
        return entityManager.createQuery(consulta).getResultList();
    }
}

Paso 3: el repositorio extiende ambas interfaces, con extends JpaRepository<Estacion, Long>, EstacionRepositorioCustom.

Spring Data detecta que buscarConFiltros no es un método heredado ni derivable, busca una clase llamada EstacionRepositorioCustomImpl y le delega. El sufijo Impl es obligatorio —es configurable con repositoryImplementationPostfix, pero no hay motivo para cambiarlo— y es el error número uno de este mecanismo: con cualquier otro nombre, el arranque falla con No property buscarConFiltros found for type Estacion.

Fíjate en el valor real de este patrón: el cliente sigue viendo una sola interfaz. EstacionService inyecta EstacionRepositorio y llama a buscarConFiltros sin saber que su implementación está en otra clase. La composición es invisible desde fuera.

  1. @Repository y la traducción de excepciones

No hace falta anotar las interfaces con @Repository. Spring Data las detecta por extender Repository. La anotación sí es necesaria en clases de acceso a datos escritas a mano, y en EstacionRepositorioCustomImpl es opcional.

Lo que sí importa es lo que @Repository habilita: la traducción de excepciones. Un PersistenceExceptionTranslationPostProcessor —un BeanPostProcessor como los de 02-03— envuelve el bean y convierte las excepciones específicas del proveedor en la jerarquía DataAccessException de Spring:

Excepción original Traducida a
ConstraintViolationException (Hibernate) DataIntegrityViolationException
StaleObjectStateException (Hibernate) ObjectOptimisticLockingFailureException
NoResultException (JPA) EmptyResultDataAccessException
PSQLException de conexión DataAccessResourceFailureException

El beneficio es real: tu código captura excepciones de Spring y no depende de Hibernate. Si algún día cambiara la implementación de JPA, los catch seguirían siendo válidos.

En CicloUrbana esto se aprovecha en el manejador global de 03-06:

@ExceptionHandler(DataIntegrityViolationException.class)
public ProblemDetail manejarViolacionIntegridad(DataIntegrityViolationException ex) {
    log.warn("Violación de integridad: {}", ex.getMostSpecificCause().getMessage());
    ProblemDetail p = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT,
            "La operación viola una restricción de integridad de los datos.");
    p.setTitle("Conflicto de integridad");
    p.setProperty("codigo", "VIOLACION_INTEGRIDAD");
    return p;
}

Es una red de seguridad, no la vía principal: lo correcto sigue siendo comprobar existsByNombre antes de insertar y devolver un 409 explicando qué nombre está repetido; este manejador cubre las condiciones de carrera que la comprobación previa no puede evitar. Nótese además que el mensaje al cliente no incluye el detalle de la excepción, que contiene nombres de tabla y de restricción: al log sí, a la respuesta no.

  1. Example y Specification: una primera mirada

Spring Data ofrece dos mecanismos más para consultas dinámicas. Aquí queda la panorámica; el detalle está en 04-06.

Example (consulta por ejemplo). Construyes una entidad parcialmente rellena y Spring Data busca las que se le parezcan.

Estacion sonda = new Estacion();
sonda.setActiva(true);
sonda.setCapacidad(24);
ExampleMatcher criterios = ExampleMatcher.matching()
        .withIgnoreNullValues()
        .withStringMatcher(ExampleMatcher.StringMatcher.CONTAINING).withIgnoreCase();
List<Estacion> resultado = estacionRepositorio.findAll(Example.of(sonda, criterios));

Es cómodo para filtros de igualdad simples, y muy limitado: no expresa rangos (capacidad > 20), ni OR, ni condiciones sobre asociaciones. En CicloUrbana no lo usaremos.

Specification (API Criteria empaquetada). Cada condición es un objeto componible con and y or:

public class EstacionSpecs {
    public static Specification<Estacion> nombreContiene(String texto) {
        return (raiz, consulta, cb) -> texto == null ? null
                : cb.like(cb.lower(raiz.get("nombre")), "%" + texto.toLowerCase() + "%");
    }
    public static Specification<Estacion> capacidadMinima(Integer minimo) {
        return (raiz, consulta, cb) -> minimo == null ? null
                : cb.greaterThanOrEqualTo(raiz.get("capacidad"), minimo);
    }
}
// El repositorio debe extender también JpaSpecificationExecutor<Estacion>
Page<Estacion> resultado = estacionRepositorio.findAll(
        EstacionSpecs.nombreContiene("norte").and(EstacionSpecs.capacidadMinima(20)),
        pageable);

Devolver null cuando el filtro no viene es la clave: Spring Data ignora esos predicados, así que el buscador de estaciones admite cualquier combinación de filtros opcionales sin un solo if de concatenación de SQL. Es más limpio que el EstacionRepositorioCustomImpl del apartado 10, y en 04-06 lo desarrollaremos.

Errores Comunes y Consejos

Ignorar el valor devuelto por save(). Con una entidad separada, save hace merge y devuelve una copia gestionada; la original sigue separada y sus cambios se pierden.

Llamar a save() sobre una entidad gestionada. Inofensivo pero innecesario: el dirty checking ya genera el UPDATE. Delata falta de comprensión del contexto de persistencia.

Usar findById(id).get(). Lanza NoSuchElementException, que sin manejador acaba en un 500 en lugar del 404 correcto. Usa orElseThrow con RecursoNoEncontradoException.

Devolver Page directamente desde el controlador. JSON inestable entre versiones y detalles del framework en el contrato público. Usa un PaginaResponse propio.

Nombrar mal la implementación propia. Debe ser exactamente <NombreInterfaz>Impl y estar en el mismo paquete. Con otro nombre, el arranque falla.

Usar deleteAll() sobre tablas grandes. Carga todas las entidades y emite un DELETE por cada una.

Consejo: usa getReferenceById para asignar claves ajenas. En un iniciar alquiler, ahorra dos SELECT en la operación más frecuente de CicloUrbana.

Consejo: prefiere existsById a findById(id).isPresent(). El primero es un count(*); el segundo trae toda la fila.

Consejo: limita max-page-size. Sin ese límite, un cliente puede pedir un millón de registros en una sola petición.

Consejo: no expongas deleteById donde no deba existir. Extender Repository y declarar solo los métodos permitidos convierte una regla de negocio en una imposibilidad técnica.

Ejercicios

Ejercicio 1: elegir la interfaz base

Para cada repositorio de CicloUrbana, elige la interfaz base y justifícalo en una o dos frases.

  1. EstacionRepositorio: CRUD completo con listados paginados.
  2. AlquilerRepositorio: se crean y consultan, nunca se borran; listados paginados por usuario.
  3. TarifaRepositorio: cinco filas fijas, cargadas al arrancar, solo lectura.
  4. IncidenciaRepositorio: alta, consulta, cierre y borrado de las que resulten falsas.

Ejercicio 2: paginación completa de extremo a extremo

Implementa el endpoint GET /api/v1/alquileres?usuarioId=7&page=0&size=20&sort=inicio,desc que devuelve los alquileres de un usuario paginados. Escribe el repositorio, el servicio y el controlador, y explica por qué eliges Page o Slice.

Ejercicio 3: diagnosticar tres fallos

Este servicio tiene tres defectos. Encuéntralos, explica el síntoma que produce cada uno y corrígelos.

@Service
public class EstacionService {

    private final EstacionRepositorio repositorio;
    private final EstacionMapper mapper;

    public EstacionService(EstacionRepositorio r, EstacionMapper m) {
        this.repositorio = r; this.mapper = m;
    }

    public EstacionResponse actualizar(Long id, ActualizarEstacionRequest peticion) {
        Estacion estacion = repositorio.findById(id).get();
        estacion.setNombre(peticion.nombre());
        estacion.setCapacidad(peticion.capacidad());
        repositorio.save(estacion);
        return mapper.aRespuesta(estacion);
    }

    public void eliminar(Long id) {
        repositorio.deleteById(id);
    }

    public Page<EstacionResponse> listar(Pageable pageable) {
        return repositorio.findAll(pageable).map(mapper::aRespuesta);
    }
}

Soluciones

Solución 1.

  1. JpaRepository<Estacion, Long>. Necesita CRUD completo y paginación; es el caso estándar y no hay razón para restringir la API.
  2. Repository<Alquiler, Long> con métodos declarados a mano. La regla «un alquiler nunca se borra» es de negocio, y la mejor forma de garantizarla es no exponer el método:
public interface AlquilerRepositorio extends Repository<Alquiler, Long> {
    Alquiler save(Alquiler alquiler);
    Optional<Alquiler> findById(Long id);
    Page<Alquiler> findByUsuarioId(Long usuarioId, Pageable pageable);
    long countByUsuarioIdAndFinIsNull(Long usuarioId);
}

Sin deleteById en la interfaz, nadie puede borrar un alquiler por descuido: una regla de negocio convertida en imposibilidad técnica.

  1. ListCrudRepository<Tarifa, String>, o incluso Repository con solo findAll y findByCodigo: cinco filas fijas no necesitan paginación, y exponer deleteAll sobre el catálogo de tarifas es un riesgo innecesario.
  2. JpaRepository<Incidencia, Long>: necesita las cuatro operaciones, incluido el borrado legítimo de las falsas.

Solución 2.

// Repositorio
public interface AlquilerRepositorio extends JpaRepository<Alquiler, Long> {
    Page<Alquiler> findByUsuarioId(Long usuarioId, Pageable pageable);
}
// Servicio
@Transactional(readOnly = true)
public PaginaResponse<AlquilerResponse> listarPorUsuario(Long usuarioId, Pageable pageable) {
    if (!usuarioRepositorio.existsById(usuarioId)) {
        throw new RecursoNoEncontradoException("Usuario", usuarioId);
    }
    Page<AlquilerResponse> pagina = alquilerRepositorio
            .findByUsuarioId(usuarioId, pageable)
            .map(mapper::aRespuesta);
    return PaginaResponse.de(pagina);
}
// Controlador
@GetMapping("/api/v1/alquileres")
public PaginaResponse<AlquilerResponse> listar(
        @RequestParam Long usuarioId,
        @PageableDefault(size = 20, sort = "inicio",
                         direction = Sort.Direction.DESC) Pageable pageable) {
    return alquilerService.listarPorUsuario(usuarioId, pageable);
}

Page o Slice. Para «mis alquileres» en una app móvil con desplazamiento infinito, Slice es mejor: ahorra el SELECT count(*) y el usuario nunca ve el total. Para un panel de administración con numeración de páginas, Page es necesario porque hay que pintar «página 3 de 47». Aquí elegimos Page porque el endpoint sirve a ambos consumidores y el volumen por usuario es moderado —unos cientos de alquileres—, así que el count es barato; si el histórico creciera a millones de filas por usuario, migraríamos a Slice o a paginación por cursor (09-01).

Detalles a no pasar por alto: @Transactional(readOnly = true) permite a Hibernate saltarse el dirty checking (04-07); el mapeo a DTO ocurre dentro de la transacción, evitando la LazyInitializationException con open-in-view: false; y se comprueba que el usuario existe, para devolver 404 en vez de una página vacía que mentiría sobre la existencia del recurso.

Solución 3. Los tres defectos:

1. findById(id).get(). Si la estación no existe, lanza NoSuchElementException y el cliente recibe un 500 en lugar del 404 con ProblemDetail que define el contrato de 03-06.

2. Faltan las anotaciones @Transactional. Es el más grave: actualizar ejecuta findById y save en dos transacciones distintas, así que entre ellas la entidad queda separada, se pierde el dirty checking, save debe hacer un merge con su SELECT adicional y no hay atomicidad si algo falla en medio.

3. deleteById sin comprobar la existencia. No lanza excepción si el id no existe: el cliente recibe un 204 No Content indicando que se borró algo que nunca existió. Y un cuarto defecto menor: listar devuelve Page<EstacionResponse> al controlador, con el problema de estabilidad del JSON del apartado 9. Versión corregida:

@Service
@Transactional(readOnly = true)   // por defecto para toda la clase
public class EstacionService {

    // constructor con EstacionRepositorio repositorio y EstacionMapper mapper

    @Transactional   // sobrescribe readOnly: este método escribe
    public EstacionResponse actualizar(Long id, ActualizarEstacionRequest peticion) {
        Estacion estacion = repositorio.findById(id)
                .orElseThrow(() -> new RecursoNoEncontradoException("Estación", id));
        estacion.setNombre(peticion.nombre());
        estacion.setCapacidad(peticion.capacidad());
        // sin save(): la entidad está gestionada y el dirty checking hace el UPDATE
        return mapper.aRespuesta(estacion);
    }

    @Transactional
    public void eliminar(Long id) {
        if (!repositorio.existsById(id)) {
            throw new RecursoNoEncontradoException("Estación", id);
        }
        repositorio.deleteById(id);
    }

    public PaginaResponse<EstacionResponse> listar(Pageable pageable) {
        return PaginaResponse.de(repositorio.findAll(pageable).map(mapper::aRespuesta));
    }
}

El patrón @Transactional(readOnly = true) en la clase con @Transactional en los métodos que escriben es una convención excelente: por defecto todo es de solo lectura y escribir requiere una decisión explícita. Lo desarrollaremos en 04-07.

Conclusión

EstacionRepositorioEnMemoria ya es historia. Sabes situar cada interfaz de la jerarquía de Spring Data y elegir con criterio: JpaRepository como opción por defecto y Repository con métodos declarados a mano cuando quieres que una regla de negocio —«un alquiler nunca se borra»— sea una imposibilidad técnica. Entiendes cómo aparece la implementación: JpaRepositoryFactoryBean crea un SimpleJpaRepository y lo envuelve en un proxy dinámico que implementa tu interfaz, el mismo mecanismo de proxies de 02-03 llevado al extremo de que no hay ninguna clase tuya debajo. Y has comprobado el resultado del cambio: ochenta líneas de mapa concurrente sustituidas por una interfaz de cuatro, sin tocar ni una línea de EstacionController.

Conoces la semántica exacta de los métodos heredados, incluidas las tres que sorprenden: save hace merge si la entidad tiene id y devuelve una instancia distinta que hay que usar; getReferenceById no consulta la base de datos y ahorra dos SELECT cada vez que CicloUrbana inicia un alquiler; y deleteById no falla cuando el id no existe, así que el 404 hay que provocarlo comprobando antes. Manejas Optional enlazándolo con RecursoNoEncontradoException en un orElseThrow que se lee de corrido. Has añadido paginación y ordenación a la API de Ribalta, distinguiendo Page de Slice por el coste de su SELECT count(*), recibiendo Pageable con @PageableDefault y limitando max-page-size para que ningún cliente pueda pedir un millón de filas. Y devuelves un PaginaResponse propio en lugar de serializar PageImpl, por la misma razón por la que en 03-05 no exponías entidades. Sabes componer un repositorio con métodos propios respetando el sufijo Impl, aprovechar la traducción de excepciones de @Repository como red de seguridad ante condiciones de carrera, y tienes una primera visión de Example y Specification.

Lo que aún no sabes hacer es preguntar. Todo lo que has consultado ha sido por identificador o la tabla entera. La red de Ribalta necesita mucho más: las estaciones con hueco libre, las bicicletas con batería por debajo del umbral, los alquileres de un usuario entre dos fechas, las estaciones más cercanas a una coordenada, el informe de ocupación en una sola consulta. La lección 04-06, Métodos de Consulta en Spring Data JPA, cubre todo el arsenal: las consultas derivadas del nombre del método con su tabla exhaustiva de palabras clave, @Query con JPQL y proyecciones a DTO, JOIN FETCH para resolver de una vez el N+1 de 04-04, consultas nativas de PostgreSQL para lo que JPQL no alcanza, @Modifying con sus trampas de sincronización, las proyecciones por interfaz y por record, @EntityGraph y las Specification para el buscador con filtros opcionales combinables.

Curso de Spring Boot

Módulo 1: Introducción a Spring Boot

Módulo 2: Conceptos Básicos de Spring Boot

Módulo 3: Construyendo Servicios Web RESTful

Módulo 4: Acceso a Datos con Spring Boot

Módulo 5: Seguridad en Spring Boot

Módulo 6: Pruebas en Spring Boot

Módulo 7: Funciones Avanzadas de Spring Boot

Módulo 8: Despliegue de Aplicaciones Spring Boot

Módulo 9: Rendimiento y Monitoreo

Módulo 10: Mejores Prácticas y Consejos

© Copyright 2026. Todos los derechos reservados