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

รูปแบบ Input / Payload

mutation ในบทเรียนที่แล้วรับ argument กระจัดกระจายและคืน object เปล่า ๆ กลับมา แบบนั้นโอเคตอนมีสอง field แต่ mutation ของจริงจะโตขึ้นเรื่อย ๆ คุณเพิ่ม field แบบ optional หนึ่งตัว แล้วอีกตัว ตามด้วยกฎ validation ที่ล้มเหลวได้ด้วยเหตุผลสามแบบ มีสองธรรมเนียมที่ช่วยคุมการเติบโตนี้ไว้ คือ argument input ตัวเดียวที่ไหลเข้าไป และ type payload ที่มีโครงสร้างซึ่งไหลออกมา สองอย่างนี้รวมกันคือรูปร่างที่ใช้กันแพร่หลายที่สุดใน GraphQL ระดับโปรดักชัน

แทนที่จะกระจายทุก 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 คืนค่าแบบ 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 [...]"]
input หนึ่งตัวไหลเข้า; payload พา entity หรือไม่ก็ user error กลับออกไป

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 ที่ว่างเปล่า

JavaScript

อ่านผลลัพธ์ทั้งสองเทียบกัน การเรียกที่ไม่ถูกต้องคืน 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 ใน schemaInput 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, assigneeIds
  • CreateIssuePayload คืน issue + clientMutationId — ไม่คืนแค่ ID
  • convention นี้ใช้กับทุก mutation ใน GitHub API — client code มี pattern เดียวสม่ำเสมอ

Shopify Admin API:

  • ProductInput ที่ reuse ได้ใน productCreate และ productUpdate
  • payload คืน product เต็ม + userErrors — UI อัปเดตได้ทันทีหลัง mutation
ข้อได้เปรียบหลักของการรับ input object เดียวแทนการรับ argument แยกหลายตัวคืออะไร?
ในรูปแบบ payload ทำไม field ของ entity (เช่น review) จึงเป็น nullable ขณะที่ userErrors เป็น list แบบ non-null?
ปัญหาประเภทใดควรอยู่ใน userErrors ของ payload แทนที่จะอยู่ใน array errors ระดับบนสุด?