บทที่ 4 · Part 1 — Foundation
Contracts & Events
API contract ที่ระบุ error semantics ครบ, idempotency key, pagination และการออกแบบ event schema
Service ของคุณจะถูกเขียนใหม่ 2 ครั้งใน 5 ปี แต่ API จะยังอยู่ที่เดิม Contract คือคำสัญญาที่ทีมอื่นเอาไป build ต่อ — และมันอยู่นานกว่า implementation เสมอ เพราะฉะนั้นออกแบบมันก่อนออกแบบข้างใน
จบบทนี้คุณจะ
- อ่าน API contract แล้วเห็นการตัดสินใจที่ซ่อนอยู่ในแต่ละบรรทัด
- เขียนตาราง error semantics ที่บอก client ได้ว่า retry ปลอดภัยไหม
- รู้ว่าทำไมการตอบ
500เวลา bank rail timeout เป็นความผิดพลาดที่แพงที่สุดในระบบ payment - ออกแบบ event schema ที่ consumer dedupe ได้ และไม่ทำให้ PII กระจายทั่วองค์กร
Payment API contract แบบมีคำอธิบาย
POST /v1/transfers
Idempotency-Key: 9f2c1e4a-7b3d-4c8e-9f1a-2b3c4d5e6f70
Authorization: Bearer <token>
Content-Type: application/json
{
"source_account_id": "acc_123",
"destination": { "type": "promptpay", "proxy_id": "08XXXXXXXX" },
"amount": { "currency": "THB", "minor_units": 25000 },
"reference": "invoice-8891",
"client_timestamp": "2026-08-07T12:00:03Z"
}
201 Created
Content-Type: application/json
Location: /v1/transfers/trf_01J9XYZ
{
"transfer_id": "trf_01J9XYZ",
"status": "PENDING",
"created_at": "2026-08-07T12:00:03.418Z",
"_links": { "self": "/v1/transfers/trf_01J9XYZ" }
}
ในบล็อกเล็กๆ นั้นมีการตัดสินใจซ่อนอยู่ 7 เรื่อง แต่ละเรื่องคือบทเรียนจาก incident จริงที่ไหนสักแห่ง
1. เงินเป็น integer หน่วยย่อย ห้ามเป็น float
25000 คือ 25,000 สตางค์ = 250.00 บาท ไม่ใช่ 250.00
ในเลขทศนิยมฐานสอง: 0.1 + 0.2 = 0.30000000000000004
สะสม 1 ล้านรายการ → ผลต่างที่ reconciliation จะเจอในวันที่แย่ที่สุด
และ currency ต้องเดินทางไปพร้อมจำนวนเงินเสมอ — field amount: 25000
ที่ลอยเดี่ยวๆ คือ bug ที่รอวันโผล่ตอนเปิดสกุลเงินที่ 2
2. Idempotency key เป็น required ไม่ใช่ optional
Mobile network ทำ response หาย client จะ retry ถ้าไม่มี key คุณแยกไม่ได้ว่านั่นคือ retry หรือการจ่ายครั้งที่ 2
กฎที่ต้องเขียนใน contract ให้ชัด:
- key เดิม + body เดิม → คืน ผลลัพธ์เดิม พร้อม status code เดิม
- key เดิม + body ต่าง → 409 Conflict (นี่คือ bug ของ client)
- key มีอายุเท่าไหร่ (ปกติ 24–72 ชั่วโมง) และหลังหมดอายุจะเกิดอะไร
การ implement เก็บไว้ที่บทที่ 16
3. Status เป็น PENDING ไม่ใช่ SUCCESS
ถ้าเงินออกจากระบบของคุณไปยัง rail ที่คุณไม่ได้ควบคุม คุณสัญญาความสำเร็จแบบ synchronous ไม่ได้ การโกหกใน contract บังคับให้ consumer ทุกรายต้องเขียน workaround
4. Path มี version (/v1/)
คุณจะต้องมี v2 ตัดสินใจเรื่องกลยุทธ์ versioning ตั้งแต่วันแรก การมาใส่ทีหลังเจ็บกว่ามาก
| กลยุทธ์ | ใช้เมื่อ | ข้อเสีย |
|---|---|---|
Version ใน path (/v1/) | breaking change ที่ชัดเจน มี consumer ภายนอก | ต้องดูแลหลาย version พร้อมกัน |
Version ใน header (Accept: application/vnd.x.v2+json) | ต้องการให้ URL คงที่ | คนทดสอบด้วย browser/curl ลำบาก |
| Additive only (ไม่มี version) | API ภายในที่ deploy พร้อมกันได้ | จะพังเมื่อวันหนึ่งต้องลบ field |
5. มี URL ให้ poll และ (บน production) มี webhook
ห้ามให้ client เดา ว่าจะรู้ผลลัพธ์ได้อย่างไร ในสัญญาต้องระบุทั้ง 2ทาง พร้อมกฎของ webhook: signature ตรวจยังไง, retry กี่ครั้ง, ส่งซ้ำได้ไหม (ได้ — consumer ต้อง dedupe)
6. รับ timestamp จาก client แต่ไม่เชื่อ
มีประโยชน์สำหรับ debug clock skew และช่วยตรวจ duplicate แต่ห้ามใช้จัดลำดับหรือใช้ตัดสิน authorization — นาฬิกาของ client ตั้งเองได้
7. Destination เป็น tagged union ไม่ใช่ column optional เรียงกัน
{ "destination": { "type": "promptpay", "proxy_id": "08XXXXXXXX" } }
ดีกว่า
{ "promptpay_id": null, "bank_account": "123-4-56789", "bank_code": "014", "wallet_id": null }
เพราะเพิ่ม rail ใหม่ได้โดยไม่ทำให้ client เก่าพัง และ validation เขียนได้ต่อ type
Error semantics — ส่วนที่คนข้ามกันหมด
คำถามที่ error response ต้องตอบให้ client ได้มีข้อเดียว: retry ได้ไหม
| สถานการณ์ | Status | Body ต้องบอก client ว่า | Retry ปลอดภัย? |
|---|---|---|---|
| Request ผิดรูป | 400 | field ไหน ผิดเพราะอะไร | ไม่ — แก้แล้วส่งใหม่ |
| Credential ผิด/หมดอายุ | 401 | ให้ re-authenticate | ได้ หลัง refresh |
| ยืนยันตัวตนแล้วแต่ไม่มีสิทธิ์ | 403 | ไม่บอกอะไรที่อ่อนไหว ห้ามบอกว่า resource มีอยู่หรือไม่ | ไม่ |
| ใช้ idempotency key ซ้ำด้วย body ต่าง | 409 | conflict — นี่คือ bug ของ client | ไม่ |
| เงินไม่พอ / เกิน limit | 422 | code ที่เครื่องอ่านได้และคงที่ เช่น INSUFFICIENT_FUNDS | ไม่ — เป็นการปฏิเสธเชิงธุรกิจ |
| ถูก rate limit | 429 | ต้องมี Retry-After header ทุกครั้ง | ได้ หลังรอตามที่บอก |
| Downstream rail timeout ไม่รู้ผล | 202 หรือ 200 + PENDING | ห้าม 500 — request ถูกรับแล้ว ผลลัพธ์ยังไม่ทราบ | ปลอดภัยด้วย idempotency key เดิม |
| Bug ฝั่งเรา | 500 | correlation/trace ID เท่านั้น ไม่มีอย่างอื่น | ได้ ด้วย key เดิม |
| Overload กำลัง shed load | 503 | Retry-After + บอกชัดว่า ไม่มีอะไรเกิดขึ้น | ได้ ด้วย backoff + jitter |
ความผิดพลาดที่แพงที่สุดใน API ของระบบ payment
การตอบ 500 เวลา downstream rail timeout
ความจริงคือ "เราไม่รู้ว่าเงินย้ายไปแล้วหรือยัง" แต่ 500 บอก client ว่า
"ล้มเหลว retry ได้เลย" — และถ้าครั้งแรกสำเร็จที่ปลายทางไปแล้ว
การ retry คือการจ่ายเงินซ้ำ
คำตอบที่ถูกคือ state "รับเรื่องแล้วแต่ยังไม่ทราบผล" (PENDING)
บวกกระบวนการ reconciliation ที่มาปิดสถานะให้ ระบบ payment ที่โตแล้วทุกระบบ
มี state นี้อยู่ใน state machine อย่างชัดเจน
`UNKNOWN` ต้องไม่ auto-retry
เมื่อ transfer เข้าสถานะ UNKNOWN ให้ ตัดออกจาก retry อัตโนมัติ, alert ops,
และแสดงกับลูกค้าว่า "กำลังดำเนินการ" ห้ามแสดงยอดเงินที่ผิด
การตัดสินว่าจะ retry หรือ reverse รายการที่ค้างเป็น UNKNOWN นานเกินเกณฑ์
เป็นการตัดสินใจที่ต้องมีคนที่มีอำนาจอนุมัติ ไม่ใช่ logic ในโค้ด
Error body ที่ใช้งานได้
{
"error": {
"code": "INSUFFICIENT_FUNDS",
"message": "ยอดเงินคงเหลือไม่เพียงพอ",
"trace_id": "01J9XYZABCDEF",
"details": { "available_minor_units": 12000, "required_minor_units": 25000 }
}
}
codeเป็น enum ที่คงที่ — client เขียน logic กับมันได้ ห้ามเปลี่ยนคำmessageเป็นข้อความสำหรับคน อาจเปลี่ยนได้ตามภาษา — client ห้าม parsetrace_idทำให้ support ไล่ log ต่อได้ใน 1 นาที ไม่ใช่ 1 ชั่วโมงdetailsใส่เฉพาะที่ client ต้องใช้ตัดสินใจ และต้องคิดว่ามันรั่วอะไรไหม
`details` ที่รั่วข้อมูล
การใส่ available_minor_units ลงไปหมายความว่า caller รู้ยอดคงเหลือ
ถ้า API นี้เปิดให้ partner หรือ merchant เรียก นั่นคือการเปิดเผยข้อมูลลูกค้า
ตัดสินใจเรื่องนี้ที่ contract ไม่ใช่ตอน code review
Pagination และ list endpoint
| แบบ | วิธี | ใช้เมื่อ | ปัญหา |
|---|---|---|---|
| Offset | ?page=3&size=20 | ตาราง admin ที่ข้อมูลนิ่ง | ข้อมูลเลื่อนระหว่างหน้า, OFFSET 100000 ช้ามาก |
| Cursor (keyset) | ?after=<opaque>&limit=20 | feed, transaction history | ข้ามหน้าไปหน้าที่ 50 ตรงๆ ไม่ได้ |
| Time window | ?from=&to= | รายงาน, reconciliation | ต้องกำหนดเพดานช่วงเวลา |
สำหรับ transaction history ให้ใช้ cursor เสมอ และ cursor ต้องเป็น opaque string
(encode created_at + id เข้าด้วยกัน) ไม่ใช่ตัวเลขที่ client เดาต่อได้
GET /v1/accounts/acc_123/transfers?limit=20&after=eyJ0IjoiMjAyNi0wOC0wN1QxMjowMDowM1oiLCJpZCI6InRyZl8wMUo5WFlaIn0
{
"data": [ { "transfer_id": "trf_01J9XYZ", "amount": { "currency": "THB", "minor_units": 25000 } } ],
"next_cursor": "eyJ0IjoiMjAyNi0wOC0wN1QxMTo1OTo0MVoiLCJpZCI6InRyZl8wMUo5V1FSIn0",
"has_more": true
}
ถ้า interface เป็น asynchronous ก็ต้องออกแบบ event ด้วย
{
"event_id": "evt_01J9ABC",
"event_type": "TransferSettled",
"occurred_at": "2026-08-07T12:00:07.412Z",
"version": 1,
"transfer_id": "trf_01J9XYZ",
"account_id": "acc_123",
"amount": { "currency": "THB", "minor_units": 25000 },
"trace_id": "01J9XYZABCDEF"
}
Metadata ของ topic ที่ต้องตัดสินใจไปพร้อมกัน:
topic: payments.transfer.v1 # domain.entity.version
key: transfer_id # การรับประกันลำดับเป็นแบบต่อ key เท่านั้น
4 กฎที่ต้องทำ:
- ตั้งชื่อ event เป็นอดีตกาล —
TransferSettledไม่ใช่SettleTransferevent คือ ข้อเท็จจริงที่เกิดขึ้นแล้ว, command คือ คำสั่งให้ทำ การผสม 2 อย่างนี้คือจุดเริ่มที่ระบบ event-driven กลายเป็นสปาเกตตีกระจาย - ต้องมี
event_idเพราะ delivery guarantee ปกติคือ at-least-once consumer ต้อง dedupe ได้ - อย่าใส่ข้อมูลส่วนบุคคลที่ไม่จำเป็น — event จะถูก copy ไป 5 ระบบกับ 3 data lake ภายใต้ PDPA คำขอลบข้อมูลต้องไปถึงทุกที่ ให้ publish identifier แล้วให้ consumer ไปดึงรายละเอียดผ่าน API ที่มี access control
- ใส่ version ที่ topic หรือที่ payload และตัดสินใจกฎ compatibility ไว้ — ปกติคือ consumer ต้องทน field ที่ไม่รู้จัก และ producer ห้ามลบหรือเปลี่ยนความหมายของ field
Event ที่ทำให้ระบบพังในภายหลัง
Event ที่ใส่ "ทุกอย่าง" ลงไปเพื่อให้ consumer ไม่ต้องเรียก API จะกลายเป็น schema ที่แก้ไม่ได้ เพราะมี consumer 12 รายที่พึ่ง field ต่างๆ กัน ทางสายกลางที่ใช้ได้: ใส่ field ที่ consumer ทุกราย ต้องใช้เพื่อ routing/filter ที่เหลือให้ไปดึง
REST หรือ gRPC หรือ event
| REST/JSON | gRPC | Event (broker) | |
|---|---|---|---|
| เหมาะกับ | API สาธารณะ, partner, mobile | service-to-service ภายในที่ latency สำคัญ | การแจ้งว่ามีอะไรเกิดขึ้น, fan-out |
| Coupling | ปานกลาง | สูง (ต้องแชร์ proto) | ต่ำสุด |
| Debug | ง่ายสุด (curl ได้) | ต้องมีเครื่องมือ | ยากสุด — ต้องมี tracing จริงจัง |
| Contract test | OpenAPI | protobuf + buf breaking check | schema registry |
| ข้อควรระวัง | payload บวม, over-fetching | breaking change เงียบถ้าไม่มี CI ตรวจ | ordering ต่อ key เท่านั้น, at-least-once |
กฎที่ใช้ได้: ขอบนอก (partner, mobile) ใช้ REST; ภายในใช้ gRPC ถ้า latency สำคัญ; การแจ้งข้อเท็จจริงใช้ event — และห้ามใช้ event เป็น request/response แบบอ้อมๆ
Checklist ก่อนปิด contract
- เงินเป็น integer หน่วยย่อย และมี currency ติดไปทุกที่
- Idempotency key required พร้อมกฎ key ซ้ำ + อายุ key
- Status ตรงกับความจริง (
PENDINGเมื่อยังไม่รู้ผล) - ตาราง error ครบ พร้อมคอลัมน์ "retry ปลอดภัยไหม"
-
429ส่งRetry-Afterและ503บอกชัดว่าไม่มีอะไรเกิดขึ้น - Error body มี
codeแบบ enum +trace_id -
403ไม่รั่วว่า resource มีอยู่จริงไหม - List endpoint ใช้ cursor แบบ opaque และมีเพดาน
limit - Event เป็นอดีตกาล มี
event_idและไม่มี PII เกินจำเป็น - กลยุทธ์ versioning ตัดสินใจแล้วและเขียนไว้
- มีการยืนยันกับ consumer จริง อย่างน้อย 1 ราย ก่อนประกาศว่า contract นิ่ง
สรุปบทนี้
Contract อยู่นานกว่าโค้ด · เงินเป็น integer + currency · idempotency key เป็น required ·
PENDING เมื่อไม่รู้ผล ห้าม 500 · error ต้องตอบได้ว่า retry ปลอดภัยไหม ·
event เป็นอดีตกาล มี id ให้ dedupe และไม่พา PII ไปทั่วองค์กร
อ้างอิงที่ควรอ่าน: Zalando RESTful API Guidelines · Stripe API reference (มาตรฐานของการออกแบบ payment API) · ISO 20022 (มาตรฐานข้อความของ payment rail สมัยใหม่)