Command Palette

Search for a command to run...

[Advanced Java] Xây dựng REST API với Spring Boot

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.

Ba HTTP verb đi vào một khối REST API và ba status code đi ra

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ộcNghĩ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 interfaceVerb chính là operation. GET đọc, POST tạo, PUT thay thế, DELETE xoá. Không có field action nào trong body.
StatelessMỗ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. PUTDELETE 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.

Đường đi của một request qua DispatcherServlet, với 404 và 405 rẽ ra từ bước routing

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.

XML
<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.

Java
@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ợ:

Bash
curl -i http://localhost:18095/api/task/1
curl -i -X PATCH http://localhost:18095/api/tasks/1
Text
HTTP/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:

Java
@Controller
public class LegacyController {
 
    @GetMapping("/api/version")
    @ResponseBody
    public String version() {
        return "v1";
    }
}
Bash
curl -i http://localhost:18095/api/version
Text
HTTP/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ắtTương đươngDù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 consumesproduces để 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.

Java
@GetMapping
public List<Task> list(
        @RequestParam(required = false) Boolean done,
        @RequestParam(defaultValue = "20") int limit) {
    return store.findAll(done, limit);
}
Java
@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);
}
AnnotationLấy từ đâuKhi thiếu giá trị
@PathVariablemột {placeholder} trong pathkhông thể thiếu; sai kiểu là 400
@RequestParamquery string hoặc form datamặc định bắt buộc, thiếu là 400 — trừ khi có required = false hoặc defaultValue
@RequestHeadermột request headercùng quy tắc với @RequestParam
@RequestBodyrequest body, đã deserializemặ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:

Bash
curl -s -H 'X-Request-Id: abc-123' http://localhost:18095/api/echo
curl -s http://localhost:18095/api/echo
JSON
{"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ó.

Java
@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:

Bash
curl -i -X POST http://localhost:18095/api/tasks \
  -H 'Content-Type: application/json' \
  -d '{"title":"Write the API layer","priority":2}'
Text
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:

Bash
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
JSON
[{"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:

Bash
curl -i -X POST http://localhost:18095/api/tasks \
  -H 'Content-Type: application/json' \
  -d '{"title":"write the api layer","priority":3}'
Text
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:

Bash
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/2
Text
HTTP/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ì.

Ba luồng — giá trị trả về, exception đã map và Spring trả lời thay bạn — cùng đổ về một status line

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:

Java
@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:

Java
@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 { }
Bash
curl -i http://localhost:18095/api/tasks/999
Text
HTTP/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:

Bash
curl -i http://localhost:18095/api/boom
Text
HTTP/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ó:

Text
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

NghĩaTrả về khi nào
200 OKthành công kèm bodycác lệnh đọc, và update trả về trạng thái mới
201 Createdmột resource mới đã tồn tạiPOST vừa tạo ra thứ gì đó; nhớ đặt Location
204 No Contentthành công, không có gì để nóiDELETE, và update bạn chọn không echo lại
400 Bad Requestrequest sai cú phápJSON không parse được, param sai kiểu, thiếu header bắt buộc
404 Not Foundkhông có resource đóid không tồn tại, hoặc không mapping nào giữ path
405 Method Not Allowedsai verb cho path đóSpring trả giùm bạn, kèm Allow
409 Conflictrequest hợp lệ nhưng bất khả thi với trạng thái hiện tạitrùng key, lệch version, xoá thứ đang được dùng
415 Unsupported Media Typekhông đọc được thứ được gửi lênContent-Type: text/plain trên một endpoint JSON
422 Unprocessable Contentcú pháp ổn, ngữ nghĩa saivi phạm constraint trên một body đúng dạng
500 Internal Server Errorbug của bạnkhô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.

Java
public record CreateTaskRequest(
        @NotBlank @Size(max = 60) String title,
        @Min(1) @Max(5) int priority) {}
Java
@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.

Pipeline validation rẽ nhánh thành body 400 mặc định mỏng dính và body problem+json 422 liệt kê từng field

Bash
curl -i -X POST http://localhost:18095/api/tasks \
  -H 'Content-Type: application/json' \
  -d '{"title":"","priority":9}'
Text
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.

Java
@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:

Text
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 blankmust 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:

JSON
{"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:

JSON
{"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 ProblemDetailResponseEntityExceptionHandler cố ý không bắt Exception. 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ét ex.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à đủ:

Properties
spring.mvc.problemdetails.enabled=true

Bậ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.

Bash
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/1
Text
HTTP/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: 0

Cả 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:

Bash
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}}'
Text
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:

Properties
spring.jackson.deserialization.fail-on-unknown-properties=true

Field 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.

Java
@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:

Text
[INFO] Running com.example.demo.TaskControllerTest
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS

Test 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{"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.

Bài viết liên quan

[Advanced Java] Xây dựng dự án thực tế: REST API quản lý bán hàng với Spring Boot

Một REST API quản lý bán hàng hoàn chỉnh dựng từ đầu tới cuối trên Spring Boot 4.1.1, Hibernate 7.4.5 và Java 21: bốn JPA entity với owning side rõ ràng, schema sinh ra, service giữ toàn bộ rule, controller dùng DTO và header Location, một @RestControllerAdvice map bốn exception thành 400, 404 và 409, cùng một phiên curl đầy đủ có cả rollback transaction chứng minh bằng log SQL.

[Advanced Java] Unit Testing trong Java với JUnit 5

Unit testing trong Java với JUnit 5.11.3 trên OpenJDK 21: kiến trúc Platform, Jupiter và Vintage, các callback lifecycle, mỗi test method một instance mới, message báo lỗi thật của từng assertion, assertThrows và assertAll, DisplayName, Nested, Disabled và Tag, parameterized test với đủ các argument source, assumption so với assertion, và những thói quen khiến một test trở nên vô dụng.

[Advanced Java] Kết nối Spring Boot với Database bằng Spring Data JPA

Spring Data JPA trên Spring Boot 4.1.1 và Hibernate 7.4.5: JDBC, JPA, Hibernate và repository xếp chồng thế nào, entity mapping cùng DDL sinh ra, derived query method, vấn đề N+1 đếm bằng log SQL thật, LazyInitializationException, @Transactional dựa trên proxy, dirty checking và flush khác commit.

[Advanced Java] JDBC trong Java: Kết nối và thao tác Database

JDBC trong Java trên OpenJDK 21 với một database SQLite thật: kiến trúc driver phía sau DriverManager, try-with-resources cho Connection, Statement và ResultSet, một màn SQL injection chạy thật mà PreparedStatement chặn được, ResultSet như một cursor, transaction với commit, rollback và Savepoint, batch update, getGeneratedKeys và cách đọc một SQLException.