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.

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.

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.

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.

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.

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.

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.

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.

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.

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.

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

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.

Una prueba de controlador verifica el contrato de la interfaz y su delegación. Las reglas de negocio se prueban en la capa de dominio; las consultas y escrituras reales, en pruebas de persistencia.
  • clase/daw/dws/1eval/controller.txt
  • Última modificación: 2026/10/02 12:00
  • por cesguiro