Schema-First vs Code-First
GraphQL server ทุกตัวมี schema และชุด resolvers คำถามที่บทเรียนนี้ตอบคือ: คุณเขียนอันไหนก่อน และอันไหนเป็น source of truth? มีสองคำตอบ และตัวเลือกนี้ระบายสีให้ codebase ของคุณทั้งหมด
Schema-first — SDL นำ
หัวข้อที่มีชื่อว่า “Schema-first — SDL นำ”ในการพัฒนาแบบ schema-first คุณเขียน schema ด้วย SDL — syntax type, Query และ ! แบบเดียวกับที่คุณเรียนในโมดูลก่อนหน้า — แล้วถือว่าข้อความนั้นเป็น source of truth เดียว ส่วน resolvers ถูกเขียนแยกต่างหากและ แนบ เข้ากับ schema นั้นด้วยชื่อ ไฟล์ SDL คือ contract ส่วน resolvers คือ implementation ที่ทำให้ contract นั้นเป็นจริง
type Query { hello(name: String!): String!}const typeDefs = /* GraphQL */ ` type Query { hello(name: String!): String! }`;
const resolvers = { Query: { hello: (_parent: unknown, args: { name: string }) => `Hello, ${args.name}!`, },};จุดแข็งคือ contract อยู่ กลางเวทีและเด่นชัด ใคร ๆ ก็ตาม — frontend, backend หรือคนที่ไม่ใช่วิศวกร — ก็อ่าน SDL แล้วรู้ได้ทันทีว่า API ให้บริการอะไรบ้าง ความเสี่ยงคือ drift: ไม่มีอะไรใน SDL ล้วน ๆ ที่บังคับให้ return type ของ resolver ตรงกับ type ที่ field ประกาศไว้ resolver อาจคืนค่าผิดรูปร่างได้อย่างเงียบ ๆ แล้วคุณจะพบก็ต่อเมื่อ runtime เท่านั้น ช่องว่างตรงนั้นคือสิ่งที่ code generation ในบทเรียนถัดไปเข้ามาปิด
Code-first — codeนำ
หัวข้อที่มีชื่อว่า “Code-first — codeนำ”ในการพัฒนาแบบ code-first คุณสร้าง schema ด้วยภาษาโปรแกรมที่ใช้อยู่ คือสร้าง type object ขึ้นใน code แล้ว generate SDL ออกมาจาก object เหล่านั้น code คือ source of truth ส่วน SDL เป็น artifact ที่พิมพ์ออกมาดูได้
import { GraphQLObjectType, GraphQLSchema, GraphQLString, GraphQLNonNull,} from 'graphql';
const QueryType = new GraphQLObjectType({ name: 'Query', fields: { hello: { type: new GraphQLNonNull(GraphQLString), args: { name: { type: new GraphQLNonNull(GraphQLString) } }, resolve: (_parent, args: { name: string }) => `Hello, ${args.name}!`, }, },});
const schema = new GraphQLSchema({ query: QueryType });ตรงนี้ field, type, argument และ resolver อยู่ ด้วยกัน ในหนึ่ง object compiler ตรวจทั้งหมดนี้เป็นหน่วยเดียว ปัญหา drift จึงแทบหายไป resolver ที่คืน type ผิดจะกลายเป็น type error ตั้งแต่ก่อนคุณจะรันอะไร ต้นทุนคือความอ่านง่าย เพราะตอนนี้ contract ถูกฝังอยู่ใน constructor call แทนที่จะเป็น SDL สะอาด ๆ มือใหม่จึงต้องอ่าน code ไม่ใช่เอกสาร เพื่อทำความเข้าใจ API
ข้อแลกเปลี่ยน เทียบกันเคียงข้าง
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน เทียบกันเคียงข้าง”flowchart TD SF["Schema-first: write SDL"] SFR["Attach resolvers by name"] CF["Code-first: build types in code"] CFR["Generate SDL from types"] Schema["Executable schema (identical)"] SF --> SFR SFR --> Schema CF --> CFR CFR --> Schema
- Source of truth. Schema-first: SDL. Code-first: code
- ความอ่านง่ายของ contract Schema-first ชนะ เพราะ SDL เรียบและแชร์ง่าย ส่วน code-first ซ่อน contract ไว้หลัง constructor
- Type safety แบบไม่ต้องตั้งค่าเพิ่ม. Code-first ชนะ — codeและ type ถูกตรวจสอบไปด้วยกัน ส่วน schema-first ต้องใช้ code generation (บทเรียนถัดไป) เพื่อจับ resolver drift
- Tooling และการทำงานร่วมกัน. ไฟล์ SDL ของ schema-first diff ได้สะอาดตอน review และแชร์กับคนที่ไม่ใช่วิศวกรหรือเครื่องมือออกแบบได้ ส่วนการเปลี่ยนแปลงแบบ code-first อ่านเหมือนการเปลี่ยนcodeทั่วไป
ไม่มีแบบไหนถูกเสมอไป stack TypeScript สมัยใหม่จำนวนมากเลือกทางสายกลาง คือเขียน SDL (schema-first) เพื่อให้ได้ contract ที่อ่านง่าย แล้ว generate type ออกมาจาก SDL เพื่อให้ได้ safety แบบ code-first ด้วย แนวทางผสมนี้คือสิ่งที่โมดูลที่เหลือต่อยอดขึ้นไป
schema เดียวกัน สองสไตล์ รันจริง
หัวข้อที่มีชื่อว่า “schema เดียวกัน สองสไตล์ รันจริง”เดโมด้านล่างสร้าง schema hello(name) ที่ เหมือนกันเป๊ะ สองครั้ง — ครั้งหนึ่งแบบ schema-first ด้วย buildSchema จาก SDL อีกครั้งแบบ code-first ด้วยการสร้าง type ขึ้นมา — แล้วพิมพ์ schema ทั้งสองเป็น SDL เพื่อให้คุณยืนยันได้ว่าเหมือนกันจริง จากนั้นรัน query เดียวกันผ่านทั้งสองแบบ กด Run
สังเกตว่า printSchema คืน SDL เดียวกันสำหรับทั้งสองแบบ และทั้งสอง query คืนข้อมูลเดียวกัน ปลายทาง — schema ที่มี type และรันได้ — เหมือนกันทุกประการ สิ่งที่ต่างกันมีเพียงว่าคุณเขียน artifact ไหนด้วยมือ และเครื่องสร้าง artifact ไหนให้ นั่นคือแก่นทั้งหมดของการตัดสินใจระหว่าง schema-first กับ code-first
| Schema-first (SDL) | Code-first (Programmatic) | |
|---|---|---|
| เริ่มจาก | .graphql file | TypeScript/JavaScript code |
| ตัวอย่าง | type User { name: String! } | builder.objectType('User', ...) |
| ข้อดี | readable, language-agnostic, contract-first | type-safe, refactor ง่าย, ไม่ sync ไฟล์ |
| ข้อเสีย | SDL กับ resolver อาจ drift | verbose, schema อ่านยากกว่า |
| เหมาะกับ | ทีมที่มีทั้ง frontend/backend | TypeScript-heavy backend team |
ข้อผิดพลาดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ข้อผิดพลาดที่พบบ่อย”SDL ที่ Drift จาก Resolver อาการ:
- schema SDL บอกว่า
name: String!แต่ resolver return null - ไม่มี type check ระหว่าง SDL และ resolver
- ใช้ codegen สร้าง resolver type จาก SDL — TypeScript จะ catch mismatch
Code-first ที่ไม่มี Type Safety อาการ:
t.field('name', { type: 'String', resolve: (parent) => parent.fullName })— no type check onparent- ใช้ Pothos หรือ TypeGraphQL ที่ enforce type ของ parent, args, context
💡 ตัวอย่างจากของจริง
Shopify:
- ใช้ SDL-first — schema เป็น source of truth ที่ทั้ง team ดูได้
- schema review เป็นส่วนหนึ่งของ code review process
Prisma:
- Pothos (code-first) + Prisma integration ทำให้ schema generate ตาม database model
- type-safe ตั้งแต่ database ถึง GraphQL field โดยไม่ต้องเขียน sync code