Como crear un endpoint REST

Cinco piezas: controller, Request DTO, mapeo a Command, llamada al UseCase, Response DTO. Los controladores son package-private y los DTOs viven en clases separadas en el mismo paquete.

1. Crear el controller

Una clase package-private en {domainName}/adapter/in/web:

@Tag(name = ApiConstants.API_TAG)
@RestController
@RequestMapping("/v1/tasks")
@RequiredArgsConstructor
class CreateTaskController {

    private final CreateTaskUseCase createTaskUseCase;

    @Operation(summary = "Create a new task")
    @PostMapping
    ResponseEntity<String> create(@RequestBody @Valid CreateTaskRequest request) {
        String id = createTaskUseCase.create(buildCommand(request));
        return ResponseEntity.created(URI.create("/v1/tasks/" + id)).body(id);
    }

    private CreateTaskCommand buildCommand(CreateTaskRequest request) {
        return new CreateTaskCommand(request.getTitle(), request.getType());
    }
}

Puntos clave:

  • El controller depende del UseCase (input port), nunca del servicio.

  • /v1/tasks cubre el path versionado; WebConfig antepone API_PATH_PREFIX. Ver reference: path y versionado.

  • @Tag agrupa el endpoint en OpenAPI; @Operation añade descripción.

Una clase por operación es la recomendación por defecto, no una regla dura. Dos operaciones cohesionadas pueden compartir controller — el caso típico son lecturas sobre el mismo recurso:

@RestController
@RequestMapping("/v1/tasks")
class TaskQueryController {

    @GetMapping
    ResponseEntity<PageResponse<TaskResponse>> list(Pageable pageable) { /* ... */ }

    @GetMapping("/{id}")
    ResponseEntity<TaskResponse> get(@PathVariable String id) { /* ... */ }
}

El criterio es cohesión (mismo recurso, mismo port, misma proyección), no la cuenta de métodos.

2. Definir el Request DTO

Como clase aparte en el mismo paquete del controller:

@Data
public class CreateTaskRequest {

    @NotBlank
    private String title;

    @NotNull
    private IssueType type;
}

Anotar los campos con jakarta.validation.constraints.*. Una violación produce HTTP 400 con violations[] automáticamente — ver reference: validación.

3. Mapear Request → Command

El controller no llama al servicio con el DTO de transporte: traduce a Command (DTO de aplicación) y lo invoca:

private CreateTaskCommand buildCommand(CreateTaskRequest request) {
    return new CreateTaskCommand(request.getTitle(), request.getType());
}

El Command vive en {domainName}/usecase/. El controller es el único punto que conoce ambos lados; el resto del dominio no sabe nada del DTO web.

4. Devolver la respuesta

POST de creación

201 Created con Location apuntando al nuevo recurso:

return ResponseEntity.created(URI.create("/v1/tasks/" + id)).body(id);

GET de detalle

Response DTO aparte con factory of(…​):

@Data
public class TaskResponse {
    private String id;
    private String title;

    public static TaskResponse of(Task task) {
        TaskResponse response = new TaskResponse();
        response.setId(task.getId());
        response.setTitle(task.getTitle());
        return response;
    }
}
@GetMapping("/{id}")
ResponseEntity<TaskResponse> get(@PathVariable String id) {
    Task task = getTaskDetailsUseCase.getDetails(id);
    return ResponseEntity.ok(TaskResponse.of(task));
}

El factory of(…​) a mano funciona bien para DTOs pequeños con pocos campos y mapeo 1-a-1. Cuando crece el número de campos o aparecen transformaciones no triviales (mapeo de enums, achatado de value objects, anidamientos) — usar MapStruct, que ya está en el stack — y declarar el mapper en su propia interfaz.

GET paginado

Pageable como último parámetro, PageResponse.of(…​) para envolver el Page<T>:

@GetMapping
ResponseEntity<PageResponse<TaskResponse>> list(Pageable pageable) {
    Page<Task> page = taskRetrievalPort.findAll(pageable);
    return ResponseEntity.ok(PageResponse.of(page.map(TaskResponse::of)));
}

Para la forma exacta del payload paginado ver reference: paginación.

5. Probar el endpoint

X-Tenant-Id es obligatorio: sin él el TenantContextFilter no setea el contexto y los datasources multitenant fallan.

curl -X POST http://localhost:8080/v1/tasks \
    -H 'Content-Type: application/json' \
    -H 'X-Tenant-Id: 674b414f-27b5-461b-a8da-933326669018' \
    -d '{"title":"Comprar pan","type":"CHORE"}'

Errores ya cubiertos sin código adicional en el controller:

  • Validación fallida → 400 con violations[].

  • ApplicationException lanzada desde el caso de uso → status y code del enum.

Ver reference: errores para la tabla completa.

Resumen

Pieza Ubicación

Controller (Create{Aggregate}Controller)

{domainName}/adapter/in/web/

Request DTO (Create{Aggregate}Request)

{domainName}/adapter/in/web/

Response DTO ({Aggregate}Response)

{domainName}/adapter/in/web/

Command (Create{Aggregate}Command)

{domainName}/usecase/

Ver también