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.
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";
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 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<AuthorSummaryResponse> 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
) {
}
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 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:
...
<properties>
...
<org.mapstruct.version>1.6.2</org.mapstruct.version>
</properties>
...
<dependencies>
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>${org.mapstruct.version}</version>
</dependency>
</dependencies>
...
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.8.1</version>
<configuration>
<source>1.8</source> <!-- depending on your project -->
<target>1.8</target> <!-- depending on your project -->
<annotationProcessorPaths>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${org.mapstruct.version}</version>
</path>
<!-- other annotation processors -->
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>
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:
<properties>
...
<org.mapstruct.version>1.6.2</org.mapstruct.version>
<org.mapstruct.version>1.6.2</org.mapstruct.version>
<lombok-mapstruck-bindings.version>0.2.0</lombok-mapstruck-bindings.version>
</properties>
...
<annotationProcessorPaths>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${org.mapstruct.version}</version>
</path>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</path>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok-mapstruct-binding</artifactId>
<version>${lombok-mapstruck-bindings.version}</version>
</path>
</annotationProcessorPaths>
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);
}
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<String>), 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<String> toGenreNameList(List<Genre> genres);
default String toGenreName(Genre genre) {
return genre.getName();
}
}
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);
}
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.
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<BookSummaryResponse> 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<BookSummaryResponse> findAllBooks(@RequestParam(required = false, defaultValue = "1") int page,
@RequestParam(required = false, defaultValue = "10") int size) {
Page<BookDto> bookDtoPage = bookService.getAll(page, size);
List<BookSummaryResponse> 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));
}
Para mejorar el manejo de las respuestas en el controlador, vamos a cambiar el tipo de respuesta a 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<Page<BookSummaryResponse>> findAllBooks(@RequestParam(required = false, defaultValue = "1") int page,
@RequestParam(required = false, defaultValue = "10") int size) {
Page<BookDto> bookDtoPage = bookService.getAll(page, size);
List<BookSummaryResponse> bookSummaries = bookDtoPage.data().stream()
.map(BookMapper::fromBookDtoToBookSummaryResponse)
.toList();
Page<BookSummaryResponse> bookSummaryPage = new Page<>(
bookSummaries,
bookDtoPage.pageNumber(),
bookDtoPage.pageSize(),
bookDtoPage.totalElements(),
bookDtoPage.totalPages()
);
return new ResponseEntity<>(bookSummaryPage, HttpStatus.OK);
}
@GetMapping("/{isbn}")
public ResponseEntity<BookDetailResponse> 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:
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;
}
}
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);
}
}