1. Konflikt biznesowy: żądanie ok, ale nie można
Jeśli kiedykolwiek próbowałeś zapłacić za zakup, a terminal mówił „operacja niemożliwa" – już zetknąłeś się z konfliktem biznesowym. Karta prawdziwa, PIN wprowadzony, nie pomyliłeś pól, ale aktualne warunki nie pozwalają na wykonanie operacji: limit, blokada, niewystarczające środki, sklep nie przyjmuje tego typu karty. W API to ta sama historia: żądanie poprawne składniowo, walidacja przechodzi, ale reguły domenowe mówią „nie".
I tu programiście często kusi: „No skoro klient winny, to 400 Bad Request". Ale 400 – to kwestia nieprawidłowej formy żądania (invalid input), a 409 – to kwestia konfliktu ze stanem zasobu. Czyli klient robi zrozumiałą próbę zmiany, ale zasób jest teraz w takim stanie, że operacja jest zakazana. Właśnie tę granicę ustalamy w kontrakcie, żeby klient mógł rozróżnić „wysłałem śmieci" od „wysłałem normalne dane, ale wybrałem zły moment/stan".
2. 400 vs 409: prosta weryfikacja
W tym miejscu warto mieć w głowie bardzo prostą „weryfikację zdrowego rozsądku". 400 Bad Request – kiedy serwer nie przyjmuje żądania jako prawidłowego wejścia (zły typ, złe pola, złe wartości, naruszone ograniczenia). 409 Conflict – kiedy serwer przyjmuje żądanie jako prawidłowe pod względem formy, ale odmawia wykonania ze względu na znaczenie, bo aktualny stan zasobu nie jest zgodny z operacją.
Żeby było konkretniej, porównajmy to w tabeli – bez filozofii i RFC-romantyki:
| Sytuacja w Task Tracker API | Co nie tak | Prawidłowy status | Czy potrzebne fieldErrors |
|---|---|---|---|
| W TaskCreateRequest.title pusty string | Wejście nieprawidłowe (input validation) | 400 Bad Request | Tak, przydatne |
| W PATCH przysłano nieistniejący enum status = "WORKING" | Wejście nieprawidłowe (typ/enum) | 400 Bad Request | Zazwyczaj tak / zależnie od sytuacji |
| taskId nie znaleziono | Zasób nie istnieje | 404 Not Found | Nie |
| Próba przeniesienia zadania z ARCHIVED do IN_PROGRESS | Konflikt z aktualnym stanem | 409 Conflict | Zazwyczaj nie |
| Próba dołączenia pliku do zarchiwizowanego zadania | Operacja zakazana przez stan | 409 Conflict | Nie |
Zwróć uwagę na jeden ważny szczegół: 409 prawie zawsze „żyje" po znalezieniu zasobu (inaczej byłby 404) i po przyjęciu wejścia (inaczej byłby 400). W tym sensie 409 to błąd „późniejszy" w łańcuchu przetwarzania.
3. Gdzie rodzi się 409
Bardzo chce się sprawdzać takie rzeczy bezpośrednio w kontrolerze: „jeśli status ARCHIVED – od razu zwróć 409". Ale wtedy kontroler zaczyna puchnąć, zamieniając się w „centrum wszechświata", i znowu dostajemy fat controllers (a te już raz pogrzebaliśmy, niech spoczywa w spokoju).
Właściwa warstwa dla konfliktów biznesowych to warstwa domenowa/serwisowa. Dlaczego? Bo zakaz „zarchiwizowanego zadania nie można zmieniać" to nie szczegół HTTP ani nie ograniczenie walidacyjne. To reguła dziedziny: jak zbudowane jest zadanie i jego cykl życia. Kontrolerowi ważne jest tylko jedno: „jeśli serwis powiedział, że konflikt – zamień to w ProblemDetail z 409".
W kategoriach łańcucha przetwarzania wygląda to tak (uproszczone, bez szczegółów Spring internals):
flowchart TD
A["Żądanie HTTP"] --> B["Body parsing + @Valid"]
B -->|ok| C["Controller -> Service"]
B -->|błąd| V["400 INVALID_INPUT + fieldErrors"]
C --> D["Repository: znaleźć Task"]
D -->|nie| N["404 TASK_NOT_FOUND"]
D -->|tak| E["Sprawdzenie reguł biznesowych"]
E -->|konflikt| K["409 Business conflict"]
E -->|ok| S["200/201/204 udana odpowiedź"]
Taka kolejność pomaga nie mylić „niepoprawnie wypełnionego formularza" z „poprawnie wypełnionym formularzem, ale akcja zakazana".
4. Przykład: zakaz przejścia statusu
Żeby pokazać 409 w praktyce, potrzebujemy dwóch rzeczy: reguły, która może zostać naruszona, i wyjątku, który można złapać w @ControllerAdvice. Najbardziej przejrzysty przykład w Task Tracker API – zakazane przejście statusu. Istota jest prosta: zadanie ma skończony zestaw stanów, i nie wszystkie przejścia między nimi są dozwolone (np. z DONE można iść do ARCHIVED, a z ARCHIVED – nigdzie).
Pokażę jak najbardziej dydaktyczną implementację sprawdzania przejść. W prawdziwym kodzie produkcyjnym zrobiłbyś maszynę stanów staranniej, ale teraz ważna jest przejrzystość.
package com.example.tasktracker.domain.service;
import com.example.tasktracker.domain.model.TaskStatus;
public class TaskStatusRules {
/**
* Sprawdzamy, czy można przejść z jednego statusu w drugi.
* To jest reguła domenowa, niezwiązana z HTTP ani walidacją danych wejściowych.
*/
public boolean canMove(TaskStatus from, TaskStatus to) {
// Jawnie ustalamy dozwolone przejścia między stanami
return switch (from) {
case TODO -> to == TaskStatus.IN_PROGRESS || to == TaskStatus.BLOCKED;
case IN_PROGRESS -> to == TaskStatus.BLOCKED || to == TaskStatus.DONE;
case BLOCKED -> to == TaskStatus.IN_PROGRESS || to == TaskStatus.DONE;
case DONE -> to == TaskStatus.ARCHIVED;
case ARCHIVED -> false; // Archiwum – stan końcowy: dalszych przejść nie ma
};
}
}
Teraz serwis zmieniający status może zrobić czytelne sprawdzenie. Uwaga: najpierw szukamy zadania (inaczej będzie 404), potem sprawdzamy konflikt (inaczej będzie 409).
public void changeStatus(String taskId, TaskStatus newStatus) {
// 1) Najpierw upewniamy się, że zasób istnieje: inaczej to 404, nie 409
Task task = taskRepository.findById(taskId)
.orElseThrow(() -> new TaskNotFoundException(taskId));
// 2) Następnie sprawdzamy ograniczenia domenowe: poprawne żądanie, ale stan nie pozwala na operację
if (!statusRules.canMove(task.getStatus(), newStatus)) {
throw new InvalidStatusTransitionException(task.getStatus(), newStatus);
}
// 3) Dopiero po wszystkich sprawdzeniach zmieniamy stan
task.setStatus(newStatus);
}
Ważny sens tego kodu – nie w switch ani wyjątku. Sens w tym, że konflikt to wynik sprawdzenia domenowego, a nie „błąd HTTP". HTTP pojawi się później, w handlerze błędów.
5. Wyjątek dla konfliktu biznesowego
Teraz potrzebujemy wyjątku, który niesie wystarczająco danych, żeby zbudować dobry ProblemDetail. Minimum, które nas interesuje, to kod błędu i kontekst, który trafi do detail. Często przydatne jest też posiadanie konkretnych wartości, np. from i to, żeby ewentualnie dodać je jako extension fields.
package com.example.tasktracker.domain.exception;
public abstract class BusinessConflictException extends RuntimeException {
// To jedna z publicznych wartości wspólnego słownika code,
// ale jako string, żeby warstwa domenowa nie ciągnęła zależności do web DTO.
private final String code;
protected BusinessConflictException(String code, String message) {
super(message);
this.code = code;
}
public String getCode() {
return code;
}
}
I konkretny wyjątek:
package com.example.tasktracker.domain.exception;
import com.example.tasktracker.domain.model.TaskStatus;
public class InvalidStatusTransitionException extends BusinessConflictException {
// Warto zachować oryginalne wartości: można je przekazać do ProblemDetail jako extension fields
public final TaskStatus from;
public final TaskStatus to;
public InvalidStatusTransitionException(TaskStatus from, TaskStatus to) {
// INVALID_STATUS_TRANSITION – to jedna z publicznych wartości wspólnego słownika code.
// Message – dla detail (czytelne dla człowieka), code – dla stabilnej logiki po stronie klienta.
super("INVALID_STATUS_TRANSITION",
"Task with status " + from + " cannot move to " + to);
this.from = from;
this.to = to;
}
}
Czyli INVALID_STATUS_TRANSITION – nie nowy osobny system kodów, a ta sama publiczna wartość, którą klient zobaczy w ProblemDetail.code. Po prostu warstwa domenowa trzyma ją jako string, nie przyciągając wewnątrz zależności web.
6. 409 ProblemDetail przez @ControllerAdvice
Teraz robimy to, dla czego to wszystko zostało zaplanowane: łapiemy wyjątek domenowy i zamieniamy go w jednolity zewnętrzny format. W poprzednich krokach ustaliliśmy już, że nie rozsmarowujemy obsługi po kontrolerach, a robimy to centralnie. I ważne jest, że Spring MVC potrafi zwracać Problem Details w formacie JSON i pozwala rozszerzać je własnymi polami.
Poniżej nie ma nowego formatu błędu. Dla konfliktu zachowywany jest ten sam górny zestaw type/title/status/detail/instance/code; zmieniają się tylko wartości i, jeśli trzeba, conflict-specific extension fields.
Zacznijmy od minimalnego handlera.
@ExceptionHandler(BusinessConflictException.class)
public ProblemDetail handleBusinessConflict(
BusinessConflictException ex,
HttpServletRequest request
) {
// Zwracamy 409, bo żądanie poprawne pod względem formy, ale zakazane przez reguły domenowe
ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());
// type i title opisują klasę problemu na górnym poziomie kontraktu
pd.setType(URI.create("/problems/business-conflict"));
pd.setTitle("Business conflict");
// instance pomaga powiązać odpowiedź z konkretnym URI, który wywoływał klient
pd.setInstance(URI.create(request.getRequestURI()));
// code potrzebny klientowi do programowego rozróżnienia konfliktów
pd.setProperty("code", ex.getCode());
return pd;
}
Jest tu kilka ważnych kwestii.
Po pierwsze, status – HttpStatus.CONFLICT, czyli 409. Nie udajemy, że to 400, bo wejście „nie jest złe", jest po prostu niemożliwe do zastosowania w aktualnym stanie zasobu.
Po drugie, dodajemy code, bo klient musi odróżniać INVALID_STATUS_TRANSITION od, powiedzmy, FILE_UPLOAD_NOT_ALLOWED (nawet jeśli oba dają 409).
Po trzecie, instance bierzemy z request.getRequestURI(). To wygodna praktyka: odpowiedź staje się samowystarczalna. Nawet jeśli logi zaginęły, po instance wiesz, co dokładnie wywoływał klient.
Małe ulepszenie: różne title dla różnych konfliktów
Jeśli zostawisz wszystkim konfliktom jeden title = "Business conflict", technicznie wszystko będzie działać, ale człowiekowi będzie smutno. Dlatego zazwyczaj robi się albo osobne handlery, albo, co częstsze, dodaje się rozgałęzienie według typu wyjątku.
Na przykład dla przejścia statusu:
@ExceptionHandler(InvalidStatusTransitionException.class)
public ProblemDetail handleInvalidStatusTransition(
InvalidStatusTransitionException ex,
HttpServletRequest request
) {
// Bardziej konkretny title – żeby człowiekowi było łatwiej zrozumieć problem
ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());
// Dla konkretnego scenariusza ustawiamy własny type i title
pd.setType(URI.create("/problems/invalid-status-transition"));
pd.setTitle("Invalid status transition");
// Wiążemy błąd z konkretnym endpointem
pd.setInstance(URI.create(request.getRequestURI()));
// Stabilny maszynowo czytelny kod błędu
pd.setProperty("code", ex.getCode());
// Conflict-specific szczegóły ponad tym samym bazowym szkieletem
pd.setProperty("fromStatus", ex.from.name());
pd.setProperty("toStatus", ex.to.name());
return pd;
}
Tak, wygląda to jak „powielanie". Ale w rzeczywistości to inwestycja w czytelność kontraktu. Gdy API urośnie, sam sobie podziękujesz.
7. Szczegóły odpowiedzi 409
Na 409 bardzo łatwo „wpaść" w dwie skrajności. Pierwsza skrajność – odpowiadać jak najbardziej ogólnie: „Operation not allowed". Klientowi to prawie nic nie daje, bo nie rozumie, co dokładnie jest nie tak. Druga skrajność – wyrzucać zbyt wiele wewnętrznych szczegółów: cały obiekt zadania, pełną listę „możliwych przejść", wewnętrzne nazwy klas, techniczne przyczyny.
Złoty środek wygląda zazwyczaj tak: title pozostaje krótki i stabilny, code pozostaje stabilny, a detail wyjaśnia konkretny przypadek. Czasem przydatne jest dodanie 1–2 extension fields, jeśli naprawdę pomagają klientowi podjąć decyzję.
Na przykład dla przejścia statusu można dodać fromStatus i toStatus. To nie jest fieldErrors (bo problem nie w tym, że pole „nie przeszło ograniczenia"), a właśnie domenowa informacja o konflikcie.
// To właśnie te extension fields, które uzupełniają już pokazaną odpowiedź na konflikt
pd.setProperty("fromStatus", ex.from.name());
pd.setProperty("toStatus", ex.to.name());
Jeśli dodasz takie pola, powinny być tak samo „kontraktowe" jak wszystko inne: z czytelnymi nazwami, stabilnym formatem, bez wycieku wewnętrznego modelu. I najważniejsze – nie zamieniaj 409 w składowisko pól „na wszelki wypadek". Nie robisz czat-bota, który „na wszelki wypadek" mówi wszystko, co wie. Robisz API.
Przykład JSON odpowiedzi 409 Conflict
Żeby utrwalić, pokażę, jak mniej więcej może wyglądać odpowiedź na konflikt przejścia statusu (umowny URI, umowne id). Ważna nie jest konkretna nazwa type, a to, że struktura pozostaje taka sama jak dla 404 i 400: właśnie to jest „jednolity error contract".
{
"type": "/problems/invalid-status-transition",
"title": "Invalid status transition",
"status": 409,
"detail": "Task with status ARCHIVED cannot move to IN_PROGRESS",
"instance": "/api/v1/tasks/42/status",
"code": "INVALID_STATUS_TRANSITION",
"fromStatus": "ARCHIVED",
"toStatus": "IN_PROGRESS"
}
Jeśli porównasz to z błędem walidacji z poprzedniego kroku, zobaczysz ogólną zasadę: górny poziom jest taki sam, zmieniają się status, title, detail i code, a także zestaw extension fields. Przy błędzie walidacji to fieldErrors, przy konflikcie – fromStatus/toStatus albo w ogóle nic.
8. Typowe błędy przy pracy z 409 Conflict
Błąd nr 1: zwracanie 400 Bad Request zamiast 409 Conflict dla konfliktów biznesowych.
Gdy każda niepowodzenie klienta zamienia się w 400, gubi się sens statusów. Klient nie rozumie: problem w danych czy w stanie zasobu? 400 – to kwestia nieprawidłowego wejścia, a 409 – prawidłowego żądania, które nie może być wykonane z powodu aktualnego stanu.
Błąd nr 2: wrzucanie konfliktów biznesowych do fieldErrors.
Błąd w stylu status: "invalid transition" wygląda jak problem pola, choć pole może być prawidłowe. Miesza to walidację wejścia z regułami biznesowymi. W rezultacie API staje się mniej przewidywalne: klient nie rozumie, gdzie jest błąd formatu, a gdzie – logiki.
Błąd nr 3: naruszanie kolejności sprawdzeń (najpierw konflikt, potem istnienie).
Nie można sprawdzać warunków biznesowych zanim upewnisz się, że zasób istnieje. Jeśli zadania nie ma – to 404, i żadnego konfliktu być nie może. Naruszenie tej kolejności prowadzi do dziwnych sytuacji, gdzie „konfliktuję" nieistniejące dane.
Błąd nr 4: robienie detail zbyt abstrakcyjnym.
Frazy typu „operation failed" albo „conflict occurred" są formalnie poprawne, ale bezużyteczne. Klientowi ważne jest zrozumienie, co dokładnie poszło nie tak: jaki był status, jakie przejście jest zakazane. Krótkie, ale konkretne wyjaśnienie jest znacznie cenniejsze od „bezpiecznej" abstrakcji.
Błąd nr 5: trzymanie reguł wyboru statusu wewnątrz kontrolera.
Gdy kontroler sam decyduje, jaki status HTTP zwrócić, logika zaczyna się powielać i rozchodzić. Wybór statusu to część reguł biznesowych i powinien być określany w warstwie serwisowej lub przez scentralizowaną obsługę wyjątków. Lepiej, żeby kontroler pozostawał cienko i przewidywalnie.
GO TO FULL VERSION