🌐 Розділ 6 · Питання #4

В чому різниця між PUT та PATCH

Обидва методи — PUT та PATCH — використовуються в REST API для оновлення наявних ресурсів на сервері, але мають принципово різну семантику та правила обробки даних.


🟢 Junior Level

Обидва методи — PUT та PATCH — використовуються в REST API для оновлення наявних ресурсів на сервері, але мають принципово різну семантику та правила обробки даних.

Головні відмінності

  • PUT (Повна заміна ресурсу):
    • Клієнт передає повне представлення ресурсу.
    • Усі поля, які не були передані в тілі запиту PUT, сервер зобов’язаний видалити або скинути в null (значення за замовчуванням).
    • Метод є строго ідемпотентним (повторний запуск запиту з тим самим тілом не змінює стан бази даних).
  • PATCH (Часткове оновлення ресурсу):
    • Клієнт передає тільки ті поля, які потрібно змінити.
    • Усі інші поля ресурсу залишаються без змін.
    • Заощаджує мережевий трафік під час роботи з великими об’єктами.
    • За специфікацією не гарантує ідемпотентність (залежить від логіки операції).

Наочне порівняння запитів

Початковий стан користувача в базі даних:

{
  "id": 42,
  "name": "Alex",
  "age": 25,
  "email": "alex@example.com"
}

Запит методом PUT (Повна заміна):

PUT /api/v1/users/42 HTTP/1.1
Content-Type: application/json

{
  "name": "Alex",
  "age": 26
}

Результат у БД: поле email стерто в null, оскільки клієнт не надіслав його в запиті повної заміни!

Запит методом PATCH (Часткове оновлення):

PATCH /api/v1/users/42 HTTP/1.1
Content-Type: application/json

{
  "age": 26
}

Результат у БД: оновлено лише поле age, поле email зберегло значення "alex@example.com".


🟡 Middle Level

Порівняльна таблиця специфікацій (RFC 9110 vs RFC 5789)

Критерій PUT (RFC 9110) PATCH (RFC 5789)
Семантика дії Заміщення цільового ресурсу наданим станом Застосування набору змін до цільового ресурсу
Ідемпотентність ✅ Суворо гарантується специфікацією ❌ Не гарантується (залежить від типу патча)
Безпечність (Safe) ❌ Ні (модифікує дані) ❌ Ні (модифікує дані)
Стандартний Content-Type application/json, application/xml application/merge-patch+json або application/json-patch+json
Аналогія в SQL UPDATE users SET name=?, age=?, email=? UPDATE users SET age=?

Дві специфікації стандарту PATCH

В індустрії існує два стандарти реалізації методу PATCH:

1. JSON Merge Patch (RFC 7386) — Декларативний підхід

Клієнт відправляє частковий JSON-документ зі зміненими полями (Content-Type: application/merge-patch+json). Сервер просто накладає надіслані ключі поверх існуючого об’єкта. Недолік: складно відрізнити «поле не передано (залишити як є)» від «поле передано зі значенням null (стерти значення в БД)».

2. JSON Patch (RFC 6902) — Імперативний підхід

Клієнт відправляє масив атомарних інструкцій (Content-Type: application/json-patch+json):

[
  { "op": "replace", "path": "/age", "value": 26 },
  { "op": "remove", "path": "/temporaryToken" },
  { "op": "add", "path": "/tags/-", "value": "premium" }
]

Підтримує операції: add, remove, replace, move, copy, test.


🔴 Senior Level

Реалізація PUT та PATCH у Spring Boot 3

package com.example.controller;

import com.example.dto.ReplaceUserRequest;
import com.example.dto.UserResponseDto;
import com.example.service.UserService;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/v1/users")
public class UserController {

    private final UserService userService;
    private final ObjectMapper objectMapper;

    public UserController(UserService userService, ObjectMapper objectMapper) {
        this.userService = userService;
        this.objectMapper = objectMapper;
    }

    // 1. PUT: Повна заміна (усі поля валідуються, відсутні скидаються)
    @PutMapping("/{id}")
    public ResponseEntity<UserResponseDto> replaceUser(@PathVariable Long id, 
                                                      @RequestBody ReplaceUserRequest request) {
        return ResponseEntity.ok(userService.replaceUser(id, request));
    }

    // 2. PATCH: Часткове оновлення через JSON Merge Patch
    @PatchMapping(path = "/{id}", consumes = "application/merge-patch+json")
    public ResponseEntity<UserResponseDto> patchUser(@PathVariable Long id, 
                                                    @RequestBody JsonNode patchNode) {
        return ResponseEntity.ok(userService.patchUser(id, patchNode));
    }
}
package com.example.service;

import com.example.dto.ReplaceUserRequest;
import com.example.dto.UserResponseDto;
import com.example.entity.User;
import com.example.repository.UserRepository;
import com.fasterxml.jackson.databind.JsonNode;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class UserService {

    private final UserRepository userRepository;

    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Transactional
    public UserResponseDto replaceUser(Long id, ReplaceUserRequest req) {
        User user = userRepository.findById(id).orElseThrow();
        // PUT перезаписує ВСІ поля, включаючи null для непереданих
        user.setName(req.name());
        user.setAge(req.age());
        user.setEmail(req.email()); // Якщо req.email() == null, поле затирається!
        return UserResponseDto.fromEntity(user);
    }

    @Transactional
    public UserResponseDto patchUser(Long id, JsonNode patchNode) {
        User user = userRepository.findById(id).orElseThrow();
        
        // Аналізуємо тільки реально присутні в JSON вузли:
        if (patchNode.has("name")) {
            user.setName(patchNode.get("name").asText());
        }
        if (patchNode.has("age")) {
            user.setAge(patchNode.get("age").asInt());
        }
        if (patchNode.has("email")) {
            // Якщо в JSON прийшов явний "email": null — затираємо, інакше не чіпаємо
            user.setEmail(patchNode.get("email").isNull() ? null : patchNode.get("email").asText());
        }
        return UserResponseDto.fromEntity(user);
    }
}

Проблема “Three-State Null Problem” у Java DTO

При реалізації PATCH бібліотека Jackson за замовчуванням перетворює і відсутнє поле, і поле зі значенням null у звичайний null в Java об’єкті.

Для суворого розділення трьох станів використовують бібліотеку JsonNullable:

public class PatchUserDto {
    // 1. Поле відсутнє в JSON -> jsonNullable.isUndefined() == true
    // 2. У JSON прийшов "email": null -> jsonNullable.isPresent() && jsonNullable.get() == null
    // 3. У JSON прийшов "email": "a@b.c" -> jsonNullable.isPresent() && jsonNullable.get() == "a@b.c"
    private JsonNullable<String> email = JsonNullable.undefined();
}

Чому PATCH може бути НЕІДЕМПОТЕНТНИМ

Хоча PUT завжди ідемпотентний, метод PATCH може порушувати ідемпотентність залежно від виконуваної операції:

  1. Ідемпотентний PATCH:
    { "op": "replace", "path": "/status", "value": "ACTIVE" }
    

    Скільки б разів не повторювався запит, статус залишиться ACTIVE.

  2. Неідемпотентний PATCH:
    { "op": "add", "path": "/tags/-", "value": "vip" }
    

    Кожен повторний виклик додаватиме новий елемент "vip" у кінець масиву tags (["vip", "vip", "vip"]).

  3. Операція інкремента:
    { "op": "increment", "path": "/balance", "value": 100 }
    

    Кожен повторний виклик збільшує баланс ще на 100 одиниць.


🎯 Шпаргалка для інтерв’ю

30-секундна відповідь

  • PUT (RFC 9110) призначений для повної заміни цільового ресурсу. Клієнт зобов’язаний передати всі поля сутності; опущені поля сервер зобов’язаний занулити або скинути. Метод завжди ідемпотентний.
  • PATCH (RFC 5789) призначений для часткового оновлення. Передаються лише змінювані поля (економія мережевого трафіку). Метод не гарантує ідемпотентність (наприклад, додавання елемента в масив або операція інкремента).
  • Реалізується за двома стандартами: JSON Merge Patch (RFC 7386) (декларативне злиття) та JSON Patch (RFC 6902) (масив атомарних команд add, remove, replace).

4 каверзних питання з відповідями

  1. Що зобов’язаний зробити сервер за специфікацією HTTP, якщо клієнт у запиті PUT не надіслав одне з необов’язкових полів? Відповідь: Сервер зобов’язаний перезаписати існуючий ресурс переданим представленням цілком. Ненадіслані поля мають бути скинуті в null або значення за замовчуванням. Якщо потрібно оновити лише частину полів, не зачіпаючи інші, клієнт зобов’язаний використовувати PATCH.

  2. Наведіть приклад неідемпотентного запиту PATCH. Відповідь: Інструкція додавання елемента до списку [{"op": "add", "path": "/items/-", "value": "item1"}] або команда збільшення лічильника [{"op": "increment", "value": 5}]. При кожному повторі такого запиту стан масиву або лічильника в базі даних буде змінюватися.

  3. У чому різниця між стандартами RFC 7386 та RFC 6902 для методу PATCH? Відповідь: RFC 7386 (JSON Merge Patch) — декларативний підхід: клієнт надсилає звичайний частковий JSON, і сервер об’єднує його з оригіналом. RFC 6902 (JSON Patch) — імперативний підхід: клієнт передає масив точних операцій (replace, add, remove, move, copy, test) зі шляхами JSON Pointer.

  4. Як у Java вирішити проблему розрізнення «поле не передано» та «поле явно передано як null» при обробці PATCH? Відповідь: За допомогою обгортки JsonNullable<T> (бібліотека jackson-databind-nullable) або шляхом парсингу тіла запиту в деревоподібну структуру Jackson JsonNode з перевіркою методів patchNode.has("field") та node.isNull().

Червоні прапорці (чого категорично не можна говорити)

  • ❌ «PUT і PATCH — це абсолютно одне й те саме, просто різні назви» — фундаментальна помилка розуміння семантики HTTP.
  • ❌ «PATCH завжди ідемпотентний, так само як і PUT» — PATCH не гарантує ідемпотентність за RFC 5789.
  • ❌ «PUT оновлює лише ті поля, які надіслали, а решту залишає недоторканими» — це опис PATCH, а не PUT.
  • ❌ «Для PATCH не існує офіційних стандартів, кожен пише як хоче» — існують офіційні специфікації RFC 7386 та RFC 6902.

Пов’язані теми