บทที่ 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"
}
HTTP/1.1 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 ได้ไหม

สถานการณ์StatusBody ต้องบอก client ว่าRetry ปลอดภัย?
Request ผิดรูป400field ไหน ผิดเพราะอะไรไม่ — แก้แล้วส่งใหม่
Credential ผิด/หมดอายุ401ให้ re-authenticateได้ หลัง refresh
ยืนยันตัวตนแล้วแต่ไม่มีสิทธิ์403ไม่บอกอะไรที่อ่อนไหว ห้ามบอกว่า resource มีอยู่หรือไม่ไม่
ใช้ idempotency key ซ้ำด้วย body ต่าง409conflict — นี่คือ bug ของ clientไม่
เงินไม่พอ / เกิน limit422code ที่เครื่องอ่านได้และคงที่ เช่น INSUFFICIENT_FUNDSไม่ — เป็นการปฏิเสธเชิงธุรกิจ
ถูก rate limit429ต้องมี Retry-After header ทุกครั้งได้ หลังรอตามที่บอก
Downstream rail timeout ไม่รู้ผล202 หรือ 200 + PENDINGห้าม 500 — request ถูกรับแล้ว ผลลัพธ์ยังไม่ทราบปลอดภัยด้วย idempotency key เดิม
Bug ฝั่งเรา500correlation/trace ID เท่านั้น ไม่มีอย่างอื่นได้ ด้วย key เดิม
Overload กำลัง shed load503Retry-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 ห้าม parse
  • trace_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=20feed, 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 กฎที่ต้องทำ:

  1. ตั้งชื่อ event เป็นอดีตกาล — TransferSettled ไม่ใช่ SettleTransfer event คือ ข้อเท็จจริงที่เกิดขึ้นแล้ว, command คือ คำสั่งให้ทำ การผสม 2 อย่างนี้คือจุดเริ่มที่ระบบ event-driven กลายเป็นสปาเกตตีกระจาย
  2. ต้องมี event_id เพราะ delivery guarantee ปกติคือ at-least-once consumer ต้อง dedupe ได้
  3. อย่าใส่ข้อมูลส่วนบุคคลที่ไม่จำเป็น — event จะถูก copy ไป 5 ระบบกับ 3 data lake ภายใต้ PDPA คำขอลบข้อมูลต้องไปถึงทุกที่ ให้ publish identifier แล้วให้ consumer ไปดึงรายละเอียดผ่าน API ที่มี access control
  4. ใส่ version ที่ topic หรือที่ payload และตัดสินใจกฎ compatibility ไว้ — ปกติคือ consumer ต้องทน field ที่ไม่รู้จัก และ producer ห้ามลบหรือเปลี่ยนความหมายของ field

Event ที่ทำให้ระบบพังในภายหลัง

Event ที่ใส่ "ทุกอย่าง" ลงไปเพื่อให้ consumer ไม่ต้องเรียก API จะกลายเป็น schema ที่แก้ไม่ได้ เพราะมี consumer 12 รายที่พึ่ง field ต่างๆ กัน ทางสายกลางที่ใช้ได้: ใส่ field ที่ consumer ทุกราย ต้องใช้เพื่อ routing/filter ที่เหลือให้ไปดึง

REST หรือ gRPC หรือ event

REST/JSONgRPCEvent (broker)
เหมาะกับAPI สาธารณะ, partner, mobileservice-to-service ภายในที่ latency สำคัญการแจ้งว่ามีอะไรเกิดขึ้น, fan-out
Couplingปานกลางสูง (ต้องแชร์ proto)ต่ำสุด
Debugง่ายสุด (curl ได้)ต้องมีเครื่องมือยากสุด — ต้องมี tracing จริงจัง
Contract testOpenAPIprotobuf + buf breaking checkschema registry
ข้อควรระวังpayload บวม, over-fetchingbreaking 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 สมัยใหม่)