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, en nuestra aplicación, estamos compartiendo los modelos de dominio en todas las capas. Sin embargo, para el método getAll() del controlador, que devuelve un listado de libros, podríamos devolver sólo algunos atributos, como isbn, title, price, discount 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 (findByIsbn()), podemos usar un modelo completo que incluya toda la información relevante que contenga todos los atributos del libro, incluyendo sinopsis, editoriales, categorías, autores y géneros. 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 id y nombre de los autores, mientras que en el detalle de un autor devolveríamos todos los datos del mismo.
Además, en el mismo listado, puede que sólo necesitemos el atributo nombre de un modelo asociado (como categoria), sin necesidad de devolver más datos adicionales.
Vamos a crear los modelos para ver diferentes opciones. La idea es que la respuesta del listado de libros sea:
[ { "isbn": "9780142424179", "title": "El principito", "price": 15.99, "discount": 0, "cover": "9780142424179.jpg" }, { "isbn": "9780142410363", "title": "Matilda", "price": 14.99, "discount": 0, "cover": "9780142410363.jpg" }, { "isbn": "9780142418222", "title": "Charlie y la fábrica de chocolate", "price": 13.99, "discount": 0, "cover": "9780142418222.jpg" }, ...
Por otra parte, el detalle de un libro contendrá la siguiente información:
{ "isbn": "9780060557912", "title": "Buenos presagios", "price": 16.99, "discount": 0, "synopsis": "Buenos presagios cuenta la historia de un ángel y un demonio ...", "cover": "9780060557912.jpg", "genres": [ "Fantasía" ], "category": "recomendados", "publisher": { "id": 5, "name": "Alfaguara" }, "authors": [ { "id": 3, "name": "Maurice Sendak" }, { "id": 4, "name": "C.S. Lewis" } ] }
Si te fijas en la respuesta anterior, la categoría es una cadena de texto, mientras que la editorial contiene el id y el nombre. Además, mientras que el array authors está compuesto por el id y nombre de los autores, los géneros son un array de nombres.
En nuestro caso, dividiremos los modelos en dos tipos: Collection y Detail, cada uno destinado a un propósito específico. Los modelos Collection se utilizarán para representar listados de recursos, 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.
De esta forma, nuestros modelos AuthorCollection y PublisherCollection quedarían:
public record AuthorCollection( long id, String name ) { }
public record PublisherCollection( long id, String name ) { }
Por ahora, no crearemos los modelos para géneros y categorías, ya que no los necesitamos. Los modelos para libro serán:
public record BookCollection ( String isbn, String title, BigDecimal price, float discount, String cover ) { }
public record BookDetail( String isbn, String title, BigDecimal price, float discount, String synopsis, String cover, List<String> genres, String category, @JsonProperty("publisher") PublisherCollection publisherCollection, @JsonProperty("authors") List<AuthorCollection> authorsCollection ) { }
La anotación @JsonProperty se utiliza para personalizar el nombre de los campos cuando se serializan o deserializan objetos JSON, lo que puede ser útil en la capa de presentación para ajustar el formato de los datos enviados al cliente.
En el ejemplo de BookDetail, utilizamos @JsonProperty para definir el nombre que los campos publisherCollection y authorsCollection tendrán en el JSON que se envía en la respuesta. Aunque los atributos en el modelo son publisherCollection y authorsCollection, en la respuesta JSON aparecerán como publisher y authors, respectivamente.
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. Específicamente, cambiaremos el tipo de retorno del método getAll() para que devuelva una lista de BookCollection y el método findByIsbn() para que devuelva un objeto BookDetail:
@RestController @RequestMapping(BookController.URL) @RequiredArgsConstructor public class BookController { public static final String URL = "/api/books"; private final BookService bookService; @GetMapping public List<BookCollection> getAll() { return bookService.getAll() .stream() .map(BookMapper.INSTANCE::toBookCollection) .toList(); } @GetMapping("/{isbn}") public BookDetail findByIsbn(@PathVariable String isbn) { return BookMapper.INSTANCE.toBookDetail(bookService.findByIsbn(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:
@RestController @RequestMapping(BookController.URL) @RequiredArgsConstructor public class BookController { public static final String URL = "/api/books"; private final BookService bookService; @GetMapping public ResponseEntity<List<BookCollection>> getAll() { List<BookCollection> bookCollections = bookService .getAll() .stream() .map(BookMapper.INSTANCE::toBookCollection) .toList(); return new ResponseEntity<>(bookCollections, HttpStatus.OK); } @GetMapping("/{isbn}") public ResponseEntity<BookDetail> findByIsbn(@PathVariable String isbn) { BookDetail bookDetail = BookMapper.INSTANCE.toBookDetail(bookService.findByIsbn(isbn)); return new ResponseEntity<>(bookDetail, HttpStatus.OK); } }
Usar ResponseEntity en el controlador ofrece varias ventajas que mejoran la interacción con el cliente de la API:
Cuando se trata de crear excepciones personalizadas en una aplicación, existe la opción de organizarlas en un paquete común o en las capas correspondientes de la aplicación. Aunque ambas estrategias tienen sus ventajas, en este caso optaremos por crear las excepciones en un paquete común, ya que las utilizaremos en diferentes sitios.
En nuestro caso, crearemos la excepción ResourceNotFoundException.
public class ResourceNotFoundException extends RuntimeException { private static final String DESCRIPTION = "Resource not found"; public ResourceNotFoundException(String message) { super(DESCRIPTION + ". " + message); } }
De esta forma, cuando no se encuentre un recurso (un libro, por ejemplo), lanzaremos esa excepción:
@Service @RequiredArgsConstructor public class BookServiceImpl implements BookService { private final BookRepository bookRepository; @Override public List<Book> getAll() { return bookRepository.getAll(); } @Override public Book findByIsbn(String isbn) { return bookRepository.findByIsbn(isbn).orElseThrow(() -> new ResourceNotFoundException("Book isbn " + isbn + " not found")); } }
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 common: 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ó.
@Getter @ToString public class ErrorMessage { private final String error; private final String message; public ErrorMessage(Exception exception) { this.error = exception.getClass().getSimpleName(); this.message = exception.getMessage(); } }
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); } }