แนวปฏิบัติที่ดีของ Mutation
ถึงตรงนี้คุณตั้งชื่อ mutation เป็นแล้ว จัดรูปร่าง input กับ payload เป็นแล้ว และประกาศการเปลี่ยนแปลงออกไปด้วย subscription เป็นแล้ว บทเรียนสุดท้ายนี้คือการขัดเงา เป็นนิสัยไม่กี่อย่างที่แยก mutation ซึ่งใช้ได้แค่ตอนเดโม ออกจาก mutation ที่รอดจริงบนเน็ตที่ไม่นิ่ง การ retry และ UI ที่ใจร้อน มีสี่หัวข้อ คือ idempotency, error สองชนิด, ความ chatty และ optimistic UI
Idempotency: ปลอดภัยที่จะ retry
หัวข้อที่มีชื่อว่า “Idempotency: ปลอดภัยที่จะ retry”เน็ตทำ 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 ที่การเรียกซ้ำสร้างผลซ้ำขึ้นมาจริง ๆ
error สองชนิด
หัวข้อที่มีชื่อว่า “error สองชนิด”นี่คือเส้นที่บทเรียน input/payload สัญญาไว้ว่าจะลากให้ GraphQL ให้ที่สองที่ในการรายงานความล้มเหลว และทั้งสองมีความหมายต่างกัน
- user error ใน payload — ปัญหา ราย field ที่คาดไว้แล้ว กู้คืนได้ และผู้ใช้แก้เองได้ เช่น rating นอกช่วง ชื่อที่ถูกใช้ไปแล้ว คูปองหมดอายุ พวกนี้คือ ข้อมูล จึงส่งไปใน
userErrorsของ payload โดยที่ request ยังนับว่าสำเร็จ และ arrayerrorsระดับบนสุดยังว่างอยู่ errorsระดับบนสุด — ความล้มเหลวระดับ protocol และที่ ไม่คาดคิด เช่น query ผิดรูปแบบ authorization ไม่ผ่าน database ล้ม พวกนี้ผู้ใช้ปลายทางแก้เองด้วยการแก้ฟอร์มไม่ได้ จึงไปโผล่ใน arrayerrorsระดับบนสุดของ 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 []"] เหตุผลที่ต้องแยกสองอย่างนี้คือ client code ที่แสดงข้อความ validation ใต้ field ของฟอร์ม ไม่ควรต้อง parse ช่องทางเดียวกับที่ใช้บอกว่า “เซสชันของคุณหมดอายุ” เอาสิ่งที่ผู้ใช้แก้เองได้ไว้ใน userErrors แล้วปล่อยที่เหลือ throw ไป
หลีกเลี่ยง mutation ที่ chatty
หัวข้อที่มีชื่อว่า “หลีกเลี่ยง mutation ที่ chatty”การออกแบบที่ chatty บังคับให้ client เรียกหลายครั้งเพื่อบรรลุเป้าหมายเดียวของผู้ใช้ เช่น เพิ่มสินค้า เพิ่มสินค้า ใช้คูปอง ตั้งที่อยู่ แต่ละอย่างเป็น round trip แยกกัน มีสองวิธีแก้
- รวมงานที่ atomic จริง ๆ ไว้ใน mutation เดียว ถ้าการเปลี่ยนแปลงสามอย่างต้องสำเร็จพร้อมกันหรือล้มพร้อมกัน นั่นคือการเปลี่ยนแปลงเชิงตรรกะ หนึ่ง อย่าง จึงควรโมเดลเป็น mutation เดียวที่รับ input ครบขึ้น (นึกถึงกฎจากบทก่อน อย่ามัดรวม intent ที่ ไม่เกี่ยวกัน แต่งานที่ atomic จริง ๆ ควรอยู่ด้วยกัน)
- คืนข้อมูลให้พอ จะได้ไม่ต้องอ่านซ้ำทีหลัง mutation ที่คืนแค่
idบังคับให้ client ต้องGETตามอีกรอบเพื่อรู้ว่าอะไรเปลี่ยน ให้คืน object ที่ได้รับผลกระทบ พร้อม parent ที่ object นั้นสังกัดอยู่ถ้า UI ต้องใช้ จะได้จบทั้งเขียนและรีเฟรชใน round trip เดียว
ข้อพิจารณาเรื่อง optimistic UI
หัวข้อที่มีชื่อว่า “ข้อพิจารณาเรื่อง optimistic UI”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 ระดับบนสุด
บรรทัดสุดท้ายคือบทพิสูจน์ หลังจากการสร้าง การ retry และการเรียกที่ถูกปฏิเสธ store มีรีวิวอยู่ หนึ่ง ตัวพอดี การ retry คืนรีวิวตัวเดิมโดยไม่เปลี่ยนแปลง คือ idempotency ที่ทำงานจริง และการเรียกที่ไม่ถูกต้องกลับมาเป็น userError ใน payload พร้อม review: null ไม่เคยแตะ store และไม่เคยยก error ระดับบนสุดเลย นั่นคือแนวปฏิบัติที่ดีทุกข้อจากบทเรียนนี้ที่เห็นได้ในผลลัพธ์เดียว
| Best Practice | ทำไมสำคัญ |
|---|---|
Idempotency key (requestId) | ป้องกันการสร้างซ้ำเมื่อ network drop แล้ว client retry |
User error ใน payload.userErrors | client แยก 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 ที่มี
userErrorsarray เสมอ — ไม่ใช้ top-level errors สำหรับ validationApollo Client:
optimisticResponseoption ต้องการ object ที่มี shape ตรงกับ server payload- ถ้า mutation คืนแค่
idโดยไม่มี field อื่น optimistic update ทำไม่ได้