1. TaskPatchRequest jako osobne DTO
Gdy po raz pierwszy zetkniesz się z PATCH, bardzo chce się zrobić „na szybko": wziąć już istniejący TaskCreateRequest lub nawet TaskDetailsResponse, wyrzucić kilka pól i powiedzieć: „No i mam patch DTO". To normalny ludzki odruch: mózg lubi ponowne użycie. Ale w kontraktowym API ponowne użycie często zamienia się w ukryty wyciek sensu: DTO zaczyna żyć jednocześnie w kilku scenariuszach i tracisz możliwość uczciwego opisania, co dokładnie jest dozwolone klientowi, a czego nie.
Główna idea tu jest prosta: TaskPatchRequest — to model transportowy do częściowej zmiany. Ma inny cel niż modele create/update. Create mówi: „Oto dane do stworzenia zasobu". Detail response mówi: „Oto jak zasób wygląda na zewnątrz". Patch mówi: „Oto jakie kawałki zasobu chcę zmienić". To zupełnie różne gatunki tekstu, mniej więcej jak „przepis", „zdjęcie potrawy" i „lista zamienników składników". Próba zrobienia jednego DTO na wszystkie przypadki — to jak drukowanie CV, umowy i kartki do mamy jednym szablonem Word.
Jest jeszcze jedna ważna kwestia: patch DTO nie jest „okrojonym pełnym modelem". Jest celowo wąski. Powinny się w nim znaleźć tylko te pola, które naprawdę jesteśmy gotowi zmieniać „punktowo" i które rozumiemy, jak zmieniać bez dwuznaczności.
2. Pola dla patch DTO
Przed napisaniem record TaskPatchRequest(...), warto się zatrzymać i zrobić to, czego programiści zazwyczaj nie lubią: przez chwilę pomyśleć. PATCH — to historia o „zmieniam właśnie to", więc musimy odpowiedzieć na pytanie: co klient ma prawo zmieniać częściowo?
Pola zarządzane przez serwer to nie „tajne pola dla elity". To po prostu pola zadawane przez system: identyfikator, momenty czasu, czasem wartości obliczalne. Klasyczne pola server-managed w naszym projekcie:
| Pole modelu wewnętrznego Task | Skąd pochodzi „prawda" | Powinno być w TaskPatchRequest? | Dlaczego |
|---|---|---|---|
| id | serwer (UUID) | nie | identyfikator jest zadawany przez serwer i adresuje zasób przez path |
| createdAt | serwer | nie | server-managed, nie zmieniane przez klienta |
| updatedAt | serwer | nie | server-managed, obliczane po zmianach |
| status | logika dziedzinowa | zazwyczaj nie (w ramach dnia) | przejścia statusów często wymagają osobnej logiki/reguł, lepiej nie mieszać z patch pól |
| title | klient | tak | część zmienialnego stanu (ale z własnymi regułami) |
| description | klient | tak | pole opcjonalne, typowy „ulubieniec" scenariuszy patch |
| assigneeName | klient | tak | może być przypisany/wyczyszczony |
| dueDate | klient | tak | wygodny przykład pola często zmienianego osobno |
| priority | klient | tak | enum, dobrze wpisuje się w patch |
| tags | klient | tak | ale z jawną semantyką (zazwyczaj „zamiana całej listy") |
3. TaskPatchRequest w kodzie
Patch DTO w naszym kursie pozostaje typizowany. To nie „dynamiczny worek JSON", lecz staranna model wejścia. Dlatego pola wybieramy zgodnie z tym, co pokazywaliśmy w JSON-baseline: ciągi pozostają ciągami, daty — LocalDate, wyliczenia — enum, kolekcje — List<String>.
Oto kanoniczny przykład:
package com.example.tasktracker.api.dto.request;
import java.time.LocalDate;
import java.util.List;
import com.example.tasktracker.domain.model.TaskPriority;
/**
* DTO dla PATCH: wszystkie pola są opcjonalne na poziomie modelu Java.
* Sam DTO nie rozróżnia `absent` i `explicit null` — tę granicę zadaje kontrakt
* i sposób odczytu wejścia.
*/
public record TaskPatchRequest(
// Nowa nazwa zadania (jeśli klient chce zmienić właśnie ją)
String title,
// Nowy opis (typowy scenariusz: dodać/poprawić tekst)
String description,
// Wykonawca: może być zadany lub wyczyszczony (zależnie od reguł obsługi null)
String assigneeName,
// Termin wykonania: data lokalna bez czasu
LocalDate dueDate,
// Priorytet: enum, żeby uniknąć „magicznych ciągów" na wejściu
TaskPriority priority,
// Tagi: zazwyczaj traktowane jako „zastąp całą listę", a nie „dodaj jeden tag"
List<String> tags
) {
}
Świadomie robimy pola nullable. W modelu patch „opcjonalność" — to nie „wszystko nam jedno", lecz to, że reprezentacja Java musi dopuszczać brak wartości. Ale sam DTO nie przechowuje faktu obecności pola w JSON, dlatego absent i explicit null nie można sklecić w jedną frazę „null = nie zmieniaj".
4. taskId: path osobno od body
Bardzo chce się dodać do TaskPatchRequest pole id, żeby „było wygodnie". To klasyka: „żeby w jednym obiekcie było wszystko". Ale kontrakt REST właśnie próbuje uwolnić nas od takiego „wszystko w jednym". W naszym API adres zasobu — to path, a opis zmiany — to body.
Jeśli taskId przychodzi i w path, i w body, pojawia się pytanie: „A jeśli są różne, komu wierzyć?". I nagle piszesz logikę porównywania, błędy w stylu „id mismatch", i wszystko to tylko dlatego, że kiedyś zdecydowałeś „zróbmy wygodnie".
Normalny model wygląda tak:
flowchart TD
%% Adres zasobu (path) i dane zmiany (body) nie mieszamy w jednym DTO
A["PATCH /api/v1/tasks/{taskId}"] --> B["taskId (path) = adres zasobu"]
A --> C["body = TaskPatchRequest = zmiany"]
C --> D["title/description/... (część może nie być obecna)"]
Przykład kontrolera w stylu projektu:
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(
// Identyfikator zasobu pobieramy z path: odpowiada za „co zmieniamy"
@PathVariable String taskId,
// DTO w body odpowiada tylko za „jak zmieniamy"
@RequestBody TaskPatchRequest request
) {
// Tu ważna jest sama granica: path zadaje zasób, body opisuje zmiany.
// Reguły merge i sprawdzenia wejścia żyją dalej w łańcuchu.
throw new UnsupportedOperationException("Not implemented");
}
}
5. Odpowiedź po PATCH
Jest pokusa: „Klient przysłał TaskPatchRequest, no to zwrócimy mu z powrotem TaskPatchRequest — oszczędność!". Oszczędność, oczywiście, jest. Oszczędność sensu. Po PATCH klient zazwyczaj chce wiedzieć, jakim zasób się stał. A patch DTO — to nie „zasób stał się", to „co próbowaliśmy zmienić".
Dlatego zewnętrzny kontrakt zdrowego API wygląda tak: wejście — patch DTO, wyjście — pełnoprawne response DTO (zazwyczaj model detail), w którym serwer pokazuje aktualny stan zasobu po zmianie.
Dlatego trzymamy dyscyplinę architektoniczną: w endpoincie PATCH będziemy zwracać TaskDetailsResponse (lub analogiczny model detail response). To nie jest „zbędne". To sprawia, że API jest przewidywalne: po każdej zmianie klient może dostać potwierdzenie, że serwer zrozumiał go poprawnie.
Antyprzykład: response DTO na wejściu
Utrwalmy zły przykład, bo czasem bez złego przykładu dobry się nie zapamięta. Mamy TaskDetailsResponse (response DTO), i zdecydowaliśmy: „A przyjmijmy go jako request, przecież już jest".
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(
// Nawet jeśli id bierzemy z path, problem pozostaje: body staje się zbyt „szerokie"
@PathVariable String taskId,
// ŹLE: response DTO na wejściu => klient może przysyłać pola server-managed i zbędne dane
@RequestBody TaskDetailsResponse request
) {
// Zaślepka: przykład potrzebny jako ilustracja antywzorca, a nie jako działający kod
throw new UnsupportedOperationException();
}
}
Na zewnątrz wygląda to „logicznie". Ale kontraktowo oznacza: klient teraz może przysyłać wszystko, co pokazywałeś w detail response. A detail response prawie na pewno zawiera pola server-managed (id, createdAt, updatedAt) i być może pola, których w ogóle nie chcesz zmieniać w taki sposób. Właśnie zrobiłeś API szerokie i trudne do zarządzania.
6. Typowe błędy przy projektowaniu TaskPatchRequest
Błąd nr 1: „Zrobię patch DTO kopią detail response, tylko bez kilku pól".
Takie podejście prawie zawsze prowadzi do rozszerzania kontraktu bez Twojej zgody. Detail response i patch input mają różne cele. Jeśli je mylisz, sam sobie tworzysz przyszłe breaking changes i problemy z over-posting.
Błąd nr 2: dodawanie pól server-managed „dla wygody".
Najczęściej dotyczy to id, createdAt, updatedAt. W patch DTO nie ma dla nich miejsca. Prawidłowy model: taskId przychodzi przez path, timestamp-y aktualizuje serwer.
Błąd nr 3: próba zrobienia „uniwersalnego DTO na wszystkie operacje".
Jedno DTO na create, put, patch, response — brzmi jak oszczędność, ale w efekcie to wyłączenie dyscypliny kontraktowej. API staje się mętne. W naszym kursie podejście odwrotne: jedna operacja — jeden jasny model wejścia/wyjścia.
Błąd nr 4: włączanie „skomplikowanych" pól bez uzgodnienia sensu.
Na przykład status zadania. Jeśli włączysz status do patch DTO bez reguł, bardzo szybko dostaniesz „PATCH zmienia status jak mu się podoba", a potem próbę przykręcenia ograniczeń post factum. Lepiej trzymać patch DTO skupione na polach, które naprawdę wygodnie zmieniać częściowo.
Błąd nr 5: mieszanie adresu zasobu i opisu zmiany.
Jeśli taskId (lub id) przychodzi jednocześnie i w path, i w body, jesteś zmuszony rozwiązywać konflikt wartości. To sztuczny problem. Adres zasobu powinien być w path, zmiany — w body. Tak kontrakt czyta się prościej zarówno dla człowieka, jak i dla kodu klienta.
GO TO FULL VERSION