JavaRush /Курсы /Spring REST & MVC /Проектирование TaskPatchRe...

Проектирование TaskPatchRequest

Spring REST & MVC
13 уровень , 1 лекция
Открыта

1. TaskPatchRequest как отдельный DTO

Когда впервые сталкиваешься с PATCH, очень хочется сделать “по-быстрому”: взять уже существующий TaskCreateRequest или даже TaskDetailsResponse, выкинуть пару полей и сказать: “Ну вот, у меня patch DTO”. Это нормальный человеческий порыв: мозг любит переиспользование. Но в контрактном API переиспользование часто превращается в скрытую утечку смысла: DTO начинает жить сразу в нескольких сценариях, и вы теряете возможность честно описать, что именно разрешено клиенту, а что нет.

Главная идея здесь простая: TaskPatchRequest — это transport-модель для частичного изменения. У неё другая цель, чем у create/update моделей. Create говорит: “Вот данные для создания ресурса”. Detail response говорит: “Вот как ресурс выглядит наружу”. Patch говорит: “Вот какие кусочки ресурса я хочу поменять”. Это вообще разные жанры текста, примерно как “рецепт”, “фото блюда” и “список замен ингредиентов”. Пытаться сделать один DTO на все случаи — всё равно что печатать резюме, договор и открытку маме одним и тем же шаблоном Word’а.

Есть ещё один важный момент, который особенно цепляет новичков: patch DTO не является “урезанной полной моделью”. Он является намеренно узким. В нём должны остаться только те поля, которые мы действительно готовы менять “точечно”, и которые мы понимаем, как менять без двусмысленности. Всё остальное туда не входит не потому, что “нам жалко”, а потому что мы проектируем контракт так, чтобы он был предсказуемым для клиента и безопасным для сервера.

2. Поля для patch DTO

Перед тем как написать record TaskPatchRequest(...), полезно остановиться и сделать то, что программисты обычно не любят: на минуту подумать. PATCH — это история про “я меняю вот это”, значит нам нужно ответить на вопрос: а что именно клиенту разрешено менять частично? И тут очень удобно разделить все поля Task на две большие группы: то, чем управляет клиент, и то, чем управляет сервер.

Сервер-управляемые поля — это не “секретные поля для элиты”. Это просто поля, которые задаются системой: идентификатор, моменты времени, иногда вычисляемые значения, иногда инфраструктурные ключи. Они могут быть видны в response (например, id почти всегда виден), но их нельзя принимать как вход для изменений. Если вы разрешите клиенту присылать createdAt, то вы в какой-то момент получите задачу, которая “создана завтра” (и в этот момент вселенная не схлопнется, но отчёты точно).

Давайте для нашего Task (из Task Tracker API) посмотрим на поля и честно отметим: какие поля patchable, а какие нет.

Поле внутренней модели Task Откуда берётся “истина” Должно быть в TaskPatchRequest? Почему
id сервер (UUID) нет идентификатор задаётся сервером и адресует ресурс через path
createdAt сервер нет server-managed, не меняется клиентом
updatedAt сервер нет server-managed, вычисляется после изменений
status доменная логика обычно нет (в рамках дня) статусные переходы часто требуют отдельной логики/правил, лучше не смешивать с patch полей
title клиент да часть изменяемого состояния (но со своими правилами)
description клиент да необязательное поле, как раз “любимчик” patch-сценариев
assigneeName клиент да может быть назначен/очищен
dueDate клиент да удобный пример поля, которое часто меняют отдельно
priority клиент да enum, хорошо ложится в patch
tags клиент да но с явной семантикой (обычно “замена списка целиком”)

Заметьте: мы не спорим, “можно ли вообще когда-нибудь менять статус”. Можно. Просто в рамках этого дня мы строим понятный patch-like контракт, и status — слишком “богатое” поле, которое часто связано с бизнес-переходами и конфликтами. Если мы засунем status в patch DTO без заранее согласованных правил, то завтра получим “PATCH и изменение статуса как попало”, а послезавтра — “почему у нас нельзя ограничить переходы?”. То есть вместо API-контракта получится лотерея.

Из этого следует практическое правило: в patch DTO попадают только “обычные” изменяемые поля. Если поле тянет за собой бизнес-процесс (например, переход статуса), лучше не пытаться прятать его в “общий мешок PATCH”, пока вы не готовы проектировать этот бизнес-процесс отдельно и осознанно.

3. TaskPatchRequest в коде

Теперь мы готовы перейти от “что хотим” к “как это выглядит в Java”. И здесь важно не устроить комедию вида: “А давайте все поля сделаем Object, чтобы Jackson точно всё прочитал”. Jackson, конечно, прочитает. Но потом вы будете читать это в сервисе и тихо плакать в debug-лог.

Patch DTO в нашем курсе остаётся типизированным. Это не “динамический JSON мешок”, а аккуратная модель входа. Поэтому поля мы выбираем в соответствии с тем, что уже показывали в JSON-baseline: строки остаются строками, даты — LocalDate, перечисления — enum, коллекции — List<String>.

Вот канонический пример:

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

import java.time.LocalDate;
import java.util.List;

import com.example.tasktracker.domain.model.TaskPriority;

/**
 * DTO для PATCH: все поля опциональны на уровне Java-модели.
 * Сам DTO не различает `absent` и `explicit null` — эту грань задаёт контракт
 * и способ чтения входа.
 */
public record TaskPatchRequest(
        // Новое название задачи (если клиент хочет изменить именно его)
        String title,
        // Новое описание (частый сценарий: добавить/исправить текст)
        String description,
        // Исполнитель: может быть задан или очищен (в зависимости от правил обработки null)
        String assigneeName,
        // Срок выполнения: локальная дата без времени
        LocalDate dueDate,
        // Приоритет: enum, чтобы избежать "магических строк" на входе
        TaskPriority priority,
        // Теги: обычно воспринимаем как "заменить список целиком", а не "добавить один тег"
        List<String> tags
) {
}

Почему это выглядит именно так (и почему это нормально):

Мы сознательно делаем поля nullable. В patch-модели “необязательность” — это не про “нам всё равно”, а про то, что Java-представление должно допускать отсутствие значения. Но сам DTO ещё не хранит факт присутствия поля в JSON, поэтому absent и explicit null нельзя склеивать в одну фразу “null = не менять”. Для description null может быть командой очистки, а для title — уже ошибкой входа. Поэтому patch DTO лучше читать как список потенциально изменяемых полей, а не как готовую merge-политику.

Также обратите внимание на tags. Частая ошибка новичка — засунуть теги в Set<String> и порадоваться “уникальности”. Но JSON — это не Set, это список значений, и для DTO проще использовать List<String>, а уникальность и ограничения на размер/формат — это отдельная тема (позже в модуле validation). Сейчас нам важно, чтобы контракт был понятным для клиента: он присылает массив строк — мы читаем массив строк.

4. taskId: path отдельно от body

Очень хочется добавить в TaskPatchRequest поле id, чтобы “было удобно”. Это классика: “чтобы в одном объекте было всё”. Но REST-контракт как раз и пытается избавить нас от такого “всё в одном”. В нашем API адрес ресурса — это path, а описание изменения — это body. Смешивание этих ролей ухудшает читаемость контракта и добавляет вам лишние проверки.

Если taskId приходит и в path, и в body, появляется вопрос: “А если они разные, кому верить?”. И вы внезапно пишете логику сравнения, ошибки вида “id mismatch”, и всё это только потому, что вы однажды решили “сделаем удобненько”. Это как поставить два руля в машину: да, теоретически ими можно управлять, но вы сами создали проблему синхронизации.

Нормальная модель выглядит так:

flowchart TD
    %% Адрес ресурса (path) и данные изменения (body) не смешиваем в одном DTO
    A["PATCH /api/v1/tasks/{taskId}"] --> B["taskId (path) = адрес ресурса"]
    A --> C["body = TaskPatchRequest = изменения"]
    C --> D["title/description/... (часть может отсутствовать)"]

Именно так мы и хотим видеть нашу сигнатуру контроллерного метода: один аргумент для идентификатора, один аргумент для patch DTO.

Пример контроллера в стиле проекта:

package com.example.tasktracker.api.controller;

import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

import com.example.tasktracker.api.dto.request.TaskPatchRequest;
import com.example.tasktracker.api.dto.response.TaskDetailsResponse;

@RestController
public class TaskController {

    @PatchMapping("/api/v1/tasks/{taskId}")
    public TaskDetailsResponse patchTask(
            // Идентификатор ресурса берём из path: он отвечает за "что меняем"
            @PathVariable String taskId,
            // DTO в body отвечает только за "как меняем"
            @RequestBody TaskPatchRequest request
    ) {
        // Здесь важна сама граница: path задаёт ресурс, body описывает изменения.
        // Правила merge и проверки входа живут дальше по цепочке.
        throw new UnsupportedOperationException("Not implemented");
    }
}

Такой скелет фиксирует внешний shape endpoint’а: taskId в path, patchable-поля в body. Можно по-разному реализовать чтение тела запроса, чтобы не потерять факт присутствия поля, но сама граница path/body от этого не меняется.

5. Ответ после PATCH

Есть соблазн сделать так: “Клиент прислал TaskPatchRequest, ну и вернём ему обратно TaskPatchRequest — экономия же!”. Экономия, конечно, есть. Экономия смысла. После PATCH клиент обычно хочет понять, каким ресурс стал. А patch DTO — это не “ресурс стал”, это “что мы пытались поменять”.

Поэтому внешний контракт здорового API выглядит так: вход — patch DTO, выход — полноценный response DTO (обычно detail-модель), в которой сервер показывает актуальное состояние ресурса после изменения. Это особенно важно, потому что сервер может:

переупорядочить теги, применить нормализацию, обновить updatedAt, вычислить какие-то derived поля или просто вернуть актуальный снимок. Клиенту полезнее увидеть “истину сервера”, чем ещё раз увидеть то, что он и так отправил.

Поэтому мы держим архитектурную дисциплину: в PATCH-endpoint мы будем возвращать TaskDetailsResponse (или аналогичную detail response модель). И это не “лишнее”. Это делает API предсказуемым: после любого изменения клиент может получить подтверждение, что сервер понял его правильно.

Антипример: response DTO на вход

Теперь давайте закрепим плохой пример, потому что иногда без плохого примера хороший не запоминается. Допустим, у нас есть TaskDetailsResponse (response DTO), и мы решили: “А давайте примем его как request, он же уже есть”.

Что может пойти не так? Почти всё.

package com.example.tasktracker.api.controller;

import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;

import com.example.tasktracker.api.dto.response.TaskDetailsResponse;

public class BadTaskController {

    @PatchMapping("/api/v1/tasks/{taskId}")
    public TaskDetailsResponse patchTask(
            // Даже если id берём из path, проблема остаётся: body становится слишком "широким"
            @PathVariable String taskId,
            // ПЛОХО: response DTO на вход => клиент может присылать server-managed поля и лишние данные
            @RequestBody TaskDetailsResponse request
    ) {
        // Заглушка: пример нужен как иллюстрация антипаттерна, а не как рабочий код
        throw new UnsupportedOperationException();
    }
}

Снаружи это выглядит “логично”. Но контрактно это означает: клиент теперь может присылать всё, что вы показывали в detail response. А detail response почти наверняка содержит server-managed поля (id, createdAt, updatedAt) и, возможно, поля, которые вы вообще не хотите менять таким способом. Вы только что сделали API широким и плохо управляемым.

Плюс вы смешали две роли: response DTO обычно оптимизирован под чтение, а request DTO под вход. У request DTO могут быть другие ограничения, другие названия, другое отношение к null и отсутствие поля. Когда вы переиспользуете response DTO на вход, вы теряете возможность нормально управлять этими различиями.

И да, отдельно “вишенка на торте”: вы повышаете риск того, что кто-то когда-нибудь добавит в TaskDetailsResponse новое поле (например, archived или internalFlags), и внезапно это поле начнут присылать клиенты. А вы это даже не планировали. Такие баги особенно веселы тем, что выглядят как “вроде всё работало, а теперь странно”.

6. Типичные ошибки при проектировании TaskPatchRequest

Ошибка №1: “Сделаю patch DTO копией detail response, только без пары полей”.
Такой подход почти всегда приводит к расширению контракта без вашего согласия. У detail response и patch input разные цели. Detail response должен показывать состояние ресурса, а patch DTO должен ограничивать, что можно менять. Если вы путаете эти роли, вы сами себе создаёте будущие breaking changes и проблемы с over-posting.

Ошибка №2: добавление server-managed полей “для удобства”.
Чаще всего это id, createdAt, updatedAt. В patch DTO им не место. Иначе вам придётся придумывать, что делать с “изменением createdAt”, а это обычно не то, чем хочется заниматься в здравом уме. Правильная модель: taskId приходит через path, таймстемпы обновляет сервер.

Ошибка №3: попытка сделать “универсальный DTO на все операции”.
Один DTO на create, put, patch, response — звучит как экономия, но по факту это отключение контрактной дисциплины. API становится нечётким: непонятно, какие поля обязательны, какие можно присылать, какие игнорируются, какие запрещены. В нашем курсе подход обратный: одна операция — одна ясная модель входа/выхода.

Ошибка №4: включение “сложных” полей без договорённости о смысле.
Например, статус задачи. Если включить status в patch DTO без правил, вы очень быстро получите “PATCH меняет статус как попало”, а потом попытку прикрутить ограничения задним числом. Это больно и для кода, и для клиентов. Лучше держать patch DTO сфокусированным на полях, которые действительно удобно менять частично.

Ошибка №5: смешивание адреса ресурса и описания изменения.
Если taskId (или id) приходит одновременно и в path, и в body, вы вынуждены решать конфликт значений. Это искусственная проблема. Адрес ресурса должен быть в path, изменения — в body. Так контракт читается проще и для человека, и для клиентского кода.

1
Задача
Spring REST & MVC, 13 уровень, 1 лекция
Недоступна
Отдельный `TaskPatchRequest` для PATCH endpoint
Отдельный `TaskPatchRequest` для PATCH endpoint
1
Задача
Spring REST & MVC, 13 уровень, 1 лекция
Недоступна
Patch DTO для профиля автора без server-managed полей
Patch DTO для профиля автора без server-managed полей
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ