Saltar al contenido principal

Completando el CRUD

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) Add createFilm operation, Implement updateFilm operation, Implement deleteFilm operation.

En los documentos anteriores conectamos las entidades JPA a la arquitectura hexagonal y construimos endpoints de solo lectura. Este documento agrega las operaciones de escritura que faltan: crear, actualizar y eliminar. Al finalizar, el recurso Film va a estar completamente CRUD.

No se necesitan dependencias nuevas. El stack existente (Spring Data JPA, MapStruct / ModelMapper, OpenAPI generator) se encarga de todo.

Vista general de los archivos

Archivos a Crear/Modificar
File Tree
├── ...
└── src/
├── main/java/dev/pollito/spring_java/sakila/film/
│ ├── adapter/
│ │ ├── in/rest/
│ │ │ └── FilmRestController.java
│ │ └── out/jpa/
│ │ ├── FilmJpaMapper.java
│ │ ├── FilmRepositoryImpl.java
│ │ └── LanguageJpaRepository.java
│ └── domain/
│ ├── port/
│ │ ├── in/
│ │ │ └── FilmUseCases.java
│ │ └── out/
│ │ └── FilmRepository.java
│ └── service/
│ └── FilmUseCasesImpl.java
└── test/java/dev/pollito/spring_java/sakila/film/
├── adapter/
│ ├── in/rest/
│ │ └── FilmRestControllerMockMvcTest.java
│ └── out/jpa/
│ └── FilmRepositoryImplDataJpaTest.java
└── domain/
└── service/
└── FilmUseCasesImplTest.java

Los cambios abarcan las tres capas de la arquitectura hexagonal: el adaptador REST de entrada, los puertos y el servicio de dominio, y el adaptador JPA de salida. Los tests se actualizan en paralelo para cubrir el nuevo comportamiento.

Definir los puertos de dominio

Empezá con los contratos. Tanto el puerto primario (FilmUseCases) como el puerto secundario (FilmRepository) crecen con nuevas firmas de métodos para createFilm, updateFilm y deleteFilm.

java/dev/pollito/spring_java/sakila/film/domain/port/in/FilmUseCases.java
package dev.pollito.spring_java.sakila.film.domain.port.in;

import dev.pollito.spring_java.sakila.film.domain.model.Film;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;

public interface FilmUseCases {
Film createFilm(Film film);

Film getFilm(Integer id);

Page<Film> getFilms(Pageable pageable);

Film updateFilm(Integer id, Film film);

void deleteFilm(Integer id);
}
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;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;

public interface FilmRepository {
Film createFilm(Film film);

Film getFilm(Integer id);

Page<Film> getFilms(Pageable pageable);

Film updateFilm(Integer id, Film film);

void deleteFilm(Integer id);
}

Cablear el servicio

FilmUseCasesImpl delega en el puerto secundario. Sin reglas de negocio todavía, solo pasamanos.

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.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;

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

@Override
public Film createFilm(Film film) {
return repository.createFilm(film);
}

@Override
public Film getFilm(Integer id) {
return repository.getFilm(id);
}

@Override
public Page<Film> getFilms(Pageable pageable) {
return repository.getFilms(pageable);
}

@Override
public Film updateFilm(Integer id, Film film) {
return repository.updateFilm(id, film);
}

@Override
public void deleteFilm(Integer id) {
repository.deleteFilm(id);
}
}

Implementar el repositorio

La implementación del repositorio busca las entidades de idioma, mapea el modelo de dominio a una entidad JPA, la guarda y mapea el resultado de vuelta.

Creando una película

java/dev/pollito/spring_java/sakila/film/adapter/out/jpa/FilmRepositoryImpl.java
@Override
public Film createFilm(@NonNull Film film) {
var language = languageJpaRepository.findByName(film.getLanguage().getValue()).orElseThrow();
var originalLanguage =
film.getOriginalLanguage() != null
? languageJpaRepository.findByName(film.getOriginalLanguage().getValue()).orElseThrow()
: null;
var entity = mapper.map(film, language, originalLanguage);
entity.setLastUpdate(java.time.LocalDateTime.now());
return mapper.map(repository.save(entity));
}

Fijate la búsqueda de idioma. FilmFields tiene "English" como string, pero la base de datos guarda los idiomas en una tabla separada. El repositorio consulta LanguageJpaRepository por nombre, mapea el modelo de dominio a la entidad JPA usando las referencias de idioma resueltas, y setea lastUpdate antes de guardar.

Actualizando una película

La actualización sigue el mismo patrón que la creación, excepto que el repositorio setea el ID de la película explícitamente antes de guardar para que JPA lo trate como una fila existente.

java/dev/pollito/spring_java/sakila/film/adapter/out/jpa/FilmRepositoryImpl.java
@Override
public Film updateFilm(Integer id, @NonNull Film film) {
var language = languageJpaRepository.findByName(film.getLanguage().getValue()).orElseThrow();
var originalLanguage =
film.getOriginalLanguage() != null
? languageJpaRepository.findByName(film.getOriginalLanguage().getValue()).orElseThrow()
: null;
var entity = mapper.map(film, language, originalLanguage);
entity.setFilmId(id);
entity.setLastUpdate(java.time.LocalDateTime.now());
return mapper.map(repository.save(entity));
}
}

La diferencia crítica respecto a la creación: entity.setFilmId(id) (o entity.filmId = id). Sin esto, JPA insertaría una nueva fila en lugar de actualizar la existente.

Eliminando una película

La eliminación es más simple. El repositorio verifica que la película exista y luego la remueve. Si el ID no existe, findById(...).orElseThrow() lanza NoSuchElementException, que el ControllerAdvice global ya mapea a 404 NOT_FOUND.

java/dev/pollito/spring_java/sakila/film/adapter/out/jpa/FilmRepositoryImpl.java
@Override
public void deleteFilm(Integer id) {
repository.findById(id).orElseThrow();
repository.deleteById(id);
}

Exponer el endpoint

El controlador REST mapea el DTO entrante a un modelo de dominio, llama al caso de uso y envuelve el resultado en la respuesta HTTP correspondiente.

Creando una película

El controlador devuelve 201 CREATED con la película recién creada en el body.

java/dev/pollito/spring_java/sakila/film/adapter/in/rest/FilmRestController.java
@Override
public ResponseEntity<FilmResponse> createFilm(FilmFields filmFields) {
return status(CREATED)
.body(
new FilmResponse()
.data(mapper.map(useCases.createFilm(mapper.map(filmFields))))
.instance(request.getRequestURI())
.timestamp(now())
.trace(current().getSpanContext().getTraceId())
.status(CREATED.value()));
}

Actualizando una película

java/dev/pollito/spring_java/sakila/film/adapter/in/rest/FilmRestController.java
@Override
public ResponseEntity<FilmResponse> updateFilm(Integer id, FilmFields filmFields) {
return ok(
new FilmResponse()
.data(mapper.map(useCases.updateFilm(id, mapper.map(filmFields))))
.instance(request.getRequestURI())
.timestamp(now())
.trace(current().getSpanContext().getTraceId())
.status(OK.value()));
}

Eliminando una película

El controlador devuelve 204 NO_CONTENT en caso de éxito. Sin body, solo el status. Y si la película no existe, el chequeo de findById lanza antes de que pase la eliminación, así que nunca eliminás algo por accidente o tenés un éxito silencioso.

java/dev/pollito/spring_java/sakila/film/adapter/in/rest/FilmRestController.java
@Override
public ResponseEntity<Void> deleteFilm(Integer id) {
useCases.deleteFilm(id);
return ResponseEntity.noContent().build();
}

Probando las nuevas operaciones

Cada commit agrega tests en tres niveles:

  • Tests de MockMvc verifican el adaptador REST: status HTTP correcto, forma del body de respuesta y manejo de errores.
  • Tests de Data JPA verifican la implementación del repositorio contra una base de datos real.
    • Para crear y actualizar, los tests de Data JPA afirman que el modelo de dominio devuelto tiene el título y el ID esperados.
    • Para eliminar, afirman que la entidad se remueve y que llamar a getFilm después lanza NoSuchElementException.
    • Caso de no encontrado para eliminar: pasar un ID inexistente dispara el mismo path de NoSuchElementException que el ControllerAdvice global convierte en 404.
  • Tests unitarios verifican el servicio de dominio en aislamiento con repositorios mockeados.

Probálo

Con todas las operaciones en su lugar, podés ejercitar el ciclo de vida completo:

Terminal
# Create a new film

curl -s -X POST https://sakila-java.pollito.tech/api/films \
-H "Content-Type: application/json" \
-d '{"title":"NEW FILM","language":"English","rentalDuration":3,"rentalRate":4.99,"replacementCost":20.99}' | jq

{
"instance": "/api/films",
"status": 201,
"timestamp": "2026-04-29T11:05:41.803702787Z",
"trace": "65c2da37361f6d669884f7caf6ed2a93",
"data": {
"title": "NEW FILM",
"language": "English",
"rentalDuration": 3,
"rentalRate": 4.99,
"replacementCost": 20.99,
"id": 1001,
"lastUpdate": "2026-04-29T11:05:41.738848092Z",
"description": null,
"length": null,
"originalLanguage": null,
"rating": null,
"releaseYear": null,
"specialFeatures": null
}
}

# Update an existing film

curl -s -X PUT https://sakila-java.pollito.tech/api/films/1001 \
-H "Content-Type: application/json" \
-d '{"title":"UPDATED FILM","language":"English","rentalDuration":3,"rentalRate":4.99,"replacementCost":20.99}' | jq

{
"instance": "/api/films/1001",
"status": 200,
"timestamp": "2026-04-29T11:39:44.242877421Z",
"trace": "1ff671817013fd4896e987c81994b685",
"data": {
"title": "UPDATED FILM",
"language": "English",
"rentalDuration": 3,
"rentalRate": 4.99,
"replacementCost": 20.99,
"id": 1001,
"lastUpdate": "2026-04-29T11:39:44.236617507Z",
"description": null,
"length": null,
"originalLanguage": null,
"rating": null,
"releaseYear": null,
"specialFeatures": null
}
}

# Delete a film

curl -s -X DELETE https://sakila-java.pollito.tech/api/films/1001 -w "\n%{http_code}\n"

204

Ahora tenés un recurso CRUD completo. Los límites hexagonales se mantuvieron intactos: el dominio solo conoce los puertos, el adaptador REST solo conoce los DTOs y los casos de uso, y el adaptador JPA se encarga del trabajo sucio de mapeo, búsquedas de idioma y estado de la entidad.