Bề mặt công khai của một HTTP API rất nhỏ: một URL, một verb, một status code và hai khối JSON. Mọi thứ còn lại — service của bạn, object của bạn, thiết kế bên trong hay ho tới đâu — client đều không nhìn thấy. Vì vậy phần thú vị khi viết API không nằm ở class controller mà ở những quyết định mà controller mã hoá: URL nào đại diện cho thứ gì, verb nào thay đổi nó, và con số nào quay về khi có chuyện.
Spring Boot làm cho nửa cơ học gần như miễn phí. Nửa mà client thật sự cảm nhận thì không: đó là status code và error body. Bài này dựng một resource CRUD nhỏ, gọi thật mọi endpoint bằng HTTP, và dành phần lớn dung lượng cho kết quả trả về: 201 kèm header Location, 204 khi delete, 409 khi conflict, 422 với body nêu đích danh field nào sai.
![]()
Toàn bộ nội dung dưới đây chạy trên Spring Boot 4.1.1 (Spring Framework 7.0.9, Tomcat 11.0.24, Jackson 3.1.5, Hibernate Validator 9.1.3.Final) với OpenJDK 21.0.6 trên arm64. Mọi status line, header và response body đều được copy từ terminal đang chạy, không viết theo trí nhớ. Các con số thời gian khởi động và thời gian mỗi request đã bị cắt khỏi transcript một cách có chủ ý — một con số mili giây đo trên một máy vào một buổi chiều thì không nói lên điều gì, nên bài này không đăng con số nào cả.
REST thật ra đòi hỏi những gì
REST là một architectural style, và những phần của nó còn sống sót trong công việc hằng ngày gồm bốn thứ:
| Ràng buộc | Nghĩa là gì trong một controller |
|---|---|
| Resource được đặt tên bằng URL | /api/tasks là collection, /api/tasks/1 là một phần tử. URL đặt tên cho một thứ, không phải một hành động — không có /api/getTask. |
| Uniform interface | Verb chính là operation. GET đọc, POST tạo, PUT thay thế, DELETE xoá. Không có field action nào trong body. |
| Stateless | Mỗi request mang theo đủ thứ server cần. Không có session phía server nhớ "bạn đang xem task nào". |
| Message tự mô tả | Content-Type nói bạn gửi gì, Accept nói bạn muốn nhận gì, và status code nói chuyện gì đã xảy ra. |
Nói thẳng phần còn lại: đại đa số thứ được gọi là REST API thực chất là HTTP cộng JSON với URL hợp lý, và dừng khá xa định nghĩa đầy đủ của Roy Fielding. Hypermedia control — response mang theo link cho client biết bước tiếp theo được phép làm gì — là ràng buộc gần như không ai implement, và một API thiếu nó thì nói cho chặt chẽ không phải REST. Trong thực tế sự phân biệt này chẳng tốn của bạn thứ gì. Đặt tên resource bằng danh từ, dùng verb đúng nghĩa của nó, trả status code trung thực, thì API của bạn dùng hằng ngày không khác gì một API làm hài lòng phe thuần tuý. Cái client thật sự khổ sở không phải là thiếu một link relation; đó là một 200 OK bọc lấy {"error": "not found"}.
Chỗ duy nhất mà cách đọc chặt chẽ thật sự đáng giá là caching và tính an toàn. GET không được thay đổi gì, vì proxy, browser và cơ chế retry đều mặc định như vậy. PUT và DELETE nên idempotent — gửi hai lần để lại đúng trạng thái như gửi một lần. POST thì không, và đó chính là lý do nó là verb dùng để tạo.
Từ socket tới method của bạn
Một ứng dụng Spring MVC chỉ có một servlet. DispatcherServlet nhận mọi request đi vào ứng dụng rồi quyết định, dựa trên path và method cùng lúc, handler method nào của bạn sẽ chạy.

Gọi tên từng giai đoạn là việc đáng làm, vì nó cho biết một lỗi phát sinh từ đâu. Handler mapping fail trước khi code của bạn chạy. Argument binding fail trước khi code của bạn chạy. Một body không parse được cũng fail trước khi code của bạn chạy. Chỉ khi cả ba bước đó thành công thì method của bạn mới được gọi, và chỉ sau khi method trả về thì Jackson mới biến giá trị trả về thành JSON.
Project chỉ là một bản Spring Initializr bình thường với hai starter. Chú ý tên artifact: trên Spring Boot 4, lựa chọn web của Initializr cho ra spring-boot-starter-webmvc chứ không phải spring-boot-starter-web như bạn quen từ Boot 3.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>Controller là một bean có các request mapping gắn lên. Coi như bài trước đã lo phần đó: component scan tìm ra nó và constructor injection cung cấp các collaborator.
@RestController
@RequestMapping("/api/tasks")
public class TaskController {
private final TaskStore store;
TaskController(TaskStore store) {
this.store = store;
}
@GetMapping("/{id}")
public Task one(@PathVariable long id) {
return store.findById(id).orElseThrow(() -> new TaskNotFoundException(id));
}
}Routing rất dễ chứng minh. Gọi một path không ai map, rồi gọi một path có map nhưng bằng verb nó không hỗ trợ:
curl -i http://localhost:18095/api/task/1
curl -i -X PATCH http://localhost:18095/api/tasks/1HTTP/1.1 404
Content-Type: application/json
{"timestamp":"2026-09-10T08:57:21.833Z","status":404,"error":"Not Found","path":"/api/task/1"}
HTTP/1.1 405
Allow: DELETE, GET, PUT
Content-Type: application/json
{"timestamp":"2026-09-10T08:57:21.845Z","status":405,"error":"Method Not Allowed","path":"/api/tasks/1"}Đây là chỗ nhiều người nhầm. 404 nghĩa là không mapping nào giữ path đó. 405 nghĩa là path khớp còn verb thì không — và Spring chứng minh điều đó bằng cách liệt kê những verb lẽ ra dùng được trong header Allow, thứ mà đặc tả HTTP yêu cầu và bạn được nhận miễn phí. Thứ tự các verb bên trong header đó không có ý nghĩa và không ổn định giữa các lần chạy cùng một ứng dụng; chỉ tập hợp mới quan trọng.
Các annotation mapping
@RestController so với @Controller cộng @ResponseBody
@RestController chính xác là @Controller cộng @ResponseBody gộp lại, áp cho cả class. @ResponseBody mới là phần quan trọng: nó nói giá trị trả về chính là response body, sẽ được một message converter ghi ra, chứ không phải tên một view để resolve thành template. Viết dạng dài:
@Controller
public class LegacyController {
@GetMapping("/api/version")
@ResponseBody
public String version() {
return "v1";
}
}curl -i http://localhost:18095/api/versionHTTP/1.1 200
Content-Type: text/plain;charset=UTF-8
Content-Length: 2
v1Để ý content type. Trả về một String thì bạn nhận text/plain chứ không phải JSON — converter được chọn phụ thuộc vào kiểu trả về, và một String trần được string converter xử lý. Điều này làm nhiều người bất ngờ vì họ nghĩ @RestController nghĩa là "luôn luôn JSON". Không phải; nó nghĩa là "giá trị trả về chính là body".
Bỏ @ResponseBody khỏi method đó thì Spring coi "v1" là tên view và đi tìm một template tên v1. Đó là toàn bộ khác biệt thực tế giữa hai annotation này.
Path, verb và các annotation viết tắt
@RequestMapping là dạng tổng quát và nhận verb qua một thuộc tính. Các annotation viết tắt chỉ là alias đã điền sẵn của nó, và đó mới là thứ bạn nên viết:
| Viết tắt | Tương đương | Dùng khi nào |
|---|---|---|
@GetMapping | @RequestMapping(method = RequestMethod.GET) | đọc một resource hoặc một collection |
@PostMapping | @RequestMapping(method = RequestMethod.POST) | tạo một phần tử mới trong collection |
@PutMapping | @RequestMapping(method = RequestMethod.PUT) | thay thế trọn vẹn một phần tử |
@PatchMapping | @RequestMapping(method = RequestMethod.PATCH) | sửa một phần |
@DeleteMapping | @RequestMapping(method = RequestMethod.DELETE) | xoá một phần tử |
@RequestMapping đặt trên class là tiền tố cho mọi method bên trong, nên @RequestMapping("/api/tasks") trên class cộng @GetMapping("/{id}") trên method cho ra GET /api/tasks/{id}. Cả hai annotation còn nhận consumes và produces để thu hẹp một mapping theo media type — hữu ích khi hai handler dùng chung path và chỉ khác nhau ở thứ chúng chấp nhận.
Bind path, query và header
Bốn annotation kéo các mảnh của một request vào tham số của method, và mỗi cái có một quy tắc riêng cho trường hợp giá trị vắng mặt.
@GetMapping
public List<Task> list(
@RequestParam(required = false) Boolean done,
@RequestParam(defaultValue = "20") int limit) {
return store.findAll(done, limit);
}@GetMapping("/api/echo")
public Map<String, String> echo(
@RequestHeader("X-Request-Id") String requestId,
@RequestHeader(value = "X-Tenant", defaultValue = "public") String tenant) {
return Map.of("requestId", requestId, "tenant", tenant);
}| Annotation | Lấy từ đâu | Khi thiếu giá trị |
|---|---|---|
@PathVariable | một {placeholder} trong path | không thể thiếu; sai kiểu là 400 |
@RequestParam | query string hoặc form data | mặc định bắt buộc, thiếu là 400 — trừ khi có required = false hoặc defaultValue |
@RequestHeader | một request header | cùng quy tắc với @RequestParam |
@RequestBody | request body, đã deserialize | mặc định bắt buộc; body không parse được là 400 |
Cả hai mặc định đều đúng khi chạy thật. Gửi header thì nhận lại echo; bỏ header đi thì nhận 400 mà body mặc định không hề nói thiếu header nào — hãy nhớ chi tiết này, vì sửa nó chính là nội dung phần error handling:
curl -s -H 'X-Request-Id: abc-123' http://localhost:18095/api/echo
curl -s http://localhost:18095/api/echo{"requestId":"abc-123","tenant":"public"}
{"timestamp":"2026-09-10T08:57:21.970Z","status":400,"error":"Bad Request","path":"/api/echo"}Chuyện convert cũng đáng dừng lại một chút. @PathVariable long id nghĩa là Spring phải biến chuỗi "1" thành một long, và khi không làm được thì request không bao giờ tới chỗ bạn. GET /api/tasks/abc trả 400, GET /api/tasks?limit=lots cũng vậy. Khai báo tham số là String rồi tự parse chỉ dời chỗ lỗi vào bên trong method, nơi việc báo lỗi trở thành trách nhiệm của bạn.
Toàn bộ resource và mọi endpoint được gọi thật
Store là một ConcurrentHashMap trong bộ nhớ với một AtomicLong sinh id — phần persistence là chủ đề của bài kế tiếp, và không có gì ở đây phụ thuộc vào nó.
@RestController
@RequestMapping("/api/tasks")
public class TaskController {
private final TaskStore store;
TaskController(TaskStore store) {
this.store = store;
}
@GetMapping
public List<Task> list(
@RequestParam(required = false) Boolean done,
@RequestParam(defaultValue = "20") int limit) {
return store.findAll(done, limit);
}
@GetMapping("/{id}")
public Task one(@PathVariable long id) {
return store.findById(id).orElseThrow(() -> new TaskNotFoundException(id));
}
@PostMapping
public ResponseEntity<Task> create(@Valid @RequestBody CreateTaskRequest body) {
Task created = store.create(body.title(), body.priority());
return ResponseEntity.created(URI.create("/api/tasks/" + created.id())).body(created);
}
@PutMapping("/{id}")
public Task replace(@PathVariable long id, @Valid @RequestBody CreateTaskRequest body) {
return store.replace(id, body.title(), body.priority());
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable long id) {
store.delete(id);
}
}Từng endpoint, chạy trên ứng dụng đang sống:
curl -i -X POST http://localhost:18095/api/tasks \
-H 'Content-Type: application/json' \
-d '{"title":"Write the API layer","priority":2}'HTTP/1.1 201
Location: /api/tasks/1
Content-Type: application/json
Transfer-Encoding: chunked
{"id":1,"title":"Write the API layer","priority":2,"done":false}Sau khi một POST thứ hai tạo ra "Add validation", tới phần đọc:
curl -s http://localhost:18095/api/tasks
curl -s 'http://localhost:18095/api/tasks?done=false&limit=1'
curl -s http://localhost:18095/api/tasks/1[{"id":1,"title":"Write the API layer","priority":2,"done":false},{"id":2,"title":"Add validation","priority":1,"done":false}]
[{"id":1,"title":"Write the API layer","priority":2,"done":false}]
{"id":1,"title":"Write the API layer","priority":2,"done":false}Store từ chối task thứ hai trùng title, không phân biệt hoa thường, bằng cách ném DuplicateTitleException — và đó là 409 chứ không phải 400, vì bản thân request chẳng có gì sai cú pháp:
curl -i -X POST http://localhost:18095/api/tasks \
-H 'Content-Type: application/json' \
-d '{"title":"write the api layer","priority":3}'HTTP/1.1 409
Content-Type: application/json
{"timestamp":"2026-09-10T08:57:21.937Z","status":409,"error":"Conflict","path":"/api/tasks"}Rồi tới replace và delete:
curl -i -X PUT http://localhost:18095/api/tasks/1 \
-H 'Content-Type: application/json' \
-d '{"title":"Write the API layer (v2)","priority":1}'
curl -i -X DELETE http://localhost:18095/api/tasks/2HTTP/1.1 200
Content-Type: application/json
Content-Length: 69
{"id":1,"title":"Write the API layer (v2)","priority":1,"done":false}
HTTP/1.1 204 204 hoàn toàn không có body, nên sau phần header là hết. Gửi đúng lệnh DELETE đó lần thứ hai thì nhận 404, vì chẳng còn gì để xoá — trạng thái thì idempotent, status code thì không, và điều đó là bình thường.
Status code và nơi mỗi con số được quyết định
Đây là phần nhiều bài hướng dẫn làm tệ nhất, thường là bằng cách trả 200 cho mọi thứ rồi nhét kết quả thật vào body. Status code là kiểu trả về của API. Client rẽ nhánh theo nó trước khi parse bất cứ thứ gì.

ResponseEntity: status, header và body
Trả về một object thường thì luôn là 200. ResponseEntity là cách nói khác đi, và cũng là cách đặt header:
@PostMapping
public ResponseEntity<Task> create(@Valid @RequestBody CreateTaskRequest body) {
Task created = store.create(body.title(), body.priority());
return ResponseEntity.created(URI.create("/api/tasks/" + created.id())).body(created);
}ResponseEntity.created(uri) làm hai việc cùng lúc: đặt status thành 201 và ghi header Location. Header đó mới là điểm mấu chốt của 201 — nó cho client biết URL của thứ vừa tồn tại, thông tin mà client không tự dựng được vì id do server cấp. Bản capture ở trên cho thấy Location: /api/tasks/1, dạng tương đối vì đó đúng là URI được truyền vào; một URL tuyệt đối cũng hợp lệ và là thứ bạn sẽ dựng từ request hiện tại khi API nằm sau proxy.
Với delete thì chẳng có gì để trả về, và @ResponseStatus(HttpStatus.NO_CONTENT) trên một method void là cách ngắn nhất để nói 204. ResponseEntity.noContent().build() là cùng một câu trả lời viết dưới dạng giá trị.
@ResponseStatus trên class exception
Một handler không có gì hợp lý để trả về thì nên ném exception. Gắn annotation lên chính kiểu exception là map được nó sang một status mà không cần nối dây gì thêm:
@ResponseStatus(HttpStatus.NOT_FOUND)
public class TaskNotFoundException extends RuntimeException {
public TaskNotFoundException(long id) {
super("No task with id " + id);
}
}
@ResponseStatus(value = HttpStatus.CONFLICT, reason = "A task with that title already exists")
public class DuplicateTitleException extends RuntimeException { }curl -i http://localhost:18095/api/tasks/999HTTP/1.1 404
Content-Type: application/json
{"timestamp":"2026-09-10T08:57:21.929Z","status":404,"error":"Not Found","path":"/api/tasks/999"}409 hoạt động y hệt khi một lệnh create đụng phải title đã tồn tại. Hai điểm về body mặc định đó đáng chú ý ngay bây giờ, vì chúng là lý do tồn tại của hai phần kế tiếp: nó không chứa message của exception, và cũng không chứa reason bạn đã viết trên annotation. Spring đang cẩn thận — message của một exception có thể mang theo thông tin nội bộ mà bạn không muốn công bố — nhưng kết quả là một body chẳng nói với client điều gì mà status line chưa nói.
Một exception bạn chưa map gì cả sẽ thành 500:
curl -i http://localhost:18095/api/boomHTTP/1.1 500
Content-Type: application/json
{"timestamp":"2026-09-10T08:57:21.992Z","status":500,"error":"Internal Server Error","path":"/api/boom"}Stack trace ở lại trên server, đúng chỗ của nó:
ERROR 57195 --- [demo] [io-18095-exec-1] o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: java.lang.IllegalStateException: counter was never initialised] with root cause
java.lang.IllegalStateException: counter was never initialised
at com.example.demo.EchoController.boom(EchoController.java:20) ~[!/:0.0.1-SNAPSHOT]500 là một phát biểu về code của bạn, không phải về request. Nếu client có thể gây ra 500 bằng cách gửi một thứ gì đó, cách sửa đúng là một mã 4xx nêu rõ thứ đó sai ở đâu.
400 so với 422, và những mã đáng thuộc lòng
| Mã | Nghĩa | Trả về khi nào |
|---|---|---|
| 200 OK | thành công kèm body | các lệnh đọc, và update trả về trạng thái mới |
| 201 Created | một resource mới đã tồn tại | POST vừa tạo ra thứ gì đó; nhớ đặt Location |
| 204 No Content | thành công, không có gì để nói | DELETE, và update bạn chọn không echo lại |
| 400 Bad Request | request sai cú pháp | JSON không parse được, param sai kiểu, thiếu header bắt buộc |
| 404 Not Found | không có resource đó | id không tồn tại, hoặc không mapping nào giữ path |
| 405 Method Not Allowed | sai verb cho path đó | Spring trả giùm bạn, kèm Allow |
| 409 Conflict | request hợp lệ nhưng bất khả thi với trạng thái hiện tại | trùng key, lệch version, xoá thứ đang được dùng |
| 415 Unsupported Media Type | không đọc được thứ được gửi lên | Content-Type: text/plain trên một endpoint JSON |
| 422 Unprocessable Content | cú pháp ổn, ngữ nghĩa sai | vi phạm constraint trên một body đúng dạng |
| 500 Internal Server Error | bug của bạn | không bao giờ được chọn có chủ ý |
Chuyện 400 so với 422 là thứ đáng dứt điểm trong đầu bạn. RFC 9110 định nghĩa 400 là lỗi client tổng quát còn 422 là "request đúng dạng nhưng không xử lý được", khớp gọn gàng với ranh giới giữa "JSON này không parse được" và "JSON này parse được và nói priority là 9 trong khi tối đa là 5". Mặc định của Spring cho một @Valid thất bại là 400. Cả hai mã đều bảo vệ được; thứ không bảo vệ được là dùng chúng thiếu nhất quán trong cùng một API. Bài này trả 422 cho vi phạm constraint và 400 cho mọi thứ Jackson không đọc nổi, và ghi rõ lựa chọn đó — đó mới là yêu cầu thật sự.
Validate request body
Constraint được đặt trên kiểu của request, ở đây là một record. @Valid trên tham số mới là thứ khiến chúng chạy.
public record CreateTaskRequest(
@NotBlank @Size(max = 60) String title,
@Min(1) @Max(5) int priority) {}@PostMapping
public ResponseEntity<Task> create(@Valid @RequestBody CreateTaskRequest body) { ... }Không có @Valid, các annotation chỉ là trang trí vô hại — object được dựng và giao cho bạn mà không ai kiểm tra. Có nó, Bean Validation chạy mọi constraint, gom mọi violation, và ném MethodArgumentNotValidException trước khi method của bạn được vào. Chú ý thứ tự: Jackson dựng object trước, nên một body không parse được sẽ không bao giờ chạm tới constraint.
Dưới đây là cùng một violation được mô tả hai lần — lần đầu do Spring, bạn không viết dòng nào, lần sau do một advice.

curl -i -X POST http://localhost:18095/api/tasks \
-H 'Content-Type: application/json' \
-d '{"title":"","priority":9}'HTTP/1.1 400
Content-Type: application/json
{"timestamp":"2026-09-10T08:57:21.983Z","status":400,"error":"Bad Request","path":"/api/tasks"}Đó là mặc định, và nó không dùng được. Server biết hai điều — title bị rỗng và priority vượt quá 5 — và không nói với client điều nào cả. Client nhận response này không thể highlight một ô form, không thể log ra thứ gì hành động được, và không làm gì hơn ngoài hiện lên "có gì đó sai". Cách sửa là một class.
Error handling với @RestControllerAdvice và ProblemDetail
@RestControllerAdvice là một bean mà các method @ExceptionHandler của nó áp cho mọi controller trong ứng dụng. Kế thừa ResponseEntityExceptionHandler còn cho bạn sẵn các exception của chính Spring — body không đọc được, thiếu header, sai verb — đã được map, nên bạn chỉ override những cái mình quan tâm.
ProblemDetail là bản hiện thực RFC 9457 của Spring, shape chuẩn cho một HTTP error body: type, title, status, detail, instance, cộng thêm bất kỳ member nào bạn thêm vào. Nó nằm sẵn trong framework; không phải thêm gì vào build.
@RestControllerAdvice
public class ApiExceptionHandler extends ResponseEntityExceptionHandler {
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(
MethodArgumentNotValidException ex, HttpHeaders headers,
HttpStatusCode status, WebRequest request) {
List<Violation> errors = ex.getBindingResult().getFieldErrors().stream()
.map(f -> new Violation(f.getField(),
String.valueOf(f.getDefaultMessage()),
String.valueOf(f.getRejectedValue())))
.toList();
ProblemDetail body = ProblemDetail.forStatusAndDetail(
HttpStatus.UNPROCESSABLE_ENTITY, "The request body failed validation");
body.setTitle("Validation failed");
body.setType(URI.create("https://api.example.com/problems/validation-failed"));
body.setProperty("errors", errors);
return handleExceptionInternal(ex, body, headers, HttpStatus.UNPROCESSABLE_ENTITY, request);
}
@ExceptionHandler(TaskNotFoundException.class)
ProblemDetail handleNotFound(TaskNotFoundException ex) {
ProblemDetail body = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
body.setTitle("Task not found");
body.setType(URI.create("https://api.example.com/problems/task-not-found"));
body.setProperty("taskId", ex.id());
return body;
}
record Violation(String field, String message, String rejected) {}
}Vẫn request đó, trên chính ứng dụng đó, chỉ thêm đúng một class:
HTTP/1.1 422
Content-Type: application/problem+json
{
"detail": "The request body failed validation",
"instance": "/api/tasks",
"status": 422,
"title": "Validation failed",
"type": "https://api.example.com/problems/validation-failed",
"errors": [
{
"field": "title",
"message": "must not be blank",
"rejected": ""
},
{
"field": "priority",
"message": "must be less than or equal to 5",
"rejected": "9"
}
]
}must not be blank và must be less than or equal to 5 là message mặc định của Hibernate Validator, nguyên văn — bạn không viết chúng và cũng không cần viết. Content type là application/problem+json, thứ báo cho client biết đây là một tài liệu lỗi chứ không phải một resource. instance được điền bằng path của request một cách tự động.
Một lưu ý phát hiện được khi chạy đi chạy lại nhiều lần: thứ tự các entry trong errors không ổn định. Bean Validation trả về một Set các violation, nên cùng một request có thể báo title trước ở lần này và priority trước ở lần sau. Hãy sort trước khi xuất ra nếu thứ tự quan trọng với bạn, và đừng bao giờ viết test khẳng định trên errors[0].
Handler cho 404 và 409 cho ra cùng một shape, kèm cả member tự thêm:
{"detail":"No task with id 999","instance":"/api/tasks/999","status":404,"title":"Task not found","type":"https://api.example.com/problems/task-not-found","taskId":999}
{"detail":"Another task already uses that title","instance":"/api/tasks","status":409,"title":"Duplicate title","type":"https://api.example.com/problems/duplicate-title"}Các lỗi của chính Spring đi qua những handler kế thừa, giờ cũng là problem+json và đã có một detail dùng được:
{"detail":"Failed to read request","instance":"/api/tasks","status":400,"title":"Bad Request"}
{"detail":"Method 'PATCH' is not supported.","instance":"/api/tasks/1","status":405,"title":"Method Not Allowed"}
{"detail":"Required header 'X-Request-Id' is not present.","instance":"/api/echo","status":400,"title":"Bad Request"}⚠️ Một exception không được xử lý vẫn cho ra body 500 trơn, không phải
ProblemDetail—ResponseEntityExceptionHandlercố ý không bắtException. Chỉ thêm@ExceptionHandler(Exception.class)nếu bạn thật sự muốn, và nếu thêm thì hãy log exception thật rồi trả về một detail chung chung; đừng bao giờ nhétex.getMessage()của một throwable không rõ nguồn gốc vào response.
Nếu bạn muốn body RFC 9457 cho các exception của Spring mà không viết class nào cả, một property là đủ:
spring.mvc.problemdetails.enabled=trueBật nó lên và không có advice nào, một verb sai sẽ trả về application/problem+json với {"detail":"Method 'PATCH' is not supported.","instance":"/api/tasks/1","status":405,"title":"Method Not Allowed"}. Đây là một mặc định nên bật; tuy nhiên nó không làm gì cho các kiểu exception của riêng bạn, và đó là lý do advice vẫn có chỗ đứng.
Content negotiation và các mặc định JSON
Content-Type mô tả body bạn đang gửi. Accept mô tả những body bạn sẵn sàng nhận. Spring chọn message converter dựa trên cả hai, và trả về một status code khi không chọn được.
curl -i -X POST http://localhost:18095/api/tasks -H 'Content-Type: text/plain' -d 'title=x'
curl -i -H 'Accept: application/xml' http://localhost:18095/api/tasks/1HTTP/1.1 415
Accept: application/json, application/*+json
{"timestamp":"2026-09-10T08:57:22.007Z","status":415,"error":"Unsupported Media Type","path":"/api/tasks"}
HTTP/1.1 406
Accept: application/json, application/*+json
Content-Length: 0Cả hai response đều mang header Accept liệt kê những gì endpoint này thật sự xử lý được — application/json và họ +json, vì Jackson là converter duy nhất trên classpath cho object. Thêm một XML converter vào build thì 406 kia trở thành một tài liệu XML; đó chính là ý nghĩa thực tế của "content negotiation".
Bản thân việc serialize là việc của Jackson và một bài trước trong series đã nói về nó. Thứ đáng biết ở đây là những mặc định do Spring Boot đặt, vì chúng không phải mặc định của Jackson:
curl -i -X POST http://localhost:18095/api/tasks -H 'Content-Type: application/json' \
-d '{"title":"Unknown field demo","priority":3,"colour":"red","nested":{"a":1}}'HTTP/1.1 201
Location: /api/tasks/3
{"id":3,"title":"Unknown field demo","priority":3,"done":false}Field lạ bị bỏ qua trong im lặng. Spring Boot tắt FAIL_ON_UNKNOWN_PROPERTIES của Jackson, ngược với mặc định của chính Jackson, với lý lẽ rằng một client gửi thừa field không nên vỡ khi API của bạn thêm field sau này. Bật lại thì cùng request đó thành 400:
spring.jackson.deserialization.fail-on-unknown-properties=trueField thiếu lại là chuyện khác, và đây mới là cái bẫy. Bỏ hẳn priority ra thì record được dựng với 0 cho int — Jackson không phân biệt được "vắng mặt" với "bằng không" đối với một primitive. Giá trị đó sau đó không qua nổi @Min(1) và request bị từ chối, tức là kết quả đúng, nhưng đúng chỉ vì tình cờ có một constraint chặn lại. Nếu khoảng hợp lệ có chứa số không, một field vắng mặt sẽ được lặng lẽ chấp nhận là không. Hãy dùng kiểu boxed khi sự vắng mặt có ý nghĩa, và để @NotNull nói ra điều đó.
Test API với @WebMvcTest
@WebMvcTest chỉ khởi động tầng web — controller, converter, advice — và bỏ qua service của bạn, thứ bạn cung cấp dưới dạng mock. MockMvc sau đó đẩy request qua trọn vẹn pipeline DispatcherServlet mà không mở socket nào, nên routing, binding, validation và advice đều chạy thật.
@WebMvcTest(TaskController.class)
class TaskControllerTest {
@Autowired
MockMvc mvc;
@MockitoBean
TaskStore store;
@Test
void createReturns201WithLocation() throws Exception {
given(store.create("Write the API layer", 2))
.willReturn(new Task(7, "Write the API layer", 2, false));
mvc.perform(post("/api/tasks")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"title\":\"Write the API layer\",\"priority\":2}"))
.andExpect(status().isCreated())
.andExpect(header().string("Location", "/api/tasks/7"))
.andExpect(jsonPath("$.id").value(7));
}
@Test
void invalidBodyIsUnprocessableEntity() throws Exception {
mvc.perform(post("/api/tasks")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"title\":\"\",\"priority\":9}"))
.andExpect(status().isUnprocessableEntity())
.andExpect(jsonPath("$.errors.length()").value(2))
.andExpect(jsonPath("$.errors[*].field",
containsInAnyOrder("title", "priority")));
}
}Hai chi tiết của Spring Boot 4 rất dễ vấp. @WebMvcTest giờ nằm ở org.springframework.boot.webmvc.test.autoconfigure, theo đúng đợt module hoá đã đổi tên starter. Và @MockBean đã bị bỏ: thay thế nó là @MockitoBean trong org.springframework.test.context.bean.override.mockito.
Bốn test, chạy bằng Maven wrapper:
[INFO] Running com.example.demo.TaskControllerTest
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESSTest thứ hai là cái đáng đồng tiền nhất. Nó không hề đụng tới TaskStore — request chưa bao giờ đi xa tới đó — nên nó là một khẳng định trực tiếp về contract validation và về advice của bạn, và nó fail ngay khoảnh khắc có người xoá mất @Valid.
FAQ
REST API có bao giờ nên trả 200 kèm lỗi bên trong body không?
Không. Đó là sai lầm phổ biến nhất ở mảng này, và nó vô hiệu hoá mọi client tổng quát, proxy, retry policy và luật monitoring bạn sẽ đặt trước API, vì tất cả đều đọc status line chứ không đọc gì khác. 200 nghĩa là đã xong. Nếu chưa xong thì hãy nói vậy bằng một mã 4xx hoặc 5xx và đặt chi tiết vào một body ProblemDetail. Ngoại lệ gần giống duy nhất bảo vệ được là endpoint xử lý hàng loạt, nơi một phần thành công và một phần thất bại, mà ngay cả khi đó câu trả lời trung thực vẫn là 207 hoặc một 200 mà body rõ ràng là danh sách kết quả từng phần tử, chứ không phải một lỗi bị giấu.
Khác nhau giữa 400 và 422 là gì?
400 nghĩa là server không hiểu nổi request với tư cách một request — JSON không parse được, một query param không phải số, thiếu một header bắt buộc. 422 nghĩa là request hoàn toàn đúng dạng và server đã hiểu, nhưng nội dung của nó không chấp nhận được — title rỗng, priority là 9 trong khi tối đa là 5. Mặc định của Spring cho @Valid thất bại là 400; bài này override thành 422 trong advice. Chọn cái nào cũng ổn miễn là bạn chọn một và áp dụng ở mọi nơi, vì giá trị đối với client nằm ở tính nhất quán chứ không ở con số.
Có cần @RequestBody trên tham số không?
Có, với một JSON body. Thiếu nó Spring coi tham số là model attribute và điền nó từ query param cùng form field, hoàn toàn bỏ qua body — JSON bạn gửi lên không bao giờ được đọc. Bỏ nó khỏi handler create rồi POST một body hoàn toàn hợp lệ đã được kiểm chứng là fail với Failed to convert value of type 'null' to required type 'int' ở priority, vì query string chẳng có gì để bind mà một record thì không thể dựng nửa vời. Nếu dùng một class với các field nullable thay cho record thì bạn gặp phiên bản khó hiểu hơn của cùng bug đó: một object toàn null và không có lỗi nào cả. @RequestBody chính là thứ đưa tham số đi qua một message converter.
Vì sao POST của tôi trả 200 thay vì 201?
Vì một handler trả về object thường thì luôn trả 200 — 201 không bao giờ được suy ra từ việc method được map vào POST. Hãy trả ResponseEntity.created(uri).body(created), thứ đặt status và header Location cùng một lúc. Location mới là phần có giá trị: nó đưa cho client URL của thứ vừa tồn tại.
405 có phải thứ tôi phải tự implement không?
Không. Spring trả nó từ bảng mapping trước khi code của bạn chạy, và kèm luôn header Allow liệt kê các verb đã đăng ký cho path đó. Nếu bạn thấy mình đang viết một handler kiểm tra verb rồi trả 405, thì mapping đã sai ở đâu đó — hay gặp nhất là một @RequestMapping không có thuộc tính method, khớp với mọi verb nên chẳng còn gì cho Spring từ chối.
@Valid thật sự làm gì, và khi nào nó không chạy?
Nó đánh dấu một tham số cho Bean Validation, thứ sau đó đánh giá mọi constraint jakarta.validation trên object đó và ném MethodArgumentNotValidException nếu có cái nào fail. Nó không chạy khi bạn quên gõ — các constraint trở thành annotation vô hại và object đi thẳng qua mà không ai kiểm tra, đó là lý do một bug validation trong Spring rất hay hoá ra chỉ là thiếu một từ. Nó cũng không chạy trên một body mà Jackson không dựng nổi ngay từ đầu; lỗi đó là 400 và xảy ra sớm hơn. Và để validate object lồng nhau bạn cần @Valid trên cả field lồng bên trong, vì nó không tự đi xuống.
Có cần spring-boot-starter-validation như một dependency riêng không?
Có, và trên Spring Boot 4 bạn biết ngay lập tức: bỏ nó ra rồi build lại thì fail ngay lúc compile với package jakarta.validation.constraints does not exist, vì web starter hoàn toàn không đưa Bean Validation API lên classpath. Tuy vậy phiên bản im lặng của lỗi này vẫn tồn tại và đáng biết mặt — chạy các class đã compile sẵn với API trên một classpath không có implementation, thì @Valid đơn giản là không làm gì. Đã kiểm chứng: cùng một body sai vốn trả 422 khi có starter thì được chấp nhận với 201 và {"id":1,"title":"","priority":9,"done":false} khi không có. Không lỗi, không cảnh báo. Nếu constraint có vẻ không có tác dụng gì, hãy kiểm tra dependency đó trước tiên.
Kết luận
Một controller method thì nhỏ; những quyết định quanh nó thì không. Hãy đặt tên resource bằng URL dạng danh từ và để verb làm operation. Hãy trả về status code đúng sự thật — 201 kèm Location khi bạn vừa tạo ra thứ gì đó, 204 khi không có gì để nói, 404 khi id không tồn tại, 409 khi request ổn nhưng trạng thái không cho phép, 422 khi body parse được mà nội dung sai. Và hãy gửi một error body mà client hành động được: ProblemDetail nằm sẵn trong framework, tốn đúng một @RestControllerAdvice, và nó biến "Bad Request" thành danh sách các field đã fail cùng lý do.
Mọi thứ trình bày ở đây chạy trên một Map trong bộ nhớ, và điều đó là có chủ ý — tầng HTTP không liên quan gì tới chỗ dữ liệu nằm, và trộn hai thứ đó lại là cách một bài về REST biến thành một bài về database. Sự tách bạch đó cũng là lý do code phía trên sẽ gần như không đổi khi store thay đổi.
Và đó đúng là chỗ bài kế tiếp đi tới: Spring Data JPA, cùng chuyện một repository interface không có implementation lại nói chuyện được với một database thật.