ข้ามไปยังเนื้อหา

การออกแบบ 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 หนึ่งตัวควรแทนสิ่งที่ผู้ใช้ทำหนึ่งอย่าง และแบ่งย่อยกว่านั้นไม่ได้ ถ้า 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"]
แต่ละ intent ของผู้ใช้แมปเข้ากับ mutation หนึ่งตัวพอดี ซึ่งเปลี่ยนแปลงเชิงตรรกะหนึ่งอย่าง

อีกด้านหนึ่งก็สำคัญไม่แพ้กัน อย่ามัดการเปลี่ยนแปลงที่ไม่เกี่ยวกันรวมเป็น 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 ที่ตั้งชื่อตาม intent บน store ที่เปลี่ยนค่าได้ตัวเดียว กด Run แล้วเราจะสร้างรีวิว จากนั้นเผยแพร่รีวิวนั้น แต่ละ mutation คืน Review ที่ได้รับผลกระทบ และชื่อก็แยกสอง intent ออกจากกันชัดจนไม่มีทางสับสน

JavaScript

ผลลัพธ์เล่าเรื่องทั้งหมดให้ฟังเอง ตัว 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-fetchpayload ขนาดใหญ่กว่า 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 คืน Issue object พร้อม field ที่ได้รับผลกระทบทั้งหมด
  • audit log บอกได้ว่าใครทำอะไรจาก mutation name โดยตรง

Shopify Storefront API:

  • cartCreate, cartLinesAdd, cartLinesUpdate, cartLinesRemove — แยก intent ชัดเจน
  • คืน Cart object เต็มทุกครั้ง เพื่อให้ client sync UI ได้ใน round-trip เดียว
ทำไม publishReview จึงดีกว่า updateReview(published: true) แบบทั่วไป?
"หนึ่ง mutation ต่อหนึ่งการเปลี่ยนแปลงเชิงตรรกะ" ป้องกันอะไร?
ทำไม mutation จึงควรคืน object ที่ตัวเองส่งผลกระทบ?