รูปแบบ Input / Payload
mutation ในบทเรียนที่แล้วรับ argument กระจัดกระจายและคืน object เปล่า ๆ กลับมา แบบนั้นโอเคตอนมีสอง field แต่ mutation ของจริงจะโตขึ้นเรื่อย ๆ คุณเพิ่ม field แบบ optional หนึ่งตัว แล้วอีกตัว ตามด้วยกฎ validation ที่ล้มเหลวได้ด้วยเหตุผลสามแบบ มีสองธรรมเนียมที่ช่วยคุมการเติบโตนี้ไว้ คือ argument input ตัวเดียวที่ไหลเข้าไป และ type payload ที่มีโครงสร้างซึ่งไหลออกมา สองอย่างนี้รวมกันคือรูปร่างที่ใช้กันแพร่หลายที่สุดใน GraphQL ระดับโปรดักชัน
argument input เดียว
หัวข้อที่มีชื่อว่า “argument input เดียว”แทนที่จะกระจายทุก field ออกไปทั่ว argument list ให้นิยาม input object type หนึ่งตัว แล้วรับเป็น argument เดียวชื่อ input
input CreateReviewInput { trackId: ID! rating: Int! body: String}
type Mutation { createReview(input: CreateReviewInput!): CreateReviewPayload!}type input หน้าตาเหมือน object type แต่ใช้คีย์เวิร์ด input และ field ข้างในเป็นได้แค่ scalar, enum หรือ input type อื่นเท่านั้น เป็น object type ไม่ได้ ผลตอบแทนคือความง่ายในการวิวัฒน์ การเพิ่ม field แบบ optional ลงใน CreateReviewInput ไม่กระทบ signature ของ mutation client เดิมจึงทำงานต่อได้โดยไม่ต้องแก้อะไรเลย ยิ่งไปกว่านั้นคุณยังได้ชื่อเรียก input ทั้งชุด ที่หยิบไปอ้างอิง เขียนเอกสาร และใช้ซ้ำได้
type คืนค่า payload
หัวข้อที่มีชื่อว่า “type คืนค่า payload”type คืนค่าแบบ Review! เปล่า ๆ ไม่เหลือที่ว่างให้อะไรนอกจากตัวรีวิว แต่ mutation ต้องรายงานสองเรื่อง คือ entity ที่สร้างหรือเปลี่ยนไป และ ปัญหาที่ client ลงมือแก้เองได้ ตรงนี้แหละที่ type payload พาไปได้ทั้งคู่
type UserError { message: String! field: [String!]}
type CreateReviewPayload { review: Review userErrors: [UserError!]!}ตรงนี้มีตัวเลือกการออกแบบสำคัญสองอย่าง อย่างแรก review เป็น nullable เพราะเมื่อล้มเหลวก็ไม่มีอะไรให้คืน จึงกลับมาเป็น null อย่างที่สอง userErrors เป็น list แบบ non-null ของ error ที่ non-null คือมีอยู่เสมอ เพียงแต่ว่างเปล่า ([]) เมื่อทุกอย่างสำเร็จ logic ฝั่ง client จึงเหมือนกันทุกครั้ง คือตรวจ userErrors ก่อน ถ้าว่าง ก็เชื่อ review ได้เลย
flowchart LR
In["CreateReviewInput!"] --> Mut["createReview"]
Mut --> Valid{"valid?"}
Valid -->|"yes"| OK["payload: review set, userErrors []"]
Valid -->|"no"| Err["payload: review null, userErrors [...]"] ทำไมรูปแบบนี้ถึงสเกลได้
หัวข้อที่มีชื่อว่า “ทำไมรูปแบบนี้ถึงสเกลได้”userErrors เหล่านี้คือปัญหาที่ คาดไว้แล้วและกู้คืนได้ เช่น rating ที่อยู่นอกช่วง หรือ title ที่หายไป เป็นปัญหาที่ผู้ใช้แก้แล้วลองใหม่ได้ และอยู่ใน payload ในรูปของข้อมูล client จึงแสดงผลไว้ข้าง ๆ field ของฟอร์มที่มีปัญหาได้เลย ส่วน error ระดับ protocol (query ที่ผิดรูปแบบ หรือ auth ล้มเหลว) ควรอยู่ใน array errors ระดับบนสุด บทเรียนถัดไปเรื่องแนวปฏิบัติที่ดีจะลากเส้นแบ่งนี้ให้ละเอียด สำหรับตอนนี้จำกฎเดียวพอ คือ การ validate เชิงธุรกิจอยู่ใน payload
รูปแบบนี้ รันได้จริง
หัวข้อที่มีชื่อว่า “รูปแบบนี้ รันได้จริง”ตัวอย่างนี้อิมพลีเมนต์ createReview(input: CreateReviewInput!): CreateReviewPayload! พร้อม validation จริง กด Run ได้เลย การเรียกครั้งแรกส่ง rating ที่ไม่ถูกต้อง จึงได้ userErrors ที่มีข้อมูลกลับมาพร้อม review เป็น null ส่วนครั้งที่สองส่งข้อมูลที่ถูกต้อง จึงได้รีวิวกลับมาพร้อม userErrors ที่ว่างเปล่า
อ่านผลลัพธ์ทั้งสองเทียบกัน การเรียกที่ไม่ถูกต้องคืน review: null และ entry ใน userErrors ที่ชี้ไปยัง field rating และที่สำคัญคือ array errors ระดับบนสุดไม่ปรากฏ เพราะไม่มีอะไร ผิดพลาด กับ request ผู้ใช้แค่ส่งข้อมูลที่ไม่ดีมา ส่วนการเรียกที่ถูกต้องคืนรีวิวและ userErrors: [] ที่ว่างเปล่า client จึงแตกกิ่งด้วยกฎเดียวได้ คือ “userErrors ว่างหรือไม่?” ไม่ว่าจะเรียก mutation ตัวไหนก็ตาม
| ข้อดี | ข้อแลกเปลี่ยน |
|---|---|
| Input Type เป็น named type — เพิ่ม field แบบ optional ได้โดยไม่ทำลาย client เดิม | เพิ่ม type อีก 2 ตัวต่อ mutation — schema ยาวขึ้น |
payload พา userErrors ไว้ด้วย — client มี pattern เดียวสำหรับทุก mutation | ต้องตรวจ userErrors ทุกครั้ง แทนที่จะแค่ใช้ data โดยตรง |
| Input Type reuse ได้ข้าม mutation — ลด duplication ใน schema | Input Type ซับซ้อนเกินไปอาจเป็น “God Input” ที่ยากต่อการ validate |
ข้อผิดพลาดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ข้อผิดพลาดที่พบบ่อย”Payload บางเกินไป — คืนแค่ id
อาการ:
- client รู้แค่ว่า mutation สำเร็จ แต่ไม่รู้ว่า state เปลี่ยนอย่างไร
- ต้องส่ง query ตามหลังทุกครั้งเพื่อ refresh UI
- optimistic update sync ไม่ได้เพราะขาด server-generated field
God Input — input type ตัวเดียวสำหรับทุกอย่าง
อาการ:
UpdateUserInputที่มี field ทุก field ของ user ทั้งหมดเป็น optional- ไม่รู้ว่า field ไหน required สำหรับ operation ใด
- validation logic ซับซ้อนเพราะต้องครอบคลุมทุก combination
💡 ตัวอย่างจากของจริง
GitHub GraphQL API:
CreateIssueInputมีrepositoryId,title(required) และ optional field เช่นbody,assigneeIdsCreateIssuePayloadคืนissue+clientMutationId— ไม่คืนแค่ ID- convention นี้ใช้กับทุก mutation ใน GitHub API — client code มี pattern เดียวสม่ำเสมอ
Shopify Admin API:
ProductInputที่ reuse ได้ในproductCreateและproductUpdate- payload คืน
productเต็ม +userErrors— UI อัปเดตได้ทันทีหลัง mutation