====== 03_1 - Capa presentación ======
La capa de presentación en una arquitectura por capas se encarga de gestionar la interacción del usuario con la aplicación. Su principal función es recibir las solicitudes del usuario (normalmente a través de controladores REST o MVC en el caso de aplicaciones web), procesarlas y devolver una respuesta adecuada. Aquí se implementan los controladores que definen los endpoints de la API y, opcionalmente, la lógica de validación básica de las solicitudes.
Esta capa debe ser lo más independiente posible de la lógica de negocio y la persistencia. Se comunica con la capa de dominio o de aplicación para ejecutar los casos de uso requeridos, devolviendo al usuario las respuestas en el formato adecuado (como JSON o HTML). También puede encargarse del manejo de excepciones y la validación básica de los datos de entrada.
===== endpoints =====
En el desarrollo de APIs, es una buena práctica organizar y estructurar los endpoints para que sean intuitivos y fáciles de mantener. Un cambio común es añadir un prefijo como **/api** a las rutas, lo que ayuda a distinguir los recursos de la API del resto de las rutas que puede tener la aplicación. En nuestro caso, pasaremos de localhost:8080/books a localhost:8080**/api**/books, lo que mejora la claridad al indicar que estas rutas pertenecen a la API de la aplicación. Este enfoque es útil para proyectos que combinan API y otros tipos de recursos, como páginas web o servicios estáticos, ya que permite tener una estructura más limpia y comprensible.
Además, este tipo de organización facilita el mantenimiento de la aplicación a medida que crece. Los equipos de desarrollo pueden añadir fácilmente nuevas funcionalidades sin que se mezcle la lógica de negocio con otros aspectos de la aplicación.
@RestController
@RequestMapping(BookController.URL)
@RequiredArgsConstructor
public class BookController {
public static final String URL = "/api/books";
También es común añadir la versión de la API en los endpoints, como localhost:8080/api/v1/books. Esto permite mantener múltiples versiones de la API en producción, ofreciendo flexibilidad para hacer cambios sin romper las integraciones con los clientes existentes. En este caso, no hemos optado por versionar la API, ya que no es necesario en nuestra aplicación actual.
===== Modelos de la capa de presentación =====
En la capa de presentación, a veces es conveniente utilizar modelos diferentes a los de la capa de dominio para adaptarse a las necesidades de la interfaz y la experiencia del usuario. Esto permite reducir la cantidad de información enviada en ciertas situaciones y evitar exponer detalles innecesarios.
Por ejemplo, para el método //findAllBooks()// del controlador, que devuelve un listado de libros, podríamos devolver sólo algunos atributos, como //isbn//, //titleEs//, //titleEn//, //basePrice//, //discountPercentage//, //price// y //cover//. Aquí podríamos crear un modelo de presentación simplificado que contenga solo estos campos.
Por otro lado, para el detalle de un libro (//getBookByIsbn()//), podemos usar un modelo completo que incluya toda la información relevante que contenga todos los atributos del libro, incluyendo sinopsis, editoriales y autores. Esto asegura que el modelo utilizado en la respuesta del controlador esté optimizado y ajustado al contexto de cada operación, manteniendo la separación de responsabilidades entre la capa de presentación y la capa de dominio.
Para el resto de modelos sería lo mismo. Por ejemplo, en el detalle de un libro podemos devolver sólo el nombre y //slug// de los autores, mientras que en el detalle de un autor devolveríamos todos los datos del mismo.
Vamos a crear los modelos para ver diferentes opciones. La idea es que la respuesta del listado de libros sea:
{
"data": [
{
"isbn": "9780142424179",
"titleEs": "El principito",
"titleEn": "The Little Prince",
"basePrice": 15.99,
"discountPercentage": 10.00,
"price": 14.39,
"cover": "http://images.cesguiro.es/books/9780142424179.webp"
},
{
"isbn": "9780142410363",
"titleEs": "Matilda",
"titleEn": "Matilda",
"basePrice": 14.99,
"discountPercentage": 5.00,
"price": 14.24,
"cover": "http://images.cesguiro.es/books/9780142410363.webp"
},
{
"isbn": "9780142418222",
"titleEs": "Charlie y la fábrica de chocolate",
"titleEn": "Charlie and the Chocolate Factory",
"basePrice": 13.99,
"discountPercentage": 15.00,
"price": 11.89,
"cover": "http://images.cesguiro.es/books/9780142418222.jpeg"
},
...
],
"pageNumber": 1,
"pageSize": 10,
"totalElements": 24,
"totalPages": 3
}
Por otra parte, el detalle de un libro contendrá la siguiente información:
{
"isbn": "9780060557912",
"titleEs": "Buenos presagios",
"titleEn": "Good Omens",
"synopsisEs": "Buenos presagios cuenta la historia de un ángel y un demonio que unen fuerzas para evitar el apocalipsis. Ambos han vivido en la Tierra durante siglos y se han encariñado con la humanidad, por lo que harán todo lo posible para detener el fin del mundo.",
"synopsisEn": "Good Omens tells the story of an angel and a demon who team up to prevent the apocalypse. Both have lived on Earth for centuries and have grown fond of humanity, so they will do everything possible to stop the end of the world.",
"basePrice": 16.99,
"discountPercentage": 0.00,
"price": 16.99,
"cover": "http://images.cesguiro.es/books/9780060557912.jpg",
"publicationDate": "1990-05-01",
"publisher": {
"name": "Alfaguara",
"slug": "alfaguara"
},
"authors": [
{
"name": "Terry Pratchett",
"slug": "terry-pratchett"
},
{
"name": "Neil Gaiman",
"slug": "neil-gaiman"
}
]
}
En nuestro caso, dividiremos los modelos en dos tipos: //Summary// y //Detail//, cada uno destinado a un propósito específico. Los modelos //Summary// se utilizarán para representar el resumen de los modelos, mientras que los modelos //Detail// ofrecerán información detallada sobre un recurso individual.
Para implementar esta división de manera eficiente, podemos aprovechar el tipo de datos [[https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/Record.html|Record]] introducido en Java 16. Los records nos permiten crear clases inmutables de manera concisa, ideales para representar estos modelos sin la sobrecarga de escribir código repetitivo como getters, setters o constructores.
Los modelos los crearemos en el package /controller/webModel/response para las respuestas y /controller/webModel/request para los datos de entrada (para insertar libros, actualizar autores...).
De esta forma, nuestros modelos de respuesta quedarían:
public record AuthorDetailResponse(
String name,
String nationality,
String biographyEs,
String biographyEn,
Integer birthYear,
Integer deathYear,
String slug
) {
}
public record AuthorSummaryResponse(
String name,
String slug
) {
}
public record BookDetailResponse(
String isbn,
String titleEs,
String titleEn,
String synopsisEs,
String synopsisEn,
BigDecimal basePrice,
BigDecimal discountPercentage,
BigDecimal price,
String cover,
LocalDate publicationDate,
PublisherSummaryResponse publisher,
List authors
)
{}
public record BookSummaryResponse(
String isbn,
String titleEs,
String titleEn,
BigDecimal basePrice,
BigDecimal discountPercentage,
BigDecimal price,
String cover
) {
}
public record PublisherDetailResponse(
String name,
String slug
) {
}
public record PublisherSummaryResponse(
String name,
String slug
) {
}
===== Mapeadores =====
Para mapear entre los modelos de la capa de dominio y los modelos de la capa de presentación, podríamos crear nuestros propios mapeadores. En nuestro caso, vamos a utilizar [[https://mapstruct.org/|MapStruct]], un framework de mapeo que genera automáticamente el código necesario para realizar la conversión entre objetos. Esto nos permite mantener el código limpio y libre de lógica de conversión manual, mejorando la eficiencia y reduciendo errores.
Para usar MapStruct en nuestro proyecto, tenemos que añadir la dependencia en pom.xml:
...
...
1.6.2
...
org.mapstructmapstruct${org.mapstruct.version}
...
org.apache.maven.pluginsmaven-compiler-plugin3.8.11.81.8org.mapstructmapstruct-processor${org.mapstruct.version}
Cuando utilizamos tanto Lombok como MapStruct en el mismo proyecto, pueden surgir conflictos, ya que Lombok genera código en tiempo de compilación, y MapStruct puede no ser capaz de detectar correctamente los métodos generados por Lombok (como los getters, setters, y constructores).
Para solucionar este problema, debemos añadir una dependencia adicional llamada lombok-mapstruct-binding, que permite que ambos frameworks trabajen correctamente juntos. Las dependencias necesarias para solucionar este conflicto en el archivo pom.xml son las siguientes:
...
1.6.21.6.20.2.0
...
org.mapstructmapstruct-processor${org.mapstruct.version}org.projectlomboklombok${lombok.version}org.projectlomboklombok-mapstruct-binding${lombok-mapstruck-bindings.version}
==== MapStruct ====
MapStruct es una herramienta que genera automáticamente el código de mapeo entre objetos en tiempo de compilación. Funciona definiendo interfaces que describen los métodos de mapeo, y genera las implementaciones basadas en esas definiciones.
Por ejemplo vamos a crear //AuthorMapper//, que será una interfaz que MapStruct utilizará para generar el código que convierte un objeto //Author// del dominio en un objeto //AuthorCollection// de la capa de presentación:
@Mapper
public interface AuthorMapper {
AuthorMapper INSTANCE = Mappers.getMapper(AuthorMapper.class);
AuthorCollection toAuthorCollection(Author author);
}
* **Mapper**: Indica que esta interfaz es un mapeador.
* **INSTANCE**: Es una instancia singleton que MapStruct genera automáticamente.
* **toAuthorCollection(Author author)**: Define el método que MapStruct usará para convertir un objeto //Author// a //AuthorCollection//.
Para que MapStruct funcione correctamente, es necesario que las clases que participan en el mapeo, tanto en la capa de dominio como en la capa de presentación, tengan un constructor vacío, así como métodos //getters// y //setters// para todos los atributos. Esto permite que MapStruct pueda acceder y manipular los campos de los objetos durante el proceso de conversión.
Si no se incluyen estos métodos, MapStruct no podrá generar el código necesario para el mapeo, lo que resultará en errores en tiempo de compilación. En caso de utilizar Lombok para generar estos métodos, es fundamental asegurarse de que MapStruct tenga acceso a ellos, lo cual puede solucionarse utilizando la dependencia //lombok-mapstruct-binding//, como mencionamos anteriormente.
Además, los nombres de los atributos en las clases que se están mapeando deben ser idénticos para que MapStruct funcione correctamente. Cuando los nombres coinciden, MapStruct puede realizar el mapeo automáticamente sin necesidad de configuraciones adicionales. Sin embargo, si los nombres de los atributos son diferentes, podemos utilizar la anotación @Mapping para especificar cómo se deben mapear los atributos entre las clases.
Por ejemplo, si el atributo en la clase //Author// se llamara //fullName// y en //AuthorCollection// se llamara //name//, podemos definir el mapeo así:
@Mapping(source = "fullName", target = "name")
AuthorCollection toAuthorCollection(Author author);
De la misma forma, //PublisherMapper// quedará:
@Mapper
public interface PublisherMapper {
PublisherMapper INSTANCE = Mappers.getMapper(PublisherMapper.class);
PublisherCollection toPublisherCollection(Publisher publisher);
}
En el caso del //GenreMapper//, el objetivo es convertir una lista de objetos //Genre// en una lista de cadenas (//List//), donde cada cadena representa el nombre del género. Para lograr esto, podemos utilizar tanto métodos de mapeo directos como un método de conversión personalizado:
@Mapper
public interface GenreMapper {
GenreMapper INSTANCE = Mappers.getMapper(GenreMapper.class);
List toGenreNameList(List genres);
default String toGenreName(Genre genre) {
return genre.getName();
}
}
* **toGenreNameList(List genres)**: Este método se encarga de mapear una lista de objetos //Genre// a una lista de //String//. MapStruct generará el código necesario para convertir cada elemento de la lista utilizando los métodos de conversión disponibles.
* **default String toGenreName(Genre genre)**: Este es un método de conversión personalizado que permite extraer el nombre del género de un objeto //Genre//. Al ser un método default, se puede implementar directamente en la interfaz, y MapStruct lo utilizará automáticamente cuando realice la conversión de la lista.
Con esta configuración, cuando llamemos a //GenreMapper.INSTANCE.toGenreNameList(genres)//, MapStruct utilizará el método //toGenreName// para convertir cada objeto //Genre// en su correspondiente nombre (una cadena), generando así la lista de nombres de géneros que necesitas.
En el caso del //BookMapper//, utilizamos MapStruct para mapear un objeto //Book// de la capa de dominio a diferentes representaciones en la capa de presentación, específicamente a //BookCollection// y //BookDetail//. También se especifica que se utilizarán otros mapeadores, como //PublisherMapper//, //AuthorMapper// y //GenreMapper//, lo que permite una mayor modularidad y reutilización en el código:
@Mapper(uses = {PublisherMapper.class, AuthorMapper.class, GenreMapper.class})
public interface BookMapper {
BookMapper INSTANCE = Mappers.getMapper(BookMapper.class);
BookCollection toBookCollection(Book book);
@Mapping(target ="publisherCollection", source = "publisher")
@Mapping(target="authorsCollection", source = "authors")
@Mapping(target = "category", source = "category.name")
@Mapping(target = "genres", source = "genres")
BookDetail toBookDetail(Book book);
}
* **@Mapper(uses = {PublisherMapper.class, AuthorMapper.class, GenreMapper.class})**: Esta anotación indica que //BookMapper// utiliza otros mapeadores, lo que permite a MapStruct integrar sus funciones en las conversiones que realiza. Esto es útil para evitar la duplicación de código y para gestionar de forma más efectiva el mapeo de objetos complejos.
* **BookCollection toBookCollection(Book book)**: Este método convierte un objeto //Book// en un //BookCollection//, que contiene solo la información necesaria para el listado de libros.
* **@Mapping(target ="publisherCollection", source = "publisher")**: Este mapeo especifica que el atributo //publisher// en el objeto //Book// se debe mapear al atributo //publisherCollection// en //BookDetail//. Aquí se utiliza el //PublisherMapper// para llevar a cabo la conversión.
* **@Mapping(target="authorsCollection", source = "authors")**: Similar al anterior, este mapeo se encarga de convertir la lista de //authors// del objeto //Book// en //authorsCollection// en //BookDetail//, utilizando el //AuthorMapper//.
* **@Mapping(target = "category", source = "category.name")**: Este mapeo convierte el atributo //category// en //Book//, extrayendo su nombre para asignarlo a //category// en //BookDetail//.
* **@Mapping(target = "genres", source = "genres")**: Aquí se realiza un mapeo directo de //genres// a //genres//, donde se espera que se utilice el //GenreMapper// para convertir cada //Genre// en su representación correspondiente.
Con esta configuración, el //BookMapper// permite mapear de manera efectiva un objeto //Book// a sus representaciones en la capa de presentación, aprovechando otros mapeadores para manejar la complejidad de la conversión de atributos relacionados. Esto mejora la organización y la claridad del código, manteniendo la lógica de mapeo modular y reutilizable.
===== Respuesta =====
Para utilizar los nuevos modelos en las respuestas del controlador, debemos realizar cambios en los métodos que manejan las solicitudes. Por ejemplo, cambiaremos el tipo de retorno del método //findAllBooks()// para que devuelva //Page// y el método //getBookByIsbn()// para que devuelva un objeto //BookDetailResponse//:
@RestController
@RequestMapping("/api/books")
public class BookController {
private final BookService bookService;
public BookController(BookService bookService) {
this.bookService = bookService;
}
@GetMapping
public Page findAllBooks(@RequestParam(required = false, defaultValue = "1") int page,
@RequestParam(required = false, defaultValue = "10") int size) {
Page bookDtoPage = bookService.getAll(page, size);
List bookSummaries = bookDtoPage.data().stream()
.map(BookMapper::fromBookDtoToBookSummaryResponse)
.toList();
return new Page<>(
bookSummaries,
bookDtoPage.pageNumber(),
bookDtoPage.pageSize(),
bookDtoPage.totalElements(),
bookDtoPage.totalPages()
);
}
@GetMapping("/{isbn}")
public BookDetailResponse getBookByIsbn(@PathVariable String isbn) {
return BookMapper.fromBookDtoToBookDetailResponse(bookService.getByIsbn(isbn));
}
==== ResponseEntity ====
Para mejorar el manejo de las respuestas en el controlador, vamos a cambiar el tipo de respuesta a [[https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/http/ResponseEntity.html|ResponseEntity]]. La clase //ResponseEntity// es una parte fundamental de Spring que permite representar una respuesta HTTP, incluyendo el cuerpo, los encabezados y el estado de la respuesta. Esto proporciona un mayor control sobre cómo se envían las respuestas al cliente:
@GetMapping
public ResponseEntity> findAllBooks(@RequestParam(required = false, defaultValue = "1") int page,
@RequestParam(required = false, defaultValue = "10") int size) {
Page bookDtoPage = bookService.getAll(page, size);
List bookSummaries = bookDtoPage.data().stream()
.map(BookMapper::fromBookDtoToBookSummaryResponse)
.toList();
Page bookSummaryPage = new Page<>(
bookSummaries,
bookDtoPage.pageNumber(),
bookDtoPage.pageSize(),
bookDtoPage.totalElements(),
bookDtoPage.totalPages()
);
return new ResponseEntity<>(bookSummaryPage, HttpStatus.OK);
}
@GetMapping("/{isbn}")
public ResponseEntity getBookByIsbn(@PathVariable String isbn) {
BookDetailResponse bookDetailResponse = BookMapper.fromBookDtoToBookDetailResponse(bookService.getByIsbn(isbn));
return new ResponseEntity<>(bookDetailResponse, HttpStatus.OK);
}
Usar //ResponseEntity// en el controlador ofrece varias ventajas que mejoran la interacción con el cliente de la API:
* **Control del estado de la respuesta**: Permite establecer el código de estado HTTP que se desea enviar al cliente, lo que es útil para indicar el resultado de la operación (por ejemplo, HttpStatus.OK, HttpStatus.NOT_FOUND, etc.).
* **Personalización de encabezados**: Puedes agregar encabezados personalizados a la respuesta, lo que puede ser útil para informar sobre el tipo de contenido, la caché, etc.
* **Flexibilidad en el cuerpo de la respuesta**: Permite enviar cualquier tipo de objeto como cuerpo de la respuesta, manteniendo la capacidad de enviar datos complejos sin perder la estructura de la respuesta HTTP.
===== Tratamiento de excepciones =====
Podríamos tratar las excepciones en los controladores utilizando bloques //try...catch//, pero esto podría resultar en un código repetitivo y difícil de mantener, especialmente en aplicaciones grandes. Afortunadamente, Spring nos ofrece una forma de centralizar el tratamiento de excepciones a través de la anotación **@ControllerAdvice**.
El uso de @ControllerAdvice permite definir un único lugar para manejar todas las excepciones que pueden ocurrir en los controladores de la aplicación. Esto no solo simplifica la gestión de errores al evitar la duplicación de lógica en cada controlador, sino que también asegura que todas las respuestas de error tengan una estructura y formato consistentes, facilitando así a los consumidores de la API la comprensión y el manejo de los errores.
Además, @ControllerAdvice proporciona flexibilidad para manejar diferentes tipos de excepciones de manera específica, lo que permite devolver respuestas personalizadas según la situación, con códigos de estado HTTP adecuados y mensajes de error descriptivos. Esta aproximación contribuye a un código más limpio y mantenible, separando la lógica de manejo de errores de la lógica de negocio en los controladores.
Para manejar las excepciones en la aplicación de manera efectiva, crearemos dos clases en el paquete //exception//: //ErrorMessage// y //ApiExceptionHandler//.
La clase //ErrorMessage// contiene dos propiedades: //error// y //message//. Estas propiedades se utilizan para enviar información relevante sobre el error que ocurrió.
public class ErrorMessage {
private final String error;
private final String message;
public ErrorMessage(Exception exception) {
this.error = exception.getClass().getSimpleName();
this.message = exception.getMessage();
}
public String getError() {
return error;
}
public String getMessage() {
return message;
}
}
Asegúrate de añadir los getters en las clases de respuesta (//ErrorMessage//),
ya que si no Jackson no podrá serializar los campos a JSON y el //@ControllerAdvice//
no devolverá correctamente el cuerpo de error al cliente.
La clase //ApiExceptionHandler// utiliza la anotación @ControllerAdvice para manejar excepciones globalmente. Aquí, se maneja la excepción //ResourceNotFoundException// devolviendo un //ErrorMessage// con un código de estado 404 (NO FOUND).
Para capturar el resto de las excepciones de manera más segura y evitar exponer detalles sensibles sobre el error interno, podemos crear un método en la clase ApiExceptionHandler para que devuelva un mensaje genérico como "Internal error". Esto garantiza que cualquier excepción no manejada específicamente también se trate de manera adecuada, proporcionando una respuesta coherente al cliente.
@ControllerAdvice
public class ApiExceptionHandler {
@ResponseStatus(HttpStatus.NOT_FOUND)
@ExceptionHandler({
ResourceNotFoundException.class
})
@ResponseBody
public ErrorMessage notFoundRequest(ResourceNotFoundException exception) {
return new ErrorMessage(exception);
}
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
@ExceptionHandler(Exception.class)
@ResponseBody
public ErrorMessage handleGeneralException(Exception exception) {
return new ErrorMessage(exception);
}
}