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. Responsabilidades y límites
El controlador es un adaptador de entrada. Traduce el protocolo externo a una operación de la aplicación y adapta su resultado al formato que espera el cliente.
Sus responsabilidades generales son:
- Identificar la operación solicitada a partir de la interfaz y los datos recibidos.
- Comprobar que la entrada tiene la forma esperada por esa interfaz.
- Invocar la capacidad de aplicación correspondiente.
- Traducir el resultado o error a una respuesta comprensible y coherente para el cliente.
El controlador coordina la interacción, pero no implementa las reglas del negocio ni accede directamente al almacenamiento. Así, las reglas no quedan ligadas a una interfaz concreta y pueden reutilizarse desde otros puntos de entrada.
2. Contratos de entrada y salida
Los datos que cruzan el límite entre el cliente y la aplicación forman parte de un contrato externo. Es conveniente diseñarlos para las necesidades de cada operación, en lugar de exponer directamente los modelos internos.
- Entrada: contiene únicamente los datos que el cliente puede proporcionar para solicitar una operación. No debería permitir modificar información que controla la aplicación, como identificadores generados o estados internos.
- Salida: contiene la información que se decide comunicar al cliente. Puede omitir datos internos o sensibles y presentar una vista adecuada para una consulta concreta.
Una creación, una actualización, un resumen y un detalle pueden tener estructuras diferentes. Esta separación permite evolucionar la interfaz sin obligar a que coincida con el modelo interno.
Ejemplo en Java: DTO con records
En Java, un record resulta adecuado para representar un DTO sencillo e inmutable. El ejemplo muestra contratos distintos para crear un libro y devolver su resumen:
public record CreateBookRequest(String title, String author) {}
public record BookResponse(Long id, String title, String author) {}
El identificador no se recibe en la petición de creación porque lo asigna la aplicación. Los campos concretos dependerán del contrato que se quiera ofrecer.
3. Validación de entrada y reglas de negocio
No todas las comprobaciones tienen la misma responsabilidad:
| Tipo | Pregunta que responde | Ejemplo |
|---|---|---|
| Validación de entrada | ¿La petición tiene una forma aceptable? | El título no está vacío y no supera la longitud admitida por la interfaz |
| Regla de negocio | ¿La operación está permitida según el estado y las políticas de la aplicación? | No se puede confirmar un pedido vacío |
La primera puede rechazarse en la frontera antes de invocar la aplicación. La segunda debe protegerse en el dominio para que se cumpla aunque la operación se solicite desde otra interfaz o módulo.
Ejemplo en Spring Boot
Spring puede aplicar restricciones de formato declaradas en el DTO de entrada. El controlador delega la operación y no decide si el pedido cumple las reglas 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
) {}
public record OrderResponse(Long id, String status) {}
@RestController
@RequestMapping("/orders")
public class OrderController {
private final CreateOrder createOrder;
private final OrderMapper mapper;
public OrderController(CreateOrder createOrder, OrderMapper mapper) {
this.createOrder = createOrder;
this.mapper = mapper;
}
@PostMapping
public ResponseEntity<OrderResponse> create(
@Valid @RequestBody CreateOrderRequest request) {
OrderResult result = createOrder.create(mapper.toCommand(request));
return ResponseEntity.status(HttpStatus.CREATED)
.body(mapper.toResponse(result));
}
}
Las restricciones de entrada comprueban la forma de los datos: la referencia no está vacía y no supera la longitud admitida, la lista está presente y cada código y cantidad tiene un formato admisible. El caso de uso conserva la responsabilidad de comprobar las invariantes del pedido. Una restricción sencilla, como que una cantidad sea positiva, puede comprobarse también en la entrada para dar un error temprano, pero el dominio debe proteger la invariante si forma parte de sus reglas. Una lista vacía puede ser una petición formalmente válida y rechazarse como regla del dominio. En cambio, decisiones como un límite total de unidades deben pertenecer al dominio.
// No trasladar reglas de negocio al controlador.
int totalUnits = request.lines().stream()
.mapToInt(OrderLineRequest::quantity)
.sum();
if (totalUnits > MAX_ORDER_QUANTITY) {
return ResponseEntity.badRequest().build();
}
Si MAX_ORDER_QUANTITY es una política de negocio, debe aplicarla el dominio. El ejemplo muestra el tipo de decisión que no se debe añadir al controlador; las restricciones de sintaxis de la entrada sí pueden comprobarse en esta frontera.
4. Respuestas y errores
Una respuesta debe comunicar el resultado de la operación mediante el estado, los metadatos y, cuando corresponda, un cuerpo. El formato externo debe ser consistente: clientes distintos deberían poder interpretar los errores de manera previsible sin recibir trazas ni detalles internos.
La traducción de errores de aplicación a errores del protocolo pertenece al adaptador de presentación. Conviene centralizarla para evitar duplicar el tratamiento en cada endpoint. Las excepciones propias del dominio describen situaciones de la aplicación; el controlador o un componente común de presentación decide cómo representarlas externamente.
Ejemplo en Spring Boot
En Spring, una capa de asesoramiento global puede mapear errores a estados y cuerpos HTTP con @RestControllerAdvice. Esto mantiene la política de representación de errores fuera de la lógica de cada controlador.
5. Mapeo entre contratos y modelos internos
Los DTO externos y los modelos internos tienen propósitos distintos. Al cruzar este límite, un mapeador transforma los datos entre representaciones. El mapeo no debe tomar decisiones de negocio ni validar invariantes que correspondan al dominio.
Ejemplo con MapStruct y Spring Boot
MapStruct puede generar la conversión entre un DTO de entrada y el modelo que recibe la aplicación, y entre el resultado y el DTO de salida. En un mapper ubicado en el adaptador Spring de controller, componentModel = “spring” permite que Spring lo registre e inyecte:
@Mapper(componentModel = "spring")
public interface OrderMapper {
CreateOrderCommand toCommand(CreateOrderRequest request);
OrderResponse toResponse(OrderResult result);
}
La configuración concreta del componente generado depende de cómo se integren los mappers en la aplicación. El límite arquitectónico importante es que los DTO de la interfaz no se conviertan en los modelos enriquecidos del dominio.
6. Pruebas de la capa de presentación
Las pruebas de esta capa comprueban que la interfaz acepta y traduce correctamente las solicitudes, delega en la operación adecuada y presenta la respuesta esperada. No deben volver a probar las reglas del dominio ni el acceso a datos.
| Comprobar | Dependencias a integrar | Dependencias a aislar |
|---|---|---|
| Ruta, método y parámetros | Enrutamiento y capa web | Dominio y persistencia |
| Conversión y validación de entrada | Binding y validación de la interfaz | Dominio y persistencia |
| Estado, encabezados y cuerpo de respuesta | Serialización y capa web | Dominio y persistencia |
| Delegación y manejo de errores | Adaptador de presentación | Implementación real del dominio y colaboradores externos |
Ejemplo en Spring Boot con MockMvc y Mockito
Una prueba de controlador puede integrar Spring MVC y la serialización HTTP, mientras sustituye la dependencia de aplicación por un mock. Así comprueba el contrato HTTP sin ejecutar la lógica del dominio ni acceder a la base de datos.
public record OrderLineCommand(String productCode, int quantity) {}
public record CreateOrderCommand(String customerReference, List<OrderLineCommand> 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.create(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).create(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 usa MockMvc para recorrer la capa HTTP y Mockito para controlar el resultado de la operación delegada y comprobar que se invocó con los datos esperados. En una prueba de validación se enviaría una petición inválida y se comprobaría que se devuelve un error sin invocar createOrder.
Los nombres de anotaciones para registrar mocks en el contexto de prueba varían según la versión de Spring Boot; el ejemplo usa @MockitoBean. La decisión sobre qué se integra y qué se aísla es independiente de esa anotación.