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

แนวปฏิบัติที่ดีของ Mutation

ถึงตรงนี้คุณตั้งชื่อ mutation เป็นแล้ว จัดรูปร่าง input กับ payload เป็นแล้ว และประกาศการเปลี่ยนแปลงออกไปด้วย subscription เป็นแล้ว บทเรียนสุดท้ายนี้คือการขัดเงา เป็นนิสัยไม่กี่อย่างที่แยก mutation ซึ่งใช้ได้แค่ตอนเดโม ออกจาก mutation ที่รอดจริงบนเน็ตที่ไม่นิ่ง การ retry และ UI ที่ใจร้อน มีสี่หัวข้อ คือ idempotency, error สองชนิด, ความ chatty และ optimistic UI

เน็ตทำ response หล่นหาย client ยิง addReview ออกไปแล้วไม่เห็นคำตอบ จึง retry ซ้ำ สุดท้ายได้รีวิวมาสองอัน เราเรียก mutation ว่า idempotent เมื่อรันสองครั้งแล้วได้ผลเท่ากับรันครั้งเดียว วิธีที่ใช้กันทั่วไปคือ idempotency key ที่ client เป็นคนส่งมา โดย client สร้าง token ที่ไม่ซ้ำหนึ่งตัวต่อหนึ่ง intent แล้วแนบไปกับ mutation ฝั่ง server จดจำ key ที่เคยเห็นไว้ พอเจอตัวซ้ำก็ตัดทางลัดคืนผลเดิมไปเลย

input AddToCartInput {
productId: ID!
quantity: Int!
requestId: ID! # client-generated key — the same retry carries the same id
}

ไม่ใช่ทุก mutation ที่ต้องมี key การตั้ง published: true เป็น idempotent อยู่แล้วโดยธรรมชาติ เพราะรันสองครั้งก็จบที่ state เดียวกัน ตัวที่ได้ประโยชน์เต็ม ๆ คือ mutation ทรง create และ increment ที่การเรียกซ้ำสร้างผลซ้ำขึ้นมาจริง ๆ

นี่คือเส้นที่บทเรียน input/payload สัญญาไว้ว่าจะลากให้ GraphQL ให้ที่สองที่ในการรายงานความล้มเหลว และทั้งสองมีความหมายต่างกัน

  • user error ใน payload — ปัญหา ราย field ที่คาดไว้แล้ว กู้คืนได้ และผู้ใช้แก้เองได้ เช่น rating นอกช่วง ชื่อที่ถูกใช้ไปแล้ว คูปองหมดอายุ พวกนี้คือ ข้อมูล จึงส่งไปใน userErrors ของ payload โดยที่ request ยังนับว่าสำเร็จ และ array errors ระดับบนสุดยังว่างอยู่
  • errors ระดับบนสุด — ความล้มเหลวระดับ protocol และที่ ไม่คาดคิด เช่น query ผิดรูปแบบ authorization ไม่ผ่าน database ล้ม พวกนี้ผู้ใช้ปลายทางแก้เองด้วยการแก้ฟอร์มไม่ได้ จึงไปโผล่ใน array errors ระดับบนสุดของ response และ data ของ field ที่กระทบจะเป็น null
flowchart TD
  Req["Mutation request"] --> Kind{"what failed?"}
  Kind -->|"user can fix it"| Pay["payload.userErrors (data ok)"]
  Kind -->|"protocol / unexpected"| Top["top-level errors (data null)"]
  Kind -->|"nothing"| OK["payload.entity, userErrors []"]
การ validate ที่กู้คืนได้อยู่ใน payload; ความล้มเหลวระดับ protocol และที่ไม่คาดคิดอยู่ใน array errors ระดับบนสุด

เหตุผลที่ต้องแยกสองอย่างนี้คือ client code ที่แสดงข้อความ validation ใต้ field ของฟอร์ม ไม่ควรต้อง parse ช่องทางเดียวกับที่ใช้บอกว่า “เซสชันของคุณหมดอายุ” เอาสิ่งที่ผู้ใช้แก้เองได้ไว้ใน userErrors แล้วปล่อยที่เหลือ throw ไป

การออกแบบที่ chatty บังคับให้ client เรียกหลายครั้งเพื่อบรรลุเป้าหมายเดียวของผู้ใช้ เช่น เพิ่มสินค้า เพิ่มสินค้า ใช้คูปอง ตั้งที่อยู่ แต่ละอย่างเป็น round trip แยกกัน มีสองวิธีแก้

  • รวมงานที่ atomic จริง ๆ ไว้ใน mutation เดียว ถ้าการเปลี่ยนแปลงสามอย่างต้องสำเร็จพร้อมกันหรือล้มพร้อมกัน นั่นคือการเปลี่ยนแปลงเชิงตรรกะ หนึ่ง อย่าง จึงควรโมเดลเป็น mutation เดียวที่รับ input ครบขึ้น (นึกถึงกฎจากบทก่อน อย่ามัดรวม intent ที่ ไม่เกี่ยวกัน แต่งานที่ atomic จริง ๆ ควรอยู่ด้วยกัน)
  • คืนข้อมูลให้พอ จะได้ไม่ต้องอ่านซ้ำทีหลัง mutation ที่คืนแค่ id บังคับให้ client ต้อง GET ตามอีกรอบเพื่อรู้ว่าอะไรเปลี่ยน ให้คืน object ที่ได้รับผลกระทบ พร้อม parent ที่ object นั้นสังกัดอยู่ถ้า UI ต้องใช้ จะได้จบทั้งเขียนและรีเฟรชใน round trip เดียว

client ที่ตอบสนองไวมักอัปเดตหน้าจอ ก่อน server ยืนยัน คือเดาไว้ก่อนว่า mutation จะสำเร็จ แสดงผลทันที แล้วค่อยปรับให้ตรงตอน payload จริงมาถึง การออกแบบ schema ของคุณคือตัวตัดสินว่าขั้นตอนนี้จะราบรื่นแค่ไหน

  • คืน object ที่ได้รับผลกระทบแบบเต็ม รวมถึง field ที่เซิร์ฟเวอร์กำหนดให้ เพื่อให้การเดาแบบ optimistic ถูกแทนที่ด้วยความจริงได้โดยไม่ต้อง re-fetch
  • ทำให้ id คาดเดาได้พอจะจับคู่กันได้ พอ payload มาถึง client ต้องจับคู่ record optimistic ชั่วคราวเข้ากับตัวจริงจาก server การคืน id ตัวจริงกลับไปคือสิ่งที่ปิดวงจรนี้
  • ส่ง userErrors ออกมาให้สะอาด เพราะ optimistic UI ต้อง ย้อนกลับ ได้ ถ้า payload กลับมาพร้อม validation error client จะยกเลิกการเปลี่ยนแปลงแบบ optimistic แล้วแสดงข้อความแทน ซึ่งทำได้ก็ต่อเมื่อ error เป็นข้อมูลที่คาดเดาได้ใน payload ไม่ใช่ exception ใน array ระดับบนสุด

ตัวอย่างนี้แสดง idempotency และช่องทาง error สองทางใน resolver เดียว กด Run requestId ที่ซ้ำกันคืนรีวิว ตัวเดิม แทนที่จะสร้างตัวที่สอง rating ที่อยู่นอกช่วงคืน userError พร้อม review: null ส่วนทุกอย่างที่เหลือที่ throw จะเติมลงใน array errors ระดับบนสุด

JavaScript

บรรทัดสุดท้ายคือบทพิสูจน์ หลังจากการสร้าง การ retry และการเรียกที่ถูกปฏิเสธ store มีรีวิวอยู่ หนึ่ง ตัวพอดี การ retry คืนรีวิวตัวเดิมโดยไม่เปลี่ยนแปลง คือ idempotency ที่ทำงานจริง และการเรียกที่ไม่ถูกต้องกลับมาเป็น userError ใน payload พร้อม review: null ไม่เคยแตะ store และไม่เคยยก error ระดับบนสุดเลย นั่นคือแนวปฏิบัติที่ดีทุกข้อจากบทเรียนนี้ที่เห็นได้ในผลลัพธ์เดียว

Best Practiceทำไมสำคัญ
Idempotency key (requestId)ป้องกันการสร้างซ้ำเมื่อ network drop แล้ว client retry
User error ใน payload.userErrorsclient แยก validation error จาก protocol error ได้โดยไม่ต้อง parse errors[0].message
คืน object ที่ได้รับผลกระทบแบบเต็มoptimistic UI ปรับ sync ได้ในทันทีโดยไม่ต้อง re-fetch
หลีกเลี่ยง mutation ที่ chattyลด round-trip และความเสี่ยงจาก partial failure กลางลำดับ

ส่ง user error ผ่าน top-level errors แทน payload

อาการ:

  • client ต้อง parse errors[0].message เพื่อแสดง validation message ใต้ form field
  • ไม่สามารถแยก “rating ไม่ถูกต้อง” กับ “session หมดอายุ” ได้จาก channel เดียวกัน
  • optimistic UI ไม่รู้ว่าต้อง rollback หรือ retry

คืนแค่ Boolean หรือ id จาก mutation

อาการ:

  • client ต้องส่ง query ตามหลังทุกครั้งเพื่อรู้ว่า state เปลี่ยนอย่างไร
  • 2 round-trip แทนที่จะเป็น 1
  • optimistic UI sync ไม่ได้เพราะขาด server-assigned field เช่น updatedAt, slug

💡 ตัวอย่างจากของจริง

Relay (Facebook’s GraphQL client):

  • กำหนด convention ว่า mutation ทุกตัวต้องมี clientMutationId สำหรับ idempotency
  • คืน payload object ที่มี userErrors array เสมอ — ไม่ใช้ top-level errors สำหรับ validation

Apollo Client:

  • optimisticResponse option ต้องการ object ที่มี shape ตรงกับ server payload
  • ถ้า mutation คืนแค่ id โดยไม่มี field อื่น optimistic update ทำไม่ได้
อะไรทำให้ mutation เป็น idempotent และโดยทั่วไปทำได้อย่างไร?
ผู้ใช้ส่ง rating เป็น 9 ส่วนช่วงที่อนุญาตคือ 1–5 ควรรายงานเรื่องนี้ที่ไหน?
ทำไมการคืน object ที่ได้รับผลกระทบแบบเต็มจึงช่วย optimistic UI?