โหมดมืด
บทที่ 1 — REST Design ลึก
หลังจบบท คุณจะ:
- ออกแบบ URL ตามมาตรฐาน
- เลือก HTTP method + status code ที่ถูก
- จัดการ nested resource, sub-resource
- ทำ action ที่ไม่ใช่ CRUD
- เข้าใจ idempotent + safety + HATEOAS
1. Resource Modeling
REST เริ่มจากคำถาม: "app เรามีอะไรเป็น 'resource'?"
E-commerce:
- User
- Product
- Order
- OrderItem
- Cart
- Review
Blog:
- Post
- Comment
- Author
- Tag
- Categoryแต่ละ resource → endpoint
2. URL Convention
กฎทอง
✅ /users noun + plural
❌ /user singular
❌ /getUsers verb
❌ /User PascalCase
❌ /Users PascalCase
✅ /users lowercase
❌ /Users-List multiple words mixed
✅ /user-profiles kebab-case (multi-word)
❌ /user_profiles snake_case ใน URL
✅ /api/v1/users prefix + versionQuery parameter ใช้
camelCaseหรือsnake_caseก็ได้ — แต่ต้อง consistent ทั้ง API (เช่นถ้า body ใช้ camelCase → query ก็ camelCase)
ลำดับ Hierarchy
/users # all users
/users/{id} # specific user
/users/{id}/orders # orders ของ user นี้
/users/{id}/orders/{orderId} # specific order ของ user นี้
/users/{id}/orders/{orderId}/items # items ใน order→ URL = "เส้นทาง" ที่อ่านแล้วเข้าใจ
Nesting — อย่าลึกเกิน 2 ระดับ
✅ /users/{id}/orders
❌ /users/{id}/orders/{oid}/items/{iid}/refunds/{rid}หากลึกเกิน → ทำ top-level:
GET /refunds?orderItemId={iid}
GET /orders/{oid}/items3. HTTP Method — Map กับ CRUD
List all: GET /users
Get one: GET /users/{id}
Create: POST /users
Update full: PUT /users/{id}
Update part: PATCH /users/{id}
Delete: DELETE /users/{id}| Verb | ภาษาไทย | คำอ่าน | Idempotent? | Safe? |
|---|---|---|---|---|
GET | ดึงข้อมูล | เก็ท | ✅ | ✅ |
POST | สร้างใหม่ | โพสต์ | ❌ (สร้างซ้ำได้) | ❌ |
PUT | แทนที่ทั้งหมด | พุท | ✅ | ❌ |
PATCH | แก้บางส่วน | แพทช์ | ⚠️ ไม่จำเป็น (แล้วแต่ implement) | ❌ |
DELETE | ลบ | ดีลีท | ✅ (ลบซ้ำ = ลบไปแล้ว) | ❌ |
Idempotent (อ่าน ไอ-เด็ม-โพ-เทน-ท์) = เรียกซ้ำกี่ครั้งผลก็เหมือนเดิม. Safe = ไม่เปลี่ยน state บน server. PUT idempotent ตามนิยาม แต่ PATCH ไม่จำเป็น ต้อง idempotent (ถ้า PATCH body เป็น "เพิ่ม 1" เรียก 3 ครั้ง = เพิ่ม 3) — RFC 9110
PUT vs PATCH — ต่างยังไง?
PUT = แทนที่ทั้ง resource
http
PUT /users/1
{ "name": "Anna", "email": "anna@x.com", "age": 25 }→ field ที่ไม่ส่ง = ถูกเซ็ตเป็น default/null
⚠️ PUT ตามมาตรฐานต้อง replace ทุก field — ถ้า body ไม่ส่ง field มาด้วย ต้อง reset เป็น default/null. ใน Spring
@RequestBody Userส่วนมาก ไม่ null ออก field ที่หายไป (มัน deserialize ทับเฉพาะที่ส่ง) ทำให้ PUT พฤติกรรมกลายเป็น PATCH โดยไม่ตั้งใจ — ถ้าไม่แน่ใจ ใช้ PATCH ปลอดภัยกว่า
PATCH = แก้บางส่วน
http
PATCH /users/1
{ "age": 26 }→ field อื่นไม่กระทบ
ในงานจริง: ใช้ PATCH ส่วนใหญ่ — PUT ใช้กับ replace ทั้ง entity จริง ๆ
⚠️ PATCH ต้องประกาศ Content-Type ที่ยอมรับ —
application/merge-patch+json(RFC 7396) หรือapplication/json-patch+json(RFC 6902) สอง format มี semantic ต่างกันสิ้นเชิง. ถ้าใช้application/jsonเฉย ๆ จะกำกวม client ไม่รู้ว่า server ตีความ body แบบไหน
PATCH formats
RFC = เอกสารมาตรฐานสากลของอินเทอร์เน็ต เลขข้างหลังคือหมายเลขเอกสาร (ไม่ต้องจำ — รู้แค่ว่ามันคือ "มาตรฐานที่มีคนกำหนดไว้แล้ว")
JSON Merge Patch (RFC 7396) — simple
json{ "age": 26, "phone": null } // null = remove💡 Mnemonic จำง่าย — Merge = "รวม" body กับ resource เดิม (ง่าย) / Patch = ระบุ operation ทีละขั้น (ละเอียด)
JSON Patch (RFC 6902) — operation-based
🚀 ขั้นสูง — กางดู (มือใหม่ข้ามได้)
json[ { "op": "replace", "path": "/age", "value": 26 }, { "op": "remove", "path": "/phone" } ]ใช้เมื่อต้องการ atomic operation หลายขั้น (add/remove/replace/move/copy/test) ส่วนใหญ่ใช้ใน config-as-code หรือ collaborative editing
มือใหม่ใช้ Merge Patch — ง่ายกว่า
4. Status Code — เลือกให้ถูก
HTTP status code คือภาษาที่ API บอก client ว่าเกิดอะไรขึ้น — เลือกให้ตรงความหมาย (201 สร้างสำเร็จ, 204 ลบแล้วไม่มี body, 404 ไม่เจอ, 409 ขัดแย้ง) ช่วยให้ client จัดการผลลัพธ์ได้ถูกโดยไม่ต้อง parse body ส่วนนี้รวมเคสที่ใช้บ่อยพร้อมตัวอย่าง:
Create
http
POST /users → 201 Created
Location: https://api.example.com/v1/users/123
Body: { "id": 123, "name": "..." }
Locationheader = บอก client ว่า resource ที่เพิ่งสร้างอยู่ URL ไหน — client เอาไป fetch ต่อ/redirect ได้. RFC 9110 แนะนำใช้ absolute URL (https://api.example.com/v1/users/123) ถึงแม้ relative path (/users/123) client ส่วนใหญ่ยอมรับ
Update
http
PATCH /users/123 → 200 OK + updated body
หรือ
PATCH /users/123 → 204 No Content (ถ้าไม่ต้องการ body)Delete
http
DELETE /users/123 → 204 No Content
หรือ
DELETE /users/123 → 200 OK + body (ระบุที่ลบ)Not Found
http
GET /users/9999 → 404 Not FoundValidation Fail
http
POST /users { "email": "invalid" } → 422 Unprocessable Entity
Body: { "error": { "code": "VALIDATION", "details": [...] } }⚠️ 400 vs 422 ไม่ใช่ของแทนกัน (RFC 9110):
- 400 Bad Request = client ส่งมา ผิด syntax เช่น JSON parse ไม่ผ่าน, header หายไป
- 422 Unprocessable Entity = syntax ถูก (JSON valid) แต่ fail business validation เช่น email format ไม่ตรง, age < 0
อย่าใช้สลับกัน — client log จะแยก error case ไม่ออก
Conflict
http
POST /users { "email": "exists@x.com" } → 409 Conflict
Body: { "error": "Email already in use" }Unauthorized
http
GET /admin → 401 Unauthorized (ไม่ได้ login)
GET /admin → 403 Forbidden (login แล้วแต่ไม่มีสิทธิ์)5. Resource Hierarchy Patterns
A. Sub-resource (Composition)
GET /users/{id}/orders # orders ของ user นี้
POST /users/{id}/orders # สร้าง order ให้ user นี้
GET /orders/{id} # ดู order ตรง ๆ ก็ได้→ Order belongs to User — ไม่มี user = ไม่มี order
B. Independent Resource
POST /orders { "userId": 1, ... } # สร้าง order ที่ระบุ user ใน body
GET /orders?userId=1 # filter→ Order อิสระ — relate กับ user ผ่าน FK
เลือกยังไง?
- Sub-resource เมื่อ relation คงที่ + เห็นจาก parent
- Independent เมื่อ relation flexible (เช่น many-to-many)
หรือทำ ทั้งคู่ — sub-resource สำหรับ list ตาม parent, independent สำหรับ direct access
6. Action ที่ไม่ใช่ CRUD
REST = "everything is a resource" — แต่บางอย่างก็ไม่ใช่ resource ตรง ๆ
ส่ง email reset password — ไม่ใช่ CRUD
ยกเลิก order — DELETE? ไม่ใช่ ลบจริง ๆ
Refund — POST resource ใหม่Option 1: POST + verb ใน URL
POST /users/{id}/reset-password
POST /orders/{id}/cancel
POST /payments/{id}/refund
POST /users/{id}/promote-to-admin💡 Action endpoint = ยกเว้นกฎ noun-only — ปกติ URL ควรเป็นคำนาม แต่ action ที่ไม่ใช่ CRUD ตรง ๆ (reset, cancel, refund) ใช้ verb ใน URL ได้ — เป็น pragmatic exception ที่ Stripe/GitHub ใช้กันอย่างกว้างขวาง
✅ Pragmatic — เห็นใน Stripe, GitHub ✅ ชัด action
Option 2: Action = Resource
POST /password-reset-requests { "email": "..." } # request
POST /order-cancellations { "orderId": 1 } # cancellation event
POST /refunds { "paymentId": 1, "amount": 50 } # refund (มี resource จริง)✅ ตามจริงแบบ REST ✅ track history ได้
Option 3: Update Status
PATCH /orders/{id} { "status": "CANCELLED" }⚠️ แต่ status transition rule ซับซ้อน — controller มี business logic → Option 1 ชัดกว่า
กฎ: ถ้า action มี side effect / history → ทำเป็น resource. ถ้าเป็น state transition ง่าย ๆ → PATCH หรือ POST action
7. Query Parameter vs Path Parameter
ใช้ path หรือ query กันแน่? กฎง่าย ๆ คือ path parameter ใช้ "ระบุ resource" (/users/{id}) ส่วน query parameter ใช้ "ปรับผลลัพธ์" (filter, pagination, sort, projection) แยกให้ถูกทำให้ URL อ่านง่ายและสื่อความหมายชัด:
Path Parameter — บอก resource
/users/{id} # id = ส่วนของ identity
/users/123/orders/456Query Parameter — modify ผลลัพธ์
/users?status=active # filter
/users?page=2&size=20 # pagination
/users?sort=name # sort
/users?fields=id,name # sparse fieldset
/users?include=orders # related dataใช้ path → identify resource
ใช้ query → filter / paginate / project / sort8. Response Format
Single Resource
json
{
"id": 1,
"email": "anna@example.com",
"name": "Anna",
"createdAt": "2026-05-18T10:00:00Z"
}Wrapped Format
json
{
"data": { "id": 1, ... },
"meta": { "version": "v1", "requestId": "..." }
}Collection
json
{
"data": [
{ "id": 1, ... },
{ "id": 2, ... }
],
"meta": {
"page": 1,
"size": 20,
"total": 150,
"hasNext": true
}
}หรือ unwrapped:
json
[
{ "id": 1, ... },
{ "id": 2, ... }
]ทีมเลือกเอง — แค่ consistent ทั้ง API
Field naming
json
✅ camelCase
{
"firstName": "Anna",
"createdAt": "2026-05-18T10:00:00Z",
"isActive": true
}
❌ snake_case (อันนี้ debate ได้ — Stripe ใช้ snake_case)
{ "first_name": "Anna" }
❌ PascalCase
{ "FirstName": "Anna" }JS ecosystem เอนเอียง camelCase แต่ไม่ใช่กฎเหล็ก — Stripe (JS-heavy) ใช้ snake_case ทั้ง API ส่วน GitHub ใช้ผสม
→ ทีมเดียวกัน — เลือก 1 + consistent ทั้ง API
9. Date/Time Format
วันที่/เวลาเป็นจุดที่ API พังกันบ่อยเพราะแต่ละที่ format ต่างกัน — มาตรฐานคือ ISO 8601 + UTC (2026-05-18T10:00:00Z) เพราะ parser ทุกภาษารองรับและไม่กำกวมเรื่อง timezone หลีกเลี่ยง format อ่านง่ายแต่กำกวมอย่าง "18/05/2026":
✅ ISO 8601 + UTC + timezone
"2026-05-18T10:00:00Z"
"2026-05-18T10:00:00+07:00"
❌ "May 18, 2026"
❌ "18/05/2026"
❌ Unix timestamp (1747562400) — เว้นแต่ low-level💡
Zvs+07:00—Z(Zulu time) = UTC (เท่ากับ+00:00), ส่วน+07:00= เร็วกว่า UTC 7 ชั่วโมง (เวลาประเทศไทย). ทั้งสองเป็น ISO 8601 ถูกต้อง — แต่ทีมส่วนใหญ่เก็บ UTC ลง DB (Z) แล้วให้ client แปลงเป็น timezone ของผู้ใช้เอง
ISO 8601 = สากล + parser ทุกภาษารองรับ
10. Field — Project / Sparse Fieldset
ลด data ที่ส่งเมื่อ client ต้องการแค่บางอย่าง:
GET /users?fields=id,name,emailjson
[
{ "id": 1, "name": "Anna", "email": "anna@x.com" },
...
]→ ไม่ส่ง field อื่น
→ ลด bandwidth + เร็วขึ้นบน mobile
11. Related Data — Include / Expand
client มักต้องการข้อมูลที่เกี่ยวข้องพร้อมกัน (order + user + items) — ถ้าให้เรียกแยกหลาย endpoint จะเปลือง round-trip โดยเฉพาะบน mobile การให้ ?include= ดึง related data มาด้วยในครั้งเดียวช่วยลด latency (แต่ต้องระวังไม่ให้ over-fetch):
GET /orders/123 → order
GET /orders/123?include=user,items → order + user + itemsjson
{
"id": 123,
"total": 100,
"user": { "id": 1, "name": "Anna" },
"items": [{ "productId": 5, "quantity": 2 }]
}→ ลด round-trip จาก client (ไม่ต้องเรียก /users/1 + /items แยก)
12. HATEOAS — Hypermedia
HATEOAS (อ่าน ฮา-ที-โอ-แอส หรือ เฮ-เท-โอ-แอส) = Hypermedia As The Engine Of Application State — แนวคิดที่ response บอก client ว่า "จะทำอะไรต่อได้บ้าง" ผ่าน link ใน body
REST แท้ — response บอกว่า "ทำอะไรต่อได้บ้าง":
🚀 โซนขั้นสูง — ส่วนใหญ่ไม่ทำ ข้ามได้รอบแรก API ส่วนใหญ่ใน 2026 (Stripe/GitHub/Twitter) ไม่ใช้ HATEOAS — ใช้ OpenAPI doc แทน. อ่านเอาแนวคิดพอ
json
{
"id": 123,
"status": "PENDING",
"_links": {
"self": { "href": "/orders/123" },
"cancel": { "href": "/orders/123/cancel", "method": "POST" },
"pay": { "href": "/orders/123/payment", "method": "POST" }
}
}ข้อดี:
- Client ค้น API ได้เอง
- Server บอกว่า action ไหนใช้ได้ตอนนี้ (ตาม status)
ความจริง:
- ใช้น้อยมากในปี 2026
- API doc (OpenAPI) แทน
- Stripe, Twitter, GitHub ไม่ใช้
→ Optional — ทำเฉพาะเมื่อจำเป็น (เช่น api ที่ client ฉลาดน้อย, integration partner)
13. Idempotency
Idempotency (อ่าน ไอ-เด็ม-โพ-เทน-ซี่) = คุณสมบัติ "เรียกซ้ำกี่ครั้งผลก็เหมือนเดิม" — สำคัญมากตอน network ไม่เสถียร เพราะ client retry ได้โดยไม่กลัวสร้างซ้ำ
GET /users/1 → ผลเดิม (no side effect)
PUT /users/1 {...} → set state เป็น ... (เรียก N ครั้ง = state เดิม)
DELETE /users/1 → ผลเดิม (ลบเหมือนเดิม + 404 ถ้าไม่มี)
POST /users {...} → สร้างใหม่ทุกครั้ง (ไม่ idempotent)ทำไมสำคัญ?
- Retry ปลอดภัยขึ้น
- Mobile/network ไม่เสถียร → app retry → ห้าม duplicate
Idempotency-Key (สำหรับ POST)
http
POST /payments
Idempotency-Key: 7e8f9-abc-def...
{ "amount": 100, ... }Server เก็บ key + return cached response ถ้า retry ภายใน N ชั่วโมง
⚠️ Edge cases ที่ต้องคิด (ตามแนวทาง Stripe):
- Same key + body ต่างกัน → return
422 Unprocessable Entity(key mismatch — ห้ามใช้ซ้ำกับ payload ใหม่)- Concurrent same key → ใช้ lock หรือ return
409 Conflict(request กำลังประมวลผลอยู่)- TTL ~24 ชั่วโมง (Stripe default) — เกินนั้น key หมดอายุ retry ไม่ได้
- ต้อง cache error response ด้วย ไม่ใช่แค่ success — ถ้า request แรกได้
400retry ก็ต้องได้400เหมือนกัน
java
@PostMapping("/payments")
public Payment create(
@RequestHeader("Idempotency-Key") String key,
@RequestBody PaymentRequest req
) {
Optional<Payment> cached = idempotencyService.find(key);
if (cached.isPresent()) return cached.get();
Payment p = paymentService.create(req);
idempotencyService.store(key, p, Duration.ofHours(24));
return p;
}Stripe + GitHub ใช้ pattern นี้
14. Bulk Operation
POST /users/bulk-delete { "ids": [1, 2, 3] }
POST /users/batch-create { "users": [...] }
PATCH /users/bulk-update { "filter": {...}, "set": {...} }💡 POST สำหรับ bulk = pragmatic exception — RFC 9110 §9.3.5 บอกว่า
DELETEมี body ได้ แต่ semantic undefined และ proxy/CDN บางตัว strip body ทิ้ง. ดังนั้นPOST /resource/bulk-deleteปลอดภัยกว่า ถึงจะดู anti-RESTful ก็ตาม
หรือ:
POST /users (one)
POST /users (one)
...แต่ network call เยอะ — bulk operation ดีกว่า
⚠️ ระวัง:
- Partial failure (5 ใน 10 fail) → tell client ทุก case
- Transaction หรือ best-effort?
- Limit: max 100 / 1000 ต่อ call
15. ตัวอย่างจริง — Twitter-like API
http
# Tweets
GET /api/v1/tweets # timeline (your feed)
GET /api/v1/tweets?author=alice # filter
GET /api/v1/tweets/{id} # detail
POST /api/v1/tweets # create
DELETE /api/v1/tweets/{id} # delete (own tweets only)
# Interactions (actions)
POST /api/v1/tweets/{id}/likes # like (idempotent toggle — ซ้ำ = 200, ไม่ใช่ 409)
DELETE /api/v1/tweets/{id}/likes # unlike
POST /api/v1/tweets/{id}/retweets # retweet
POST /api/v1/tweets/{id}/replies # reply
# Users
GET /api/v1/users/{username} # profile
GET /api/v1/users/{username}/tweets # their tweets
POST /api/v1/users/{username}/follows # follow
DELETE /api/v1/users/{username}/follows # unfollow
GET /api/v1/users/{username}/followers
GET /api/v1/users/{username}/following
# Me (current user)
GET /api/v1/me
PATCH /api/v1/me
GET /api/v1/me/timeline
GET /api/v1/me/notifications
POST /api/v1/me/notifications/mark-read
# Search
GET /api/v1/search/tweets?q=react
GET /api/v1/search/users?q=annaสังเกต:
- Resource oriented (
/tweets,/users) - Action ที่มี history → resource (
/likes,/retweets) - Special path
/meสำหรับ current user (ดีกว่าใช้ id ของตัวเอง) - Nested ลึกไม่เกิน 2 ระดับ
16. Spring Boot Implementation
มาดูว่าหลักการ REST ทั้งหมดในบทแปลงเป็นโค้ดจริงยังไง — controller Spring Boot ที่ map endpoint ตาม resource, ใช้ @ResponseStatus ให้ status code ตรง, แยก action (/likes) เป็น sub-resource และดึง current user จาก security context สังเกตว่าโครงสร้างตรงกับที่ออกแบบไว้:
📘 ถ้ายังไม่ได้อ่าน Spring Boot — ดูแค่ความสัมพันธ์ endpoint → method ก็พอ. ตัว annotation (
@ResponseStatus,@AuthenticationPrincipal,@Valid,Pageable) มีบทแยกอยู่ในหนังสือ Java/Spring Boot — ตอนนี้รู้แค่ว่า "นี่คือวิธี Spring map HTTP เป็น method" พอ
java
@RestController
@RequestMapping("/api/v1/tweets")
@RequiredArgsConstructor
public class TweetController {
private final TweetService tweetService;
@GetMapping
public PageResponse<TweetResponse> list(
@RequestParam(required = false) String author,
@PageableDefault Pageable pageable
) {
return tweetService.list(author, pageable);
}
@GetMapping("/{id}")
public TweetResponse get(@PathVariable Long id) {
return tweetService.getById(id);
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public TweetResponse create(
@RequestBody @Valid CreateTweetRequest req,
@AuthenticationPrincipal CustomUserDetails user
) {
return tweetService.create(req, user.getId());
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(
@PathVariable Long id,
@AuthenticationPrincipal CustomUserDetails user
) {
tweetService.delete(id, user.getId());
}
// Like = idempotent toggle: ใช้ 204 No Content (ไม่มี representation)
// ถ้าจะ return 201 Created ต้องมี Location header ชี้ไปที่ like resource
// เช่น Location: /tweets/{id}/likes/{userId}
@PostMapping("/{id}/likes")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void like(
@PathVariable Long id,
@AuthenticationPrincipal CustomUserDetails user
) {
tweetService.like(id, user.getId());
}
@DeleteMapping("/{id}/likes")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void unlike(
@PathVariable Long id,
@AuthenticationPrincipal CustomUserDetails user
) {
tweetService.unlike(id, user.getId());
}
}⚠️ 201 Created +
void= anti-pattern — RFC 9110 บอกว่า 201 ควรมีLocationheader ชี้ไปที่ resource ใหม่ ถ้าไม่มี body ไม่มี Location → ใช้ 204 No Content ดีกว่า (เหมือนตัวอย่างด้านบน)
17. ⚠️ Common Pitfalls
| ❌ | ✅ |
|---|---|
GET /api/getUsers | GET /api/users |
GET /api/user/list | GET /api/users |
POST /api/users/delete/1 | DELETE /api/users/1 |
POST /api/users/1 (update) | PATCH /api/users/1 |
GET ที่มี side effect | use POST/DELETE — GET = safe |
Status 200 + { "error": ... } | proper 4xx/5xx status |
| Status 500 ทุก error | 400 (validation), 404 (not found), 409 (conflict), ... |
| Mix conventions ใน API เดียว | consistent — เลือก 1 |
| Nested URL ลึก > 2 | flatten |
ลืม version (/api/users) | /api/v1/users |
18. Checkpoint
🛠️ Checkpoint 1.1 — Design API
ออกแบบ API สำหรับ "Online Bookstore":
- Resource: book, author, customer, order, review, cart
- 20+ endpoint ครบ CRUD + nested
- Status code ที่ถูก
- ระบุ idempotent / safe
🛠️ Checkpoint 1.2 — Refactor Bad API
จากตัวอย่าง bad API:
GET /getAllOrders
POST /orders/cancel/123
GET /api/orders?id=5
POST /api/users/{userid}/orders/getListRefactor เป็น API ที่ดี
🛠️ Checkpoint 1.3 — Implement (Spring Boot)
เอา API ที่ออกแบบใน 1.1 — ทำ controller จริง 5 endpoint แรก
ตรวจ status code, response format
19. สรุปบท
✅ URL = noun + plural + lowercase (/users ไม่ใช่ /getUser)
✅ HTTP method ตรงกับ CRUD + idempotency rule
✅ PATCH > PUT สำหรับ partial update (ใช้บ่อย)
✅ Status code: 200/201/204 success, 400/401/403/404/409/422 client error, 500 server error
✅ Sub-resource (/users/{id}/orders) แต่อย่าลึกเกิน 2 ระดับ
✅ Action: ใช้ POST /resource/{id}/verb หรือ ทำเป็น resource ใหม่
✅ Response format consistent + camelCase + ISO 8601 date
✅ Idempotency-Key header สำหรับ POST ที่อยาก retry safe
✅ HATEOAS = optional — OpenAPI doc แทนได้