1. Сенс write-операцій і анотацій
Коли ви починаєте писати REST API, рука так і тягнеться: «ну от тут @PostMapping, тут @PutMapping… і начебто готово». Але у зрілому API все навпаки: спочатку ви вирішуєте, що саме означає операція для клієнта, а потім обираєте HTTP-метод і оформлюєте це у Spring MVC. Інакше виходить типова історія: код працює, але контракт читається як випадкова суміш традицій, здогадок і «а в нас у попередньому проєкті було так».
Write-операції особливо чутливі, тому що вони змінюють стан системи. Якщо GET ще можна трохи підкрутити, то помилки в POST/PUT/PATCH/DELETE швидко перетворюють API на місце, де клієнт боїться зайвий раз натиснути кнопку «Зберегти». Нам потрібно сформувати у клієнта відчуття: якщо я повторю запит — я розумію, що станеться; якщо я надішлю неповні дані — я розумію, що буде; якщо я видалю — я розумію, чим це відрізняється від архівації.
Із read-стороною в нас і так усе непогано: задача вміє жити як ресурс, ми вміємо отримувати її списком, фільтрувати та читати за id. Але в API досі є прогалина: клієнт має так само передбачувано створювати, замінювати, частково змінювати та видаляти цей самий ресурс. Саме цю write-частину CRUD ми зараз і збираємо.
У межах нашого Task Tracker API ми фіксуємо загальний принцип: один ресурс Task підтримує кілька операцій запису, але кожна з них — окрема домовленість зі своїм сенсом і своїм контрактом. І так, це саме домовленість: API — не «набір можливостей сервера», а «правила гри» між клієнтом і сервером.
2. Контракт операції запису
Термін «контракт запису» звучить трохи бюрократично, але на практиці він рятує від хаосу. Контракт write-операції — це не лише URI та HTTP-метод. Це повний набір обіцянок: що клієнт зобов’язаний надіслати, що сервер зобов’язаний зробити, що поверне у відповідь і як саме виглядатимуть помилки. Якщо цей набір не зафіксувати, ви дуже швидко отримаєте ситуацію, коли два різні клієнти розуміють один і той самий endpoint по-різному, а сервер на це відповідає філософськи: «ну… залежить».
У нашому курсі ми тримаємо контракт write-операції щонайменше в такому складі: URI, HTTP-метод, request DTO, response DTO, status code, headers (якщо потрібні) та єдині правила помилок через ProblemDetail. Важливо розуміти, що деякі елементи контракту можуть не містити тіла: наприклад, DELETE часто повертає порожню відповідь, але це не означає, що контракт відсутній — навпаки, він просто говорить: «тіла немає, і це нормально».
Зручно сприймати це як «паспорт операції». У коді цей паспорт відображається в сигнатурі методу контролера — які аргументи, який DTO, яка відповідь, — у ResponseEntity — який статус і які headers, — і в сервісній операції — який стан реально змінюється. Якщо ви бачите endpoint, але не можете швидко відповісти на запитання «що саме він обіцяє клієнту?», «які поля клієнт може надсилати?», «що буде, якщо надіслати лише половину полів?» — значить контракт не визначено, а endpoint поки що живе на удачу.
Давайте зафіксуємо це в маленькій схемі — не юридичній, а інженерній:
flowchart TD
%% Контракт: що клієнт надсилає, що сервер обіцяє повернути
Client["Клієнт (UI/мобільний застосунок)"] -->|HTTP метод + URI + request DTO| API["Task Tracker API"]
API -->|"Валідація + бізнес-правила"| Service["Сервісний шар"]
Service -->|зміна стану| Repo["Репозиторій у памʼяті"]
API -->|"status + headers + response DTO"| Client
API -->|ProblemDetail у разі помилки| Client
Ми зараз не обговорюємо внутрішню будову репозиторію та те, де зберігається задача, — це вже зроблено. Сьогодні нам важливо інше: контракт — це те, що бачить клієнт, а не те, як сервер влаштований зсередини.
3. Write-операції для Task
Коли у нас є один ресурс Task, він не зобов’язаний обмежуватися однією write-операцією. Навпаки, реальний клієнтський сценарій майже завжди потребує і створення, і повної заміни, і часткової зміни, і видалення. Проблема починається тоді, коли ці операції починають імітувати одна одну: PATCH працює як PUT, PUT працює як «часткове оновлення», DELETE раптово робить «архівацію», а POST іноді чомусь використовується як «update, але через обхідне рішення». У цей момент клієнт перестає вірити API — і починає писати обхідні рішення.
Щоб не допустити цього, корисно тримати просту матрицю. Вона не замінює документацію, але дає мозку «скелет», на який потім накладаються деталі кожної операції.
| Операція | URI | Сенс для клієнта | Типовий request DTO | Типовий response DTO | Типовий статус успіху |
|---|---|---|---|---|---|
| Створення | POST /api/v1/tasks | «Створи нову задачу в колекції» | TaskCreateRequest | TaskDetailsResponse | 201 Created |
| Повна заміна | PUT /api/v1/tasks/{taskId} | «Повністю заміни редаговану частину задачі» | TaskPutRequest | TaskDetailsResponse | 200 OK |
| Часткове оновлення | PATCH /api/v1/tasks/{taskId} | «Зміни лише те, що я явно надіслав» | TaskPatchRequest | TaskDetailsResponse | 200 OK |
| Видалення | DELETE /api/v1/tasks/{taskId} | «Видали задачу як ресурс» | — | — | 204 No Content |
Одразу два важливі уточнення, щоб не посіяти в голові неправильні очікування. По-перше, «повна заміна» через PUT майже завжди означає «повна заміна редагованої частини», а не «переписати все взагалі, включно з id і createdAt». Це приводить нас до поняття server-managed полів: те, чим керує сервер, клієнт не повинен надсилати ні в create, ні в replace, ні в patch.
По-друге, PATCH — це не «PUT, тільки можна надіслати менше полів». Це інша домовленість, і якщо ми не оформимо її окремим DTO, окремою логікою та окремим розумінням null/absent, то PATCH стане просто «другим PUT», який виглядає коротшим, але поводиться менш передбачувано.
POST: створення
На рівні інтуїції POST найпростіший: клієнт приходить до колекції /tasks і каже: «створи мені нову задачу». Але навіть тут починаються помилки, якщо не зафіксувати контракт. Наприклад, деякі клієнти «за звичкою» надсилають id (тому що в їхньому UI вже є «якийсь id») або намагаються встановити createdAt. Якщо сервер це «мовчки проковтне», ви створите клієнту ілюзію контролю там, де його бути не повинно.
Семантика POST у нашому курсі така: POST створює новий ресурс. Тому типовий успіх — 201 Created. У create-контракті майже завжди виникає запитання: що повернути у відповідь? Ми заздалегідь тримаємо контракт зрозумілим для клієнта: або повертаємо створений TaskDetailsResponse, або, якщо тіло не потрібне, принаймні повертаємо Location, щоб клієнт міг перейти до створеного ресурсу. Тут достатньо побачити базову річ: POST — це про створення, і ця відмінність має читатися і в статусі, і в заголовках, і в DTO.
Мініприклад доброго контракту — request DTO для створення, у якому немає server-managed полів:
import jakarta.validation.constraints.NotBlank;
public record TaskCreateRequest(
// Клієнт надсилає лише те, чим реально керує (server-managed поля тут не живуть)
@NotBlank String title
) {}
Так, це суперскорочена версія. І це нормально для ілюстрації: контракт має бути зрозумілим навіть у мінімальному варіанті.
PUT: повна заміна
PUT виглядає як «звичайне оновлення», але його сенс суворіший: клієнт каже серверу «я надсилаю тобі повну картину редагованої частини ресурсу — зроби так, щоб ресурс відповідав цій картині». Найтиповіша помилка тут — реалізувати PUT, але трактувати відсутні поля як «ну гаразд, залишимо старе». Це не PUT. Це вже PATCH. І через це клієнт починає думати, що PUT — це «оновити те, що є», а потім раптом в іншому проєкті отримує справжній PUT і втрачає дані.
У нашому API PUT адресує конкретну задачу: /api/v1/tasks/{taskId}. І навіть якщо у задачі 10 редагованих полів, контракт PUT чесно говорить: надішліть усі 10 або принаймні всі обов’язкові редаговані. У відповідь ви отримуєте детермінованість. Клієнтський код простіший: «я завжди надсилаю цілу форму». Сервер теж простіший: «я завжди знаю, що в replace-запиті лежить повний набір даних».
При цьому ми не віддаємо клієнту владу над server-managed полями. Тому TaskPutRequest — це повна заміна саме редагованих полів, а id/createdAt/updatedAt живуть окремо і керуються сервером.
PATCH: часткове оновлення
PATCH — це спроба зробити оновлення більш «економним» за даними, але не ціною хаосу. Правильний PATCH — це коли клієнт явно говорить: «ось ці поля змінюю, решту не чіпаю». Якщо поле не надійшло — воно не бере участі у зміні. І тут з’являються ті самі підступні моменти, які ми вже обговорювали раніше: чим відрізняється «поле відсутнє» від «поле надійшло як null»? Чи можна null розуміти як «очистити значення»? Або як «я не знаю, що туди поставити»? Якщо ці правила не зафіксувати, PATCH починає жити залежно від реалізації сервера.
Щоб не розмивати контракт, ми використовуємо patch-like DTO. Тобто ми заздалегідь обмежуємо, які поля взагалі дозволено змінювати через PATCH, і надаємо цим полям зрозумілу семантику. Це захищає нас від двох крайнощів. Перша крайність — «універсальний Map<String, Object> і потім “як-небудь розберемося”». Друга крайність — «давайте тягнути RFC і робити повноцінний JSON Patch». Для навчального production-like REST API розумніше бути посередині: typed DTO + зрозумілі правила.
Ось мінімальний приклад patch DTO, у якому всі поля необов’язкові (тобто можуть бути null, якщо не надійшли):
import jakarta.validation.constraints.Size;
public record TaskPatchRequest(
// Якщо поле не надійшло — воно не бере участі у зміні.
// Тут для простоти показуємо nullable-поле (за контрактом трактуємо як "не змінюй").
@Size(min = 3, max = 120) String title
) {}
Поки що ми не заглиблюємося в деталі «як валідовати лише поля, що надійшли» — це у нас уже вміє Bean Validation у зв’язці з тим, як ми формуємо DTO та правила контракту. Зараз нам важливо зафіксувати сенс: PATCH змінює лише те, що явно передано.
DELETE: видалення
DELETE — це не «оновлення без тіла». Це окрема семантика: видалити ресурс. У навчальних проєктах часто трапляється спокуса: «а давайте DELETE буде просто ставити статус ARCHIVED». З погляду бізнесу архівація може бути чудовою ідеєю, але в термінах API-контракту це вже інша операція та інші правила помилок. DELETE має залишатися тим, чого клієнт очікує: після нього ресурс або зникає, або стає недоступним як ресурс, а що саме відбувається всередині — це вже внутрішня реалізація.
На рівні транспорту DELETE належить до ідемпотентних методів: повторний однаковий запит не повинен безкінечно змінювати стан усе сильніше й сильніше. Але тут важливо не переплутати: ідемпотентність — це про ефект на стан, а не про «обов’язково однакова відповідь щоразу». Для delete-контракту тут достатньо зафіксувати ядро: DELETE /api/v1/tasks/{taskId} видаляє задачу, типовий успіх — 204 No Content, і в разі помилки ми повертаємо такий самий ProblemDetail-формат, що і всюди.
4. Контракт у коді
Коли ви тримаєте контракт у голові чітко, код стає майже нудним — і це комплімент. Нудний код зазвичай означає, що ви заздалегідь продумали сенс. У Spring MVC write-контракт читається прямо за контролером: за URI, за HTTP-методом, за вхідним DTO, за типом відповіді та за ResponseEntity. А у сервісному шарі він читається за назвою операції: create, replace, patch, delete. Саме назви методів тут важливі: вони дисциплінують мислення команди.
Почнімо із сервісу. З погляду шару domain.service — і не забуваймо, що сервісний шар не повинен знати про ResponseEntity та інші MVC-деталі, — нам потрібні чотири операції, тому що це чотири різні наміри клієнта. Навіть якщо всередині ви реалізуєте їх частково спільним кодом, зовні вони мають виглядати окремо: клієнту важливо розуміти сенс.
Нижче — мінімальний приклад інтерфейсу сервісу. Для простоти я показую варіант, де сервіс приймає request DTO і повертає response DTO: це зручно для навчальних прикладів сигнатур. У більш строгій багатошаровій архітектурі сервіс міг би працювати з внутрішньою моделлю Task, а мапінг відбувався б на межі API через TaskMapper, але семантика від цього не змінюється.
import com.example.tasktracker.api.dto.request.TaskCreateRequest;
import com.example.tasktracker.api.dto.request.TaskPatchRequest;
import com.example.tasktracker.api.dto.request.TaskPutRequest;
import com.example.tasktracker.api.dto.response.TaskDetailsResponse;
public interface TaskWriteService {
// Створення нового ресурсу (семантика POST)
TaskDetailsResponse create(TaskCreateRequest request);
// Повна заміна редагованої частини ресурсу (семантика PUT)
TaskDetailsResponse replace(String taskId, TaskPutRequest request);
// Часткове оновлення лише явно переданих полів (семантика PATCH)
TaskDetailsResponse patch(String taskId, TaskPatchRequest request);
// Видалення ресурсу (семантика DELETE); зазвичай не повертає тіло
void delete(String taskId);
}
Тепер подивімося на контролер. Тут важливо не «як написати анотацію», а як зробити так, щоб із коду читався контракт. Для цього ми не змішуємо все в один «універсальний update». Ми прямо називаємо методи create, replace, patch, delete. І так, це нормально, що в Java-коді є метод patch(): головне, щоб людина, яка читає контролер, бачила намір.
Нижче — два фрагменти одного TaskController, просто щоб не роздувати приклад. Зверніть увагу: я показую лише сигнатури, без докладної реалізації, бо це саме лекція про семантику. Деталі відповіді 201 Created, Location, 204 No Content тощо ми будемо акуратно розкладати в наступних лекціях.
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import com.example.tasktracker.api.dto.request.TaskCreateRequest;
import com.example.tasktracker.api.dto.request.TaskPatchRequest;
import com.example.tasktracker.api.dto.request.TaskPutRequest;
import com.example.tasktracker.api.dto.response.TaskDetailsResponse;
@RestController
@RequestMapping("/api/v1/tasks")
class TaskController {
@PostMapping
ResponseEntity<TaskDetailsResponse> create(@Valid @RequestBody TaskCreateRequest request) {
// Контракт: створюємо новий ресурс (зазвичай 201 Created + за потреби Location)
// Реалізацію опущено: у лекції нам важливі семантика та форма сигнатури
throw new UnsupportedOperationException(); // заглушка для прикладу
}
@PutMapping("/{taskId}")
ResponseEntity<TaskDetailsResponse> replace(@PathVariable String taskId,
@Valid @RequestBody TaskPutRequest request) {
// Контракт: повна заміна редагованих полів задачі за taskId (зазвичай 200 OK)
throw new UnsupportedOperationException();
}
}
І окремо — другий фрагмент того самого TaskController для PATCH і DELETE, щоб не робити один великий лістинг. Зверніть увагу, як метод (@PatchMapping vs @DeleteMapping) стає частиною контракту, а не «просто анотацією для маршрутизації».
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/v1/tasks")
class TaskController {
@PatchMapping("/{taskId}")
ResponseEntity<TaskDetailsResponse> patch(@PathVariable String taskId,
@Valid @RequestBody TaskPatchRequest request) {
// Примітка: тут @Valid використано так само, як і в попередньому прикладі (імпорт опущено для стислості)
// Контракт: змінюємо лише явно передані поля (зазвичай 200 OK)
throw new UnsupportedOperationException();
}
@DeleteMapping("/{taskId}")
ResponseEntity<Void> delete(@PathVariable String taskId) {
// Контракт: видаляємо ресурс (зазвичай 204 No Content)
throw new UnsupportedOperationException();
}
}
Зараз може виникнути запитання: «чому не можна зробити один update(TaskUpdateRequest) і приймати його і для PUT, і для PATCH, і взагалі для всього?» Тому що тоді контракт перестає бути ясним. Клієнт не знає, що означає пропущене поле. Сервер починає робити «якщо поле null, то…», потім «якщо поле відсутнє, то…», потім «якщо клієнт надіслав порожній список тегів, то…», і все це перетворюється на розпливчастий набір правил, який важко документувати і тестувати. Окремі DTO та окремі операції — це не бюрократія. Це спосіб зробити API передбачуваним.
5. Типові помилки під час write-операцій
Помилка №1: починати з коду, а не зі сенсу.
Дуже легко сісти, написати контролер, додати чотири анотації, а потім уже «по ходу» вирішити, що таке PUT, що таке PATCH, і який статус краще поставити. Проблема в тому, що клієнту байдуже, як вам зручніше: він житиме з цим контрактом довго. Тому спочатку ви фіксуєте сенс операції, і лише потім пишете код, який цей сенс виражає.
Помилка №2: один request DTO на все, що рухається, і навіть на те, що не рухається.
Універсальний TaskRequest, який використовується для створення, повного оновлення та часткового оновлення, виглядає економно: менше класів. Але потім виявляється, що одні поля обов’язкові під час create, інші — під час replace, а під час patch взагалі «можна не надсилати». У результаті DTO перетворюється на дивну істоту, де половина полів nullable «про всяк випадок», а валідація намагається вгадати, що мав на увазі клієнт. Поділ на TaskCreateRequest, TaskPutRequest, TaskPatchRequest зазвичай робить код більшим на три файли, але зменшує кількість непорозумінь на порядок.
Помилка №3: PUT, який поводиться як PATCH.
Найпідступніша помилка: ви оголосили PUT, але якщо клієнт не надіслав поле, ви «залишаєте старе». Це здається зручним, поки в клієнта не з’явиться другий розробник або друга фронтенд-команда. Один буде думати, що PUT — це повна форма, другий буде думати, що PUT — це «оновити те, що надіслав», і система почне жити в конфлікті очікувань. Якщо вам потрібне часткове оновлення — це PATCH, і в нього мають бути власний DTO та власні правила.
Помилка №4: PATCH, який відкриває доступ до всієї внутрішньої моделі.
Якщо через PATCH дозволити «міняти все підряд», дуже швидко клієнт почне надсилати поля, які ви самі не хотіли віддавати назовні. Наприклад, createdAt або якісь внутрішні прапорці. Це руйнує ідею server-managed полів і ламає контракт у майбутньому: ви боятиметеся змінювати внутрішню модель, тому що «клієнти можуть це надсилати». Patch-like DTO має бути контрольованим: «ось конкретно ці поля можна змінювати частково».
Помилка №5: DELETE, який насправді робить щось інше.
Видалення легко переплутати з архівацією, зміною статусу або «мʼяким видаленням». Саме по собі це може бути правильно з бізнесового погляду, але якщо ви називаєте це DELETE, клієнт очікує «ресурс зникне». Якщо вам потрібна архівація — це окрема домовленість і окрема операція. У нашому курсі ми тримаємо DELETE чесним: «видалити ресурс», із зрозумілою відповіддю успіху та зрозумілою поведінкою за відсутності ресурсу.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ