การออกแบบ Mutation
field ของ query ตอบคำถามว่า “ค่าของสิ่งนี้คืออะไร?” ส่วน field ของ mutation ตอบคำถามที่ต่างออกไปมาก คือ “เซิร์ฟเวอร์ควร ทำ อะไร?” ความต่างนั้นควรมองออกได้ตั้งแต่ชื่อ ความผิดพลาดที่พบบ่อยที่สุดคือการมองการเขียนเป็นแถวในฐานข้อมูล เช่น มี updateUser ตัวเดียวที่เปลี่ยนอะไรก็ได้ แทนที่จะมองเป็น การกระทำ ที่ผู้ใช้ลงมือทำจริง ๆ บทเรียนนี้ว่าด้วยการออกแบบ mutation รอบ ๆ intent
ตั้งชื่อด้วยกริยา ไม่ใช่ชื่อตาราง
หัวข้อที่มีชื่อว่า “ตั้งชื่อด้วยกริยา ไม่ใช่ชื่อตาราง”ชื่อ mutation ที่ดีอ่านแล้วเหมือนคำสั่ง คือเริ่มด้วยกริยาและบอกชัดว่าลงมือทำอะไร
type Mutation { createReview(trackId: ID!, rating: Int!, body: String): Review! publishReview(reviewId: ID!): Review! deleteReview(reviewId: ID!): ID!}ลองเทียบกับการออกแบบทรง CRUD ที่พึ่งพาตัวอัปเดตยักษ์ตัวเดียว
type Mutation { # Anti-pattern: one mutation that can change anything, meaning nothing. updateReview(reviewId: ID!, rating: Int, body: String, published: Boolean): Review!}เวอร์ชัน CRUD ใช้งานได้ แต่ทำข้อมูลสำคัญหายไประหว่างทาง เมื่อ client เรียก updateReview พร้อม published: true เซิร์ฟเวอร์แยกไม่ออกว่า “ผู้เขียนแก้ข้อความ” หรือ “ผู้เขียนกดเผยแพร่” ทั้งสองเป็นคนละ event ที่มีกฎ ผลข้างเคียง และความหมายเชิงการตรวจสอบต่างกัน การตั้งชื่อตาม intent อย่าง publishReview ดึงความหมายนั้นกลับคืนมา เซิร์ฟเวอร์จึงรัน logic ที่การกระทำนั้นต้องการได้ตรงจุด
หนึ่ง mutation ต่อหนึ่งการเปลี่ยนแปลงเชิงตรรกะ
หัวข้อที่มีชื่อว่า “หนึ่ง mutation ต่อหนึ่งการเปลี่ยนแปลงเชิงตรรกะ”mutation หนึ่งตัวควรแทนสิ่งที่ผู้ใช้ทำหนึ่งอย่าง และแบ่งย่อยกว่านั้นไม่ได้ ถ้า client ต้องเรียก mutation สามตัวติด ๆ กันเพื่อทำงานเดียวให้จบ แสดงว่า API ดัน workflow ไปให้ client รับผิดชอบแทน และถ้าพังกลางคัน ข้อมูลจะค้างอยู่แค่ครึ่งทาง
flowchart TD U1["User posts a review"] --> M1["createReview"] U2["User hits Publish"] --> M2["publishReview"] U3["User removes a review"] --> M3["deleteReview"] M1 --> C1["one write: insert review"] M2 --> C2["one write: set published"] M3 --> C3["one write: delete review"]
อีกด้านหนึ่งก็สำคัญไม่แพ้กัน อย่ามัดการเปลี่ยนแปลงที่ไม่เกี่ยวกันรวมเป็น mega-mutation ตัวเดียวเพียงเพื่อประหยัด round trip ให้ยึด “หนึ่งการเปลี่ยนแปลงเชิงตรรกะ” เป็นหน่วยวัด การโพสต์รีวิวกับการติดตามศิลปินคือสอง intent จึงต้องเป็นสอง mutation แม้ UI ของคุณจะบังเอิญยิงทั้งคู่ออกไปพร้อมกันก็ตาม
คืนข้อมูลที่ได้รับผลกระทบเสมอ
หัวข้อที่มีชื่อว่า “คืนข้อมูลที่ได้รับผลกระทบเสมอ”การเขียนที่ไม่คืนอะไรกลับมาเลย บังคับให้ client ต้อง query ซ้ำทันทีเพื่อดูว่าเกิดอะไรขึ้น ทางที่ดีกว่าคือคืน object ที่ mutation ส่งผลกระทบกลับไป client จะได้อัปเดต cache ของตัวเองตรง ๆ
type Mutation { createReview(trackId: ID!, rating: Int!, body: String): Review!}การคืน Review! ทำให้ผู้เรียกเลือก id ตัวใหม่ได้ เลือก field ที่เซิร์ฟเวอร์ใส่ค่า default ให้ก็ได้ และยังไล่เข้าไปใน type ที่ลิงก์กันอยู่ได้ด้วย ทั้งหมดนี้จบใน round trip เดียวกับที่เขียน
ออกแบบ mutation แล้วรันจริง
หัวข้อที่มีชื่อว่า “ออกแบบ mutation แล้วรันจริง”ตัวอย่างนี้นิยาม mutation ที่ตั้งชื่อตาม intent บน store ที่เปลี่ยนค่าได้ตัวเดียว กด Run แล้วเราจะสร้างรีวิว จากนั้นเผยแพร่รีวิวนั้น แต่ละ mutation คืน Review ที่ได้รับผลกระทบ และชื่อก็แยกสอง intent ออกจากกันชัดจนไม่มีทางสับสน
ผลลัพธ์เล่าเรื่องทั้งหมดให้ฟังเอง ตัว createReview คืนรีวิวใหม่เอี่ยมที่มี published: false ส่วน publishReview ที่แยกออกมาต่างหาก พลิกเพียง field เดียวนั้น สอง intent สอง mutation แต่ละตัวคืน object ที่ได้รับผลกระทบ คนที่กวาดตามอง schema เดาออกทันทีว่าแต่ละตัวทำอะไร นี่คือหัวใจของการตั้งชื่อตาม intent
| ข้อดี | ข้อแลกเปลี่ยน |
|---|---|
mutation ตั้งชื่อตาม intent — publishReview แทน updateReview — อ่านแล้วเข้าใจทันที | ต้องออกแบบ mutation ใหม่ทุกครั้งที่ intent ใหม่เกิดขึ้น — schema เติบโตเร็วกว่า REST endpoint |
| แต่ละ mutation แทนการเปลี่ยนแปลงเชิงตรรกะเดียว — failure กลางทางไม่ทิ้งข้อมูลครึ่ง ๆ กลาง ๆ | mutation เล็กหลายตัวอาจเพิ่ม round-trip ถ้า client ต้องเรียกหลายครั้งตามลำดับ |
| คืน object ที่ได้รับผลกระทบ — client อัปเดต cache ได้ทันทีโดยไม่ต้อง re-fetch | payload ขนาดใหญ่กว่า REST 204 No Content — bandwidth เพิ่มขึ้นเล็กน้อย |
ข้อผิดพลาดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ข้อผิดพลาดที่พบบ่อย”ตั้งชื่อ mutation ตาม CRUD แทน intent
อาการ:
updateUser(id, fields)ที่รับ input ทุกอย่างและเปลี่ยนอะไรก็ได้- server ไม่รู้ว่า client กำลัง publish, archive, หรือแค่แก้ชื่อ
- ไม่สามารถรัน validation หรือ side effect ที่ต่างกันต่อ intent
มัด intent หลายอย่างไว้ใน mutation เดียวเพื่อประหยัด round-trip
อาการ:
createAndPublishReviewที่ทำสองสิ่งพร้อมกัน- failure ครึ่งทางทำให้ข้อมูล inconsistent
- unit test ยากเพราะครั้งเดียวต้องครอบคลุมหลาย behavior
💡 ตัวอย่างจากของจริง
GitHub GraphQL API:
- แยก
createIssue,updateIssue,closeIssue,reopenIssueออกจากกัน- แต่ละ mutation คืน
Issueobject พร้อม field ที่ได้รับผลกระทบทั้งหมด- audit log บอกได้ว่าใครทำอะไรจาก mutation name โดยตรง
Shopify Storefront API:
cartCreate,cartLinesAdd,cartLinesUpdate,cartLinesRemove— แยก intent ชัดเจน- คืน
Cartobject เต็มทุกครั้ง เพื่อให้ client sync UI ได้ใน round-trip เดียว