@Controller vs @RestController và thiết kế REST cho OrderController

4 câu hỏi~25 phútTừ khóa: @Controller, @RestController, @ResponseBody, @RequestMapping, @GetMapping, @PostMapping, @PutMapping, @PatchMapping, @DeleteMapping, REST, idempotent, Thymeleaf

Tiếp đi: @Controller và @RestController. Project nào mình cũng thấy @RestController, vậy @Controller còn dùng làm gì?

Ngắn gọn: @RestController = @Controller + @ResponseBody trên mọi method. @Controller vẫn dùng khi server render HTML.

1. Bài toán: shop-app có hai loại client

2. Vấn đề: dùng nhầm annotation

3. Giải pháp

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Controller
@ResponseBody
public @interface RestController { ... }     // mã nguồn Spring, rút gọn

Các annotation mapping đều là viết tắt của @RequestMapping:

AnnotationTương đươngDùng cho
@GetMapping@RequestMapping(method = GET)Đọc
@PostMappingmethod = POSTTạo mới, hoặc action
@PutMappingmethod = PUTThay toàn bộ resource
@PatchMappingmethod = PATCHSửa một phần
@DeleteMappingmethod = DELETEXóa

@RequestMapping ở class đặt prefix chung; ở method có thêm consumes, produces, params, headers.

4. Code từng bước

Bước 1: Dependency

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- chỉ cần nếu render HTML -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>

Bước 2: Config

spring:
  thymeleaf:
    prefix: classpath:/templates/   # mặc định
    suffix: .html

Bước 3a: @Controller cho trang admin

@Controller
@RequestMapping("/admin/orders")
@RequiredArgsConstructor
public class AdminOrderController {

    private final OrderService orderService;

    @GetMapping
    public String list(Model model) {
        model.addAttribute("orders", orderService.findRecent());
        return "admin/orders";               // → templates/admin/orders.html
    }

    @PostMapping("/{id}/cancel")
    public String cancel(@PathVariable long id) {
        orderService.cancel(id);
        return "redirect:/admin/orders";     // Post/Redirect/Get, tránh F5 gửi lại form
    }
}

Bước 3b: @RestController cho API, thiết kế REST

@RestController
@RequestMapping("/api/orders")
@RequiredArgsConstructor
public class OrderController {

    private final OrderService orderService;

    @GetMapping                                  // GET /api/orders?status=PAID&page=0
    public Page<OrderResponse> list(@RequestParam(required = false) OrderStatus status,
                                    Pageable pageable) {
        return orderService.search(status, pageable);
    }

    @GetMapping("/{id}")                         // GET /api/orders/101
    public OrderResponse get(@PathVariable long id) {
        return orderService.get(id);
    }

    @PostMapping                                 // POST /api/orders → 201
    public ResponseEntity<OrderResponse> create(@RequestBody CreateOrderRequest req) {
        OrderResponse created = orderService.create(req);
        URI location = URI.create("/api/orders/" + created.id());
        return ResponseEntity.created(location).body(created);
    }

    @PatchMapping("/{id}/shipping-address")      // sửa một phần
    public OrderResponse changeAddress(@PathVariable long id,
                                       @RequestBody AddressRequest req) {
        return orderService.changeAddress(id, req);
    }

    @PostMapping("/{id}/cancel")                 // action nghiệp vụ, không phải CRUD
    public OrderResponse cancel(@PathVariable long id) {
        return orderService.cancel(id);
    }

    @DeleteMapping("/{id}")                      // chỉ cho đơn nháp
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void deleteDraft(@PathVariable long id) {
        orderService.deleteDraft(id);
    }
}

Chi tiết các kiểu tham số ở bài 4.2.4, còn ResponseEntity và status code ở bài 4.2.9.

Bước 4: Test bằng curl

curl -i -X POST localhost:8080/api/orders \
  -H "Content-Type: application/json" -d '{"productId":1,"quantity":2}'
# HTTP/1.1 201
# Location: /api/orders/101
# {"id":101,"status":"NEW","total":200000}

curl localhost:8080/api/orders/101
# {"id":101,"status":"NEW",...}

curl -X POST localhost:8080/api/orders/101/cancel
# {"id":101,"status":"CANCELLED",...}

curl -i -X PUT localhost:8080/api/orders/101
# HTTP/1.1 405 Method Not Allowed
# Allow: GET, DELETE

curl localhost:8080/admin/orders
# <html>...<table>...  (HTML từ Thymeleaf)

5. Cách hoạt động

                      return value
                           │
             ┌─────────────┴──────────────┐
   có @ResponseBody?                  không có
   (@RestController)                 (@Controller)
             │                             │
   HttpMessageConverter              String = tên view
   chọn theo Accept + kiểu            Model = dữ liệu
             │                             │
   Jackson → {"id":101,...}          ViewResolver → templates/admin/orders.html
   Content-Type: application/json     Thymeleaf render → <html>...
             │                             │
             └──────────── response ───────┘

Thiết kế URL cho resource "order":
  /api/orders                 collection   GET (list), POST (create)
  /api/orders/{id}            item         GET, PATCH, DELETE
  /api/orders/{id}/cancel     action       POST
  /api/orders/{id}/items      sub-resource GET

6. So sánh

@Controller@RestController
Bản chấtStereotype, là @Component@Controller + @ResponseBody
Trả StringTên viewNội dung text
Trả objectCần @ResponseBody từng methodTự ra JSON
Dùng vớiThymeleaf, JSP, redirect, formSPA, mobile, microservice
HTTP methodSafeIdempotentVí dụ shop-app
GETCóCóXem đơn
POSTKhôngKhôngTạo đơn (retry có thể tạo trùng)
PUTKhôngCóGhi đè địa chỉ
PATCHKhôngKhông bắt buộcSửa một trường
DELETEKhôngCóXóa đơn nháp

7. Khi nào dùng cái nào

Hủy đơn mình định viết PUT /api/orders/{id} với body {"status":"CANCELLED"} cho đúng REST. Có ổn không?

Về cú pháp thì ổn, nhưng về nghiệp vụ dễ có vấn đề. Hủy đơn không chỉ là đổi một trường: còn hoàn kho qua ProductService, hoàn tiền, kiểm tra trạng thái hiện tại.

PATCH/PUT {"status":"CANCELLED"}         POST /api/orders/{id}/cancel
  │                                         │
  client tự đổi status bất kỳ              server quyết định chuyển trạng thái
  {"status":"PAID"} cũng gửi được?         chỉ cho NEW/PAID → CANCELLED
  phải viết if-else đoán ý định            OrderService.cancel(): hoàn kho, hoàn tiền
                                           có thể nhận body {"reason":"..."}

Thực tế nhiều API lớn dùng dạng "action sub-resource" bằng POST cho chuyển trạng thái. Dùng PATCH cho những trường dữ liệu thuần, như địa chỉ giao hàng. Quan trọng là nhất quán trong toàn bộ API.

Mobile app gửi POST /api/orders, bị timeout nên bấm lại, thế là có hai đơn. Mình chặn kiểu gì?

Đúng rồi, đó chính là hệ quả của "POST không idempotent". Cách phổ biến là Idempotency-Key: client sinh UUID cho mỗi lần đặt hàng, retry thì gửi lại đúng key đó.

@PostMapping
public ResponseEntity<OrderResponse> create(
        @RequestHeader("Idempotency-Key") UUID key,
        @RequestBody CreateOrderRequest req) {
    OrderResponse res = orderService.createOnce(key, req);
    return ResponseEntity.created(URI.create("/api/orders/" + res.id())).body(res);
}

// OrderService
@Transactional
public OrderResponse createOnce(UUID key, CreateOrderRequest req) {
    return orderRepository.findByIdempotencyKey(key)
        .map(OrderResponse::from)                         // lần 2: trả đơn cũ
        .orElseGet(() -> create(key, req));               // lần 1: tạo mới
}
Lần 1: key=abc → chưa có → tạo đơn 101 → (timeout, client không nhận được)
Lần 2: key=abc → đã có đơn 101 → trả lại đơn 101, không tạo thêm

Cột idempotency_key phải có unique constraint để hai request đồng thời không cùng lọt qua bước findBy.

Phỏng vấn hỏi @Controller và @RestController thì trả lời sao?

Q1. @Controller khác @RestController thế nào?

🇻🇳 Tiếng Việt:

@RestController là annotation gộp của @Controller và @ResponseBody. Với @Controller, giá trị String trả về là tên view và được ViewResolver render thành HTML. Với @RestController, giá trị trả về được HttpMessageConverter ghi thẳng vào body, thường là JSON qua Jackson. API dùng @RestController, trang render phía server dùng @Controller.

🇬🇧 English:

@RestController is a composed annotation of @Controller and @ResponseBody. With @Controller, a returned String is a view name rendered to HTML by a ViewResolver. With @RestController, the return value is written straight to the body by an HttpMessageConverter, usually JSON via Jackson. Use @RestController for APIs and @Controller for server-rendered pages.

Q2. Bạn thiết kế REST API cho đơn hàng thế nào?

🇻🇳 Tiếng Việt:

URL là danh từ số nhiều: /api/orders cho collection, /api/orders/{id} cho item. GET để đọc, POST tạo mới trả 201 kèm Location, PATCH sửa một phần, DELETE xóa trả 204. Chuyển trạng thái có logic nghiệp vụ như hủy đơn thì dùng POST /api/orders/{id}/cancel. Trả DTO thay vì entity, có phân trang, và dùng Idempotency-Key cho POST tạo đơn.

🇬🇧 English:

URLs are plural nouns: /api/orders for the collection and /api/orders/{id} for an item. GET reads, POST creates and returns 201 with a Location header, PATCH updates partially, DELETE returns 204. State transitions with business rules, like cancelling, use POST /api/orders/{id}/cancel. I return DTOs instead of entities, paginate lists, and use an Idempotency-Key for order creation.

Q3. Idempotent là gì? Method nào idempotent?

🇻🇳 Tiếng Việt:

Idempotent nghĩa là gọi một lần hay nhiều lần thì trạng thái server cuối cùng như nhau. GET, PUT, DELETE là idempotent; POST thì không. Điều này quan trọng khi retry: retry GET hay PUT an toàn, còn POST cần idempotency key để không tạo trùng.

🇬🇧 English:

Idempotent means calling it once or many times leaves the server in the same final state. GET, PUT and DELETE are idempotent; POST is not. This matters for retries: retrying GET or PUT is safe, while POST needs an idempotency key to avoid duplicates.

Cách nhớ ngắn gọn


Tự kiểm tra

@RestController trả "admin/orders" thì client nhận gì?

Chuỗi text admin/orders, không có HTML.

@GetMapping("/{id}") là viết tắt của gì?

@RequestMapping(value = "/{id}", method = RequestMethod.GET).

Tạo đơn thành công nên trả status và header gì?

201 Created kèm header Location: /api/orders/{id}.

Gọi PUT vào endpoint chỉ có GET và DELETE thì sao?

405 Method Not Allowed, header Allow: GET, DELETE.

Vì sao @Controller sau POST form nên redirect:?

Post/Redirect/Get: tránh trình duyệt gửi lại form khi người dùng F5.