1. La signatura como contrato de la API
Cuando ves Spring MVC por primera vez, tienes muchas ganas de pensar que el contrato del endpoint es solo la anotación @GetMapping("/algo") y que el método interior es «código Java normal». Pero en el desarrollo web el método del controlador no es simplemente un método: es la forma de un contrato público entre el cliente y el servidor. Y la signatura (argumentos + tipo de retorno) hace ese contrato concreto.
Si lo simplificamos a un modelo de trabajo, las anotaciones le dicen a Spring «qué petición encaja aquí», mientras que la signatura dice «qué tengo que sacar de la petición para que este método pueda siquiera invocarse» y «qué hay que poner en la respuesta tras su ejecución». Por eso la signatura no es un detalle cosmético ni una «formalidad de Java», sino la segunda mitad de la interfaz pública de la API, justo al lado del URI.
Hay otra cosa importante que entender: Spring prepara los argumentos antes de llamar al método. Es decir, no «recibes un HttpServletRequest y lo parseas a mano» (como en las viejas leyendas). Spring mira los parámetros del método y las anotaciones sobre ellos e intenta «resolverlos» desde la petición. Si eso no es posible, el método puede incluso no llegar a ejecutarse —y eso es normal: el límite de la API debe ser estricto—.
Signatura: entrada y salida
La signatura del método del controlador en Spring MVC se parece a un pedido bien organizado en un café. No dices simplemente «tráeme algo rico»; especificas los detalles: qué hay que traer (argumentos) y qué quieres obtener al final (valor de retorno). Si no precisas el pedido, el camarero (Spring) o trae algo equivocado o directamente se detiene y dice: «No entendí qué querías».
En el nivel actual nos basta con tener en mente un esquema simple: el mapping elige el método, luego Spring prepara los argumentos, luego se ejecuta el código del método y luego el resultado se convierte en una respuesta HTTP. Por «se convierte» entendemos por ahora algo muy prosaico: «lo que devolvió el método le llega al cliente». Cómo funciona técnicamente esa conversión es un tema aparte al que hoy no nos sumergimos.
A continuación hay un pequeño «mapa de correspondencias» que ayuda a no confundirse. No es una referencia para todos los casos (nadie lo aguantaría), sino el mínimo para el día de hoy:
| Qué escribimos en el método | De dónde lo toma Spring | Ejemplo mínimo |
|---|---|---|
| método sin argumentos | de ningún sitio — la petición es autosuficiente | public String ping() |
| @PathVariable String taskId | del path, del segmento {taskId} | GET /tasks/{taskId} |
| String como retorno | se convertirá en el cuerpo de la respuesta | "pong" |
| List<String> como retorno | se convertirá en el cuerpo de la respuesta (lista) | List.of("t1", "t2") |
Y sí: de momento evitamos deliberadamente los query params, el body, las cabeceras y demás fuentes de entrada. Hoy nos importa la habilidad básica: ver cómo un trozo de URL llega a un argumento del método.
2. Colección: GET /api/v1/tasks
La lista de recursos (colección) es el escenario feliz en el que podemos escribir un método sin argumentos de entrada sin sentirnos culpables. Si el cliente hace GET /api/v1/tasks, no está apuntando a una tarea concreta, sino pidiendo «la colección de tareas» entera. En este nivel no necesitamos taskId porque el cliente no lo envió.
Es importante sentirlo: la ausencia de argumentos no es «me olvidé los parámetros», sino el reflejo del significado del endpoint. La lista de tareas tiene un contrato: «dame la lista». Y ya que la petición no contiene el identificador de un recurso concreto, la signatura tampoco lo requiere. Pero qué devolver es ya nuestra responsabilidad como API.
Ejemplo mínimo del endpoint de lista (en versión educativa, sin servicios ni modelos reales de momento):
import java.util.List;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController // Le decimos a Spring que este es un controlador REST (la respuesta irá al cuerpo de la respuesta HTTP)
class DemoController {
@GetMapping("/api/v1/tasks") // Mapping para la petición de colección
public List<String> listTaskIds() {
// Para el ejemplo devolvemos ids de tareas «de juguete»
return List.of("t1", "t2");
}
}
Aquí lo importante no es el contenido de la lista (por ahora es de juguete), sino el hecho en sí: el método no recibe nada porque la petición no lleva datos de entrada en el path. El cliente pidió «la colección», y nosotros en este nivel respondemos con «la colección». Sí, más adelante tendremos estructuras normales, pero el principio no cambiará.
3. @PathVariable: taskId de la URL
Cuando pasamos de la colección al recurso concreto, aparece la idea clave del pensamiento REST: el direccionamiento. Una tarea concreta vive en la dirección /api/v1/tasks/{taskId} y el cliente literalmente dice: «dame esa cosa en esa dirección». Es como un apartamento por número: portal/piso/puerta — no son filtros ni preferencias, sino una dirección exacta.
En Spring MVC el «número de puerta» se pone en un argumento del método mediante @PathVariable. En la URL escribes el placeholder entre llaves, y en el método un parámetro con la anotación. Y Spring hace su magia… o mejor dicho, hace un trabajo muy concreto: toma el valor de la URL y lo sustituye en el argumento.
La imagen mental puede ser esta:
flowchart LR
A["GET /api/v1/tasks/abc-123"] --> B["mapping: /api/v1/tasks/{taskId}"]
B --> C["@PathVariable taskId = 'abc-123'"]
C --> D["llamada al método del controlador"]
Ejemplo mínimo del detail endpoint:
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@GetMapping("/api/v1/tasks/{taskId}") // En la plantilla URL hay un placeholder {taskId}
public String getTask(@PathVariable String taskId) { // Spring tomará el valor de la URL y lo sustituirá aquí
// Por claridad devolvemos lo que llegó en el path variable
return "taskId=" + taskId;
}
Aquí conviene detenerse y hacerse una comprobación: taskId no es «alguna variable en el código», sino parte de la URL. El cliente la envió en el path, Spring la extrajo del path, y ahora en el método recibes el valor listo. No parseas la cadena a mano, no haces substring, no escribes split("/"), y gracias a eso el mundo se vuelve un poco más amable.
Nombres: cuándo hace falta @PathVariable("taskId")
El problema pequeño pero irritante más frecuente con @PathVariable es el desajuste de nombres. En la plantilla URL escribiste {taskId} pero en el parámetro del método pusiste String id marcado simplemente con @PathVariable. Para un humano «¿qué más da si lo entiendo?», pero para Spring a veces es como decir «tengo la variable taskId pero tú pides id — ¿a quién te refieres?».
La regla que ayuda a vivir tranquilo: si el nombre del parámetro del método coincide con el nombre entre llaves, puedes escribir la forma corta. Si no coincide — mejor indica el nombre explícitamente en @PathVariable("..."). No es «burocracia», sino simplemente la explicitez del contrato. Especialmente útil cuando renombraste el parámetro en el IDE y olvidaste que la URL sigue siendo la misma.
Ejemplo de «los nombres coinciden — todo sencillo»:
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@GetMapping("/api/v1/tasks/{taskId}") // {taskId} en la plantilla coincide con el nombre del parámetro
public String getTask(@PathVariable String taskId) { // Por eso no hace falta especificar el nombre explícitamente
// Devolvemos una cadena para ver el valor en la respuesta
return "taskId=" + taskId;
}
Ejemplo de «los nombres son distintos — lo escribimos explícitamente»:
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@GetMapping("/api/v1/tasks/{taskId}") // En el contrato externo (URL) sigue siendo taskId
public String getTask(@PathVariable("taskId") String id) { // Vinculamos explícitamente {taskId} con el parámetro id
// Dentro del método usamos el nombre de variable que nos convenga
return "taskId=" + id;
}
Fíjate en el sutil beneficio: el método ahora se puede leer como una frase. «Tenemos el endpoint /tasks/{taskId}; extrae taskId y ponlo en la variable id». Es decir, el contrato externo (la palabra taskId) permanece estable, mientras que el nombre interno del parámetro puedes elegirlo id, taskIdentifier o lo que quieras (pero lo último mejor no, que no eres un villano).
4. Valor de retorno y respuesta
El tipo de retorno del método del controlador también es parte del contrato. No en el sentido de «el cliente ve el tipo Java» (no), sino en el sentido de qué forma de datos envías al exterior. En @RestController el valor de retorno se convierte en el cuerpo de la respuesta HTTP. Eso significa algo simple: al elegir String, List<...> o algún objeto, en realidad estás eligiendo cómo lucirá la respuesta.
En el nivel actual basta entender dos tesis. Primera: si el método se ejecutó correctamente y devolvió un valor, normalmente el cliente recibirá un 200 OK con algún cuerpo. Segunda: la forma del cuerpo de la respuesta está directamente relacionada con lo que devolviste del método. Por eso el tipo de retorno no debe tratarse como «bueno, pongo Object y ya veremos», sino como una decisión pública.
Un par de mini-ejemplos ayuda a sentir la diferencia.
Si devuelves String, entregas texto plano:
import org.springframework.web.bind.annotation.GetMapping;
@GetMapping("/api/v1/ping") // Endpoint sencillo de health-check
public String ping() {
// Devolvemos texto simple (luego se convertirá en el cuerpo de la respuesta HTTP)
return "pong";
}
Si devuelves Map, entregas datos estructurados (normalmente el cliente verá un objeto JSON):
import java.util.Map;
import org.springframework.web.bind.annotation.GetMapping;
@GetMapping("/api/v1/demo") // Endpoint que devuelve un «objeto»
public Map<String, Object> demo() {
// Map es cómodo como «DTO» educativo rápido
return Map.of(
"status", "OK",
"count", 2
);
}
Si devuelves List<String>, entregas una colección (normalmente será un array de valores):
import java.util.List;
import org.springframework.web.bind.annotation.GetMapping;
@GetMapping("/api/v1/demo-list") // Endpoint que devuelve un «array»
public List<String> demoList() {
// La lista en la respuesta normalmente se serializa como array JSON
return List.of("a", "b", "c");
}
Fíjate en que ahora deliberadamente no discutimos «cómo exactamente se convierte en JSON» ni qué configuraciones hay. Por ahora nos importa otra cosa: el tipo de retorno no es un detalle de implementación, sino una decisión sobre la forma de la respuesta. Y aunque ahora devuelvas cadenas simples «para calentar», el hábito mismo de pensar en la forma de la respuesta te resultará útil en cada paso siguiente.
5. Mini-ensamblaje: tasks a través de signaturas
Para que el cuadro sea completo, ensamblemos un pequeño DemoTaskController. Sigue siendo un mini-ensamblaje educativo: solo existe para ver el contraste entre el endpoint de colección y el endpoint del recurso concreto.
A continuación hay un ejemplo de controlador donde los datos son «condicionales» por ahora, pero las signaturas reflejan el significado de la API de forma muy honesta. Este código es cómodo como esqueleto intermedio: se lee en 15 segundos y enseguida entiendes que la API tiene un endpoint de lista y un endpoint de detalle. Y si tú lo entiendes por el código, al cliente también le resultará más fácil entenderlo por el URI.
import java.util.List;
import org.springframework.web.bind.annotation.*;
@RestController // Controlador REST: lo que devolvamos de los métodos irá a la respuesta HTTP
@RequestMapping("/api/v1/tasks") // Ruta base para todos los endpoints de este controlador
class DemoTaskController {
@GetMapping // GET /api/v1/tasks — colección
public List<String> getAllTasks() {
// Colección: no hay datos de entrada, por eso la signatura no tiene argumentos
return List.of("t1", "t2");
}
@GetMapping("/{taskId}") // GET /api/v1/tasks/{taskId} — recurso concreto
public String getTaskById(@PathVariable String taskId) {
// Detail: para direccionar el recurso se necesita taskId de la URL
return "taskId=" + taskId;
}
}
Fíjate en lo «expresivas» que resultan las dos signaturas. La primera: no se necesita nada de entrada, porque es una colección. La segunda: se necesita taskId, porque es un recurso individual. Y ese es exactamente el pensamiento REST que fijamos los días anteriores: la colección y el recurso individual son puntos distintos de la API.
6. Errores típicos con @PathVariable
En @PathVariable no hay casi «magia compleja», pero sí pequeñas trampas en las que es fácil caer, especialmente cuando estás empezando. La ironía es que los errores parecen una tontería (una letra que no coincide, se te olvidó un corchete), pero el resultado es como si «Spring se hubiera roto» y «nada funciona». En realidad todo funciona — simplemente el contrato se volvió contradictorio y el framework honestamente se niega a adivinar tus intenciones.
Error n.º 1: el placeholder en el path y el parámetro del método tienen nombres distintos, pero no se especificó el nombre explícitamente.
Escribes el mapping "/{taskId}" pero en el método el parámetro se llama id y está marcado simplemente con @PathVariable. Dependiendo de las condiciones, esto puede hacer que Spring no entienda qué valor sustituir. El hábito que salva: o llama al parámetro igual (taskId), o escribe siempre @PathVariable("taskId") String id.
Error n.º 2: se olvidaron las llaves o accidentalmente se «comió» la barra.
A veces se escribe @GetMapping("/taskId") en lugar de @GetMapping("/{taskId}"). Para un humano es «casi lo mismo», pero para el enrutamiento son dos mundos distintos. En el primer caso la URL debe ser literalmente /taskId, y en el segundo puede ser cualquier valor. Si de repente un endpoint no se llama, lo primero que hay que comprobar es que la plantilla en el mapping sea correcta.
Error n.º 3: intentar hacer el detail endpoint sin @PathVariable pero con «hardcode» dentro.
Un clásico de los ejemplos educativos: el método se llama getTaskById pero dentro siempre devuelve la misma tarea porque taskId no se recibe en ningún sitio. Esto rompe el significado de la API: el cliente direcciona un recurso concreto, pero el servidor finge no haber visto la dirección. El minimalismo correcto consiste en recibir taskId como argumento, aunque por ahora solo lo imprimas en la respuesta.
Error n.º 4: el tipo de retorno se elige «a ojo» y no según el significado de la respuesta.
El principiante a menudo piensa: «Bueno, devuelvo Object y ya lo veo». En @RestController es un mal hábito: pierdes claridad en el contrato y el método se vuelve ilegible. Es mucho más útil elegir un tipo concreto: String para texto simple, List<String> para una lista, Map<String, Object> para una respuesta estructurada simple. Incluso en los ejemplos educativos eso entrena la disciplina de la forma de respuesta.
Error n.º 5: la signatura se sobrecarga «por si acaso».
A veces se empieza a añadir parámetros al método que ahora mismo no son necesarios: «por si hay que usarlos». El resultado es que el método se parece a la mochila del campista principiante que carga tienda de campaña, guitarra, cazuela y plancha. En la fase actual mantenemos las signaturas cortas: para la colección sin argumentos, para un recurso individual un solo @PathVariable. Eso hace obvios tanto el código como el contrato.
GO TO FULL VERSION