Completando el CRUD
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
- Java
- Kotlin
- Groovy
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
- Kotlin
- Groovy
- Java
- Kotlin
- Groovy
Cablear el servicio
FilmUseCasesImpl delega en el puerto secundario. Sin reglas de negocio todavía, solo pasamanos.
- Java
- Kotlin
- Groovy
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
- Kotlin
- Groovy
@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));
}
override fun createFilm(film: Film): Film {
val language =
languageJpaRepository.findByName(film.language.getValue()) ?: throw NoSuchElementException()
val originalLanguage =
film.originalLanguage?.let {
languageJpaRepository.findByName(it.getValue()) ?: throw NoSuchElementException()
}
val entity = mapper.map(film, language, originalLanguage)
entity.lastUpdate = now()
return mapper.map(repository.save(entity))
}
@Override
Film createFilm(Film film) {
def language = languageJpaRepository.findByName(film.language.value).orElseThrow()
def originalLanguage = film.originalLanguage ? languageJpaRepository.findByName(film.originalLanguage.value).orElseThrow() : null
def entity = mapper.map(film, language, originalLanguage)
entity.lastUpdate = LocalDateTime.now()
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
- Kotlin
- Groovy
@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));
}
}
override fun updateFilm(id: Int, film: Film): Film {
val language =
languageJpaRepository.findByName(film.language.getValue()) ?: throw NoSuchElementException()
val originalLanguage =
film.originalLanguage?.let {
languageJpaRepository.findByName(it.getValue()) ?: throw NoSuchElementException()
}
val entity = mapper.map(film, language, originalLanguage)
entity.filmId = id
entity.lastUpdate = now()
return mapper.map(repository.save(entity))
}
}
@Override
Film updateFilm(Integer id, Film film) {
def language = languageJpaRepository.findByName(film.language.value).orElseThrow()
def originalLanguage = film.originalLanguage ? languageJpaRepository.findByName(film.originalLanguage.value).orElseThrow() : null
def entity = mapper.map(film, language, originalLanguage)
entity.filmId = id
entity.lastUpdate = LocalDateTime.now()
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
- Kotlin
- Groovy
@Override
public void deleteFilm(Integer id) {
repository.findById(id).orElseThrow();
repository.deleteById(id);
}
override fun deleteFilm(id: Int) {
repository.findById(id).orElseThrow()
repository.deleteById(id)
}
@Override
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
- Kotlin
- Groovy
@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()));
}
override fun createFilm(filmFields: FilmFields): ResponseEntity<FilmResponse> {
return status(CREATED)
.body(
FilmResponse(
data = mapper.map(useCases.createFilm(mapper.map(filmFields))),
instance = request.requestURI,
timestamp = now(),
trace = current().spanContext.traceId,
status = CREATED.value(),
)
)
}
@Override
ResponseEntity<FilmResponse> createFilm(FilmFields filmFields) {
status(CREATED)
.body(
new FilmResponse()
.data(mapper.map(useCases.createFilm(mapper.map(filmFields))))
.instance(request.requestURI)
.timestamp(now())
.trace(Span.current().spanContext.traceId)
.status(CREATED.value())
)
}
Actualizando una película
- Java
- Kotlin
- Groovy
@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()));
}
override fun updateFilm(id: Int, filmFields: FilmFields): ResponseEntity<FilmResponse> {
return ok(
FilmResponse(
data = mapper.map(useCases.updateFilm(id, mapper.map(filmFields))),
instance = request.requestURI,
timestamp = now(),
trace = current().spanContext.traceId,
status = OK.value(),
)
)
}
@Override
ResponseEntity<FilmResponse> updateFilm(Integer id, FilmFields filmFields) {
ok(
new FilmResponse()
.data(mapper.map(useCases.updateFilm(id, mapper.map(filmFields))))
.instance(request.requestURI)
.timestamp(now())
.trace(Span.current().spanContext.traceId)
.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
- Kotlin
- Groovy
@Override
public ResponseEntity<Void> deleteFilm(Integer id) {
useCases.deleteFilm(id);
return ResponseEntity.noContent().build();
}
override fun deleteFilm(id: Int): ResponseEntity<Unit> {
useCases.deleteFilm(id)
return ResponseEntity.noContent().build()
}
@Override
ResponseEntity<Void> deleteFilm(Integer id) {
useCases.deleteFilm(id)
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
getFilmdespués lanzaNoSuchElementException. - Caso de no encontrado para eliminar: pasar un ID inexistente dispara el mismo path de
NoSuchElementExceptionque elControllerAdviceglobal convierte en404.
- 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:
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.