====== 02 - Capa controller ====== La capa de presentación conecta a los clientes con las capacidades de una aplicación. Recibe solicitudes mediante una interfaz, interpreta sus datos, delega el trabajo en la aplicación y comunica el resultado mediante una respuesta. ===== 1. Responsabilidad del controlador ===== El controlador es un adaptador de entrada: traduce una solicitud externa a una operación de la aplicación y adapta el resultado a una respuesta para el cliente. Su trabajo es coordinar esa interacción, no implementar la operación solicitada. Esta separación permite que las mismas capacidades de la aplicación se utilicen desde interfaces diferentes, por ejemplo una API HTTP, una tarea programada o la API interna de otro módulo. Si el controlador contiene decisiones de negocio, esas decisiones se duplican cuando aparece otro punto de entrada y pueden aplicarse de forma distinta. Por eso el controlador no debería calcular políticas de negocio, cambiar estados según reglas propias ni acceder directamente al almacenamiento. En una aplicación Spring Boot, el método del endpoint puede limitarse a transformar la entrada, invocar un caso de uso y transformar el resultado: @PostMapping("/orders") public ResponseEntity create(@Valid @RequestBody CreateOrderRequest request) { CreateOrderCommand command = mapper.toCommand(request); OrderResult result = createOrder.execute(command); OrderResponse response = mapper.toResponse(result); return ResponseEntity.status(HttpStatus.CREATED).body(response); } El controlador conoce la forma de la interfaz y el contrato que necesita invocar. Las decisiones que determinan si la operación es válida pertenecen al componente de aplicación o al dominio correspondiente. ===== 2. Contratos externos y modelos internos ===== Los datos que cruzan la frontera entre un cliente y la aplicación forman un contrato externo. Ese contrato responde a lo que el cliente necesita enviar y recibir; el modelo interno responde a cómo conviene representar y proteger el negocio. No tienen por qué coincidir. Usar DTO de entrada evita que el cliente pueda asignar campos que controla la aplicación, como identificadores generados, roles o estados internos. Usar DTO de salida evita exponer accidentalmente información privada o detalles del modelo enriquecido. Además, el formato de la API puede evolucionar sin obligar a cambiar los modelos internos, y estos pueden refactorizarse sin romper a todos los clientes. En Java, un ''record'' permite expresar de forma concisa un DTO de datos. Por ejemplo, la creación no recibe un identificador que debe asignar el sistema, mientras que la respuesta puede incluirlo: public record CreateOrderRequest(String customerReference, List lines) {} public record OrderResponse(Long id, String status) {} El DTO no debe convertirse en un modelo de dominio ni ser la entidad de persistencia. Un mapeador transforma entre representaciones en el límite entre capas; esa transformación adapta datos, no decide reglas de negocio. En un adaptador Spring, MapStruct puede generar estas conversiones y producir un componente que Spring inyecta. La interfaz siguiente muestra la dirección del mapeo; el dominio no necesita depender de Spring para que esto funcione: @Mapper(componentModel = "spring") public interface OrderMapper { CreateOrderCommand toCommand(CreateOrderRequest request); OrderResponse toResponse(OrderResult result); } ===== 3. Validación de entrada y reglas de negocio ===== Una solicitud puede ser incorrecta por motivos distintos: ^ Comprobación ^ Pregunta ^ Ejemplo ^ | Forma de entrada | ¿Los datos se pueden interpretar y cumplen las restricciones del contrato? | Falta un campo obligatorio o la cantidad no es un entero positivo | | Regla de negocio | ¿La operación está permitida por las políticas e invariantes de la aplicación? | No se puede confirmar un pedido vacío o superar el límite permitido | La validación de forma pertenece a la frontera de presentación porque depende del contrato recibido. Rechazar pronto una petición mal formada evita ejecutar trabajo innecesario y permite comunicar con claridad qué dato no cumple el formato. Las reglas de negocio deben aplicarse dentro de la aplicación o el dominio, no solo en el controlador. Así se garantiza que la regla rige para cualquier forma de invocar la capacidad, no únicamente para HTTP. También se evita duplicar la misma política en cada interfaz. Una comprobación de entrada puede repetir una restricción simple para dar respuesta temprana, pero no sustituye la protección de una invariante en el dominio. Por ejemplo, Bean Validation puede rechazar una referencia ausente o una lista no proporcionada antes de invocar el caso de uso. Una lista presente pero vacía puede llegar al dominio si que el pedido tenga líneas es una regla del negocio: public record OrderLineRequest(@NotBlank String productCode, @Positive int quantity) {} public record CreateOrderRequest( @NotBlank @Size(max = 40) String customerReference, @NotNull List<@Valid OrderLineRequest> lines ) {} @PostMapping("/orders") public ResponseEntity create(@Valid @RequestBody CreateOrderRequest request) { OrderResult result = createOrder.execute(mapper.toCommand(request)); return ResponseEntity.status(HttpStatus.CREATED).body(mapper.toResponse(result)); } En cambio, calcular en el controlador el total de unidades y rechazarlo según una política del negocio mezcla la frontera HTTP con el dominio. Esa decisión debe realizarla el dominio y comunicarse como un resultado o error de aplicación que la capa de presentación traducirá a una respuesta HTTP. // No decidir en el controlador si el pedido supera una política de negocio. int totalUnits = request.lines().stream() .mapToInt(OrderLineRequest::quantity) .sum(); if (totalUnits > MAX_ORDER_QUANTITY) { return ResponseEntity.badRequest().build(); } ===== 4. Respuesta y errores ===== La presentación traduce el resultado de la aplicación a los elementos propios de su interfaz: estado, metadatos y cuerpo de respuesta. Esa traducción permite mantener un contrato externo consistente y evita filtrar detalles internos, como trazas o mensajes técnicos. Del mismo modo, los errores del dominio o de aplicación deben mapearse a respuestas comprensibles para el cliente. Conviene centralizar esta traducción para no repetirla en cada controlador. En Spring, ''@RestControllerAdvice'' puede reunir esa adaptación de excepciones a estados y cuerpos HTTP, manteniéndola fuera de las reglas de negocio y del flujo normal de cada endpoint. ===== 5. Pruebas de la capa controller ===== Las pruebas de presentación responden a esta pregunta: ¿la interfaz convierte correctamente una solicitud en una invocación de la aplicación y presenta su resultado según el contrato establecido? Conviene probar: * Que la ruta, el método y los parámetros seleccionan la operación correcta. * Que el cuerpo y los parámetros se convierten adecuadamente y que las restricciones de entrada producen el error esperado. * Que la respuesta contiene el estado, encabezados y cuerpo previstos, incluidos los errores públicos. * Que el controlador invoca la operación correspondiente con los datos esperados. No corresponde a estas pruebas demostrar que las reglas de negocio sean correctas ni que la base de datos persista los cambios: eso se comprueba en pruebas del dominio y de persistencia. Tampoco se trata de probar Spring; el framework se integra como mecanismo para recorrer HTTP, binding, validación y serialización. Se sustituyen por dobles las dependencias de aplicación y los colaboradores que no pertenecen a la presentación. Con ''MockMvc'' y Mockito se puede probar el contrato HTTP usando la capa web real y simulando el caso de uso y el mapeador. La prueba siguiente comprueba estado y cuerpo, y verifica la delegación. Una segunda petición inválida comprueba que la validación de entrada impide invocar el dominio: public record OrderLineCommand(String productCode, int quantity) {} public record CreateOrderCommand(String customerReference, List lines) {} public record OrderResult(Long id) {} @WebMvcTest(OrderController.class) class OrderControllerTest { @Autowired private MockMvc mockMvc; @Autowired private ObjectMapper objectMapper; @MockitoBean private CreateOrder createOrder; @MockitoBean private OrderMapper mapper; @Test void createsOrderAndReturnsCreatedResponse() throws Exception { var request = new CreateOrderRequest( "WEB-2026", List.of(new OrderLineRequest("BOOK-1", 2))); var command = new CreateOrderCommand("WEB-2026", List.of( new OrderLineCommand("BOOK-1", 2))); var result = new OrderResult(42L); var response = new OrderResponse(42L, "CREATED"); when(mapper.toCommand(request)).thenReturn(command); when(createOrder.execute(command)).thenReturn(result); when(mapper.toResponse(result)).thenReturn(response); mockMvc.perform(post("/orders") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isCreated()) .andExpect(jsonPath("$.id").value(42L)) .andExpect(jsonPath("$.status").value("CREATED")); verify(mapper).toCommand(request); verify(createOrder).execute(command); verify(mapper).toResponse(result); } @Test void rejectsInvalidInputWithoutCallingDomain() throws Exception { mockMvc.perform(post("/orders") .contentType(MediaType.APPLICATION_JSON) .content("{\"customerReference\":\"\",\"lines\":[" + "{\"productCode\":\"\",\"quantity\":0}]}")) .andExpect(status().isBadRequest()); verifyNoInteractions(createOrder); } } La prueba integra la frontera web y aísla la aplicación: comprueba el contrato HTTP, la conversión del JSON a DTO, la validación y la delegación, no vuelve a probar la lógica del caso de uso. Como ''OrderMapper'' está simulado, esta prueba no comprueba la conversión entre DTO y comando; los mapeos generados relevantes se prueban aparte. La base de datos tampoco se inicia. El nombre de la anotación para registrar mocks en el contexto de prueba puede variar según la versión de Spring Boot; aquí se usa ''@MockitoBean''. La capa de presentación debe adaptar y delegar. Las reglas deben quedar protegidas en la aplicación o dominio, y las pruebas de presentación deben centrarse en el contrato externo y en la delegación.