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

Schema-First vs Code-First

GraphQL server ทุกตัวมี schema และชุด resolvers คำถามที่บทเรียนนี้ตอบคือ: คุณเขียนอันไหนก่อน และอันไหนเป็น source of truth? มีสองคำตอบ และตัวเลือกนี้ระบายสีให้ codebase ของคุณทั้งหมด

ในการพัฒนาแบบ 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 คุณสร้าง 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
schema เดียวกันที่ไปถึงด้วยสองเส้นทาง — และ source of truth ไหลไปในทิศตรงข้ามกัน
  • 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 hello(name) ที่ เหมือนกันเป๊ะ สองครั้ง — ครั้งหนึ่งแบบ schema-first ด้วย buildSchema จาก SDL อีกครั้งแบบ code-first ด้วยการสร้าง type ขึ้นมา — แล้วพิมพ์ schema ทั้งสองเป็น SDL เพื่อให้คุณยืนยันได้ว่าเหมือนกันจริง จากนั้นรัน query เดียวกันผ่านทั้งสองแบบ กด Run

JavaScript

สังเกตว่า printSchema คืน SDL เดียวกันสำหรับทั้งสองแบบ และทั้งสอง query คืนข้อมูลเดียวกัน ปลายทาง — schema ที่มี type และรันได้ — เหมือนกันทุกประการ สิ่งที่ต่างกันมีเพียงว่าคุณเขียน artifact ไหนด้วยมือ และเครื่องสร้าง artifact ไหนให้ นั่นคือแก่นทั้งหมดของการตัดสินใจระหว่าง schema-first กับ code-first

Schema-first (SDL)Code-first (Programmatic)
เริ่มจาก.graphql fileTypeScript/JavaScript code
ตัวอย่างtype User { name: String! }builder.objectType('User', ...)
ข้อดีreadable, language-agnostic, contract-firsttype-safe, refactor ง่าย, ไม่ sync ไฟล์
ข้อเสียSDL กับ resolver อาจ driftverbose, schema อ่านยากกว่า
เหมาะกับทีมที่มีทั้ง frontend/backendTypeScript-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 on parent
  • ใช้ 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
ในการพัฒนาแบบ schema-first อะไรคือ source of truth เดียว?
อะไรคือความเสี่ยงหลักของการพัฒนาแบบ schema-first ล้วน ๆ ที่ code generation เข้ามาแก้ในภายหลัง?
อะไรคือข้อเสียหลักของการพัฒนาแบบ code-first?
ในเดโม printSchema แสดงอะไรเกี่ยวกับสองแนวทางนี้?