Ir al contenido

Tutorial: DTOs y Controller con CRUD completo

Al terminar este tutorial vas a ser capaz de:

  • Separar el DTO básico (ArtistaDTO) del DTO completo (ArtistaDTOCompleto) y explicar por qué el JSON nunca tiene ciclos.
  • Conectar el Controller al Service, y convertir entre entidad y DTO con ModelMapper.
  • Exponer los cinco verbos del CRUD con los códigos 200, 201, 204, 400 y 404.
  • Traducir las excepciones del Service a respuestas HTTP que traen el mensaje, con un @RestControllerAdvice.
  • Armar un segundo DTOCompleto, el del álbum con sus canciones, y exponerlo con GET /albumes/{id}.
  • Comprobar cada petición con Postman.

Prerrequisitos: haber terminado el Tutorial: El Service de una entidad (tienes ArtistaService y las dos excepciones), y haber leído la teoría de Implementación de un API REST con Spring.


Tu ArtistaController todavía inyecta el ArtistaRepository, solo tiene un GET y un POST que no guarda nada, y ArtistaDTO trae una lista de álbumes con sus canciones. Este tutorial deja el Controller así:

Controller → Service → Repository
  • El Controller recibe el Service y un ModelMapper, y ya no conoce el repository.
  • Expone el CRUD completo.
  • Nunca retorna una entidad: siempre un DTO.

Cada recurso tiene dos representaciones, como en el diseño del API:

  • ArtistaDTO: solo los atributos básicos, sin colecciones. Es lo que el cliente envía en un POST o un PUT, y lo que esos métodos devuelven.
  • ArtistaDTOCompleto: extiende al anterior y agrega las colecciones. Es lo que devuelven todos los GET.

Reemplaza el contenido de ArtistaDTO.java, quitándole la lista de álbumes. Además, las fechas pasan a LocalDate, el mismo tipo que ya tienen las entidades. En el JSON siguen siendo texto yyyy-MM-dd, así que el front no cambia:

package co.edu.uniandes.musical.artistas;
import java.time.LocalDate;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ArtistaDTO {
private Long id;
private String nombre;
private String fotoArtistaUrl;
private String paisOrigen;
private String biografia;
private LocalDate fechaNacimiento;
}

AlbumDTO.java también pierde su lista de canciones: dentro de un artista, el álbum viaja solo con sus atributos básicos.

package co.edu.uniandes.musical.artistas;
import java.time.LocalDate;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class AlbumDTO {
private Long id;
private String titulo;
private String fotoPortadaUrl;
private LocalDate fechaLanzamiento;
}

Y crea ArtistaDTOCompleto.java:

package co.edu.uniandes.musical.artistas;
import java.util.ArrayList;
import java.util.List;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@EqualsAndHashCode(callSuper = true)
public class ArtistaDTOCompleto extends ArtistaDTO {
private List<AlbumDTO> albumes = new ArrayList<>();
}

Fíjate en las piezas:

  • extends ArtistaDTO hace que ArtistaDTOCompleto tenga todos los atributos básicos más albumes.
  • List<AlbumDTO> y no List<AlbumDTOCompleto>: la colección anidada usa el DTO básico. Un AlbumDTO no vuelve a incluir a sus artistas, así que el JSON es un árbol y no un grafo con vueltas.
  • @EqualsAndHashCode(callSuper = true) le dice a Lombok que equals y hashCode también consideren los atributos de la clase padre.

4. Traducir las excepciones a respuestas HTTP

Sección titulada «4. Traducir las excepciones a respuestas HTTP»

Cuando una regla falla, el Service lanza una excepción y el cliente debe recibir un código HTTP y un mensaje. El Controller no tiene que atrapar nada: lo hace una clase aparte, anotada con @RestControllerAdvice, que atiende las excepciones de todos los controllers.

Primero, ApiError.java, el objeto que viaja en el cuerpo de la respuesta de error:

package co.edu.uniandes.musical.artistas;
import java.time.LocalDateTime;
import lombok.Data;
import org.springframework.http.HttpStatus;
@Data
public class ApiError {
private int status;
private String message;
private LocalDateTime timestamp = LocalDateTime.now();
public ApiError(HttpStatus status, String message) {
this.status = status.value();
this.message = message;
}
}

Y RestExceptionHandler.java:

package co.edu.uniandes.musical.artistas;
import co.edu.uniandes.musical.exception.EntityNotFoundException;
import co.edu.uniandes.musical.exception.IllegalOperationException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class RestExceptionHandler {
@ExceptionHandler(EntityNotFoundException.class)
public ResponseEntity<ApiError> handleEntityNotFound(EntityNotFoundException ex) {
return new ResponseEntity<>(new ApiError(HttpStatus.NOT_FOUND, ex.getMessage()), HttpStatus.NOT_FOUND);
}
@ExceptionHandler(IllegalOperationException.class)
public ResponseEntity<ApiError> handleIllegalOperation(IllegalOperationException ex) {
return new ResponseEntity<>(new ApiError(HttpStatus.BAD_REQUEST, ex.getMessage()), HttpStatus.BAD_REQUEST);
}
}
  • @ExceptionHandler(EntityNotFoundException.class) dice qué excepción atiende cada método. Cuando un Controller deja escapar esa excepción, Spring llama a este método en vez de devolver un error genérico.
  • new ResponseEntity<>(cuerpo, código) arma la respuesta con el código HTTP y el cuerpo que elijas. El mensaje de la excepción (el que escribiste en el Service) llega al cliente.
  • El 404 es para el recurso de la URL que no existe, y el 400 para una regla de negocio violada.

Reemplaza todo ArtistaController.java:

package co.edu.uniandes.musical.artistas;
import co.edu.uniandes.musical.exception.EntityNotFoundException;
import co.edu.uniandes.musical.exception.IllegalOperationException;
import java.util.List;
import org.modelmapper.ModelMapper;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/artistas")
public class ArtistaController {
private final ArtistaService artistaService;
private final ModelMapper modelMapper;
public ArtistaController(ArtistaService artistaService, ModelMapper modelMapper) {
this.artistaService = artistaService;
this.modelMapper = modelMapper;
}
@GetMapping
public List<ArtistaDTOCompleto> findAll() {
return artistaService.findAll().stream()
.map(artista -> modelMapper.map(artista, ArtistaDTOCompleto.class))
.toList();
}
@GetMapping("/{id}")
public ArtistaDTOCompleto findById(@PathVariable Long id) throws EntityNotFoundException {
return modelMapper.map(artistaService.findById(id), ArtistaDTOCompleto.class);
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public ArtistaDTO create(@RequestBody ArtistaDTO artistaDTO) throws IllegalOperationException {
ArtistaEntity artista = artistaService.create(modelMapper.map(artistaDTO, ArtistaEntity.class));
return modelMapper.map(artista, ArtistaDTO.class);
}
@PutMapping("/{id}")
public ArtistaDTO update(@PathVariable Long id, @RequestBody ArtistaDTO artistaDTO)
throws EntityNotFoundException, IllegalOperationException {
ArtistaEntity artista = artistaService.update(id, modelMapper.map(artistaDTO, ArtistaEntity.class));
return modelMapper.map(artista, ArtistaDTO.class);
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable Long id) throws EntityNotFoundException {
artistaService.delete(id);
}
}

Fíjate en las piezas:

  • El constructor recibe ArtistaService y ModelMapper: el Controller ya no conoce el repository.
  • @RequestMapping("/artistas") en la clase pone el path común; cada método solo agrega lo que le falta (/{id}).
  • La lectura usa ArtistaDTOCompleto (findAll, findById); la escritura usa ArtistaDTO.
  • Conversión en los dos sentidos: en create y update el DTO que llega se convierte a entidad para el Service, y la entidad que este devuelve se convierte a DTO para responder.
  • @ResponseStatus en el método fija el código de una respuesta que salió bien: 201 para create y 204 para delete. Los demás métodos responden 200 por defecto.
  • throws declara las excepciones comprobadas del Service. No hay ningún try/catch: las atiende el RestExceptionHandler.
  • No lleva @CrossOrigin. El CORS lo cubre la clase CorsConfig que creaste en el tutorial del R2. Si tu ArtistaController todavía tiene esa anotación de entonces, quítala: ya no hace falta.

Compila para verificar que todo cuadra:

Ventana de terminal
./mvnw compile

6. Un segundo DTOCompleto: el álbum con sus canciones

Sección titulada «6. Un segundo DTOCompleto: el álbum con sus canciones»

ArtistaDTOCompleto trae sus álbumes como AlbumDTO, sin canciones. Para mostrar las canciones de un álbum, el cliente pide el álbum por su cuenta: GET /albumes/{id}, que responde un AlbumDTOCompleto. Es el mismo patrón que acabas de escribir, pero con otra relación: la colección del álbum es un @OneToMany (sus canciones), no un @ManyToMany.

Crea CancionDTO.java:

package co.edu.uniandes.musical.artistas;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class CancionDTO {
private Long id;
private String titulo;
private String fotoPortadaUrl;
private String duracion;
}

Y AlbumDTOCompleto.java:

package co.edu.uniandes.musical.artistas;
import java.util.ArrayList;
import java.util.List;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@EqualsAndHashCode(callSuper = true)
public class AlbumDTOCompleto extends AlbumDTO {
private List<CancionDTO> canciones = new ArrayList<>();
}

Fíjate en las piezas:

  • CancionDTO no tiene el álbum. La entidad CancionEntity sí apunta a su álbum, pero el DTO no: si la canción volviera a incluir a su álbum, el JSON tendría un ciclo. Es la misma razón por la que AlbumDTO no trae canciones.
  • AlbumDTOCompleto extiende a AlbumDTO y agrega la lista de CancionDTO, igual que ArtistaDTOCompleto agrega los álbumes.

Para exponerlo hace falta un Service y un Controller. Tu AlbumService tendrá los cinco métodos, como ArtistaService; aquí solo se muestra el que necesita este endpoint. Antes, agrega a MensajesEs (la clase del tutorial del Service) la constante del álbum, en una sección nueva debajo de la de artista:

// Álbum
public static final String ALBUM_NO_EXISTE = "El álbum no existe";

Crea AlbumService.java:

package co.edu.uniandes.musical.artistas;
import co.edu.uniandes.musical.exception.EntityNotFoundException;
import co.edu.uniandes.musical.exception.MensajesEs;
import java.util.List;
import java.util.Optional;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class AlbumService {
private final AlbumRepository albumRepository;
public AlbumService(AlbumRepository albumRepository) {
this.albumRepository = albumRepository;
}
@Transactional(readOnly = true)
public List<AlbumEntity> findAll() {
return albumRepository.findAll();
}
@Transactional(readOnly = true)
public AlbumEntity findById(Long id) throws EntityNotFoundException {
Optional<AlbumEntity> album = albumRepository.findById(id);
if (album.isEmpty()) {
throw new EntityNotFoundException(MensajesEs.ALBUM_NO_EXISTE);
}
return album.get();
}
}

Y AlbumController.java:

package co.edu.uniandes.musical.artistas;
import co.edu.uniandes.musical.exception.EntityNotFoundException;
import java.util.List;
import org.modelmapper.ModelMapper;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/albumes")
public class AlbumController {
private final AlbumService albumService;
private final ModelMapper modelMapper;
public AlbumController(AlbumService albumService, ModelMapper modelMapper) {
this.albumService = albumService;
this.modelMapper = modelMapper;
}
@GetMapping
public List<AlbumDTOCompleto> findAll() {
return albumService.findAll().stream()
.map(album -> modelMapper.map(album, AlbumDTOCompleto.class))
.toList();
}
@GetMapping("/{id}")
public AlbumDTOCompleto findById(@PathVariable Long id) throws EntityNotFoundException {
return modelMapper.map(albumService.findById(id), AlbumDTOCompleto.class);
}
}

ModelMapper copia canciones a AlbumDTOCompleto porque el atributo se llama igual en la entidad y en el DTO. El RestExceptionHandler es el mismo: un álbum que no existe responde 404 con el mensaje. Los demás verbos de /albumes (POST, PUT, DELETE) siguen el patrón del ArtistaController.

Compila:

Ventana de terminal
./mvnw compile

Arranca la aplicación (./mvnw spring-boot:run) y prueba con Postman, como en el tutorial de Postman. Las respuestas son las de un artista con un álbum; con tus datos serán parecidas.

Petición Código Qué debes ver
GET /artistas 200 Una lista de artistas, cada uno con su lista albumes
GET /artistas/1 200 El artista con albumes
POST /artistas con {"nombre":"Datty","paisOrigen":"Colombia"} 201 El artista creado, con su id
POST /artistas con {} 400 "message":"El nombre del artista es obligatorio"
POST /artistas con un nombre que ya existe 400 "message":"Ya existe un artista con ese nombre"
GET /artistas/999 404 "message":"El artista no existe"
PUT /artistas/1 con otro nombre 200 El artista actualizado
DELETE /artistas/2 204 Sin cuerpo
DELETE /artistas/2 otra vez 404 "message":"El artista no existe"
GET /albumes 200 Una lista de álbumes, cada uno con su lista canciones
GET /albumes/1 200 El álbum con su lista canciones
GET /albumes/999 404 "message":"El álbum no existe"

Una respuesta de error se ve así:

{"status":404,"message":"El artista no existe","timestamp":"2026-10-10T19:30:18.2758687"}

Y un GET /artistas/1 se ve así (con los atributos que tenga tu artista):

{"albumes":[{"id":1,"titulo":"Pa'lla Voy","fotoPortadaUrl":null,"fechaLanzamiento":"2004-05-25"}],"biografia":null,"fechaNacimiento":null,"fotoArtistaUrl":null,"id":1,"nombre":"Marc Anthony","paisOrigen":"Estados Unidos"}

Y un GET /albumes/1 trae las canciones del álbum:

{"canciones":[{"id":1,"titulo":"Valio la Pena","fotoPortadaUrl":null,"duracion":"4:06"},{"id":2,"titulo":"Y Como Es El","fotoPortadaUrl":null,"duracion":"3:48"}],"fechaLanzamiento":"2004-05-25","fotoPortadaUrl":null,"id":1,"titulo":"Pa'lla Voy"}

Tres detalles para notar en estas pruebas:

  • Un PUT no toca los álbumes. Actualiza los atributos básicos que envías, y los álbumes del artista siguen ahí. Compruébalo: haz un PUT y vuelve a pedir GET /artistas/1.
  • Un artista creado con POST nace con albumes vacío. Los álbumes se relacionan con el artista después, con los endpoints de asociación del Release 7.
  • El id del cuerpo de un POST se ignora. Pruébalo: envía {"id":1,"nombre":"Otro"}. Se crea un artista nuevo con otro id; el artista 1 no se modifica.

Se puede usar IA para la mecánica: los métodos update y delete siguen el patrón de findById y create, así que puedes pedirlos a partir de un método que ya funciona. También el ModelMapper y las llamadas a map(...). El prompt debe incluir la clase de la entidad y los dos DTOs.

Lo que verificas tú: que los códigos HTTP son los correctos para cada método, y que los atributos de la entidad y de los DTOs se llaman igual, porque ModelMapper los empareja por nombre.


Al final de este tutorial tienes:

  • ArtistaDTO básico y ArtistaDTOCompleto con la lista de álbumes.
  • Un ArtistaController que usa el Service y ModelMapper, con los cinco verbos y los códigos 200, 201, 204, 400 y 404.
  • Un AlbumDTOCompleto con las canciones y GET /albumes/{id} para pedirlas.
  • Un RestExceptionHandler y un ApiError que devuelven el código y el mensaje de cada excepción.
  • Las peticiones comprobadas con Postman.

Lo que viene después, siguiendo el orden de Release 7:

  • Repetir el patrón con las demás entidades de tu módulo.
  • Escribir las pruebas del Service.
  • Los endpoints de asociación entre módulos, y las colecciones de Postman.