@RestController & Request Mapping — controller annotation-driven
@RestController = @Controller + @ResponseBody. Bài này giải thích tại sao @ResponseBody tách ra thành meta-annotation, cách @RequestMapping + 7 shortcut map URL đến handler method, và PathPattern syntax Spring 6.
TL;DR: @RestController là meta-annotation gộp @Controller + @ResponseBody — áp @ResponseBody cho mọi method trong class, loại bỏ duplicate annotation từng method. @RequestMapping là annotation gốc 7 attribute (value, method, consumes, produces, headers, params, name); 5 shortcut @GetMapping/@PostMapping/@PutMapping/@DeleteMapping/@PatchMapping thu gọn cho 99% case. Spring 6 dùng PathPatternParser thay AntPathMatcher — hỗ trợ path variable {id}, regex constraint {id:\d+}, capture-remaining {*path}. Pitfall cốt lõi: dùng @Controller thay @RestController cho REST API khiến return value bị lookup ViewResolver thay serialize JSON — 500 tại runtime.
Bài trước (URL routing & DispatcherServlet) bóc cơ sở hạ tầng DispatcherServlet + HandlerMapping. Bài này đi xuống một tầng ứng dụng: annotation mà bạn viết để khai báo controller và route.
1. Lịch sử controller annotation — 3 generation
Hiểu lịch sử giải thích vì sao @RestController tồn tại và tại sao nó không thể đơn giản hóa hơn nữa.
Generation 1 — Spring 1.x/2.x (XML mapping)
<bean name="/orders" class="com.olhub.OrderController"/>
Class implement interface Controller với method duy nhất handleRequest(HttpServletRequest, HttpServletResponse). Mapping URL trong XML — mọi thứ đều verbose. Legacy code 2003-2009; không còn gặp trong code mới.
Generation 2 — Spring 2.5+ (@Controller + @ResponseBody)
@Controller
@RequestMapping("/api/orders")
public class OrderController {
@RequestMapping(method = RequestMethod.GET, value = "/{id}")
@ResponseBody
public OrderDto getOrder(@PathVariable Long id) { ... }
@RequestMapping(method = RequestMethod.POST)
@ResponseBody
public OrderDto create(@RequestBody OrderRequest req) { ... }
}
Cải thiện lớn: annotation-driven, không XML. Nhưng pain point rõ ràng: @ResponseBody phải lặp ở mỗi method. Nếu class có 10 method, phải viết 10 lần.
Generation 3 — Spring 4+ (@RestController + shortcut)
@RestController
@RequestMapping("/api/orders")
public class OrderController {
@GetMapping("/{id}")
public OrderDto getOrder(@PathVariable Long id) { ... }
@PostMapping
public OrderDto create(@RequestBody OrderRequest req) { ... }
}
Đây là syntax chuẩn 2026. Hai thay đổi trọng tâm:
@RestControllergộp@ResponseBodyáp cho toàn bộ class — không còn lặp.- Shortcut
@GetMapping,@PostMapping,... thay thế@RequestMapping(method = ...)dài dòng.
2. @RestController — tại sao cần @ResponseBody
Để hiểu @RestController, cần hiểu trước tại sao @ResponseBody tồn tại và bản chất của nó.
@ResponseBody làm gì
Khi một handler method trả về object Java (vd OrderDto), Spring MVC có hai lựa chọn:
- Không có
@ResponseBody: Spring xem return value là tên view — tìm templateOrderDto.html,OrderDto.jspquaViewResolver. Đây là hành vi mặc định cho server-side rendering (Thymeleaf, JSP). - Có
@ResponseBody: Spring bypass ViewResolver, gọiHttpMessageConverter(thường là Jackson) để serialize object thành JSON/XML rồi viết thẳng vào HTTP response body.
Tóm gọn: @ResponseBody = "serialize trực tiếp vào body, không qua view".
Tại sao chọn serialize trực tiếp
Trước khi REST API phổ biến (pre-2010), Spring MVC dùng chủ yếu để render HTML qua ViewResolver. @ResponseBody được thêm vào sau khi developer cần trả JSON/XML cho AJAX và mobile client — một "escape hatch" khỏi View layer.
Khi REST API trở thành default (post-2015), cần thiết kế annotation thể hiện intent toàn class: "class này là REST controller — mọi method đều serialize". Đó là lý do @RestController ra đời ở Spring 4.
@RestController source
// org/springframework/web/bind/annotation/RestController.java (rut gon)
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Controller
@ResponseBody
public @interface RestController {
String value() default "";
}
@RestController là meta-annotation — annotation tổng hợp từ 2 annotation khác. Spring xử lý meta-annotation theo chain:
@RestControllerkế thừa@Controller, vốn kế thừa@Component— nhờ vậy component scan tìm thấy class và đăng ký bean.@RestControllerkế thừa@ResponseBody, và annotation này áp cho mọi method trong class.
Không có "magic" nào khác. Bổ sung @ResponseBody một lần trên class thay vì N lần trên N method — đó là toàn bộ giá trị.
Giá trị đó chỉ lộ ra khi nhìn điều gì xảy ra lúc thiếu @ResponseBody. Cùng một dòng return orderDto, hai kết cục hoàn toàn khác nhau:

Bên phải chính là pitfall 1 ở phần 7 — và là lý do @RestController đáng dùng: dán một lần trên class thì không method nào còn cơ hội bị quên.
Khi nào dùng @Controller vs @RestController
| Scenario | Annotation | Lý do |
|---|---|---|
| REST API trả JSON/XML | @RestController | @ResponseBody áp tất cả method |
| Server-side render HTML (Thymeleaf) | @Controller | Method trả String = view name |
| Mix REST method + view method trong 1 class | @Controller + @ResponseBody từng method | Hiếm, tránh mix nếu được |
3. @RequestMapping — annotation gốc
@RequestMapping là annotation tổng quát, có thể đặt trên class (class-level prefix) hoặc method (method-level pattern).
@RequestMapping(
value = "/orders", // URL pattern; alias: path
method = RequestMethod.GET, // HTTP method filter
consumes = MediaType.APPLICATION_JSON_VALUE, // Content-Type filter
produces = MediaType.APPLICATION_JSON_VALUE, // Accept header filter
headers = "X-API-Version=2", // request header constraint
params = "filter=active" // query param constraint
)
public List<OrderDto> list() { ... }
7 attribute chính và hành vi của từng attribute:
| Attribute | Mô tả | Mismatch HTTP status |
|---|---|---|
value (alias path) | URL pattern, có thể là array | — |
method | Array RequestMethod | 405 Method Not Allowed |
consumes | Filter theo Content-Type request | 415 Unsupported Media Type |
produces | Filter theo Accept header request | 406 Not Acceptable |
headers | Header constraint Key=Value hoặc !Key | 400 / no match |
params | Query param constraint key=value hoặc !key | no match |
name | Tên để lookup URL qua MvcUriComponentsBuilder | — |
Class-level + method-level concat
@RestController
@RequestMapping("/api/v1/orders") // class-level prefix
public class OrderController {
@GetMapping("/{id}") // concat -> /api/v1/orders/{id}
public OrderDto get(@PathVariable Long id) { ... }
@PostMapping // concat -> /api/v1/orders
public OrderDto create(@RequestBody OrderRequest req) { ... }
@GetMapping // concat -> /api/v1/orders
public List<OrderDto> list() { ... }
}
Class-level và method-level cộng lại thành URL cuối. Khi method-level để rỗng, URL cuối chính là class-level prefix.
4. 7 shortcut annotation — tại sao cần
Spring 4.3 thêm 5 shortcut tương đương @RequestMapping(method = ...):
| Shortcut | Tương đương | HTTP method | Safe | Idempotent |
|---|---|---|---|---|
@GetMapping | @RequestMapping(method = GET) | GET | Có | Có |
@PostMapping | @RequestMapping(method = POST) | POST | Không | Không |
@PutMapping | @RequestMapping(method = PUT) | PUT | Không | Có |
@DeleteMapping | @RequestMapping(method = DELETE) | DELETE | Không | Có |
@PatchMapping | @RequestMapping(method = PATCH) | PATCH | Không | Không |
Safe = không thay đổi state server. Idempotent = gọi nhiều lần kết quả giống gọi một lần.
Tại sao shortcut cần thiết (không chỉ là syntactic sugar)
Ngoài việc giảm verbosity, shortcut annotation ràng buộc intent tại compile time. Khi viết @GetMapping, không thể vô tình truyền method = RequestMethod.POST vào — attribute method không tồn tại trong shortcut. Lỗi này được bắt bởi compiler, không phải runtime.
// Compile error -- @GetMapping khong co attribute method:
@GetMapping(value = "/orders", method = RequestMethod.POST)
// OK -- @RequestMapping dung khi can nhieu HTTP method:
@RequestMapping(value = "/orders", method = {RequestMethod.GET, RequestMethod.HEAD})
Shortcut accept tất cả attribute @RequestMapping trừ method:
@PostMapping(
value = "/orders",
consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE
)
public OrderDto create(@RequestBody OrderRequest req) { ... }
Khuyến nghị: dùng shortcut cho 99% case. Dùng @RequestMapping trực tiếp chỉ khi cần match nhiều HTTP method cùng URL.
5. PathPattern syntax — URL matching Spring 6
Spring 6 thay AntPathMatcher bằng PathPatternParser làm default. PathPatternParser parse pattern tại startup (compile-time), không phải per-request — nhanh hơn và strict hơn về cú pháp.
Các pattern cơ bản
@GetMapping("/orders") // exact -- chi match /orders
@GetMapping("/orders/{id}") // path variable -- /orders/42, /orders/abc
@GetMapping("/orders/{id:\\d+}") // path variable + regex -- chi digits: /orders/42
@GetMapping("/orders/{*path}") // capture remaining -- /orders/a/b/c
@GetMapping("/api/v?/orders") // single char wildcard -- /api/v1/orders, /api/v2/orders
@GetMapping("/api/*/orders") // single segment wildcard -- /api/v1/orders (1 segment)
@GetMapping("/files/**") // multi-segment wildcard -- /files/a/b/c
| Pattern | Matches | Khong match |
|---|---|---|
"/orders/{id}" | /orders/42, /orders/abc | /orders/42/items |
"/orders/{id:\\d+}" | /orders/42 | /orders/abc |
"/orders/{*path}" | /orders/a, /orders/a/b/c | /orders |
"/api/v?/orders" | /api/v1/orders, /api/v2/orders | /api/v10/orders |
Most-specific match khi nhiều pattern trùng
@GetMapping("/orders/latest") // (A) -- exact
@GetMapping("/orders/{id}") // (B) -- path variable
@GetMapping("/orders/{id:\\d+}") // (C) -- regex constraint
Request GET /orders/latest:
- (A) match exact.
- (B) match với
id = "latest".
Spring chọn most specific = (A). Thứ tự specificity (cao xuống thấp): literal segment > regex constraint > path variable > single wildcard > multi wildcard. Bài Content negotiation & versioning có sơ đồ thả một URL thật vào cả năm bậc để thấy từng bậc khớp hay không.
Spring sort theo specificity tại startup, không phụ thuộc thứ tự khai báo trong code.
6. Cơ chế bên dưới — request đến handler
Để hiểu toàn bộ luồng, cần biết HandlerMapping đóng vai trò gì khi matching annotation.
Khi @RestController được scan vào container, RequestMappingHandlerMapping đọc tất cả annotation trên class và method, build một registry — map từ RequestMappingInfo (URL pattern + method + consumes + produces + headers + params) đến handler method. Đây là bước startup, không phải per-request.
Khi request đến:
DispatcherServlethỏiRequestMappingHandlerMapping: "request này match handler nào?"HandlerMappingtra registry, chạy specificity ranking nếu nhiều handler match.- Handler method được invoke, return value đi qua
HttpMessageConverternếu có@ResponseBody.

Cột trái chạy đúng một lần rồi đóng băng — đây là lý do trùng URL throw IllegalStateException lúc khởi động, không phải lúc request đến. Cũng vì thế không có cách nào thêm route khi app đang chạy.
7. Pitfall
❌ Nhầm 1 — Dùng @Controller cho REST API:
@Controller
public class OrderController {
@GetMapping("/api/orders/{id}")
public OrderDto get(@PathVariable Long id) {
return orderService.findById(id); // BUG: Spring tim view "OrderDto"
}
}
Spring xem OrderDto là view name và đem hỏi ViewResolver; không template nào tên đó tồn tại nên request kết thúc bằng 500. Fix: đổi @Controller thành @RestController.
❌ Nhầm 2 — Trùng URL giữa controller:
@RestController @RequestMapping("/api/orders")
public class OrderController {
@GetMapping("/{id}") public OrderDto get(...) { ... }
}
@RestController @RequestMapping("/api")
public class ApiController {
@GetMapping("/orders/{id}") public Object alt(...) { ... } // trung URL!
}
Spring throw IllegalStateException tại startup. Không có rule "first wins". Fix: refactor URL space hoặc phân biệt qua produces/consumes.
❌ Nhầm 3 — Sai type cho path variable:
@GetMapping("/orders/{id}")
public OrderDto get(@PathVariable String id) { ... } // String thay Long
Code chạy nhưng cần convert manual. Nếu id không phải số, convert thủ công throw NumberFormatException và client nhận 400. Fix: khai đúng type @PathVariable Long id để Spring auto-convert.
❌ Nhầm 4 — Trailing slash trong class-level mapping:
@RequestMapping("/api/orders/") // trailing slash
public class OrderController {
@GetMapping("/{id}") // -> /api/orders//{id} double slash
Spring 6 strict hơn với double slash. Fix: bỏ trailing slash ở class-level: @RequestMapping("/api/orders").
❌ Nhầm 5 — Truyền method vào shortcut annotation:
@GetMapping(value = "/orders", method = RequestMethod.POST) // compile error
Shortcut không có attribute method. Dùng @RequestMapping nếu cần nhiều method.
Liên hệ các bài khác
- URL routing & DispatcherServlet: bài trước giải thích
DispatcherServlet+HandlerMappingnhận request và tra cứu handler — bài này đi vào annotation bạn dùng để khai báo handler trong registry đó. - Content negotiation & versioning: bài tiếp theo đào sâu
produces/consumes, chiến lược versioning (path/header/media type), vàAcceptnegotiation — bài này chỉ giới thiệu attribute, bài sau giải thích cơ chế chi tiết.
Tóm tắt
@ResponseBodybáo Spring serialize return value trực tiếp vào HTTP response body quaHttpMessageConverter, bypass ViewResolver. Thiếu nó, Spring xem return value là view name và request fail 500.@RestController=@Controller+@ResponseBodyáp toàn class — meta-annotation loại bỏ duplicate@ResponseBodymỗi method. Standard cho REST API từ Spring 4.@RequestMappingcó 7 attribute;@GetMapping/@PostMapping/@PutMapping/@DeleteMapping/@PatchMappinglà shortcut fix HTTP method tại compile time.- Class-level
@RequestMappingđặt prefix; method-level concat thêm suffix. PathPatternParser(Spring 6 default):{id}path variable,{id:\d+}regex,{*path}capture-remaining,**multi-segment wildcard.- Khi nhiều handler match cùng URL, Spring chọn most specific (literal trước regex, regex trước path var, path var trước wildcard). Literal segment luôn thắng.
- Registry build tại startup, nên trùng URL gây
IllegalStateExceptionngay khi boot, không phải lúc request đến.
Tự kiểm tra
- Q1Vì sao Spring 4 đưa ra
@RestControllerthay vì để developer tiếp tục dùng@Controller+@ResponseBodymỗi method? Cơ chế bên dưới thay đổi gì? - Q2Đoạn sau có gì sai? Output khi gọi
GET /api/orders/42là gì?@Controller public class OrderController { @GetMapping("/api/orders/{id}") public OrderDto get(@PathVariable Long id) { return orderService.findById(id); } } - Q3App đã có
GET /orders/{id}. Team muốn thêmGET /orders/latesttrả order mới nhất. Hai route có conflict không? Spring chọn handler theo cơ chế nào? - Q4Vì sao Spring throw
IllegalStateExceptiontại startup khi có 2 controller map cùng URL + method, thay vì throw lúc request đến URL đó lần đầu? - Q5Giải thích sự khác nhau giữa
consumesvàproducestrong@RequestMapping. Mỗi attribute filter dựa trên header nào của request và trả HTTP status gì khi mismatch?
Bài tiếp theo: Content negotiation, versioning & CORS
Bài này đáng gửi cho bạn học cùng?
Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.
Bài này có giúp bạn hiểu bản chất không?
Hỏi đáp về bài này
Chưa có câu hỏi
Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).
Đặt câu hỏi đầu tiên