บทเรียน 36 · Day 8 · API design

กฎตั้งชื่อ 7 ข้อ · versioning · idempotency

~16 นาที · ต้องมี: บทเรียน 33–35 (resource, method, status code, query/body/header) · ใช้ json.Unmarshal จาก บทเรียน 13

กฎ 7 ข้อในสไลด์อ่านผ่าน ๆ เหมือนเรื่องความสวยของ URL — ไม่ใช่เลย
มันคือกฎที่ทำให้คนอื่นเดา endpoint ของคุณถูกโดยไม่ต้องเปิดเอกสาร และบทนี้จะรันให้ดูว่าเดาผิดตัวเดียวจะเกิดอะไรขึ้น
ระหว่างทางเราจะเจอว่าสไลด์หน้า 35 ติด ✅ กับ ❌ กลับด้าน — ผมจะไม่บอกก่อน แต่จะให้คุณจับผิดเอง แล้วเราจะปิดท้ายด้วยคำถามที่แพงที่สุดในบทนี้: ยิงจ่ายเงินซ้ำสองครั้ง เงินจะออกสองรอบไหม

🧰 บทนี้พิมพ์ตามได้ แต่ยังไม่ต้องเขียน HTTP server — ผลรันทุกอันในหน้านี้มาจากเซิร์ฟเวอร์เล็ก ๆ ที่ผมเขียนไว้ก่อนแล้วยิงด้วย curl (Go 1.26.5 darwin/arm64) กับเทส go test ที่ใช้แค่ encoding/json
เลข port 53691 ที่เห็นในคำสั่ง curl เป็นของเซิร์ฟเวอร์สาธิตตัวนั้น — คุณ copy ไปยิงตามตรง ๆ ไม่ได้ ให้อ่านเอาผลลัพธ์อย่างเดียว
โค้ดฝั่ง server จะสอนกันจริงจังใน บทเรียน 37 — บทนี้โฟกัสที่การตัดสินใจ ว่า endpoint ควรหน้าตายังไง ก่อนที่จะมีบรรทัดโค้ดสักบรรทัด

1 · URL ไม่ใช่เรื่องความสวย มันคือ signature ของฟังก์ชันที่ compiler ไม่ช่วยเช็ค

ตลอด 35 บทที่ผ่านมา เวลาคุณเรียกฟังก์ชันผิดชื่อ Go จะด่าคุณทันทีตอน compile — พิมพ์ repo.FindById ทั้งที่ของจริงชื่อ FindByID โปรแกรมไม่ยอมสร้างไฟล์ให้ด้วยซ้ำ
แต่ HTTP ไม่มีขั้นตอนนั้น คนที่เรียก API ของคุณคือโปรแกรมของคนอื่น ที่พิมพ์ path เป็น "สตริง" — พิมพ์ผิดตัวเดียวไม่มีใครเตือน จะรู้ตัวก็ตอน request วิ่งจริงแล้ว

ผมลงทะเบียน (= บอก router ว่ามี path นี้อยู่ ตามที่เจอใน บทเรียน 34 · router คือตัวจัดเส้นทางของ server ที่คอยดูว่า path ไหนให้โค้ดชิ้นไหนรับ) ไว้แค่ GET /payment-methods เส้นเดียว แล้วยิงด้วยการสะกดแบบต่าง ๆ:

GET /payment-methods     -> 200
GET /Payment-Methods     -> 404
GET /PAYMENT-METHODS     -> 404
GET /payment_methods     -> 404
GET /paymentMethods      -> 404

สี่บรรทัดล่างคือคนที่เข้าใจว่า API ของคุณมีอยู่จริง เขาแค่เดาการสะกดผิด และสิ่งที่เขาได้กลับไปคือ 404 — status code เดียวกับ "ไม่มีข้อมูลนี้" เขาจะแยกไม่ออกเลยว่า ผมพิมพ์ path ผิด หรือ ข้อมูลไม่มีจริง

📮 เทียบให้เห็นภาพ — จ่าหน้าซองผิดตัวเดียว

คุณส่งพัสดุไปบ้านเพื่อนที่ "ลาดพร้าว ซอย 15" แต่เขียนไปว่า "ซอย 51"
บุรุษไปรษณีย์ไม่ได้โกรธคุณ เขาแค่ตีซองกลับมาพร้อมข้อความเดียวว่า "ไม่พบผู้รับ"
ซึ่งเป็นข้อความเดียวกับตอนที่เพื่อนคุณย้ายบ้านออกไปแล้วจริง ๆ — คนละเรื่องกันเลย แต่ได้คำตอบกลับมาอันเดียวกัน

URL ก็คือที่อยู่ · สะกดผิดตัวเดียว server ตอบ 404 ซึ่งเป็นเลขเดียวกับตอนที่ข้อมูลไม่มีอยู่จริง คนเรียกจึงแยกไม่ออกว่าตัวเองพิมพ์ผิดหรือของไม่มี
กฎการสะกดทั้งหลาย (ตัวพิมพ์เล็ก · ขีดกลาง · ใช้พหูพจน์เหมือนกันหมด) จึงมีไว้เพื่อให้ที่อยู่ของคุณมีวิธีเขียนแบบเดียว คนอื่นเดาครั้งแรกก็ถูกเลย

ที่ไม่เหมือนกัน: บุรุษไปรษณีย์เป็นคน เห็น "ซอย 51" แล้วยังพอเอะใจได้ว่าน่าจะหมายถึง 15 แต่ server ไม่เดาให้เลยสักนิด — แค่เปลี่ยนตัวเล็กเป็นตัวใหญ่ก็ถือว่าคนละที่อยู่แล้ว

💡 ทำไม /Users กับ /users ถึงคนละเส้น
มาตรฐาน URI (RFC 3986) บอกว่า scheme กับ host เท่านั้นที่ไม่สนตัวพิมพ์ (HTTP://EXAMPLE.COM = http://example.com) ส่วนทุกอย่างหลังจากนั้น รวมถึง path — สนตัวพิมพ์เล็กใหญ่
นี่คือเหตุผลที่กฎข้อ 3 (lowercase) ไม่ใช่รสนิยม: มันคือการเลือกตัวสะกดแบบเดียว จากหลายแบบที่ระบบมองว่าเป็นคนละเส้นทางกันหมด

🗣 ศัพท์ที่เพิ่งเรียนไป

สิ่งที่เพิ่งเข้าใจไปทั้งหมด วงการเขาเรียกแบบนี้ — ใช้คำให้ตรง แล้วคำตอบที่ได้จาก senior หรือจาก AI จะตรงตาม

endpoint
“ที่อยู่หนึ่งที่” ที่ยิงเข้าไปได้จริง = คู่ของ method + path เช่น GET /payment-methods · แปลว่า GET /payment-methods กับ POST /payment-methods คือ คนละ endpoint ทั้งที่ path เดียวกัน · เวลาถามให้พูดว่า “path /payment-methods นี้มี endpoint อะไรบ้าง” จะสื่อกว่าถามว่า “ลิงก์นี้ใช้ยังไง” · (บางคนพูดคำว่า endpoint หลวม ๆ แปลว่า path เฉย ๆ — ถ้าคุยแล้วชักไม่แน่ใจ ระบุ method ไปเลยจะจบเร็วกว่า)
path
ส่วนที่อยู่หลังชื่อเว็บ เช่น /payment-methods · ต่างจาก query (ส่วนที่อยู่หลัง ?) ซึ่งจะเจอในกฎข้อ 6
route / routing
การจับคู่ว่า path ไหนวิ่งเข้าโค้ดชิ้นไหน · ที่บทนี้พูดว่า “ลงทะเบียน” คือคำเดียวกับ “register route” ที่จะได้เจอตัวเป็น ๆ ใน บทเรียน 37
case-sensitive
“สนตัวพิมพ์เล็กใหญ่” · ประโยคที่เอาไปใช้ได้เลย: “path มัน case-sensitive นะ /Users กับ /users คนละเส้น”
kebab-case
สไตล์การเขียนหลายคำด้วยขีดกลาง payment-methods (กฎข้อ 4) · ญาติของมันคือ snake_case (payment_methods) และ camelCase (paymentMethods) — รู้ชื่อทั้งสามไว้ เวลาคุยจะไม่ต้องสะกดให้ฟังทีละตัว
404 Not Found
“ไม่มีของที่ที่อยู่นี้” · จุดที่ต้องจำคือมันตอบทั้งกรณีพิมพ์ path ผิด และกรณีข้อมูลไม่มีจริง — ตอนดีบักให้ถามตัวเองก่อนเสมอว่ากำลังเจอแบบไหน

2 · กฎ 7 ข้อ จริง ๆ แล้วตอบอยู่แค่สองคำถาม

ถ้าท่องเป็น 7 ข้อจะลืม แต่ถ้าเห็นว่ามันแบ่งเป็นสองกลุ่มจะจำได้ตลอดชีวิต

กฎ✅ สไลด์บอก❌ สไลด์บอกกลุ่ม
1 ใช้ Noun มากกว่า Verb GET /users
POST /users
GET /getUsers
POST /createUser
A · "ของสิ่งนี้ชื่ออะไร"
เป้าหมาย = คนอื่นเดาถูก
2 ชื่อ resource consistent /users /orders /products /user /orders /product-list
3 lowercase /users /orders /products /Users /Orders /PRODUCTS
4 หลายคำใช้ - /payment-methods /paymentMethods
/payment_methods
/PaymentMethods
5 relationship ใช้ path /users/123/orders — B · "ข้อมูลชิ้นนี้ควรไปช่องไหน"
path / query / method
6 filter ใช้ query /orders?status=paid /orders/status/paid
7 อย่าใส่ action ใน URI
ถ้า method สื่อได้แล้ว
หัวข้อ 3 ของบทนี้ทั้งหัวข้อ

กลุ่ม A (ข้อ 1–4) คือกฎการสะกด — ทุกข้อแก้ปัญหาเดียวกับที่คุณเพิ่งเห็นในหัวข้อ 1 คือมี "ตัวสะกดที่ถูก" อยู่แบบเดียว แล้วที่เหลือกลายเป็น 404 หมด
ข้อ 2 ที่ดูจุกจิกที่สุด (/user เอกพจน์ปนกับ /orders พหูพจน์) จริง ๆ แล้วแพงที่สุด เพราะมันทำให้คนที่ใช้ API ของคุณเดาไม่ได้อีกต่อไป — พอเจอ /user ครั้งหนึ่ง เขาต้องเปิดเอกสารทุกครั้งไปตลอด ไม่กล้าเดาอีกเลย

กลุ่ม B (ข้อ 5–7) คือกฎการจัดที่อยู่ของข้อมูล — ต่อยอดจาก บทเรียน 35 โดยตรง:
ข้อ 5 = สิ่งที่ระบุตัวของ (id, ความเป็นเจ้าของ) ไปอยู่ที่ path
ข้อ 6 = สิ่งที่กรอง/เรียง/แบ่งหน้า ไปอยู่ที่ query
ข้อ 7 = สิ่งที่บอกว่าจะทำอะไรกับของชิ้นนั้น ไปอยู่ที่ method

🔍 ทำไมข้อ 6 ถึงสำคัญกว่าที่คิด

/orders/status/paid ดูสวยดี จนวันที่ PM ขอเพิ่มตัวกรองที่สอง — คุณจะได้ /orders/status/paid/from/2026-01-01 แล้วก็ต้องลงทะเบียน route ใหม่ ทุกครั้งที่มีตัวกรองเพิ่ม และลำดับก็มีความหมายขึ้นมาเฉย ๆ
ส่วน /orders?status=paid&from=2026-01-01 คือเส้นเดิม — สลับลำดับได้ ตัดออกได้ เพิ่มได้ โดยไม่แตะ routing สักบรรทัด
หลักที่ใช้ตัดสิน: path = ของชิ้นไหน · query = เอาเซ็ตย่อยแบบไหนของของชุดนั้น

3 · จับผิดสไลด์หน้า 35

หน้า 29–34 ของสไลด์ไล่กฎข้อ 1–6 มาถูกหมด พอถึงหน้า 35 ซึ่งเป็นตัวอย่างของกฎข้อ 7 หน้าจอโชว์แบบนี้ (ผมลอกมาทั้งบล็อกตามที่เห็นในสไลด์ รวมเลขบรรทัด):

1  // ✅ แนะนำ
2  POST /users/create
3  POST /users/123/delete
4
5  // ❌ แบบนี้ไม่แนะนำ
6  POST /users
7  DELETE /users/123

หน้านี้อยู่ใต้หัวข้อ Rule 7 — อย่าใส่ Action ใน URI ถ้า HTTP Method สื่อได้แล้ว
อ่านมันเทียบกับตัวกฎเอง แล้วตอบสองข้อ: (ก) มีอะไรขัดกันเองในสไลด์หน้านี้ และ (ข) ถ้าย้อนไปดูสไลด์หน้า 22 (REST – Method) จะเจอหลักฐานอะไรเพิ่ม

สไลด์ติดเครื่องหมายกลับด้าน — บรรทัด 1 ควรเป็น ❌ และบรรทัด 5 ควรเป็น ✅

(ก) มันขัดกับหัวข้อของตัวเอง: create กับ delete คือ action ที่วางอยู่ใน URI เต็ม ๆ ทั้งที่ HTTP method สื่อได้อยู่แล้ว — นั่นคือสิ่งที่กฎข้อ 7 ห้าม ตรงตัวอักษร แต่กลับถูกติด ✅ ว่าแนะนำ
แถมยังขัดกฎข้อ 1 ด้วย เพราะ create/delete เป็น verb ซึ่งหน้า 29 เพิ่งบอกไปเองว่าอย่าใช้

(ข) หน้า 22 (REST – Method) เขียนไว้ตรง ๆ ว่า:
// สร้าง User → POST /users
// ลบ User 123 → DELETE /users/123
ซึ่งคือคู่ที่หน้า 35 ติด ❌ ไว้พอดี — สไลด์ชุดเดียวกันสอนสองแบบขัดกันเอง

สิ่งที่ถูกคือ:

คู่นี้ต่างหากที่ต้องติด ❌

POST /users/create
POST /users/123/delete

verb ซ้อนอยู่ใน URI · POST ถูกใช้ทำทุกอย่าง จน method ไม่เหลือความหมาย

คู่นี้ต่างหากที่ต้องติด ✅

POST /users
DELETE /users/123

URI บอกว่าของอะไร · method บอกว่าทำอะไร — ตรงตามกฎข้อ 1 และข้อ 7

⚠️ ย้ำให้ชัด: นี่คือสไลด์พิมพ์ผิด ไม่ใช่คุณอ่านผิด
ผมเปิดภาพสไลด์หน้า 35 ยืนยันแล้วว่าเครื่องหมายเป็นอย่างนั้นจริง — ถ้าคุณอ่านหน้านั้นแล้วรู้สึกว่า "เอ๊ะ มันไม่ใช่หรือเปล่า" ความรู้สึกนั้นถูกแล้ว
และนี่คือทักษะที่มีค่ากว่าการจำกฎ: เอกสารทุกฉบับพิมพ์ผิดได้ วิธีตรวจคือเอาตัวอย่างไปทาบกับกฎที่มันอ้างอิง ถ้าตัวอย่างขัดกับกฎของตัวเอง ให้เชื่อกฎ แล้วไปหาหลักฐานยืนยันจากที่อื่นในเอกสารเดียวกัน (ในเคสนี้คือหน้า 22)
(สไลด์ชุดนี้มีอีกจุดหนึ่งคล้าย ๆ กัน: หน้า 47 หัวข้อเขียนว่า "CORS Middleware" แต่คำอธิบายใต้หัวข้อกลับเป็นนิยามของ idempotency — เป็นข้อความตกค้างจากการ copy-paste)

4 · แล้ว PATCH /users/123/suspend ล่ะ ทำไมถึงถูก

หน้า 36 ของสไลด์ต่อจากหน้า 35 ทันที และหน้านี้เขียนถูก:

// ✅ แนะนำ
PATCH /users/123/suspend
PATCH /users/123/reset-password

ตรงนี้มือใหม่งงกันเยอะมาก — เมื่อกี้บอกว่าห้ามใส่ verb ใน URI แล้วนี่มันไม่ใช่ verb เหรอ
คำตอบอยู่ในตัวกฎเอง: "อย่าใส่ Action ใน URI ถ้า HTTP Method สื่อได้แล้ว" — เงื่อนไขท้ายประโยคคือหัวใจ

HTTP มี method ให้เลือกอยู่ไม่กี่ตัว และมันสื่อได้แค่ 5 อย่าง: อ่าน (GET) / สร้าง (POST) / แทนที่ทั้งก้อน (PUT) / แก้บาง field (PATCH) / ลบ (DELETE)
ทีนี้ลองถามว่า "ระงับบัญชี" คือหนึ่งใน 5 อย่างนั้นไหม

คำถามตรวจสอบcreate / deletesuspend
method ตัวไหนสื่อได้ตรง ๆ ไหม ได้ — POST / DELETE ไม่ได้ — ไม่มี method ชื่อ SUSPEND
มันคือการแก้ field เฉย ๆ หรือมีกฎธุรกิจของตัวเอง — มีกฎของตัวเอง (ยกเลิก session, ส่งเมล, เขียน audit log)
ถ้าเขียนเป็น PATCH /users/123 เฉย ๆ จะเป็นยังไง — client ต้องส่ง {"status":"suspended"} เอง
= เปิดให้ client เซ็ต status เป็นอะไรก็ได้
สรุป ผิดกฎ 7 เป็นข้อยกเว้นที่ถูกต้อง

แถวที่สามคือเหตุผลที่แข็งแรงที่สุด: PATCH /users/123 ที่รับ {"status":"suspended"} แปลว่าคุณกำลังบอกโลกว่า status เป็น field ที่แก้ได้ตามใจ — พรุ่งนี้จะมีคนส่ง {"status":"admin"} มาให้เห็น
ส่วน PATCH /users/123/suspend คือประตูบานเดียวที่เปิดไปได้ทางเดียว — ตัว URI เองกลายเป็นการจำกัดสิทธิ์

📌 กฎง่าย ๆ ที่ใช้ได้จริงเวลาตัดสินใจ
ถ้า action ที่คุณอยากทำแปลเป็นภาษา CRUD ได้ (สร้าง/อ่าน/แทนที่/ลบ ของสิ่งหนึ่ง) → ห้ามใส่ verb ใช้ method อย่างเดียว
ถ้ามันเป็น "การเปลี่ยนสถานะ" ที่มีกฎธุรกิจของตัวเอง (suspend, reset-password, publish, cancel, refund) → ใส่เป็น sub-path ได้ และใช้ POST หรือ PATCH
เกร็ด: อีกสำนักหนึ่งจะแปลง action นั้นให้กลายเป็น resource ไปเลย เช่น PUT /users/123/suspension — ทั้งสองแบบมีคนใช้จริงในระดับ production ข้อสำคัญกว่าคือทั้งโปรเจกต์เลือกแบบเดียวกัน (นั่นคือกฎข้อ 2 อีกที)

5 · ก่อนเขียนโค้ด ต้องตอบ 5 ช่องนี้ให้ครบ

สไลด์หน้า 38 ให้ checklist มา 5 บรรทัด และมันสั้นจนอ่านผ่านได้ง่ายมาก แต่นี่คือสิ่งที่แยก "endpoint ที่ทีมอื่นเอาไปใช้ได้" ออกจาก "endpoint ที่ต้องเดินไปถามคนเขียน"

ช่องคำถามที่ต้องตอบตัวอย่าง: POST /recipes ของ Wongnok
Request bodyส่งอะไรเข้ามา field ไหนบังคับ4 field: name · cooking_time · difficulty · ingredients
— บังคับแค่ name
Response statusสำเร็จแล้วตอบเลขอะไร201 Created (สร้างของใหม่ ไม่ใช่ 200)
Response bodyตอบอะไรกลับสูตรที่เพิ่งสร้าง พร้อม id ที่ server เป็นคนตั้ง
Error statusพังแบบไหนได้บ้าง แต่ละแบบเลขอะไร400 กรอกไม่ครบ · 401 ไม่ได้ล็อกอิน · 409 ชื่อซ้ำ
Error bodyหน้าตา error เหมือนกันทั้งระบบไหม{"error":"..."} รูปแบบเดียวทุก endpoint

ช่องที่คนลืมบ่อยที่สุดคือช่องสุดท้าย และมันเจ็บที่สุด
ถ้า endpoint หนึ่งตอบ {"error":"..."} อีก endpoint ตอบ {"message":"..."} อีกอันตอบข้อความเปล่า ๆ ไม่ใช่ JSON — ฝั่ง frontend ต้องเขียนโค้ดอ่าน error สามแบบ และจะมีวันที่เขาอ่านพลาดแล้วโชว์ undefined ให้ลูกค้าเห็น

ทั้ง 5 ช่องนี้รวมกันเรียกว่า contract และคำนี้ต้องเข้าใจตามตัวอักษร: มันคือสัญญา
พอมีคนเอา endpoint ของคุณไปใช้แล้ว คุณแก้ contract ฝ่ายเดียวไม่ได้อีก
นั่นคือเหตุผลที่ต้องมี versioning ซึ่งเป็นหัวข้อสุดท้ายของบทนี้ — แต่ก่อนจะไปถึงตรงนั้น มีคำถามที่แพงกว่ารออยู่

6 · Idempotency — คำที่แปลว่า "ยิงซ้ำได้ไม่พัง"

สไลด์หน้า 39 นิยามไว้ว่า "หากมีการเรียก operation เดิมซ้ำหลายครั้ง ผลลัพธ์สุดท้ายควรเหมือนเรียกเพียงครั้งเดียว" แล้วยกตัวอย่างที่ทุกคนกลัว: POST /payments แล้ว retry (= ยิงคำสั่งเดิมซ้ำอีกรอบเพราะรอคำตอบไม่ได้) — จ่ายสองรอบไหม?

🛗 เทียบให้เห็นภาพ — ปุ่มลิฟต์ กับ สั่งชานมไข่มุก

ยืนรอลิฟต์แล้วใจร้อน กดปุ่มรัว ๆ สิบที — ผลก็คือ ลิฟต์กำลังมาชั้นนี้ เท่าเดิม กดทีเดียวหรือสิบทีก็ได้ลิฟต์ตัวเดียวกัน ไม่มีลิฟต์เพิ่มมาอีกสิบตัว
แต่ถ้ายืนหน้าร้านชานมแล้วพูดว่า "แก้วนึงครับ" สิบครั้ง — คุณจะได้ชานมสิบแก้ว และจ่ายสิบเท่า

ความต่างไม่ได้อยู่ที่กดแรงหรือเบา แต่อยู่ที่ความหมายของคำสั่ง:
· ปุ่มลิฟต์แปลว่า "ทำให้ลิฟต์มาอยู่ที่ชั้นนี้" — พูดซ้ำก็ยังเป็นสภาพเดิมที่ต้องการ
· สั่งชานมแปลว่า "เพิ่มอีกหนึ่งแก้ว" — พูดซ้ำก็บวกเพิ่มไปเรื่อย ๆ
PUT กับ DELETE พูดแบบปุ่มลิฟต์ · POST พูดแบบสั่งชานม

ที่ไม่เหมือนกัน: ปุ่มลิฟต์กดซ้ำได้เพราะช่างเดินวงจรไว้ให้แล้ว กดผิดยังไงก็ไม่พัง
ฝั่ง API สเปก (RFC 9110) ก็กำหนดไว้ล่วงหน้าเหมือนกันว่า GET · PUT · DELETE ต้อง idempotent — แต่มันเป็นแค่สัญญาบนกระดาษ ไม่มีวงจรมาบังคับ · คนที่ต้องเขียน handler ให้ทำตามสัญญานั้นจริงคือคุณ เขียนพลาดเมื่อไหร่ PUT ก็ยิงซ้ำแล้วพังได้
ส่วน PATCH สเปกไม่ได้กำหนดไว้ให้ — จะยิงซ้ำได้หรือไม่ได้ ขึ้นกับว่าคุณเขียนคำสั่งลงไปว่าอะไร ("ตั้งชื่อเป็น A" ซ้ำได้ · "บวกแต้มเพิ่ม 10" ซ้ำไม่ได้) ซึ่งจะเห็นในตารางถัดไป

ก่อนไปต่อ ต้องเข้าใจว่าทำไมถึงมี retry ตั้งแต่แรก — ไม่ใช่เพราะผู้ใช้กดปุ่มรัว แต่เพราะเน็ตขาดตอนกลางทางแล้วไม่มีใครรู้ว่าถึงหรือยัง
(ในภาพข้างล่าง client คือฝั่งที่ยิงคำสั่งเข้ามา — เว็บ แอปมือถือ หรือ service อื่น · server คือฝั่งที่รับแล้วตอบกลับ)

  client                                server
    │                                     │
    │──── POST /payments ────────────────▶│  ตัดเงินเรียบร้อย ✔
    │                                     │
    │◀╌╌╌ 201 Created ╌╌╌╌✂╌╌ (เน็ตขาด) ╌╌│  server ทำงานเสร็จแล้ว
    │                                     │
  timeout — client เห็นแค่ "ไม่มีคำตอบ"
    │
    ├─▶ ยิงซ้ำ  = เสี่ยงตัดเงินสองรอบ
    └─▶ ไม่ยิงซ้ำ = เสี่ยงลูกค้าจ่ายไม่สำเร็จทั้งที่เงินออกไปแล้ว

client ไม่มีทางรู้เลยว่า request ตายก่อนถึง server หรือตายตอนขากลับ — สองเคสนี้หน้าตาเหมือนกันเป๊ะจากฝั่ง client นี่คือเหตุผลที่ทุก HTTP client ในโลกจริงมี retry และเป็นเหตุผลที่ idempotency ต้องมีอยู่

methodsafe
(ไม่เปลี่ยนอะไรเลย)
idempotent
(ยิงซ้ำแล้ว state ปลายทางเหมือนเดิม)
เหตุผล
GET✅✅แค่อ่าน ยิงกี่ครั้งก็ไม่แตะข้อมูล
PUT❌✅"แทนที่ทั้งก้อนด้วยค่านี้" ทับซ้ำก็ได้ผลเดิม
DELETE❌✅ลบซ้ำ ปลายทางก็คือ "ไม่มีของชิ้นนี้" เหมือนกัน
POST❌❌"สร้างของใหม่" ยิงสองครั้งได้ของสองชิ้น
PATCH❌แล้วแต่ที่คุณเขียน{"name":"A"} ซ้ำได้ · {"add_points":10} ซ้ำไม่ได้

⚠️ idempotent วัดที่ "สถานะปลายทางของ server" ไม่ใช่ที่ "response ที่ client ได้"
นี่คือประโยคที่ต้องอ่านสามรอบ เพราะเดี๋ยวหัวข้อ 7 จะโชว์เคสที่ status code ต่างกันในสองครั้ง แต่ยัง idempotent อยู่
และสังเกตว่า PATCH ไม่ได้ถูกกำหนดมาให้ idempotent — มันขึ้นกับว่าคุณออกแบบ body ยังไง body ที่บอก "ค่าใหม่คือเท่านี้" ซ้ำได้ ส่วน body ที่บอก "เอาของเดิมบวกเพิ่ม" ซ้ำไม่ได้

🗣 ศัพท์ที่เพิ่งเรียนไป
idempotent
“ยิงซ้ำได้ไม่พัง” = ปุ่มลิฟต์ · อ่านว่า ไอ-เด็ม-โพ-เทนต์ · เวลารีวิวโค้ดกันให้ถามว่า “endpoint นี้ idempotent ไหม ถ้า client retry จะเกิดอะไรขึ้น” — ตรงกว่าถามว่า “กดสองทีแล้วพังไหม” เยอะ
safe method
method ที่ไม่เปลี่ยนข้อมูลเลย — ที่เจอบ่อยคือ GET กับ HEAD (สเปก RFC 9110 นับ OPTIONS กับ TRACE เป็น safe ด้วย แต่แทบไม่มีใครเขียนเองในงานจริง อย่าเผลอตอบว่า “มีแค่สองตัว”) · อย่าสับสนกับ idempotent — DELETE ไม่ safe (มันลบของจริง) แต่ idempotent
state
“สภาพของข้อมูลบน server ตอนนี้” · ประโยคหลักของหัวข้อนี้คือ “idempotency วัดกันที่ state ปลายทาง ไม่ใช่ที่ status code” — จำประโยคนี้ไว้ทั้งประโยคได้เลย
retry
การยิงคำสั่งเดิมซ้ำเมื่อไม่ได้คำตอบ · ระบบจริงมี retry อยู่แทบทุกเส้นทาง แต่ อย่าเหมาว่า library พื้นฐานทำให้เอง — fetch กับ axios ไม่ retry ให้เลยถ้าไม่ได้ใส่เอง · Go http.Client retry เฉพาะเคสแคบมาก (connection เก่าที่ค้างใน pool แล้วดันตายพอดีตอนจะยิง และเฉพาะ request ที่มันถือว่ายิงซ้ำได้) · ตัว retry ที่เจอจริงมักมาจากโค้ดที่ทีมเขียนเอง หรือชั้นที่คุณมองไม่เห็น — SDK ของผู้ให้บริการ, load balancer, service mesh · คำถามที่ควรถามก่อนปล่อยของขึ้น production คือ “เส้นทางนี้มีใคร retry ให้บ้าง กี่ครั้ง”
timeout
“รอจนหมดเวลาแล้วยังไม่มีคำตอบ” · จุดสำคัญคือ timeout ไม่ได้แปลว่างานไม่สำเร็จ มันแปลว่าไม่รู้ — และความไม่รู้นี่แหละคือต้นเหตุของทั้งหัวข้อ

ทายก่อนรัน — endpoint POST /payments ตัดเงินครั้งละ 500 บาท แล้วคืนยอดสะสมที่ตัดไปแล้วทั้งหมดมาด้วย ผมยิงมันสามครั้งติดกันโดยไม่เปลี่ยนอะไรเลย (จำลอง client ที่ retry เพราะ timeout)

$ curl -X POST http://127.0.0.1:53691/payments   # ยิงสามครั้งติด

ทั้งสามครั้งได้ status อะไร · payment_id เหมือนกันไหม · และสุดท้าย charged_total เป็นเท่าไหร่

{"payment_id":"pay_1","amount":500,"charged_total":500}   <- 201
{"payment_id":"pay_2","amount":500,"charged_total":1000}  <- 201
{"payment_id":"pay_3","amount":500,"charged_total":1500}  <- 201

$ curl http://127.0.0.1:53691/total
charged_total=1500 payments=pay_1,pay_2,pay_3

ทั้งสามครั้งสำเร็จหมด ได้ 201 ครบ ไม่มี error สักตัว — และลูกค้าโดนตัดเงินไป 1,500 บาท จากการกดจ่าย 500 บาทครั้งเดียว

จุดที่ต้องเห็นให้ชัด: ไม่มีอะไรพังเลย ไม่มี exception ไม่มี 500 log สวยงามทั้งสามบรรทัด — server ทำถูกต้องตามที่ถูกสั่งทุกประการ ปัญหาไม่ได้อยู่ที่โค้ดพัง ปัญหาอยู่ที่คำสั่ง "สร้าง payment ใหม่" ถูกส่งมาสามครั้งจริง ๆ — และ server ไม่มีทางรู้ว่าสามครั้งนั้นคือเจตนาเดียวกัน

เทียบกับ PUT ที่ยิงซ้ำแบบเดียวกัน — ผมยิงสองครั้งด้วย body เดิม:

$ curl -X PUT -d '{"name":"Somchai"}' http://127.0.0.1:53691/users/9   # ยิงสองครั้ง
{"id":"9","name":"Somchai"}  <- 200
{"id":"9","name":"Somchai"}  <- 200

เหมือนกันทุกตัวอักษร เพราะ PUT แปลว่า "ทำให้ user 9 มีหน้าตาเป็นแบบนี้" ไม่ใช่ "เพิ่มอีกหนึ่ง" — ความต่างอยู่ที่ความหมายของคำสั่ง ไม่ได้อยู่ที่ความเก่งของโค้ด

7 · DELETE /users/1 สองครั้ง ครั้งที่สองควรตอบ 404 หรือ 204

นี่เป็นคำถามที่ถูกเถียงกันในทุกทีมที่เคยทำ API และมันไม่มีคำตอบเดียว — แต่การรู้ว่าทำไมถึงไม่มีคำตอบเดียว มีค่ากว่าการจำคำตอบ ผมเขียนทั้งสองแบบแล้วยิงจริง
(กล่องแดง/เขียวข้างล่างเป็นแค่สีของกล่องเปรียบเทียบ ไม่ได้แปลว่าแบบ A ผิด — เดี๋ยวจะเห็นว่าถูกทั้งคู่)

แบบ A — ครั้งที่สองตอบ 404

1st DELETE /users/1 -> 204
2nd DELETE /users/1 -> 404
{"error":"user not found"}

พูดความจริงว่า "ตอนคุณสั่ง มันไม่มีอยู่แล้ว"
แต่ client ที่ retry อัตโนมัติจะเห็น error ทั้งที่งานสำเร็จไปแล้ว

แบบ B — ตอบ 204 เสมอ

1st DELETE /users/2 -> 204
2nd DELETE /users/2 -> 204

พูดถึงผลลัพธ์ว่า "ตอนนี้ไม่มี user 2 แล้ว ตามที่คุณต้องการ"
retry เงียบสนิท แต่ client แยกไม่ออกว่าเพิ่งลบ หรือไม่เคยมี

สำคัญที่สุด: ทั้งสองแบบ idempotent ทั้งคู่
เพราะ idempotency วัดที่สถานะปลายทางของ server — และปลายทางของทั้งสองแบบเหมือนกันเป๊ะ คือ "ไม่มี user คนนี้" ไม่ว่าจะยิงกี่ครั้ง · status code ที่ต่างกันไม่ได้ทำให้มันเลิก idempotent

เวลาเลือกจริง ให้ถามว่าใครเป็นคนเรียก endpoint นี้:

สถานการณ์เลือกเพราะ
หน้าเว็บที่ผู้ใช้กดปุ่มลบเอง 404 ถ้าของหายไปแล้ว ผู้ใช้ควรได้รู้ว่าหน้าจอตัวเองเก่า ไม่ใช่กดแล้วเงียบ
job / service อื่นที่มี retry อัตโนมัติ 204 retry ที่ "สำเร็จซ้ำ" ไม่ควรกลายเป็น error ใน log จนคนตามหาสาเหตุผิดทาง
ตัดสินใจไม่ได้จริง ๆ อันไหนก็ได้ แต่ต้องเขียนลงช่อง "Error status" ของ contract แล้วทำเหมือนกันทั้งระบบ

⚠️ สิ่งที่ผิดแน่ ๆ ไม่ว่าจะเลือกแบบไหน
1. ตอบ 500 เพราะ "ลบไม่ได้" — ไม่มีอะไรพังที่ฝั่ง server เลย การลบของที่ไม่มีไม่ใช่ความผิดพลาดของระบบ
2. ตอบ 200 พร้อม body ว่างเปล่า — ถ้าไม่มีอะไรจะส่งกลับ 204 No Content คือ status ที่ตรงความหมายกว่า
3. ตอบไม่เหมือนกันในแต่ละ endpoint — DELETE /recipes/1 ตอบ 404 แต่ DELETE /users/1 ตอบ 204 คือฝันร้ายของคนที่ใช้ API คุณ

8 · แล้ว POST ที่ retry ไม่ได้ล่ะ — Idempotency-Key

POST จะไม่มีวัน idempotent ได้ด้วยตัวมันเอง เพราะความหมายของมันคือ "สร้างของใหม่"
ทางออกจึงไม่ใช่การแก้ method แต่คือให้ client บอก server ว่า "สาม request นี้คือเจตนาเดียวกันนะ" — ด้วย header ที่ client เป็นคนสร้างค่าเอง

🧾 เทียบให้เห็นภาพ — เลขที่บิลในร้านอาหาร

ร้านตามสั่งช่วงเที่ยง คนแน่น เสียงดัง เด็กเสิร์ฟตะโกนบอกครัวว่า "ต้มยำกุ้งหนึ่ง!" แล้วไม่แน่ใจว่าครัวได้ยินไหม เลยตะโกนซ้ำอีกสองรอบ — ผลคือลูกค้าได้ต้มยำสามถ้วย

ร้านที่จัดการเรื่องนี้เป็น จะไม่ตะโกนลอย ๆ แต่เขียนบิลเลข 77 แล้วเสียบไว้ที่ราวหน้าครัว
· ตะโกนย้ำกี่รอบก็อ้างบิลใบเดิม ครัวเห็นว่าบิล 77 ทำไปแล้ว ก็ไม่ทำซ้ำ แต่ยกถ้วยเดิมที่ทำเสร็จไว้แล้วส่งออกมา — หน้าตาเหมือนเพิ่งทำใหม่ทุกประการ ทั้งที่ไม่ได้ทำเพิ่มสักถ้วย
· ส่วนลูกค้าที่ตั้งใจสั่งเพิ่มจริง ๆ จะได้บิลใบใหม่เลข 78 ครัวก็ทำให้ตามปกติ
บิลเลข 77 นี่แหละคือ Idempotency-Key — และคนที่เขียนเลขบิลคือเด็กเสิร์ฟ (client) ไม่ใช่ครัว (server) เพราะคนที่รู้ว่า "นี่คือออเดอร์เดียวกันนะ" คือฝั่งที่รับออเดอร์มา

ที่ไม่เหมือนกัน: ร้านอาหารมีราวเสียบบิลอยู่ที่เดียว ทุกคนในครัวเห็นตรงกันหมด แต่ระบบจริงมักมี server หลายเครื่องช่วยกันรับงาน — ถ้าแต่ละเครื่องจำบิลไว้ในหัวตัวเอง เครื่องที่ไม่เคยเห็นบิล 77 ก็จะทำซ้ำอยู่ดี (ตรงกับกับดักข้อ 2 ในกล่องข้างล่าง)

  client สร้าง key ครั้งเดียว ก่อนยิงครั้งแรก  →  key = "8f3c-01"
    │
    ├─▶ POST /payments   Idempotency-Key: 8f3c-01
    │      server: ยังไม่เคยเห็น key นี้ → ตัดเงินจริง → เก็บ (key → คำตอบ) ไว้
    │
    ├─▶ POST /payments   Idempotency-Key: 8f3c-01   (retry เพราะ timeout)
    │      server: เคยเห็นแล้ว → ไม่ทำอะไรใหม่ → คืนคำตอบเดิมที่เก็บไว้
    │
    └─▶ POST /payments   Idempotency-Key: 8f3c-02   (ผู้ใช้ตั้งใจจ่ายรอบใหม่จริง ๆ)
           server: key ใหม่ → ตัดเงินอีกรอบ ถูกต้องแล้ว

ผมทำ endpoint แบบนี้แล้วยิง key เดิมสามครั้ง แล้วตามด้วย key ใหม่หนึ่งครั้ง (... ในคำสั่งข้างล่างคือ http://127.0.0.1:53691 ตัวเดิม ย่อไว้ให้อ่านง่าย):

$ curl -X POST -H 'Idempotency-Key: 8f3c-01' .../idem/payments   # สามครั้ง
{"payment_id":"pay_4","amount":500,"charged_total":2000}  <- 201
{"payment_id":"pay_4","amount":500,"charged_total":2000}  <- 201
{"payment_id":"pay_4","amount":500,"charged_total":2000}  <- 201

# คนละ key = คนละเจตนา
$ curl -X POST -H 'Idempotency-Key: 8f3c-02' .../idem/payments
{"payment_id":"pay_5","amount":500,"charged_total":2500}  <- 201

charged_total ขยับจาก 1500 → 2000 ครั้งเดียว ตลอดสาม request แรก แล้วขยับเป็น 2500 ตอนเปลี่ยน key — นี่คือพฤติกรรมที่เราต้องการเป๊ะ ๆ

สังเกตว่าครั้งที่ 2 และ 3 ก็ยังตอบ 201 Created ทั้งที่ไม่ได้สร้างอะไรใหม่ — เพราะ server กำลังเล่นเทปคำตอบเดิม ให้ฟัง จุดนี้คนออกแบบมักเติม header บอกไว้ให้ client รู้ตัว ผมใส่ไว้ด้วย:

# -D -  = พิมพ์ response header ออกจอ · -o /dev/null = ทิ้ง body ไม่ต้องโชว์
$ curl -D - -o /dev/null -X POST -H 'Idempotency-Key: 8f3c-01' .../idem/payments
HTTP/1.1 201 Created
Idempotent-Replay: true

⚠️ กับดักที่ทำให้ Idempotency-Key ไร้ประโยชน์ทันที
1. client สร้าง key ใหม่ทุกครั้งที่ retry — key ต้องเกิด ครั้งเดียวตอนผู้ใช้กดปุ่ม แล้วใช้ค่าเดิมตลอดทุก retry ถ้าสร้างใหม่ทุกรอบ มันก็แค่ POST ธรรมดาที่มี header ประดับ
2. เก็บ key ไว้ใน RAM ของ process — deploy ใหม่หรือมีหลาย instance (= โปรแกรมตัวเดียวกันรันหลายตัวพร้อมกันเพื่อช่วยกันรับงาน) แล้ว key หายทันที (นี่คือปัญหาเดียวกับที่ บทเรียน 32 พูดถึง — ต้องเก็บใน store กลาง)
3. ไม่ผูก key กับผู้ใช้ — ถ้า key เป็นของกลางทั้งระบบ คนอื่นเดา key ชนแล้วจะได้เห็นคำตอบของคนอื่น · เก็บเป็น (user_id, key) เสมอ
4. เก็บตลอดกาล — ตั้งอายุให้มันด้วย (24 ชม. เป็นค่าที่หลายเจ้าใช้) ไม่งั้นตารางนี้จะโตไม่หยุด

📌 สถานะของ header ตัวนี้ — Idempotency-Key ยังไม่ใช่มาตรฐาน HTTP อย่างเป็นทางการ ตอนนี้เป็น Internet-Draft ของ IETF อยู่ (= ร่างที่ยังอยู่ระหว่างพิจารณา ยังไม่ประกาศใช้เป็นมาตรฐาน)
แต่มันเป็นธรรมเนียมที่ผู้ให้บริการชำระเงินใช้ตรงกันมานาน (Stripe ใช้ชื่อนี้) จนถือเป็นชื่อมาตรฐานโดยพฤตินัยไปแล้ว — ใช้ชื่อนี้ อย่าไปตั้งชื่อเองใหม่

🗣 ศัพท์ที่เพิ่งเรียนไป
Idempotency-Key
“เลขที่บิล” ที่ client สร้างเองแล้วแนบมาทุก retry เพื่อบอกว่าเป็นเจตนาเดียวกัน · ประโยคที่เอาไปคุยได้เลย: “endpoint จ่ายเงินตัวนี้รับ Idempotency-Key ไหม แล้วเก็บ key ไว้ที่ไหน อายุเท่าไหร่” — สามคำถามนี้ครอบคลุมกับดักเกือบทั้งหมด
replay
“เล่นเทปคำตอบเดิม” — server ไม่ได้ทำงานใหม่ แต่คืนคำตอบที่เคยเก็บไว้ · ตัวอย่างในบทนี้ใช้ header Idempotent-Replay: true บอก client ว่า “นี่คือเทปนะ” — แต่ชื่อ header ตัวนี้ไม่มีมาตรฐานกลาง แต่ละเจ้าตั้งเอง (IETF draft ของ Idempotency-Key ไม่ได้นิยาม header แจ้ง replay ไว้เลย · Stripe ใช้ชื่อ Idempotent-Replayed) — ถ้าจะใช้ ตกลงชื่อกับทีมแล้วเขียนลง contract ก่อน
shared store / store กลาง
ที่เก็บข้อมูลที่ server ทุกเครื่องมองเห็นเหมือนกัน (เช่น Redis, ฐานข้อมูล) = “ราวเสียบบิลอันเดียวที่ทุกคนในครัวเห็น” · ตรงข้ามกับการเก็บไว้ในหน่วยความจำของเครื่องใครเครื่องมัน
TTL (time to live)
“อายุของข้อมูล” — ครบเวลาแล้วลบทิ้งเอง · ที่กับดักข้อ 4 บอกว่าอย่าเก็บ key ตลอดกาล คำที่เขาใช้เรียกคือ “ตั้ง TTL ให้ key 24 ชั่วโมง”
de facto standard
“มาตรฐานโดยพฤตินัย” — ไม่มีใครประกาศเป็นกฎ แต่ทั้งวงการใช้ตรงกันจนกลายเป็นมาตรฐานไปเอง · Idempotency-Key คือตัวอย่างชั้นดี

9 · Versioning — "breaking change" คืออะไรกันแน่

สไลด์หน้า 37 บอกว่า versioning มีไว้ "รองรับกรณีที่เกิด Breaking Change เพื่อให้ Client เดิมที่ยังไม่ Migrate สามารถทำงานต่อไปได้ในช่วงเวลาที่กำหนด" แล้วโชว์ /api/v1/users → /api/v2/users

ประโยคนั้นถูก แต่มันข้ามคำถามที่ยากที่สุดไป: รู้ได้ยังไงว่าอะไรคือ breaking change
คำจำกัดความที่ใช้ได้จริงมีข้อเดียว — "client ตัวเก่าที่ไม่ได้แก้อะไรเลย ยังทำงานถูกต้องอยู่ไหม" ถ้าคำตอบคือไม่ นั่นคือ breaking
ผมเลยทดลองเป็น client เก่าจริง ๆ: struct ตัวนี้เขียนไว้ตั้งแต่ v1 แล้วไม่เคยแก้อีกเลย

// client เก่า — ไม่เคยแก้ตั้งแต่ v1
type Recipe struct {
    ID          int    `json:"id"`
    Name        string `json:"name"`
    CookingTime int    `json:"cooking_time"`
}

// แล้วเอา JSON 5 แบบนี้มา json.Unmarshal ใส่ struct เดิม
เพิ่ม field ใหม่   {"id":7,"name":"ต้มยำกุ้ง","cooking_time":30,"difficulty":"medium"}
ลบ field ทิ้ง      {"id":7,"name":"ต้มยำกุ้ง"}
เปลี่ยนชื่อ field  {"id":7,"name":"ต้มยำกุ้ง","cookingTime":30}
เปลี่ยน type      {"id":7,"name":"ต้มยำกุ้ง","cooking_time":"30 นาที"}
เปลี่ยนความหมาย   {"id":7,"name":"ต้มยำกุ้ง","cooking_time":1800}  // เปลี่ยนหน่วยนาที→วินาที

ห้าแบบนี้ แบบไหนได้ err != nil บ้าง และ แบบไหน "ผ่านฉลุยแต่ค่าผิด" — อันหลังนี่แหละที่อันตรายกว่า

// แถวแรกคือ JSON v1 เดิม ใส่ไว้เป็นตัวเทียบ ที่เหลือคือ 5 แบบในโจทย์
v1 เดิม                err=<nil>
                       ได้ {ID:7 Name:ต้มยำกุ้ง CookingTime:30}
เพิ่ม field ใหม่       err=<nil>
                       ได้ {ID:7 Name:ต้มยำกุ้ง CookingTime:30}
ลบ field cooking_time  err=<nil>
                       ได้ {ID:7 Name:ต้มยำกุ้ง CookingTime:0}
เปลี่ยนชื่อ field      err=<nil>
                       ได้ {ID:7 Name:ต้มยำกุ้ง CookingTime:0}
เปลี่ยน type           err=json: cannot unmarshal string into Go struct field Recipe.cooking_time of type int
                       ได้ {ID:7 Name:ต้มยำกุ้ง CookingTime:0}
เปลี่ยนความหมาย        err=<nil>
                       ได้ {ID:7 Name:ต้มยำกุ้ง CookingTime:1800}

มีแค่แบบเดียวที่ error คือเปลี่ยน type — อีกสี่แบบเงียบสนิท

และแบบที่อันตรายที่สุดคือแถวสุดท้าย: err=<nil> ค่าเป็นเลขจำนวนเต็มสวย ๆ ไม่มีอะไรน่าสงสัยเลย — แต่หน้าจอลูกค้าจะขึ้นว่าเมนูนี้ใช้เวลาทำ 1800 นาที
เทียบกับแถว "ลบ field" ที่ได้ 0 — อันนั้นยังพอมีโอกาสมีคนเอะใจว่า "ทำอาหาร 0 นาทีได้ไง" แต่ 1800 ดูเหมือนตัวเลขจริงทุกประการ

การเปลี่ยนแปลงbreaking ไหม
✅ = ปลอดภัย · ❌ = พัง
client เก่าเจออะไร
เพิ่ม field ใหม่ ใน response✅ ไม่ breakingerr=<nil> · field เดิมครบ · field ใหม่ถูกข้ามไปเฉย ๆ
เพิ่ม field ที่ไม่บังคับ ใน request✅ ไม่ breakingclient เก่าไม่ส่งมา server ใช้ค่า default
ลบ field ออกจาก response❌ breakingได้ zero value เงียบ ๆ (0, "") ไม่มี error
เปลี่ยนชื่อ field❌ breakingเหมือนโดนลบทุกประการ — cookingTime ไม่แมตช์ cooking_time
เปลี่ยน type (int → string)❌ breakingerror ชัดเจน — อันนี้โชคดีที่สุดแล้ว
เปลี่ยนความหมาย/หน่วย❌❌ breaking ที่แย่ที่สุดไม่มีอะไรเตือนเลย ทุกอย่างดูปกติ แต่คำตอบผิด
เพิ่ม field บังคับ ใน request❌ breakingclient เก่ายิงเหมือนเดิมแล้วโดน 400 ทันที
🏷️ เทียบให้เห็นภาพ — ป้ายราคาที่ตลาดสด

แผงหมูติดป้ายว่า "สามชั้น 180" มาตลอด ทุกคนเข้าใจตรงกันว่า 180 บาทต่อกิโล
วันหนึ่งแม่ค้าเปลี่ยนมาคิดต่อขีด แต่ป้ายยังเขียน "180" เหมือนเดิมเป๊ะ
ลูกค้าประจำอ่านป้ายออก ตัวเลขก็ดูปกติ ไม่มีอะไรน่าสงสัย — จนถึงตอนจ่ายเงิน

ทีนี้ลองเทียบกับอีกสองกรณี:
· แม่ค้าเอาป้ายออกไปเลย → เครื่องคิดเงินไม่ได้ฟ้องอะไรสักคำ มันคิดราคาช่องนั้นเป็น 0 บาท ให้เงียบ ๆ · ยังพอมีคนเอะใจได้บ้างว่า "หมูฟรีเหรอ" แต่ไม่มีอะไรเตือน
· แม่ค้าเขียนเป็นตัวหนังสือว่า "หนึ่งร้อยแปดสิบ" → เครื่องคิดเงินอ่านไม่ออก ฟ้องทันที
สามกรณีนี้คือ เปลี่ยนความหมาย · ลบ field · เปลี่ยน type ตามลำดับ และเรียงจากแย่ที่สุดไปหาโชคดีที่สุดพอดี — อันที่ไม่มีอะไรฟ้องเลย คืออันที่แพงที่สุด

ที่ไม่เหมือนกัน: ที่ตลาดยังมีแม่ค้ายืนอยู่ให้ถามได้ และถามครั้งเดียวก็จบ แต่ API ของคุณคุยกับโปรแกรมของคนที่คุณไม่เคยเจอ เขาไม่มีใครให้ถาม และไม่มีทางรู้ว่าคุณเปลี่ยนหน่วยไปเมื่อวาน — ทางเดียวที่บอกเขาได้คือขึ้นเวอร์ชันใหม่ ซึ่งเป็นเรื่องที่จะพูดถึงต่อจากนี้

💡 กับดักซ้อน: "เพิ่ม field" ไม่ breaking เสมอไป

ข้อแรกของตารางมีเงื่อนไขเงียบ ๆ ซ่อนอยู่ — client ต้องยอมข้าม field ที่ไม่รู้จัก
ซึ่งเป็นพฤติกรรม default ของ json.Unmarshal แต่ถ้า client เปิดโหมดเข้มงวด:

// js คือสตริง JSON ก้อนเดิมที่มี "difficulty" เพิ่มเข้ามา
dec := json.NewDecoder(strings.NewReader(js))
dec.DisallowUnknownFields()      // เจอ field แปลกปลอม = error
err := dec.Decode(&recipe)      // ใช้ Decode ไม่ใช่ json.Unmarshal

DisallowUnknownFields err=json: unknown field "difficulty"

การเพิ่ม field กลายเป็น breaking change ทันที
บทเรียนที่ได้: "breaking หรือไม่" ไม่ได้ขึ้นกับฝั่ง server ฝ่ายเดียว มันขึ้นกับว่า client อ่านคำตอบยังไงด้วย — เวลาออกแบบ API สาธารณะ จึงต้องเขียนลง contract ไปเลยว่า "client ต้องข้าม field ที่ไม่รู้จัก"

🗣 ศัพท์ที่เพิ่งเรียนไป
breaking change
“แก้แล้วของเดิมพัง” = client เก่าที่ไม่ได้แก้อะไรเลย ทำงานไม่ถูกต้องอีกต่อไป · ประโยคที่ต้องพูดให้เป็นในที่ประชุม: “อันนี้ breaking นะ ต้องขึ้น v2” แทนที่จะพูดว่า “แก้แล้วน่าจะมีคนพัง”
backward compatible
ตรงข้ามกับ breaking — “ของเก่ายังใช้ได้เหมือนเดิม” · การเพิ่ม field ใน response ถือว่า backward compatible (ถ้า client ยอมข้าม field ที่ไม่รู้จัก)
contract
“สัญญา” = 5 ช่องจากหัวข้อ 5 (request body / response status / response body / error status / error body) · เวลาถาม senior ให้ถามว่า “contract ของ endpoint นี้เขียนไว้ที่ไหน” จะได้เอกสารมาเลย แทนที่จะได้คำอธิบายปากเปล่า
versioning
การติดเลขเวอร์ชันให้ API (/api/v1 → /api/v2) เพื่อให้ของเก่ากับของใหม่อยู่ด้วยกันได้ชั่วคราว
deprecate / sunset
deprecate = ประกาศว่า “ของชิ้นนี้เลิกใช้แล้วนะ แต่ยังวิ่งอยู่” · sunset = วันที่จะปิดจริง · คำถามที่ต้องถามทุกครั้งที่ขึ้น v2 คือ “v1 จะ sunset วันไหน” — ถ้าไม่มีคำตอบ แปลว่ายังไม่มีแผน
zero value
ค่าตั้งต้นที่ Go ใส่ให้เมื่อไม่มีข้อมูล (0 สำหรับ int, "" สำหรับ string) · นี่คือสาเหตุที่ “ลบ field” ไม่เกิด error — json.Unmarshal ปล่อยให้ field นั้นเป็น zero value เงียบ ๆ

พอรู้ว่าอะไรคือ breaking แล้ว กฎของ /api/v1 → /api/v2 ก็ตรงไปตรงมา:

คำถามคำตอบ
เมื่อไหร่ขึ้น v2เมื่อมีอย่างน้อยหนึ่งอย่างในตารางข้างบนที่เป็น breaking · ไม่ใช่ทุกครั้งที่มีฟีเจอร์ใหม่
ขึ้น v2 แล้ว v1 ทำยังไงยังต้องวิ่งอยู่ ไปอีกช่วงเวลาที่ประกาศไว้ชัดเจน — ถ้าปิด v1 วันเดียวกับที่เปิด v2 การมี v2 ก็ไม่มีความหมาย
เอา version ไว้ที่ path หรือ headerสไลด์เลือก path (/api/v1/...) ซึ่งมองเห็นด้วยตาใน log และ curl ได้ทันที เหมาะกับทีมที่เพิ่งเริ่ม
v2 ต้องเขียนใหม่ทั้งชุดไหมไม่ — endpoint ที่ contract ไม่เปลี่ยน ให้ v2 เรียกโค้ดตัวเดิม เปลี่ยนเฉพาะเส้นที่ contract เปลี่ยนจริง

10 · เอาทั้งบทมาทาบกับ Wongnok

สไลด์หน้า 71–72 ให้ API spec ของโปรเจกต์ Wongnok (เว็บรวมสูตรอาหาร) มาแล้ว ลองเอากฎทั้งหมดในบทนี้ไปทาบดู

endpointตรวจตามกฎ 7 ข้อidempotent?
GET /recipes · GET /recipes/:id✅ noun, พหูพจน์, lowercase✅ safe ด้วย
POST /recipes✅ ตรงตามที่หน้า 22 บอก❌ ต้องระวังการยิงซ้ำ
PUT /recipes/:id✅✅
DELETE /recipes/:id✅ — และนี่คือเส้นที่ต้องตัดสินใจ 404 vs 204✅
POST /recipes/:id/favorite✅ relationship อยู่ที่ path (กฎ 5)ควรทำให้ idempotent — กดถูกใจซ้ำไม่ควรได้ 2 ดาว
DELETE /recipes/:id/favorite✅✅
POST /recipes/:id/rating✅ขึ้นกับกติกา: 1 คน 1 คะแนน → ควรเป็น PUT ด้วยซ้ำ
GET /users/:id · PUT /users/:id✅✅
GET /login · GET /callback
POST /exchange · GET /logout
แหกกฎ 1 และ 7 เต็ม ๆ — เป็น verb ล้วน—

แถวสุดท้ายไม่ใช่ความผิดพลาด — endpoint กลุ่ม auth คือข้อยกเว้นที่โลกยอมรับกันทั่วไป เพราะ /login ไม่ได้กำลัง "สร้าง resource" มันคือขั้นตอนของโปรโตคอล (OAuth เรียกชื่อพวกนี้มาตายตัวอยู่แล้ว ทีมคุณเปลี่ยนชื่อเองไม่ได้)
นี่คือบทเรียนสำคัญของทั้งบท: กฎพวกนี้เป็นค่าตั้งต้นที่ดี ไม่ใช่กฎหมาย — แต่คุณควรแหกกฎเพราะมีเหตุผลที่พูดออกมาได้ ไม่ใช่เพราะไม่รู้ว่ามีกฎ

ส่วนแถว POST /recipes/:id/favorite คือของจริงที่คุณจะเจอในโปรเจกต์นี้: ผู้ใช้กดหัวใจแล้วเน็ตช้า เขากดซ้ำอีกที ถ้าคุณเขียนเป็น "เพิ่มแถวใน favorites" เขาจะได้สองแถว — แต่ถ้าเขียนเป็น "ทำให้ (user, recipe) คู่นี้อยู่ในตาราง favorites" กดกี่ครั้งก็ได้ผลเดียว · ความต่างอยู่ที่ประโยคที่คุณคิดในหัว ไม่ใช่ที่ syntax

🎯 สามประโยคที่ควรติดตัวออกจากบทนี้

1. URI บอกว่าของอะไร · method บอกว่าทำอะไร · query บอกว่าเอาชุดย่อยแบบไหน — ถ้าจำได้แค่นี้ กฎ 7 ข้อจะตามมาเอง
2. idempotent วัดที่สถานะปลายทางของ server ไม่ใช่ที่ status code — DELETE ที่ตอบ 204 กับ 404 idempotent เท่ากัน
3. breaking change ที่แพงที่สุดไม่ทำให้เกิด error — มันคือการเปลี่ยนความหมาย ของ field ที่หน้าตาเหมือนเดิมทุกประการ

ทดสอบความเข้าใจ

1. สไลด์หน้า 35 ติด ✅ ไว้เหนือ POST /users/create กับ POST /users/123/delete — ข้อไหนอธิบายสถานการณ์นี้ได้ถูกต้อง?

กฎข้อ 7 เขียนไว้เองว่า "อย่าใส่ Action ใน URI ถ้า HTTP Method สื่อได้แล้ว" — create กับ delete มี POST/DELETE สื่อให้อยู่แล้ว ตัวอย่างที่ติด ✅ จึงขัดกับหัวข้อของตัวเอง และขัดกฎข้อ 1 (noun ไม่ใช่ verb) ด้วย
หลักฐานยืนยันอยู่ในสไลด์ชุดเดียวกัน หน้า 22 (REST – Method) ที่เขียนว่า // สร้าง User → POST /users และ // ลบ User 123 → DELETE /users/123 ตรงกับคู่ที่หน้า 35 ติด ❌ ไว้พอดี
ตัวเลือกที่ 1 ผิดเพราะความอ่านง่ายมาจากการเดาได้ ซึ่งได้จากรูปแบบที่สม่ำเสมอ ไม่ใช่จากการเติม verb · ตัวเลือกที่ 3 ผิดเพราะสไลด์ตั้งกฎไว้เองแล้ว 7 ข้อ การบอกว่า "อะไรก็ได้" คือละเลยกฎที่ตัวเองเพิ่งสอน · ตัวเลือกที่ 4 ยกเหตุผลที่ไม่เกี่ยว — การลบด้วยเงื่อนไขใช้ query หรือ path param ได้ ไม่ต้องพึ่ง body

2. ยิง DELETE /users/1 สองครั้งติดกัน ครั้งแรกได้ 204 ครั้งที่สองได้ 404 — แปลว่า endpoint นี้ไม่ idempotent ใช่ไหม?

นิยามของ idempotent คือ "เรียกซ้ำแล้วผลลัพธ์สุดท้ายเหมือนเรียกครั้งเดียว" ซึ่ง "ผลลัพธ์สุดท้าย" หมายถึงสถานะบน server — หลังยิงหนึ่งครั้งกับสิบครั้ง สถานะเหมือนกันคือ "ไม่มี user 1" จึง idempotent
ผมรันทั้งสองแบบให้ดูในหัวข้อ 7: แบบตอบ 204/404 กับแบบตอบ 204/204 idempotent เท่ากันทั้งคู่ ต่างกันแค่ว่าจะเล่าเรื่องให้ client ฟังยังไง
ตัวเลือกที่ 2 และ 3 ผิดที่จุดเดียวกัน คือเอา status code มาเป็นเกณฑ์วัด · ตัวเลือกที่ 4 สรุปถูกแต่ให้เหตุผลผิดหนัก — DELETE ไม่ใช่ safe method (safe = ไม่เปลี่ยนอะไรเลย มีแค่ GET/HEAD) มันเปลี่ยนข้อมูลจริง เพียงแต่เปลี่ยนซ้ำแล้วผลเท่าเดิม

3. client ยิง POST /payments แล้ว timeout ไปเฉย ๆ ไม่รู้ว่าเงินถูกตัดไปหรือยัง — ทางแก้ที่ถูกต้องคือข้อไหน?

ผมรันให้ดูแล้วว่า POST /payments เปล่า ๆ ยิงสามครั้งได้ pay_1, pay_2, pay_3 และ charged_total=1500 — สำเร็จ 201 ครบทั้งสามครั้ง ไม่มี error สักตัว
พอใส่ Idempotency-Key: 8f3c-01 เหมือนกันทั้งสามครั้ง ได้ pay_4 เท่ากันหมดและ charged_total ขยับครั้งเดียว · สำคัญคือ key ต้องสร้างครั้งเดียว ตอนผู้ใช้กดปุ่ม ถ้าสร้างใหม่ทุก retry จะไม่ต่างจาก POST ธรรมดาเลย
ตัวเลือกที่ 1 ผิดเพราะ IP+เวลาเป็นการเดา — ผู้ใช้คนละคนหลัง NAT เดียวกันจ่ายพร้อมกันได้จริง · ตัวเลือกที่ 2 ผิดหนักที่สุด: GET ต้อง safe คือห้ามเปลี่ยนอะไร เอามาตัดเงินไม่ได้ และ browser/proxy จะ cache มันด้วย · ตัวเลือกที่ 4 ผิดเพราะจะบล็อกคนที่ตั้งใจจ่ายยอดเดิมซ้ำจริง ๆ (เช่นเติมเงิน 500 สองรอบติดกัน)

4. ทีมกำลังจะแก้ response ของ GET /recipes/:id — การเปลี่ยนแบบไหนทำให้ client เก่าพังแบบเงียบที่สุด คือไม่มี error ให้เห็นเลยสักบรรทัด?

ผมรัน json.Unmarshal ทั้งสี่แบบใส่ struct เดิม:
เปลี่ยนหน่วย → err=<nil> ได้ CookingTime:1800 — เลขจำนวนเต็มหน้าตาปกติทุกประการ ไม่มีอะไรผิดสังเกตเลย ลูกค้าเห็น "1800 นาที" · เปลี่ยน type → error ชัด ๆ ว่า cannot unmarshal string into Go struct field Recipe.cooking_time of type int ซึ่งโชคดีที่สุดเพราะรู้ตัวทันที · ลบ field → err=<nil> แต่ได้ CookingTime:0 ซึ่งเงียบเหมือนกันแต่ยังพอมีคนเอะใจว่าทำอาหาร 0 นาทีได้ยังไง · เพิ่ม field → err=<nil> ค่าเดิมครบ = ไม่ breaking (ยกเว้น client เปิด DisallowUnknownFields ซึ่งจะได้ json: unknown field "difficulty")
ทั้งข้อ 2 3 4 เป็น breaking หมด แต่คำถามถามหาอันที่เงียบที่สุด — คือข้อที่ค่าผ่านเข้าไปได้ โดยดูสมเหตุสมผล

🛠️ ภารกิจ — ออกแบบ API ของ Wongnok ให้ยิงซ้ำได้โดยไม่พัง

ภารกิจนี้ยังไม่ต้องเขียน HTTP server (รอบทเรียน 37) — ทั้งหมดทำได้ด้วยกระดาษหนึ่งแผ่นกับไฟล์เทส Go หนึ่งไฟล์ที่ใช้แค่ encoding/json

เริ่มจากศูนย์แบบนี้ — ข้อ 1–5 เขียนลงกระดาษ/ไฟล์ .md ก็พอ ส่วนข้อ 6–7 ต้องมีโปรเจกต์ Go:

$ mkdir wongnok-contract && cd wongnok-contract
$ go mod init wongnok-contract
$ touch recipe_test.go        # ไฟล์เดียวพอ ไม่ต้องมี main.go
$ go test ./...               # รันจากในโฟลเดอร์นี้
  1. ตรวจ spec ด้วยกฎ 7 ข้อ: เขียน endpoint ทั้งหมดของ Wongnok (recipes, favorite, rating, users, auth) แล้วทำเครื่องหมายว่าข้อไหนแหกกฎข้อไหน
    ต้องอธิบายให้ได้ว่าทำไม GET /login ถึงยอมรับได้ แต่ POST /recipes/create ยอมไม่ได้
  2. เติมกฎที่สไลด์ไม่ได้ให้มา: spec มีแค่ "ค้นสูตรจากชื่อเมนู/วัตถุดิบ/เวลา/ความยาก" — ออกแบบ GET /recipes ให้รับตัวกรองพวกนี้ครบ
    ใช้กฎข้อ 5 กับข้อ 6 ตัดสินว่าอะไรไป path อะไรไป query แล้วเขียน URL เต็ม ๆ ออกมาหนึ่งเส้นที่กรองสามอย่างพร้อมกัน
  3. ทำตาราง idempotency: ทุก endpoint ตอบสามช่อง — safe? · idempotent? · ยิงซ้ำแล้วเกิดอะไร
    ข้อที่ต้องคิดหนักที่สุด: POST /recipes/:id/rating — ถ้ากติกาคือ 1 คน 1 คะแนน มันควรเป็น POST หรือ PUT
  4. ตัดสินใจเรื่อง DELETE: เลือกว่า DELETE /recipes/:id ยิงซ้ำจะตอบ 404 หรือ 204 แล้วเขียนเหตุผลหนึ่งบรรทัด — เหตุผลต้องอ้างถึงว่า "ใครเป็นคนเรียก endpoint นี้" ไม่ใช่ "ผมชอบแบบนี้"
  5. เขียน contract 5 ช่องของ POST /recipes ให้ครบ (request body / response status / response body / error status / error body)
    แล้วตอบว่า ถ้าผู้ใช้กดปุ่ม "บันทึกสูตร" สองครั้งเพราะเน็ตช้า จะเกิดอะไรขึ้น และคุณจะแก้ด้วยวิธีไหน
  6. เขียนเทสพิสูจน์: สร้าง RecipeResponse เวอร์ชัน v1 หนึ่ง struct แล้วเขียน table-driven test (บทเรียน 16) ที่ json.Unmarshal JSON ของ "v2" ใส่ struct v1 อย่างน้อย 4 เคส: เพิ่ม field · ลบ field · เปลี่ยน type · เปลี่ยนหน่วย
    assert ให้ครบสองอย่าง: เคสไหน err != nil และ เคสไหน err == nil แต่ค่าที่ได้ผิด
    เทสนี้คือเอกสารที่บอกทีมคุณว่าอะไรคือ breaking change โดยไม่ต้องเถียงกัน
    รู้ได้ยังไงว่าทำถูก: go test ./... ขึ้น ok โดยที่เคส "เปลี่ยน type" เป็นเคสเดียวที่คุณ assert ว่า err != nil ส่วนเคส "ลบ field" กับ "เปลี่ยนหน่วย" assert ว่า err == nil แต่ค่าที่ได้ ไม่เท่ากับค่าที่ควรจะเป็น
  7. ท้าทาย: เติม dec.DisallowUnknownFields() เข้าไปในเทสเคส "เพิ่ม field" — ทายก่อนรันว่าเคสนี้จะยังผ่านไหม แล้วอธิบายว่าผลที่ได้ เปลี่ยนคำตอบของข้อ "อะไรคือ breaking change" ไปยังไง

คำถามที่ต้องถามคือ "ผลลัพธ์ปลายทางที่ถูกต้องคืออะไร" ไม่ใช่ "ผู้ใช้ทำกริยาอะไร"

ถ้ากติกาคือ 1 คน ให้คะแนนสูตรหนึ่งได้ครั้งเดียว (ให้ใหม่ = ทับของเดิม) แปลว่าคำสั่งจริง ๆ คือ "ทำให้คะแนนของ (ผู้ใช้คนนี้, สูตรนี้) เท่ากับ 4" ซึ่งเป็นการแทนที่ ไม่ใช่การสร้างเพิ่ม → PUT /recipes/:id/rating ตรงความหมายกว่า และได้ idempotency มาฟรี
แต่ถ้ากติกาคือ ให้คะแนนกี่ครั้งก็ได้ แล้วเอามาเฉลี่ยกัน คำสั่งคือ "เพิ่มคะแนนใหม่หนึ่งใบ" → POST ถูกแล้ว แต่คุณต้องรับผิดชอบเคสกดซ้ำเองด้วย Idempotency-Key
ประเด็นคือ method ถูกเลือกจากความหมายของคำสั่ง ไม่ใช่จากชื่อปุ่มบนหน้าจอ

💬 ข้อ 6 คือข้อที่ผมอยากเห็นผลที่สุด — เพราะเทสนั้นจะกลายเป็นคำตอบถาวร ให้กับคำถามที่ทีมคุณจะเถียงกันอีกหลายรอบว่า "แก้แค่นี้ต้องขึ้น v2 ไหม"
และถ้าคุณลองรันแล้วได้ผลไม่ตรงกับที่เขียนไว้ในบทนี้ ส่ง output มาได้เลย ผมอยากรู้ว่าต่างกันตรงไหน (ทุกผลรันในหน้านี้มาจาก Go 1.26.5 darwin/arm64)
อีกอย่าง — ถ้าคุณเจอจุดอื่นในสไลด์ที่รู้สึกว่ามันขัดกันเอง อย่าเพิ่งคิดว่าตัวเองอ่านผิด เอามาทาบกับกฎแบบที่เราทำกับหน้า 35 แล้วมาเล่าให้ฟัง
บทถัดไปเราจะหยุดออกแบบบนกระดาษ แล้วเปิด server จริงตัวแรกด้วย net/http — บทเรียน 37

อ้างอิง