====== 03 - Capa de presentación ======
La **capa de presentación** adapta la comunicación entre clientes externos y las capacidades de la aplicación. En la arquitectura de referencia, sus controladores están en ''controller''.
===== 1. Responsabilidades =====
La presentación recibe solicitudes, interpreta y valida su formato, invoca la operación correspondiente y transforma el resultado o error en una respuesta HTTP.
^ Le corresponde ^ No le corresponde ^
| Interpretar la solicitud y aplicar validaciones de entrada | Decidir reglas e invariantes del negocio |
| Adaptar entre DTO externos y datos de la aplicación | Acceder directamente a la base de datos |
| Construir respuestas y traducir errores a HTTP | Exponer modelos internos sin revisar qué información contienen |
El controlador coordina el flujo; las reglas de negocio pertenecen a la aplicación o al dominio. Así, la misma capacidad puede invocarse desde otros puntos de entrada sin duplicar reglas.
===== 2. DTO de entrada y salida =====
Los **DTO** definen los datos que cruzan la frontera HTTP. Se diseñan para el contrato externo y no tienen por qué coincidir con los modelos internos.
Un ejemplo importante es el registro de usuarios: el cliente envía una contraseña, pero la respuesta no debe devolver ni la contraseña ni su hash.
public record RegisterUserRequest(
@NotBlank String email,
@NotBlank String password,
@NotBlank String displayName) {}
public record UserResponse(
Long id,
String email,
String displayName) {}
El DTO de entrada puede contener datos sensibles necesarios para la operación. El de salida incluye solo los datos que el cliente necesita conocer. El mapeo entre DTO y modelos internos debe ser explícito para evitar exponer campos por accidente.
===== 3. Validación de entrada =====
La presentación valida que la solicitud tenga la forma esperada por el contrato. Las reglas que determinan si la operación está permitida siguen correspondiendo al dominio.
^ Validación de entrada ^ Regla de negocio ^
| ¿El correo tiene un formato aceptable? | ¿Ese correo ya está registrado? |
| ¿La contraseña cumple la longitud mínima del contrato? | ¿El usuario puede realizar esta operación? |
| ¿Falta un campo obligatorio? | ¿El estado actual permite el cambio solicitado? |
**Jakarta Bean Validation** define anotaciones como ''@NotBlank'', ''@Email'' y ''@Size'' para declarar restricciones sobre los datos. **Spring** se integra con esta API y activa la validación al recibir el DTO marcado con ''@Valid''; normalmente **Hibernate Validator** actúa como proveedor:
public record RegisterUserRequest(
@NotBlank @Email String email,
@NotBlank @Size(min = 12) String password,
@NotBlank String displayName) {}
@PostMapping("/users")
public ResponseEntity register(
@Valid @RequestBody RegisterUserRequest request) {
// Delegar el registro y construir la respuesta.
}
La validación del formato evita procesar peticiones mal formadas. No sustituye las comprobaciones de negocio: por ejemplo, comprobar si el correo ya existe depende del estado de la aplicación.
===== 4. Respuestas y excepciones =====
La capa transforma resultados de aplicación en respuestas HTTP. En **Spring Boot**, ''ResponseEntity'' permite controlar el **estado**, las **cabeceras** y el **cuerpo** de la respuesta.
return ResponseEntity
.status(HttpStatus.CREATED)
.body(userResponse);
Si basta la respuesta estándar, no es necesario usar ''ResponseEntity'' en todos los endpoints. Se utiliza cuando interesa expresar explícitamente el estado, las cabeceras u otras opciones de la respuesta.
Los errores de aplicación deben convertirse en respuestas HTTP consistentes, sin revelar detalles internos. En **Spring Boot**, ''@RestControllerAdvice'' centraliza esta traducción y ''@ExceptionHandler'' asocia una excepción con el método que construye su respuesta:
public record ApiError(String code, String message) {}
@RestControllerAdvice
public class ApiExceptionHandler {
@ExceptionHandler(UserAlreadyExistsException.class)
ResponseEntity handleUserAlreadyExists(
UserAlreadyExistsException exception) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(new ApiError(
"USER_ALREADY_EXISTS", "No se puede crear el usuario"));
}
}
''ApiError'' es un **DTO** de ejemplo, no una clase obligatoria de Spring. La aplicación puede definir su propio formato de error o utilizar otro formato acordado en el contrato público.
El flujo es: el dominio o la aplicación lanza una excepción propia; el manejador la reconoce y la traduce a un estado HTTP y un cuerpo de error público. El controlador no necesita capturarla en cada operación.
Si varias excepciones significan lo mismo para el cliente, se pueden tratar en un único manejador y devolver el mismo estado y respuesta:
@ExceptionHandler({
EmailAlreadyRegisteredException.class,
UsernameAlreadyTakenException.class
})
ResponseEntity handleUserConflict(Exception exception) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(new ApiError(
"USER_ALREADY_EXISTS", "No se puede crear el usuario"));
}
En una **API pública**, hay que contemplar también excepciones inesperadas. En el mismo consejo se puede añadir un manejador genérico que devuelva una respuesta estándar con estado **500**, sin exponer mensajes internos, trazas ni datos sensibles. El detalle completo se registra en el servidor para diagnosticar el fallo:
@ExceptionHandler(Exception.class)
ResponseEntity handleUnexpected(Exception exception) {
log.error("Unexpected API error", exception);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ApiError(
"INTERNAL_ERROR", "Ha ocurrido un error inesperado"));
}
En una API pública, toda excepción no prevista debe producir una respuesta de error estándar y segura. No devuelvas al cliente mensajes internos, trazas ni información que facilite conocer la implementación.
===== 5. Pruebas de presentación =====
Las pruebas comprueban que la interfaz HTTP respeta su contrato:
* Ruta y método correctos; entrada JSON convertida al DTO esperado.
* Validaciones de entrada y traducción de excepciones conocidas y no previstas.
* Estado y cuerpo de respuesta, incluyendo que no se devuelven datos sensibles.
* Respuestas genéricas sin mensajes internos ni trazas ante fallos inesperados.
* Delegación a la operación de aplicación con los datos adecuados.
Con **MockMvc** se integra la capa web real —rutas, conversión, validación y serialización— y se simulan los servicios o casos de uso. No se prueba aquí la lógica de negocio ni la persistencia; esas responsabilidades se verifican en sus propias pruebas.
@WebMvcTest(UserController.class)
class UserControllerTest {
@Autowired MockMvc mockMvc;
@MockitoBean RegisterUser registerUser;
@Test
void responseDoesNotExposePassword() throws Exception {
when(registerUser.execute(any()))
.thenReturn(new UserResult(7L, "ana@example.test", "Ana"));
mockMvc.perform(post("/users")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"email":"ana@example.test",
"password":"long-password-value",
"displayName":"Ana"}
"""))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.email").value("ana@example.test"))
.andExpect(jsonPath("$.password").doesNotExist());
}
}
La prueba integra la frontera web y aísla la operación de aplicación. No comprueba el registro real ni el almacenamiento de la contraseña; comprueba que la respuesta HTTP respeta el contrato y no expone el campo sensible.