คำถามที่พบบ่อยเรื่องการทำงานเป็นทีม: contract, config และ fixture
สรุปสำคัญ: สไตล์การตั้งชื่อ, รูปแบบเวลา, ความหมายของ null, รูปแบบของ enum, รูปแบบ config, การจัดระเบียบ fixture และ merge conflict — สิบสองการแลกเปลี่ยนที่ทีมเถียงกันจริง

สรุปสั้น ๆ: ตกลงสิ่งเหล่านี้ครั้งเดียวในข้อตกลงของทีม — envelope แบบ {"data":…,"meta":…} ที่ใช้ HTTP status code, สไตล์การตั้งชื่อเดียวทั้งเว็บไซต์, timestamp แบบ RFC 3339 ที่มีเขตเวลา และ enum แบบ string — แล้วพัฒนา API ใหม่ทุกตัวบน contract เดียวกันโดยเพิ่มฟิลด์เท่านั้น ไม่เปลี่ยนหรือลบฟิลด์เดิม
คำถามเหล่านี้ไม่มีคำตอบที่ถูกเพียงคำตอบเดียว แต่ทีมต้องมีคำตอบเดียว — ไม่เช่นนั้น API ใหม่ทุกตัวจะเริ่มเถียงเรื่องเดิมอีกครั้ง
1. Contract ของ API
API ควรคืนข้อมูลโครงสร้างแบบไหน?
รูปแบบที่พบบ่อยสามแบบ แต่ละแบบมีต้นทุน:
| รูปแบบ | ตัวอย่าง | ข้อดี | ข้อเสีย |
|---|---|---|---|
| ข้อมูลดิบ | [{...}, {...}] |
เรียบง่าย | พา metadata ไม่ได้ |
| envelope คงที่ | {"code":0,"message":"ok","data":{...}} |
จัดการ error ได้สม่ำเสมอ | โค้ดธุรกิจซ้ำกับ HTTP status |
| envelope แบบ resource | {"data":{...},"meta":{...},"links":{...}} |
ขยายต่อได้ ความหมายชัด | ซ้อนเพิ่มอีกระดับ |
สำหรับ API ธุรกิจภายใน แนะนำ "envelope แบบ resource + HTTP status code ที่บอกผลลัพธ์":
{
"data": [{ "id": "1", "name": "示例" }],
"meta": { "total": 128, "page": 2, "pageSize": 20 }
}
สำหรับ error ให้ใช้ HTTP status code ร่วมกับ body ของ error ที่มีโครงสร้าง แทนการซ้อน code อีกชั้น:
{
"error": {
"type": "validation_failed",
"message": "请求参数校验失败",
"details": [
{ "path": "/email", "reason": "格式不正确" }
]
}
}
จุดสำคัญ: HTTP status code บอกความสำเร็จหรือความล้มเหลวอยู่แล้ว ดังนั้น business code จึงซ้ำซ้อน เมื่อสองระบบอยู่ร่วมกัน ความไม่สอดคล้องอย่าง "201 แต่ code เป็น 500" แทบจะหลีกเลี่ยงไม่ได้
เลี่ยง array เปล่า เมื่อระดับบนสุดเป็น array คุณแนบ pagination, total หรือ cursor ไม่ได้ และการเพิ่มทีหลังหมายถึงต้องแก้ฝั่งผู้เรียกทุกตัว
ชื่อฟิลด์ควรใช้ snake_case หรือ camelCase?
เลือกอย่างใดอย่างหนึ่ง ใช้ทั้งเว็บไซต์ และอย่าปนกันภายในเอกสาร JSON หนึ่งฉบับ
| สไตล์ | ระบบนิเวศที่ใช้บ่อย | ตัวอย่าง |
|---|---|---|
camelCase |
JavaScript / TypeScript / Java | userName, createdAt |
snake_case |
Python / Ruby / Go (บางส่วน) / คอลัมน์ฐานข้อมูล | user_name, created_at |
kebab-case |
พบน้อย; key ที่มีขีดกลางเข้าถึงยาก | user-name |
เกณฑ์:
- ใครเป็นผู้ใช้ JSON นี้: ถ้า frontend เป็นหลักให้ camelCase ถ้า data pipeline และบริการ Python เป็นหลักให้ snake_case;
- มันแมปกับคอลัมน์ฐานข้อมูลโดยตรงไหม: snake_case ประหยัดชั้นแปลงเมื่อส่งโครงสร้างตารางตรง ๆ;
- ความต่อเนื่องของระบบเดิม: คงสิ่งที่ API เดิมใช้ไว้ ความสม่ำเสมอสำคัญกว่าความถูกต้อง
ถ้าจำเป็นต้องแปลงจริง ๆ ให้ทำที่ขอบ (gateway หรือชั้น request ของ frontend) ไม่ใช่กระจายการเรียก toCamelCase ทั่วโค้ดธุรกิจ
ฟิลด์เวลาควรใช้รูปแบบไหน?
ใช้ string แบบ RFC 3339 / ISO 8601 ที่มี offset ของเขตเวลา:
{
"createdAt": "2026-10-30T11:37:57+08:00",
"updatedAt": "2026-10-30T03:37:57Z"
}
จุดสำคัญ:
- ต้องมีเขตเวลา:
2026-10-30 11:37:57ที่ไม่มีเขตเวลาจะพังในสถานการณ์ข้ามเขตเวลาอย่างแน่นอน; - ใช้
Zหรือ offset อย่างสม่ำเสมอ: ทั้งสองถูกต้อง แต่ห้ามปนกันภายใน API หนึ่งตัว; - เลี่ยง numeric timestamp: ไม่ว่าจะเป็นวินาทีหรือมิลลิวินาที และจะนับ leap second ด้วยหรือไม่ ทุกทีมต้องตีความใหม่เอง เมื่อต้องกระชับ ให้ใช้จำนวนเต็มมิลลิวินาที แต่ระบุหน่วยให้ชัดในเอกสาร
แนะนำชื่อฟิลด์ที่ลงท้ายด้วย At เช่น createdAt / updatedAt / deletedAt
จะเห็นได้ทันทีว่าค่านั้นเป็นจุดในเวลา ไม่ใช่ช่วงเวลา สำหรับช่วงเวลาใช้ timeoutMs, ttlSeconds — ใส่หน่วยไว้ในชื่อ
ค่า null ควรเป็น null หรือละเว้นฟิลด์?
ขึ้นกับว่า "ฟิลด์นี้มีความหมายหรือไม่":
| ความหมาย | รูปแบบ | หมายเหตุ |
|---|---|---|
| ฟิลด์มีอยู่แต่ยังไม่มีค่า | "name": null |
บอกชัดว่า "ตรวจแล้วและมันว่าง" |
| ฟิลด์ไม่เกี่ยวกับ resource นี้ | ละเว้น | เช่น ผู้ใช้ทั่วไปไม่ควรมี vipExpireAt |
| การอัปเดตบางส่วน (PATCH) | ละเว้น = ไม่เปลี่ยน | เฉพาะ null ที่หมายถึง "ล้างค่า" ตรงนี้ |
กรณีที่สามคือกรณีที่ถูกละเลยบ่อยที่สุด ในคำขอ PATCH:
{ "nickname": null }
อันนี้หมายถึง "ล้างชื่อเล่น" ส่วนการไม่ใส่ nickname ใน request body หมายถึง "อย่าแตะชื่อเล่น"
ถ้าคุณถือทั้งสองอย่างเป็น "ล้างค่า" ผู้ใช้ที่แก้ email ก็จะลบชื่อเล่นของตัวเองไปด้วย
ย้ำอีกครั้ง: อย่าใช้ undefined มันไม่ใช่ค่า JSON ที่ถูกต้อง และ JSON.stringify จะตัด key ออกทั้งหมด
พฤติกรรมจึงตรงกับ "ละเว้น" ขณะที่โค้ดดูเหมือน "ส่งค่าว่างเข้ามาชัดเจน"
ฟิลด์ boolean ควรตั้งชื่ออย่างไร?
ใช้คำนำหน้า is / has / can / should และเลี่ยงการปฏิเสธ
{
"isActive": true,
"hasPassword": false,
"canEdit": true
}
บทเรียนสามข้อ:
- ห้ามใช้การปฏิเสธ:
isDisabled: falseบังคับให้ผู้อ่านต้องกลับค่าหนึ่งครั้ง;isEnabled: trueตรงกว่า การปฏิเสธซ้อน (isNotDisabled) เป็นหายนะ; - อย่าใช้ตัวเลขแทน boolean:
"enabled": 1ทำให้ผู้ใช้ทุกคนต้องเขียน=== 1หนึ่งครั้ง; - อย่าใช้ string:
"enable": "true"เป็น truthy ใน JavaScript และif (data.enable)ก็เป็นจริงกับ"false"ด้วย; bug แบบนี้ตามรอยยากมาก
ฟิลด์ enum ควรใช้ตัวเลขหรือ string?
ใช้ string เว้นแต่มีข้อจำกัดเรื่องพื้นที่จัดเก็บหรือแบนด์วิดท์ชัดเจน
{ "status": "active" }
เทียบกับ { "status": 1 } string ชนะเพราะ:
- log, ไฟล์ packet capture และฐานข้อมูลอ่านได้ตรง ๆ โดยไม่ต้องเปิดตารางแมป;
- การเพิ่มค่า enum ไม่เปลี่ยนความหมายของค่าที่มีอยู่ (การแทรกค่าใน enum แบบตัวเลขจะเลื่อนทุกอย่าง);
- frontend ไม่ต้องดูแลแมป
1 → 'active'ให้ตรงกัน
ต้นทุนคือไบต์ที่เพิ่มขึ้นเล็กน้อย ซึ่งแทบไม่มีความหมายหลังบีบอัด
แนวปฏิบัติคู่กัน: ใช้ตัวพิมพ์เล็ก + ขีดล่างสำหรับค่า enum แบบ string (pending_review) และจำกัดด้วย enum ใน Schema
เพื่อให้ค่าที่ไม่ถูกต้องถูกปฏิเสธในขั้น validation
2. Configuration และ fixture
config ควรเป็น JSON, YAML หรือ TOML?
เลือกตามวัตถุประสงค์ ไม่ใช่ตามความชอบ:
| รูปแบบ | เหมาะกับ | ไม่เหมาะกับ |
|---|---|---|
| JSON | การอ่านเขียนด้วยโปรแกรม, ข้อมูลที่มีโครงสร้างเข้มงวด, validation ด้วย Schema | การแก้ด้วยมือบ่อย ๆ (ไม่ให้ comment) |
| YAML | config การ deploy ที่ซ้อนลึก (K8s) | การย่อหน้าเขียนมือพังง่าย; yes/no กลายเป็น boolean |
| TOML | config ของ dependency และเครื่องมือที่ไม่ซ้อนลึก (Cargo.toml, pyproject.toml) |
การซ้อนลึกอ่านยาก |
| JSONC | config ของ editor ที่ต้องมี comment (tsconfig.json) |
การข้ามขอบเขตระบบ |
ข้อผิดพลาดที่พบบ่อยในทีมคือเก็บ config ธุรกิจเป็น YAML — การแปลงชนิดโดยนัยของ YAML (1.0 กลายเป็น number,
NO กลายเป็น boolean, 2026-10-30 กลายเป็น date) ให้ผลต่างกันในแต่ละ parser
ถ้า config ต้องถูกตรวจอย่างเข้มงวดด้วยโปรแกรม ให้เลือก JSON + Schema และอธิบาย comment ผ่านฟิลด์ _comment หรือเอกสารแยก
ควรจัดระเบียบ fixture สำหรับทดสอบอย่างไร?
จัดตาม "baseline หนึ่งชุด + diff น้อยที่สุด" ไม่ใช่คัดลอก JSON ก้อนใหญ่สำหรับทุกกรณีทดสอบ
fixtures/
├─ users/
│ ├─ base.json # object ผู้ใช้พื้นฐาน
│ ├─ no-email.json # ขาดเฉพาะ email
│ └─ invalid-age.json # เฉพาะ age ที่นอกช่วง
└─ fixtures.ts # ฟังก์ชันช่วยโหลดและ merge แบบตื้น
import baseUser from './users/base.json';
export function makeUser(overrides: Partial<User> = {}): User {
return { ...baseUser, ...overrides };
}
ด้วยวิธีนี้กรณีทดสอบใหม่เขียนแค่ฟิลด์ที่ต่าง และการเปลี่ยนฟิลด์ใน baseline จะอัปเดตทุกกรณีโดยอัตโนมัติ
กฎสามข้อ:
- fixture ต้องผ่าน Schema validation: fixture baseline เองก็ควรเป็นข้อมูลที่ถูกต้อง;
- อย่าส่งข้อมูลจริงจาก production ออกมา: เบอร์โทร, เลขบัตรประชาชน และที่อยู่ล้วนเป็นข้อมูลส่วนบุคคล;
- fixture ควรอยู่ใน version control แต่ต้องเล็ก: ไฟล์เดียวที่เกินหลายสิบ KB สมควรถูกตัดหรือสร้างขึ้นมาใหม่
จะลด merge conflict ในไฟล์ JSON ได้อย่างไร?
JSON ขัดแย้งกันง่าย: formatter จะจัดรูปแบบใหม่ทั้งไฟล์ในครั้งเดียว สองคนที่แต่ละคนแก้คนละบรรทัดจึงขัดกันทั้งไฟล์
กลยุทธ์สี่ข้อ:
- รวมรูปแบบให้เป็นหนึ่งเดียวและใส่ใน
.editorconfig: การย่อหน้า, การขึ้นบรรทัดใหม่ และบรรทัดใหม่ท้ายไฟล์ที่สม่ำเสมอช่วยเลี่ยง diff ที่ไม่เกี่ยวกัน; - เรียง key: ตกลงลำดับตัวอักษร เพื่อให้ฟิลด์ใหม่ไม่ชนกับจุดแทรกของคนอื่น;
- แยกไฟล์: แบ่ง
config.jsonก้อนใหญ่เป็นหลายไฟล์ตามโดเมน พื้นที่ขัดแย้งก็เล็กลงเอง; - เลี่ยง commit แบบ "จัดรูปแบบใหม่ทั้งหมด": แยกการจัดรูปแบบกับการเปลี่ยนธุรกิจเป็นสอง commit เพื่อให้ review อ่านได้
ถ้าทีมใช้ lock file (pnpm-lock.yaml, package-lock.json) อย่า merge ด้วยมือ ให้สร้างใหม่แทน:
git checkout --theirs pnpm-lock.yaml
pnpm install
จะรักษาความเข้ากันได้ระหว่างเวอร์ชันได้อย่างไร?
หลักการคือ "เพิ่ม ไม่เปลี่ยน ไม่ลบ"
| การเปลี่ยนแปลง | ความเข้ากันได้ | แนวทาง |
|---|---|---|
| เพิ่มฟิลด์ทางเลือก | เข้ากันได้ | เพิ่มได้ ผู้ใช้จะเพิกเฉยฟิลด์ที่ไม่รู้จัก |
| เพิ่มฟิลด์ที่จำเป็น | ไม่เข้ากัน | ขึ้นเวอร์ชัน หรือให้ค่าเริ่มต้น |
| ลบฟิลด์ | ไม่เข้ากัน | ประกาศ deprecated ก่อน ดูผู้เรียก แล้วจึงลบ |
| เปลี่ยนชนิดของฟิลด์ | ไม่เข้ากัน | เพิ่มชื่อฟิลด์ใหม่และเขียนคู่กันในช่วงเปลี่ยนผ่าน |
| เปลี่ยนชุดค่า enum | ขึ้นกับกรณี | การเพิ่มค่าเข้ากันได้ การลบไม่ |
กลไกสนับสนุน:
- ผู้ใช้ฝั่งปลายทางอ่านแบบ "ผ่อนปรน": เพิกเฉยฟิลด์ที่ไม่รู้จักและให้ค่าเริ่มต้นกับฟิลด์ที่หายไป;
- เซิร์ฟเวอร์เก็บฟิลด์เก่าไว้ระยะหนึ่งและคืน header
Deprecationใน response เป็นคำใบ้; - ใน Schema อย่าทำเครื่องหมายฟิลด์ใหม่เป็น
requiredและติดแท็กฟิลด์ที่เลิกใช้ด้วยdeprecated: true
จะตรวจสอบ contract ใน CI ได้อย่างไร?
ถือ Schema เป็นส่วนหนึ่งของโค้ดและตรวจทุก commit
# ตรวจข้อมูลตัวอย่างกับ Schema (ใช้ ajv-cli)
npx ajv-cli validate -s schemas/user.json -d "fixtures/users/*.json" --strict=false
รันสามอย่างใน CI:
- ความถูกต้องของ Schema เอง: การประกาศ
$schemaถูกต้องหรือไม่ และ$refresolve ได้หรือไม่; - fixture ตรงกับ Schema หรือไม่: ยืนยันว่าข้อมูลทดสอบไม่คลาดเคลื่อน;
- contract เข้ากันได้ย้อนหลังหรือไม่: เทียบกับเวอร์ชันก่อนหน้าและตรวจฟิลด์ที่ถูกลบหรือชนิดที่เปลี่ยน
ข้อที่สามต้องมีเครื่องมือสนับสนุน (เช่น openapi-diff หรือ json-schema-diff)
ถ้าไม่มีอย่างน้อยให้ใช้สคริปต์เทียบการเปลี่ยนแปลงใน array required และชุด key ใน properties
3. สรุปเร็ว
| คำถาม | ข้อแนะนำ |
|---|---|
| โครงสร้าง response | {"data":..., "meta":...} และใช้ HTTP status code สำหรับ error |
| สไตล์การตั้งชื่อ | camelCase (เน้น frontend) หรือ snake_case (เน้นข้อมูล) ใช้ให้สม่ำเสมอทั้งเว็บไซต์ |
| รูปแบบเวลา | string แบบ RFC 3339 ที่มีเขตเวลา ชื่อฟิลด์ลงท้ายด้วย At |
| ค่า null | ใช้ null เมื่อไม่มีค่า ละเว้นฟิลด์ที่ไม่เกี่ยวข้อง และการละเว้นใน PATCH หมายถึง "ไม่เปลี่ยน" |
| ชื่อ boolean | คำนำหน้า is/has/can ไม่มีการปฏิเสธ ไม่ใช้ 0/1 |
| enum | string ตัวพิมพ์เล็กคั่นด้วยขีดล่าง และเพิ่ม enum ใน Schema |
| รูปแบบ config | JSON สำหรับ validation ด้วยโปรแกรม, TOML หรือ JSONC สำหรับแก้ด้วยมือ |
| fixture | baseline หนึ่งชุด + override ที่ diff น้อยที่สุด และต้องถูกต้องตาม Schema |
| merge conflict | รวมรูปแบบ, เรียง key, แยกไฟล์ |
| ความเข้ากันได้ระหว่างเวอร์ชัน | เพิ่ม ไม่เปลี่ยน ไม่ลบ; ประกาศ deprecated ก่อนลบ |