Tutorial: DTOs y Controller con CRUD completo
1. Objetivos de aprendizaje
Sección titulada «1. Objetivos de aprendizaje»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,400y404. - 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 conGET /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.
2. De dónde partes
Sección titulada «2. De dónde partes»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.
3. Separar ArtistaDTO y ArtistaDTOCompleto
Sección titulada «3. Separar ArtistaDTO y ArtistaDTOCompleto»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 unPOSTo unPUT, y lo que esos métodos devuelven.ArtistaDTOCompleto: extiende al anterior y agrega las colecciones. Es lo que devuelven todos losGET.
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@AllArgsConstructorpublic 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@AllArgsConstructorpublic 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 ArtistaDTOhace queArtistaDTOCompletotenga todos los atributos básicos másalbumes.List<AlbumDTO>y noList<AlbumDTOCompleto>: la colección anidada usa el DTO básico. UnAlbumDTOno 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 queequalsyhashCodetambié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;
@Datapublic 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;
@RestControllerAdvicepublic 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
404es para el recurso de la URL que no existe, y el400para una regla de negocio violada.
5. Reescribir el Controller
Sección titulada «5. Reescribir el Controller»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
ArtistaServiceyModelMapper: 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 usaArtistaDTO. - Conversión en los dos sentidos: en
createyupdateel DTO que llega se convierte a entidad para el Service, y la entidad que este devuelve se convierte a DTO para responder. @ResponseStatusen el método fija el código de una respuesta que salió bien:201paracreatey204paradelete. Los demás métodos responden200por defecto.throwsdeclara las excepciones comprobadas del Service. No hay ningúntry/catch: las atiende elRestExceptionHandler.- No lleva
@CrossOrigin. El CORS lo cubre la claseCorsConfigque creaste en el tutorial del R2. Si tuArtistaControllertodavía tiene esa anotación de entonces, quítala: ya no hace falta.
Compila para verificar que todo cuadra:
./mvnw compile6. 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@AllArgsConstructorpublic 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:
CancionDTOno tiene el álbum. La entidadCancionEntitysí 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 queAlbumDTOno trae canciones.AlbumDTOCompletoextiende aAlbumDTOy agrega la lista deCancionDTO, igual queArtistaDTOCompletoagrega 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;
@Servicepublic 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:
./mvnw compile7. Probar cada petición
Sección titulada «7. Probar cada petición»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
PUTno toca los álbumes. Actualiza los atributos básicos que envías, y los álbumes del artista siguen ahí. Compruébalo: haz unPUTy vuelve a pedirGET /artistas/1. - Un artista creado con
POSTnace conalbumesvacío. Los álbumes se relacionan con el artista después, con los endpoints de asociación del Release 7. - El
iddel cuerpo de unPOSTse ignora. Pruébalo: envía{"id":1,"nombre":"Otro"}. Se crea un artista nuevo con otroid; el artista 1 no se modifica.
8. Con ayuda de la IA
Sección titulada «8. Con ayuda de la IA»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.
¿Dónde quedaste?
Sección titulada «¿Dónde quedaste?»Al final de este tutorial tienes:
ArtistaDTObásico yArtistaDTOCompletocon la lista de álbumes.- Un
ArtistaControllerque usa el Service yModelMapper, con los cinco verbos y los códigos200,201,204,400y404. - Un
AlbumDTOCompletocon las canciones yGET /albumes/{id}para pedirlas. - Un
RestExceptionHandlery unApiErrorque 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.