CodeGym /Cursos /Spring REST & MVC /PATCH de tareas: TaskPatchRequest

PATCH de tareas: TaskPatchRequest

Spring REST & MVC
Nivel 24, Lección 3
Disponible

1. Rol de PATCH junto a PUT

Cuando escuchas por primera vez «actualización parcial», el cerebro hace una conclusión muy humana: «Ah, bueno, esto es PUT, solo que no enviaré todo». Y es precisamente desde aquí donde empiezan los errores más costosos, porque el servidor y el cliente interpretan de manera diferente los campos omitidos. PATCH es necesario para decir legal y explícitamente: «Solo cambio lo que he enviado», sin fingir que el cliente ha enviado una instantánea completa del estado editable de la tarea.

Imagina que PUT es reemplazar un documento completo: coges una hoja de papel, la reescribes desde cero y entregas la nueva versión. Mientras que PATCH es una corrección cuidadosa con rotulador: «aquí corrige el título, y el resto no lo toques». En el desarrollo comercial, PATCH ayuda mucho a la interfaz de usuario: el usuario cambió la prioridad y el frontend no está obligado a recopilar «la tarea completa», con el riesgo de perder algún campo o enviar un estado obsoleto.

Es importante que PATCH en nuestro curso no es «magia de actualización de cualquier cosa», sino un contrato separado. Fijamos de antemano qué campos se pueden modificar, cómo se interpreta la ausencia de un campo, qué significa null (si es que lo permitimos) y qué errores se devuelven en caso de entrada no válida. Esta es la forma de no caer en «bueno, funciona… hasta que llega el primer cliente móvil».

2. PATCH como contrato

PATCH tiene una característica engañosa: a diferencia de PUT, a menudo parece «simple» en el código (un par de if), pero «costoso» en los acuerdos. Si no has fijado las reglas, dos clientes diferentes pueden esperar comportamientos opuestos, y ambos estarán sinceramente convencidos de tener razón. Por eso, mentalmente no empezamos con la anotación @PatchMapping, sino con la promesa de la API: qué cambios son posibles y cómo se aplican.

En Task Tracker API interpretaremos PATCH así: el cliente envía un JSON con un subconjunto de campos permitidos, y el servidor aplica solo aquellos cambios que realmente están presentes en el cuerpo de la solicitud y son admisibles según las reglas. Todo lo que no ha llegado se queda como estaba. Tampoco entregamos al cliente acceso al modelo interno: PATCH no debe permitir de repente cambiar id, createdAt, updatedAt o cualquier cosa «de servicio»; de lo contrario, nos provocaremos un over-posting al estilo «el usuario actualizó la tarea y accidentalmente se asignó un createdAt de 1970».

Para consolidar la diferencia, es útil comparar PUT y PATCH no con un eslogan, sino con parámetros concretos:

Parámetro
PUT /api/v1/tasks/{taskId}
PATCH /api/v1/tasks/{taskId}
Idea de la operación Reemplazo completo de la parte editable Cambio parcial de campos individuales
Payload esperado «Conjunto completo de campos editables» «Solo lo que quiero cambiar»
Campo omitido Normalmente significa «restablecer/reemplazar» (o estás mintiendo con el contrato) Significa «no cambiar»
DTO TaskPutRequest TaskPatchRequest
Validación Verificamos todo el conjunto de campos obligatorios Verificamos solo los campos que han llegado
Previsibilidad Alta, si PUT es honesto Alta, si las reglas de PATCH están fijadas

Y un punto importante más: PATCH no tiene por qué ser «no idempotente». En muchas APIs, PATCH se hace idempotente a nivel de efecto, si describe «establecer valores» y no «sumar 1» o «añadir un elemento a la lista». En nuestro proyecto nos ceñiremos a «establecer valores», porque es más fácil de explicar, más sencillo de probar y sorprende menos al cliente.

3. DTO TaskPatchRequest

El error más frecuente de un desarrollador novato es usar para PATCH el mismo DTO que para PUT, o directamente un Map<String, Object>. En el primer caso, difuminas el significado de PUT (el reemplazo completo se convierte en «lo que llegó, eso actualizamos»); en el segundo, entregas el contrato a merced del «JSON dinámico», donde los errores se vuelven inesperados y la documentación se convierte en una adivinación con posos de café. Nuestro curso elige conscientemente un tercer camino: un DTO tipado, pero específicamente para actualización parcial.

La idea de TaskPatchRequest es simple: cada campo en el DTO es un «mango permitido» que el cliente puede tirar. Los campos en un DTO así suelen ser nullable, porque «no envié el campo» y «envié el campo» lo codificamos mediante null vs. no null. Sí, aquí hay una cuestión filosófica sobre «campo ausente» y «null explícito», pero en esta lección mantenemos el contrato de forma simple y comprensible: null se interpreta como «no cambiar». Esta regla hace que el comportamiento sea predecible y no nos obliga a incorporar al proyecto construcciones separadas para tres estados (present/absent/null) ya en la etapa Junior.

Esta simplificación es consciente. En tal contrato PATCH trabajamos solo con campos para los que es suficiente la regla «si llegó un valor no null, lo aplicamos; si no llegó, no lo tocamos»; por lo tanto, el TaskPatchRequest actual se limita a title/status/priority. No se puede limpiar description, dueDate o assigneeName a través de él: Jackson aquí no distingue entre «campo ausente» y «campo llegó como null» de manera que podamos incluir honestamente ambos significados en un único DTO. Cuando se necesita un escenario de limpieza para un campo nullable, es más honesto usar PUT con reemplazo completo o diseñar un modelo tri-estado separado.

El modelo mínimo para nuestro ejemplo podría ser así:

package com.example.tasktracker.api.dto.request;

import com.example.tasktracker.domain.model.TaskPriority;
import com.example.tasktracker.domain.model.TaskStatus;
import jakarta.validation.constraints.Size;

/**
 * DTO para la actualización parcial de una tarea mediante PATCH.
 * IMPORTANTE: null aquí significa «no cambiamos el campo».
 */
public record TaskPatchRequest(
        /* Nuevo title (si null, no lo tocamos). @Size se verifica solo si el valor está presente. */
        @Size(min = 3, max = 120) String title,

        /* Nuevo status (si null, no lo tocamos). */
        TaskStatus status,

        /* Nueva prioridad (si null, no la tocamos). */
        TaskPriority priority
) {}

Observa el agradable truco de Bean Validation: @Size no se queja con null, solo verifica la longitud si el valor realmente existe. Por lo tanto, para PATCH es casi la anotación ideal: si el campo no llegó, es null y se considera válido; si llegó, lo verificamos. Es ese raro lugar donde los «campos nullable» trabajan a nuestro favor, no en nuestra contra.

4. Controlador PATCH

A nivel HTTP no hay magia aparte: Spring MVC a través de HttpMessageConverter y Jackson lee el body en TaskPatchRequest, la validación verifica solo los campos que realmente llegaron, y al controlador solo le queda aceptar taskId y delegar en el servicio. Por lo tanto, el controlador PATCH también debe mantenerse fino.

El método del controlador para PATCH se ve muy similar al de PUT, pero el significado es diferente:

import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;

@PatchMapping("/{taskId}")
public ResponseEntity<TaskDetailsResponse> patch(
        // El identificador lo tomamos de la URL, no del cuerpo de la solicitud
        @PathVariable String taskId,

        // En PATCH validamos solo lo que realmente llegó (por ejemplo, @Size en title)
        @Valid @RequestBody TaskPatchRequest request) {

    // Delegamos la lógica de negocio al servicio: el controlador se mantiene «fino»
    TaskDetailsResponse updated = taskWriteService.patch(taskId, request);

    // Devolvemos la representación actualizada de la tarea
    return ResponseEntity.ok(updated);
}

Y añadamos un pequeño ejemplo .http para no dejar PATCH en el vacío:

### Modificar title (cambiamos solo title, el resto no lo tocamos)
PATCH http://localhost:8080/api/v1/tasks/9f5f5c2b-1d1a-4a61-9b3e-4a8c8ed2a111
Content-Type: application/json

{
  "title": "Renombrar tarea"
}

Aquí el cliente cambia solo title. No está obligado a conocer la priority actual, no tiene que repetir el status actual, no tiene que «mantener en la cabeza» todos los campos. Ese es el valor de PATCH para el código del cliente.

5. Servicio PATCH

En el servicio, PATCH se vuelve real: tomamos la tarea actual, aplicamos solo los cambios que llegaron y guardamos el resultado. Aquí los if comunes son mejores que cualquier magia: con ellos se ve inmediatamente qué campos se modifican y bajo qué reglas.

Un método típico se ve así:

import java.time.Instant;

public TaskDetailsResponse patch(String taskId, TaskPatchRequest request) {
    // 1) Obtenemos la tarea actual o lanzamos 404
    Task task = getExistingTask(taskId);

    // 2) Aplicamos solo los cambios que realmente llegaron en PATCH
    if (request.title() != null) {
        task.setTitle(request.title()); // title se actualiza solo si fue enviado
    }
    if (request.status() != null) {
        task.setStatus(request.status()); // status se actualiza solo si fue enviado
    }
    if (request.priority() != null) {
        task.setPriority(request.priority()); // priority se actualiza solo si fue enviado
    }

    // 3) Campo gestionado por el servidor: updatedAt lo establece el servidor
    task.setUpdatedAt(Instant.now());

    // 4) Guardamos y mapeamos a DTO de respuesta
    return taskMapper.toDetailsResponse(taskRepository.save(task));
}

Observa: no leemos taskId del body (ahí no está), no tocamos createdAt, nosotros mismos establecemos updatedAt. Este es un buen ejemplo del límite «el cliente solo gestiona lo que se le permite».

Y el método auxiliar «obtén la tarea o lanza 404» hace que el comportamiento sea uniforme y elimina la duplicación:

import java.util.Optional;

private Task getExistingTask(String taskId) {
    // Punto único de comportamiento para «tarea no encontrada» -> excepción de dominio -> 404
    return taskRepository.findById(taskId)
            .orElseThrow(() -> new TaskNotFoundException(taskId));
}

Como resultado, si la tarea no existe, el servicio lanza una excepción de dominio, y el @ControllerAdvice global la convierte en tu respuesta ProblemDetail. No escribes try/catch en el controlador, no creas diferentes formatos de error «según el estado de ánimo»: todo pasa por un único contrato.

Un pequeño diagrama de flujo ayuda a «ver» PATCH como parte del sistema general:

flowchart TD
    A["HTTP PATCH /api/v1/tasks/{taskId}"] --> B["TaskWriteController.patch"]
    B --> C["Bean Validation @Valid"]
    C --> D["TaskWriteService.patch"]
    D --> E["TaskRepository.findById/save"]
    D --> F["TaskMapper -> TaskDetailsResponse"]
    F --> G["HTTP 200 OK + JSON body"]

6. PATCH y validación

La idea ingenua más frecuente: «Como PATCH es parcial, se puede desactivar la validación». En la práctica, esto convierte la API en un «vertedero» donde llegan a la tarea un title de dos caracteres, un priority como la cadena "SUPER_HIGH", y luego te sorprendes de por qué el frontend falla. La idea correcta es otra: la validación se mantiene, solo que se vuelve «condicional»: verificamos solo aquellos campos que realmente participan en el cambio.

Exactamente por eso en TaskPatchRequest ponemos @Size(min=3, max=120) en title, pero no ponemos @NotBlank. Si title no llegó, es null, @Size calla, y es válido. Si title llegó, entonces la longitud debe estar en el rango. Esta es una forma limpia de lograr una validación «parcial» sin grupos ni mecanismos complejos.

Ahora el segundo punto importante: un PATCH vacío como {}. Formalmente es un JSON válido, Spring lo leerá, TaskPatchRequest tendrá todos los campos null, y si no haces nada, obtendrás una «actualización sin cambios». A veces esto es aceptable como no-op, pero en un proyecto educativo es más útil decirle honestamente al cliente: «Has enviado una solicitud sin sentido». Y esto es exactamente validación de entrada, es decir, 400 Bad Request, no 409.

La forma más simple (no la más «teóricamente pura», pero muy clara) es verificar en el servicio:

// Si el cliente no envió ningún campo, PATCH no tiene sentido
if (request.title() == null && request.status() == null && request.priority() == null) {
    throw new InvalidInputException("La solicitud PATCH debe contener al menos un campo");
}

Luego, tu manejador de errores global ya lo empaquetará en un ProblemDetail con el code y estado correctos.

Por cierto, ya que hablamos de ProblemDetail: Spring MVC admite el formato RFC de problem details y puede devolver errores en application/problem+json; esto se habilita con la configuración spring.mvc.problemdetails.enabled=true. En nuestro proyecto ya elegimos este camino antes, por lo que PATCH simplemente se «integra» en el sistema de errores existente, sin crear un formato separado de «error PATCH».

7. Campos gestionados por el servidor y «trucos»

A veces el desarrollador piensa: «¿Y qué tiene de malo que el cliente envíe updatedAt? Simplemente lo actualizamos». Y luego ese mismo desarrollador pasa un día averiguando por qué la ordenación por updatedAt se ha vuelto extraña y los clientes han empezado a «retroceder» el tiempo. Los campos gestionados por el servidor se gestionan desde el servidor no por malicia, sino porque son parte de la integridad interna del sistema.

Para Task en nuestro proyecto, los campos gestionados por el servidor incluyen como mínimo id, createdAt, updatedAt. El cliente no debe enviarlos ni en POST, ni en PUT, ni en PATCH. La protección correcta no se hace con «if (body.id != null) quejarse», sino con el propio modelo: en TaskPatchRequest simplemente no existen tales campos, por lo que el cliente físicamente no puede enviarlos como parte del contrato.

Un contraejemplo contrastante (cómo no hacerlo) se vería más o menos así:

// Antiejemplo: estos campos no deben exponerse en un DTO de PATCH
public record BadTaskPatchRequest(
        String id,
        String title,
        String updatedAt
) {}

Aquí el cliente puede «modificar» lo que no debe. Incluso si luego intentas filtrarlo, el simple hecho de que existan tales campos en un DTO público ya difumina el contrato: el cliente empieza a pensar que «se puede», y te conviertes en el soporte técnico de tu propia API.

8. Ejemplo completo de PATCH

Para que PATCH no se quede en abstracción, repasemos un mini escenario desde la perspectiva del cliente.

Supongamos que tenemos una tarea:

{
  "id": "9f5f5c2b-1d1a-4a61-9b3e-4a8c8ed2a111",
  "title": "Escribir borrador del README",
  "status": "TODO",
  "priority": "MEDIUM"
}

El usuario en la interfaz decidió: «no, esto es crítico». El cliente hace la solicitud:

### Modificar priority (cambiamos solo priority)
PATCH /api/v1/tasks/9f5f5c2b-1d1a-4a61-9b3e-4a8c8ed2a111
Content-Type: application/json

{ "priority": "CRITICAL" }

Spring MVC lee el body, convierte el JSON en TaskPatchRequest (esto lo hace la infraestructura a través de HttpMessageConverter), y obtienes un objeto donde title == null, status == null, priority == CRITICAL. El servicio aplica solo priority, actualiza updatedAt, guarda la tarea y devuelve TaskDetailsResponse. Este es exactamente el escenario que el PATCH simplificado actual sabe manejar bien: cambiamos un campo no null y dejamos todo lo demás como está. La limpieza de un campo nullable no entra aquí.

La respuesta al cliente la hacemos simple y predecible:

### 200 OK: devolvemos el estado actual de la tarea
200 OK
Content-Type: application/json

{
  "id": "9f5f5c2b-1d1a-4a61-9b3e-4a8c8ed2a111",
  "title": "Escribir borrador del README",
  "status": "TODO",
  "priority": "CRITICAL"
}

Si el cliente envía "priority": "SUPER_HIGH", entonces no es un «error de negocio», sino un error de entrada (enum incorrecto), y debe devolverse en el formato unificado application/problem+json. Spring como plataforma admite problem details y permite gestionar centralizadamente dichos errores a través de @ControllerAdvice. Una idea importante: PATCH no crea nuevas reglas de manejo de errores; utiliza las mismas que ya funcionan para POST y PUT.

9. Errores típicos al implementar PATCH

Error n.º 1: hacer de PATCH un «PUT corto» y reemplazar silenciosamente los campos ausentes por null.
Este comportamiento corrompe los datos de forma silenciosa y sin advertencia. El cliente pensaba que solo estaba actualizando priority, y tú has puesto a null description y assigneeName porque «no fueron enviados». Si quieres un reemplazo completo, eso es PUT. Si quieres una actualización parcial, estás obligado a interpretar las omisiones como «no cambiar».

Error n.º 2: reutilizar un mismo DTO para PUT y PATCH.
Esto parece económico («¡menos clases!»), pero en la práctica borra el límite entre los contratos. Comienza una confusión interminable: qué campos son obligatorios, cuáles no, qué significa la ausencia de un campo, cómo validar. Tener un TaskPutRequest separado y un TaskPatchRequest separado no es burocracia, sino una forma de hacer la API predecible.

Error n.º 3: desactivar la validación «porque es una actualización parcial».
Rápidamente obtienes valores basura y errores extraños: títulos de dos caracteres, prioridades fuera del enum, cadenas vacías donde no debería haberlas. En PATCH, la validación simplemente se vuelve condicional: verificamos los campos que llegaron y no exigimos los que no llegaron.

Error n.º 4: aceptar un {} vacío como «actualización exitosa» sin una regla explícita.
A veces es aceptable, pero la mayoría de las veces es una señal de que el cliente se equivocó. En un proyecto educativo, es más útil devolver un 400 y decir «envía al menos un campo para actualizar», para que el código del cliente no haga solicitudes no-op accidentales que luego sean difíciles de distinguir de cambios reales.

Error n.º 5: exponer el acceso a campos gestionados por el servidor a través de PATCH.
Si tu DTO de PATCH contiene id, createdAt, updatedAt u otros atributos internos, tú mismo has permitido que el cliente rompa tu sistema. La protección más fiable es diseñar el DTO de manera que el cliente físicamente no pueda enviar lo que no se le permite cambiar.

1
Tarea
Spring REST & MVC, nivel 24, lección 3
Bloqueada
`PATCH` cambia solo los campos enviados
`PATCH` cambia solo los campos enviados
1
Tarea
Spring REST & MVC, nivel 24, lección 3
Bloqueada
`PATCH` vacío como error de datos de entrada
`PATCH` vacío como error de datos de entrada
Comentarios
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION