Hình dung RestController như một người phiên dịch đứng giữa hai bên nói hai thứ tiếng: bên ngoài nói HTTP — một URL, một method, vài header, một thân JSON; bên trong nói Java — lời gọi method và đối tượng. Bài này về lớp mỏng làm đúng một việc đó: bóc phong bì request ra thành tham số, rồi gói kết quả lại thành response.

Ánh xạ cơ bản

@RestController
public class DonController {

    @GetMapping("/don/{id}")
    Ket lay(@PathVariable String id, @RequestParam(defaultValue = "1") int n) {
        return new Ket(id, n);
    }
}
  GET /don/DH-9?n=5  ->  {"ma":"DH-9","soLuong":5}

@PathVariable lấy phần trong {} — số phòng ghi trên phong bì; @RequestParam lấy query string — mẩu ghi chú kẹp kèm. Cả hai tự chuyển kiểu — n là int và Spring parse hộ.

Năm chú thích theo method: @GetMapping, @PostMapping, @PutMapping, @PatchMapping, @DeleteMapping. Chúng là dạng rút gọn của @RequestMapping(method = ...).

Gom tiền tố chung ở mức lớp:

@RestController
@RequestMapping("/api/v1/don")
class DonController {
    @GetMapping("/{id}") ...       // /api/v1/don/{id}
}

Tham số tuỳ chọn

@RequestParam(required = false) String loc          // null nếu thiếu
@RequestParam(defaultValue = "1") int trang         // 1 nếu thiếu
Optional<String> loc                                 // Optional.empty()

@RequestParam mặc định là bắt buộc — thiếu thì Spring trả 400 trước khi vào method của bạn. Đây là hành vi đúng và bạn không cần kiểm null.

Request body

@PostMapping("/don")
ResponseEntity<Ket> tao(@Valid @RequestBody Don d) { ... }

@RequestBody chuyển JSON thành đối tượng qua Jackson — lá thư bên trong phong bì, dịch sang tiếng Java. Với record, không cần chú thích gì thêm — bài 15 sẽ nói kỹ.

ResponseEntity khi cần kiểm soát

return ResponseEntity.status(HttpStatus.CREATED)
        .header("Location", "/don/" + d.ma())
        .body(new Ket(d.ma(), d.soLuong()));
  HTTP/1.1 201
  Location: /don/DH-1
  Content-Type: application/json

Trả về đối tượng trần thì luôn là 200. Cần mã khác hoặc header thì dùng ResponseEntity — đây là lúc bạn tự đóng dấu lên phong bì hồi đáp.

Hoặc @ResponseStatus(HttpStatus.CREATED) trên method nếu mã cố định và không cần header — gọn hơn.

Với 201, Location là header nên có: nó cho client biết tài nguyên vừa tạo nằm ở đâu.

Spring đã ánh xạ lỗi sẵn

  sai Content-Type       -> HTTP/1.1 415
  sai kiểu tham số (n=abc) -> HTTP/1.1 400

Đây là điểm tôi muốn nhấn mạnh. Spring đã ánh xạ đúng mã cho các lỗi chuẩn — người phiên dịch đã biết sẵn câu từ chối đúng cho từng kiểu hiểu lầm:

HttpMediaTypeNotSupportedException → 415 MethodArgumentTypeMismatchException → 400 HttpRequestMethodNotSupportedException → 405 MissingServletRequestParameterException → 400 HttpMessageNotReadableException (JSON hỏng) → 400

Đừng bắt Exception trong @ControllerAdvice rồi trả 500 cho tất cả. Bài 60 sê-ri Java đã đo được năm trường hợp bị đôn nhầm lên 500 vì lý do này. Bài mai sẽ nói cách xử lý đúng.

Ánh xạ nội dung

@GetMapping(value = "/don/{id}", produces = MediaType.APPLICATION_JSON_VALUE)
@PostMapping(value = "/don", consumes = MediaType.APPLICATION_JSON_VALUE)

produces và consumes tham gia vào việc chọn handler: hai method cùng URL nhưng khác produces thì Spring chọn theo header Accept của client.

Ít dùng trong API JSON thuần, nhưng hữu ích khi cần trả cả JSON lẫn CSV từ cùng một endpoint.

Các chú thích tham số khác

@RequestHeader("X-Request-ID") String ma
@CookieValue("phien") String phien
@RequestPart("tep") MultipartFile tep       // bài 19
HttpServletRequest req                       // truy cập thô, hạn chế dùng

HttpServletRequest dùng được nhưng nó khoá method của bạn vào Servlet API và làm test khó hơn. Chỉ dùng khi thật sự cần.

Trả về gì

Ket lay()                    // 200 + JSON
ResponseEntity<Ket>          // kiểm soát đầy đủ
void                         // 200, thân rỗng
List<Ket>                    // 200 + mảng JSON
ResponseEntity<Void>         // ví dụ 204 No Content cho DELETE

Với DELETE thành công, 204 No Content đúng hơn 200 với thân rỗng:

@DeleteMapping("/{id}")
ResponseEntity<Void> xoa(@PathVariable String id) {
    svc.xoa(id);
    return ResponseEntity.noContent().build();
}

Controller nên mỏng

Đây là nguyên tắc thiết kế quan trọng nhất của bài:

@PostMapping("/don")
ResponseEntity<KetQua> tao(@Valid @RequestBody TaoDonRequest r) {
    var don = svc.tao(r.toCommand());          // nghiệp vụ ở SERVICE
    return ResponseEntity.status(201).body(KetQua.tu(don));
}

Controller chỉ làm bốn việc: nhận và kiểm dữ liệu, gọi service, chuyển kết quả thành DTO, đặt mã HTTP. Không logic nghiệp vụ, không truy vấn CSDL. Người phiên dịch chỉ dịch và dẫn đường — không tự ra quyết định kinh doanh thay cho người bên trong.

Và dùng DTO riêng cho request và response, đừng lộ entity JPA ra API. Bài 24 sẽ nói vì sao — entity có quan hệ lazy và việc serialize nó dẫn thẳng tới LazyInitializationException cùng rò rỉ dữ liệu.

Nếu chỉ thử một thứ sau bài này, thử xem người phiên dịch của bạn có đang đôn mọi lời than lên "500" không, trong ba mươi giây:

curl -si -X POST localhost:8080/don -d "abc" | head -1
curl -si "localhost:8080/don/X?n=abc" | head -1

Nếu cả hai trả 500 thay vì 415 và 400, bạn đang có một @ExceptionHandler(Exception.class) nuốt hết — và bài mai là về cách sửa.

Mẫu số chung

Framework web nào cũng mọc ra đúng lớp phiên dịch mỏng này — controller, handler, view — để bóc dạng HTTP trên đường dây (path, query, header, thân) thành một lời gọi thuần ngôn ngữ rồi gói kết quả lại (mã, header, thân): route handler của Express trong Node, hàm view của Flask/FastAPI trong Python, http.HandlerFunc của Go, controller của ASP.NET đều làm đúng vậy. Và quy tắc bền vững là: lớp này là một bộ chuyển tiếp ở ranh giới — nó dịch và dẫn đường, không ôm logic nghiệp vụ (kiến trúc cổng-và-bộ-chuyển-tiếp, "controller mỏng, service dày"); logic nhồi vào handler là bị xích vào tầng vận chuyển và không test được nếu không dựng cả HTTP lên. Rìa dịch, lõi quyết.

Điều thứ hai: HTTP có cả một bộ từ vựng mang nghĩa — 400 khác 415 khác 405 khác 404 khác 409 — và framework đã ánh xạ các thất bại chuẩn sang đúng từ; bắt tất cả rồi trả 500 là vứt sạch cái nghĩa đó, làm client hiểu lầm và chôn mất nguyên nhân. Cùng một bài học với "đừng đôn một ngoại lệ vốn đã biết mã của nó" và cái bẫy phá-trong-im-lặng ở bài đánh phiên bản: giữ lấy mã cụ thể nhất, xử lý các thất bại đã biết cho chính xác, và để dành 500 cho đúng nghĩa "phía tôi thật sự hỏng, không phải lỗi của bạn".

Ngày mai: ràng buộc dữ liệu và validation.