บทที่ 18 · Part 5 — Decisions & Delivery

Scenarios: Money Rails

เดินครบ 7 ขั้นกับ 4 ระบบเงิน — e-wallet transfer + ledger, QR payment gateway, OTP service และ reconciliation

4 ระบบในบทนี้เป็นแกนของ fintech ทุกเจ้า และทั้ง 4มีบทเรียนคนละข้อ — ความถูกต้องเหนือทุกอย่าง, การออกแบบรอบ dependency ที่คุณไม่ได้ควบคุม, การแยก priority lane, และระบบที่ไม่มีใครออกแบบแต่ทุกคนต้องมี

จบบทนี้คุณจะ

  • เดินครบ 7 ขั้นกับ e-wallet transfer ที่มี ledger ถูกต้อง
  • ออกแบบ QR payment gateway ที่รอบ external rail อย่างปลอดภัย
  • ออกแบบ OTP/notification ที่มี priority lane และปิดช่องโหว่ที่ถูกใช้จริงมาแล้ว
  • ออกแบบ reconciliation ที่เป็น component ทางสถาปัตยกรรม ไม่ใช่งาน back office

Scenario 1 · E-wallet transfer พร้อม ledger ที่ถูกต้อง

ทำไมเริ่มที่นี่: มันเป็นแม่แบบของทุกอย่างที่ความถูกต้องสำคัญกว่าทุกสิ่ง ถ้าคุณออกแบบอันนี้ได้ดี ระบบ CRUD ที่เหลือจะง่าย

S1.1 · Clarify

Functional (ใน scope)Non-goal (v1)
เติมเงินจากบัญชีธนาคารที่ผูกไว้; โอน wallet-to-wallet; ดูยอด; ดูประวัติ; ได้รับ notificationข้ามสกุลเงิน; โอนตามตาราง/ประจำ; settlement ให้ merchant; สินเชื่อ; ออกบัตร; workflow ข้อพิพาท
Quality attributeเป้าทำไมเป็นตัวเลขนี้
Correctnessไม่ยอมรับความคลาดเคลื่อนใดๆ ไม่มีเงินหาย ไม่มีเงินเกิด ไม่มีการหักซ้ำไม่มีการต่อรอง — เมื่อไม่แน่ใจ ให้ปฏิเสธรายการ
Availability (เส้นทางโอน)99.9% ต่อเดือน (งบ ≈43 นาที)bank rail มี downtime ของตัวเอง การสัญญามากกว่า dependency ของคุณคือความไม่ซื่อสัตย์
Latencyp99 < 800 ms เพื่อรับเรื่อง (ไม่ใช่เพื่อ settle)settlement ขึ้นกับ rail ภายนอก — รับให้เร็ว settle อย่างซื่อสัตย์
DurabilityRPO = 0บังคับให้ต้องมี synchronous replication — นี่คือ constraint ที่กำหนดรูปร่างของชั้นข้อมูล
Auditabilityยอดใดก็อธิบายได้ถึงระดับ entry; retention ตามคำสั่ง complianceระยะเวลา retention ต้องมาจากเจ้าของ compliance ที่รับผิดชอบ — บันทึกว่าใครและเมื่อไหร่
Consistencyการตัดสินใจเรื่องยอด: strong · การแสดงยอด: read-your-writes สำหรับ actor, ≤5 s สำหรับคนอื่นการแยก การตัดสินใจ ออกจาก การแสดง คือสิ่งที่ทำให้ระบบนี้จ่ายไหว

S1.2 · Assess scale

1M DAU · 0.5 การเคลื่อนย้ายเงิน/คน/วัน = 500k txn/วัน ≈ 6 tps average
peak factor 10x ปกติ, 30x วันเงินเดือนออก -> design 300 tps, burst 1,000
ledger row: 500k x 5 = 2.5M/วัน ≈ 1 GB/วัน ≈ 365 GB/ปี (+ index)
การอ่านยอด: 3M/วัน ≈ 35/s avg, ~300/s peak

ข้อสรุป: ปริมาณเล็ก *ห้าม* build เผื่อ scale
ให้ build เพื่อ correctness, auditability และ retention
Postgres ที่ replicate 1 ตัวกับ schema ที่ออกแบบดีคือคำตอบที่ถูก
และการพูดข้อนี้ออกมาอย่างมั่นใจคือสิ่งที่ senior ทำ

S1.3 · Contracts

ตามที่ออกแบบในบทที่ 4: POST /v1/transfers พร้อม Idempotency-Key ที่บังคับ, integer หน่วยย่อย, status เริ่มต้น PENDING, URL ให้ poll และ webhook — บวกอีก 3 อย่าง:

GET /v1/accounts/acc_123/balance
{
  "available_minor": 412300,
  "total_minor": 437300,
  "currency": "THB",
  "as_of_entry_id": "01J9ABCXYZ"
}

`as_of_entry_id` คือ watermark ที่เปิดเผยให้ client

การเปิดเผย watermark ทำให้ client ตรวจจับความเก่าของข้อมูลได้เอง — ถ้ามันเห็น entry ที่ใหม่กว่านี้จากที่อื่น มันรู้ว่าต้องอ่านซ้ำ

  • GET /v1/accounts/{id}/transactions?limit=20&cursor=… → keyset pagination ไม่ใช่ OFFSET
  • Event: TransferCreated, TransferSettled, TransferFailed, TransferReversed ทั้งหมด key ด้วย account_id

S1.4 · Data model

accounts(account_id, customer_id, currency, status, created_at)
-- ไม่มีคอลัมน์ balance โดยเจตนา

transactions(
  transaction_id       TEXT PRIMARY KEY,        -- ULID
  idempotency_key      TEXT NOT NULL,
  request_fingerprint  TEXT NOT NULL,           -- hash ของ canonical body
  status               TEXT NOT NULL,           -- ดู state machine ด้านล่าง
  external_ref         TEXT,                    -- reference ของ rail
  response_body        JSONB,                   -- คำตอบที่เราส่งไปแล้ว
  created_at, updated_at,
  UNIQUE (idempotency_key)   -- ฐานข้อมูลเป็นตัวกันการ post ซ้ำ
);

ledger_entries(
  entry_id       BIGSERIAL PRIMARY KEY,
  transaction_id REFERENCES transactions(transaction_id),
  account_id, direction, amount_minor BIGINT CHECK (amount_minor > 0),
  currency CHAR(3), entry_type, created_at, effective_at
)
  INDEX (account_id, entry_id DESC)   -- ประวัติ + การคำนวณ delta
  PARTITION BY RANGE (created_at);    -- รายเดือน ทำให้ archive ราคาถูก

account_balances(                     -- snapshot ที่ตรวจสอบได้ ไม่ใช่ความจริง
  account_id, currency,
  balance_minor BIGINT, available_minor BIGINT,
  last_entry_id BIGINT, updated_at
);

holds(hold_id, account_id, amount_minor, expires_at, status);
-- reservation สำหรับการเคลื่อนย้าย 2 เฟส *ต้อง* หมดอายุได้

outbox(event_id, aggregate_id, topic, payload, created_at, published_at)
  INDEX (created_at) WHERE published_at IS NULL;   -- partial: เล็กจิ๋วตลอด

S1.5 · Architecture และ write path

ขั้นตอนของ write path ทีละบรรทัด

-- 1. Idempotency check ต้องมาก่อนทุกอย่าง
SELECT * FROM transactions WHERE idempotency_key = :key;
--    เจอ + fingerprint เหมือน  -> คืน response ที่เก็บไว้ (200)
--    เจอ + fingerprint ต่าง    -> 409 Conflict
--    ไม่เจอ                   -> ไปต่อ

-- 2. หน่วย atomic 1 หน่วย
BEGIN;
  INSERT INTO transactions (...) VALUES (... 'PENDING');
  -- unique key คือตัวกันจริง

  -- ล็อก account ตามลำดับ id เพื่อเลี่ยง deadlock
  SELECT * FROM accounts WHERE account_id IN (:a, :b)
   ORDER BY account_id FOR UPDATE;

  -- เช็ค limit กับแถวที่ materialize aggregate ไว้
  SELECT * FROM daily_limits WHERE account_id = :a
     AND limit_date = current_date FOR UPDATE;

  -- เช็คเงินพอจาก snapshot + delta
  INSERT INTO ledger_entries (...);  -- debit A, credit B, credit fee
  UPDATE account_balances ...;       -- ทั้ง 2 account, เลื่อน watermark
  UPDATE daily_limits ...;
  INSERT INTO outbox (...);          -- TransferCreated
COMMIT;

Rail Adapter อยู่ **นอก** transaction เสมอ

มันเรียกธนาคารด้วย reference ที่ idempotent ของเราเอง, retry แบบ backoff + jitter, และ update status ผ่าน state machine

ห้ามอยู่ใน transaction เดิม — ไม่อย่างนั้นคุณถือ row lock ไว้เท่ากับ latency ของธนาคาร (บทที่ 14)

[!CAUTION] Reconciliation ห้ามปรับยอดลูกค้าเอง งาน 02:00 เทียบ ledger ของเรากับรายงานของ rail และปิดสถานะ UNKNOWN ทุกใบ แต่ห้าม auto-adjust ยอดเงินลูกค้าจาก job นี้โดยไม่ผ่านเส้นทาง control ที่มีคนอนุมัติ — ดู Scenario 4 ในบทนี้

S1.6 · Deep dive

(ก) Idempotency ที่ทำงานได้จริงตอน concurrent

"SELECT แล้ว INSERT" แบบตรงไปตรงมามี race:
request 2 ใบที่มี key เดียวกันมาพร้อมกัน ทั้งคู่เห็นว่าไม่มี แล้ว insert ทั้งคู่
UNIQUE constraint ช่วยคุณไว้ — แต่ต้องจัดการ violation ให้ถูก:

  try:
      INSERT INTO transactions (idempotency_key, fingerprint, status)
           VALUES (:key, :fp, 'PENDING')      -- นี่คือ lock
  except UniqueViolation:
      row = SELECT * FROM transactions WHERE idempotency_key = :key
      if row.fingerprint != :fp:
          return 409                          -- bug ของ client
      if row.status == 'PENDING' and row.response_body is null:
          return 409 + Retry-After            -- ยังทำงานอยู่ ไม่ใช่เสร็จ
      return row.response_body                -- เล่นคำตอบเดิมซ้ำ

4 รายละเอียดที่แยก implementation ที่ใช้งานได้จาก demo:

  1. เก็บ response ไม่ใช่แค่คำว่า "done" — การ retry ต้องได้ body ที่เหมือนกันเป๊ะ รวมถึง transfer_id ตัวเดิม ไม่อย่างนั้น client จะสร้าง record ที่ 2 ฝั่งตัวเอง
  2. Fingerprint request — key เดิม + จำนวนต่าง คือ bug ของ client และต้องถูกปฏิเสธเสียงดัง ไม่ใช่ถูกมองเป็น replay อย่างเงียบๆ
  3. จัดการกรณี "in flight" — request แรกอาจยังรันอยู่ คืน conflict ที่ retry ได้ ไม่ใช่เริ่มความพยายามที่ 2
  4. ให้ key หมดอายุ (เช่น 24–72 ชม.) ด้วย retention job และระบุไว้ใน API doc — ตารางที่ไม่มีขอบเขตจะกลายเป็นปัญหาในที่สุด

อ้างอิง: Implementing Stripe-like idempotency keys in Postgres — มีเทคนิค "recovery point" สำหรับ resume operation หลายขั้นตรงจุดที่มันหยุด

(ข) State machine ของ transaction รวมสถานะที่ซื่อสัตย์

กฎ 5 ข้อของ state machine นี้

  1. FAILED หมายถึงเรามีหลักฐานเชิงบวกว่ามันไม่เกิดขึ้น — timeout ไม่ใช่หลักฐาน timeout คือ UNKNOWN
  2. การออกจาก UNKNOWN ไม่เกิดจาก timer หรือการเดา
  3. REVERSED ไปถึงได้ด้วยการ post ledger entry ชดเชยใบใหม่ — entry เดิมไม่เคยถูกแตะ
  4. ทุก transition ถูก log พร้อม actor, timestamp และ *หลักฐาน* (response ของ rail หรือ record ของ reconciliation ที่ให้เหตุผล)
  5. ป้ายที่ลูกค้าเห็นสำหรับ UNKNOWN คือ "กำลังดำเนินการ" ไม่ใช่ "ล้มเหลว" — การบอกลูกค้าว่าการจ่ายล้มเหลวขณะที่มันอาจสำเร็จ ทำให้เกิดความพยายามซ้ำและความสูญเสียจริง

(ค) อ่านยอดโดยไม่ scan ประวัติ

-- snapshot + delta = 2 indexed read ที่เร็ว
SELECT balance_minor, last_entry_id
  FROM account_balances WHERE account_id = :a;

SELECT COALESCE(SUM(CASE WHEN direction = 'CREDIT' THEN amount_minor
                         ELSE -amount_minor END), 0) AS delta
  FROM ledger_entries
 WHERE account_id = :a AND entry_id > :last_entry_id;

-- ผลลัพธ์ = balance_minor + delta

คุณสมบัติที่ทำให้ snapshot ปลอดภัย

delta ปกติมี 0–5 แถว จึงเร็วมาก

snapshot ถูก update ใน transaction เดียวกับ entry จึงไม่เคยตามหลังมาก — และแม้ว่าการ update นั้นจะถูกข้ามไป คำตอบก็ยังถูก เพราะ delta ครอบคลุมมันไว้

นั่นคือคุณสมบัติที่ทำให้ snapshot ปลอดภัย: มันคือ optimization ไม่ใช่ source of truth — ออกแบบข้อมูลที่ derive มาแบบนี้ทุกครั้งที่ทำได้

Verification job (ทุกคืน): คำนวณทุกยอดใหม่จาก entry ที่ 1 แล้วเทียบกับ snapshot ความไม่ตรงกันใดๆ ปลุกคน — job นี้จับ bug จริงได้ในทุกระบบที่ผมเคยเห็นมันถูกเพิ่มเข้าไป

S1.7 · Failure mode

Failureพฤติกรรมการตรวจจับ
Crash หลัง COMMIT ก่อนตอบ clientclient retry ด้วย key เดิม → ได้ response ที่เก็บไว้ ไม่มีรายการซ้ำ—
Crash ระหว่าง COMMIT กับ outbox relayrelay หยิบไปตอน restart · at-least-once → consumer ต้อง idempotentalert: แถว outbox ที่ยังไม่ publish เก่าสุด > 30 s
Rail timeoutstatus UNKNOWN · retry ด้วย rail reference ตัวเดิม · reconciliation ปิดสถานะrail latency/error rate; จำนวน UNKNOWN ที่เก่ากว่า 1 ชม.
Rail คืนสำเร็จสำหรับ transaction ที่เราไม่เคยส่งreconciliation break → quarantine ห้าม auto-postreconciliation รายวัน; alert ทุกรายการที่ไม่ match
Database failovertransaction ที่ค้างถูก abort · client retry ด้วย key เดิม · ไม่มี partial write เพราะทุกอย่างอยู่ใน transaction เดียวwrite error rate; failover event
แถว snapshot เสียหายจาก bugการอ่านยอดอาจผิดจนกว่าจะตรวจพบ — แต่ ledger ยังสมบูรณ์และสร้างใหม่ได้nightly recompute-and-compare
Ledger entry ซ้ำจาก deploy ที่เสียzero-sum monitor ยังผ่าน (ซ้ำทั้ง 2ขา) → จึงต้อง monitor จำนวน entry ต่อ transaction เทียบ pattern ที่คาดไว้ ด้วยinvariant monitor; spike ของ entry/txn

Invariant monitor 4 ตัวที่ต้องรันต่อเนื่อง

  1. Zero-sum ต่อ transaction — entry ของทุก transaction รวมกันได้ 0
  2. Global zero-sum — debit รวม = credit รวม ทั้ง ledger ต่อสกุลเงิน ตัวนี้จับ bug ได้ทั้งหมวด
  3. Snapshot = การคำนวณใหม่ — ทุกคืน ทุก account
  4. ตรงกับภายนอก — ยอด settled ของเรา = รายงานของ rail ต่อวัน ต่อ rail

query 4 อันนี้เขียนง่ายมาก และเป็นความต่างระหว่าง การเจอ bug เรื่องเงินใน 1 ชั่วโมง กับ การเจอมันใน audit รายไตรมาส — สร้างมันก่อน go-live ไม่ใช่หลัง incident แรก


Scenario 2 · QR payment gateway กับ external rail

บทเรียน: การออกแบบรอบ dependency ที่คุณไม่ได้ควบคุม ซึ่งคืองาน integration จริงส่วนใหญ่

S2.1 · Clarify

Flow: ลูกค้าสแกน QR ของร้าน → แอปแสดงชื่อร้านและจำนวน → ลูกค้ายืนยัน → รายการถูก authorize และ settle → ทั้ง 2 ฝ่ายได้ notification และใบเสร็จ

QR 2 แบบที่ต่างกันเชิงสถาปัตยกรรม:

แบบคืออะไรผลต่อ design
Static QRมีแต่ตัวตนร้าน ลูกค้าใส่จำนวนเองต้องกันการจ่ายซ้ำแรงกว่า เพราะ code เดิมถูกสแกนหลายพันครั้ง
Dynamic QRจำนวนและ reference เฉพาะ มักมีวันหมดอายุต้องมี record อายุสั้นและ sweeper เก็บของหมดอายุ

Quality attribute หลัก: ร้านต้องไม่ได้รับเงิน 2 ครั้งจากการซื้อครั้งเดียว · ลูกค้าต้องไม่ถูกหักเงินโดยที่ร้านไม่ได้รับ · หน้าจอร้านต้อง update ภายใน ~3 s (นี่คือเคาน์เตอร์ที่มีคิวรออยู่ข้างหลัง) · settlement เข้าบัญชีร้านตามรอบของ scheme ไม่ใช่ทันที

Non-goal ของ v1: refund/refund บางส่วน, tip, โหมด offline, ข้ามประเทศ, ผ่อนชำระ

กฎจริงต้องเอามาจากที่ไหน

ระบบ QR payment ของไทย (PromptPay, Thai QR มาตรฐาน) มีข้อกำหนดที่เผยแพร่แล้ว — spec, กฎของ scheme, รูปแบบข้อความ, พฤติกรรม timeout และขั้นตอนข้อพิพาท ซึ่งกำหนดโดยผู้ดำเนินการ scheme และผู้กำกับดูแล

design ของคุณต้องเป็นไปตามเอกสารเหล่านั้น ไม่ใช่ตามการสรุปทั่วไปในคอร์สนี้

เริ่มจาก primary source: หน้าระบบการชำระเงินของธนาคารแห่งประเทศไทย, NITMX สำหรับ interbank rail, และ EMVCo QR code specifications ที่มาตรฐาน Thai QR สร้างบนนั้น

ให้ถือว่าค่า timeout, กฎ retry หรือรูปแบบเลขอ้างอิงใดๆ เป็นสิ่งที่ scheme กำหนด จนกว่าคุณจะได้อ่านมันใน spec เอง

S2.2 · Assess scale

200k ร้าน, 2M การจ่าย/วัน  -> 23 tps average
พีคของค้าปลีกคม: มื้อเที่ยง (11:30-13:00) และเย็น (17:00-20:00) บวกสิ้นเดือน
สมมติ 15x -> ~350 tps peak, burst 1,000 tps

งบ latency ตั้งแต่ "ลูกค้ายืนยัน" ถึง "ร้านเห็นว่าจ่ายแล้ว":
  app -> API ของเรา          50 ms
  งาน authorization ของเรา    50 ms
  RAIL round trip        300-3,000 ms   <-- ครอบงำ และไม่ใช่ของเรา
  post-processing ของเรา      30 ms
  push ถึงเครื่องร้าน       200-1,500 ms  <-- mobile network
  ------------------------------------
  p95 ที่เป็นจริง ~2-3 s, p99 แย่กว่านั้น

ผลที่ตามมา: หน้าจอลูกค้ารอทุกอย่างแบบ synchronous ไม่ได้
ออกแบบ UX 2 เฟส: "กำลังดำเนินการ" ทันที แล้วค่อยยืนยันแบบ push
และร้านต้องมี POLLING fallback เพราะการส่ง push ไม่มีการรับประกัน

S2.3 · Contracts

POST /v1/payments
Idempotency-Key: <uuid>

{
  "qr_payload": "00020101...",
  "amount": { "currency": "THB", "minor_units": 15000 },
  "source_account_id": "acc_1",
  "device_id": "dev_9",
  "client_txn_ref": "app-ref-8891"
}
{
  "payment_id": "pay_01J9XYZ",
  "status": "PROCESSING",
  "merchant": { "name": "ร้านกาแฟหน้าปากซอย", "id": "mch_7" },
  "poll_after_ms": 500,
  "expires_at": "2026-08-08T04:12:00Z"
}

ทำไม `202` และ poll hint — และ design ที่แย่ที่สุดคืออะไร

202 บอกอย่างซื่อสัตย์ว่างานยังไม่เสร็จ และบอก client ชัดเจนว่าจะรู้ผลได้อย่างไร

design ที่แย่ที่สุดคือ endpoint แบบ synchronous ที่ block รอ latency ของ rail — มันถือ connection และ thread 1 ชุดต่อการจ่ายที่ค้างอยู่ และเมื่อ rail ช้าลงเป็น 8 วินาที pool ของคุณหมด และ gateway ทั้งตัวล่ม (Little's Law, บทที่ 11)

S2.4 · Data model ที่สำคัญ

payments(payment_id, idempotency_key UNIQUE, merchant_id,
         customer_account_id, amount_minor, currency, status,
         rail_ref, qr_hash, created_at, expires_at, terminal_at)
  INDEX (merchant_id, created_at DESC)                    -- รายการของร้าน
  INDEX (status) WHERE status IN ('PROCESSING','UNKNOWN'); -- hot set

qr_codes(qr_id, merchant_id, type, amount_minor NULL,
         reference, expires_at, status);   -- type = STATIC | DYNAMIC

rail_attempts(attempt_id, payment_id, attempt_no, request_hash,
              rail_ref, sent_at, response_code, response_at,
              raw_response JSONB);
-- ทุกการเรียก rail ถูกบันทึก ทั้ง request และ response

-- ledger_entries / transactions -- เหมือน Scenario 1 เป๊ะ

บันทึกทุกการเรียกภายนอกเป็น **ข้อมูล** ไม่ใช่เป็น log

เมื่อร้านอ้างว่าไม่ได้รับเงิน, หรือ rail อ้างว่าคุณไม่เคยส่ง request, หรือ auditor ถามว่าเกิดอะไรขึ้นวันที่ 3 มีนาคม 14:02 — ตาราง rail_attempts ที่มี request hash ที่แน่นอน, reference ของ rail, และ raw response ตอบคำถามได้ในไม่กี่วินาที

Log ถูก sample, ถูก rotate, และ query ยาก ตารางนี้คุ้มค่าตัวเองในสัปดาห์แรกที่ขึ้นระบบ

แต่ต้อง mask หรือ tokenize field ที่อ่อนไหวก่อนเก็บ และระวังอย่าเก็บข้อมูลบัตรหรือ credential ลงใน raw payload เด็ดขาด

S2.5 · Architecture

Status reconciler คือ component ที่คนมักไม่มี

ทุก 1–5 นาที มันไล่ payment ที่อยู่ใน PROCESSING หรือ UNKNOWN ที่เก่ากว่า N วินาที แล้ว query status API ของ rail

นี่คือสิ่งที่เปลี่ยน "เราไม่รู้" ให้เป็นผลลัพธ์ที่ปิดได้ โดยไม่ต้องรอไฟล์กลางคืน

S2.6 · Deep dive

(ก) เรียก external rail อย่างปลอดภัย — 7 ข้อ

1. สร้าง reference ที่ idempotent ของเราเองสำหรับ rail
   derive จาก payment_id ของเรา และ *ใช้ตัวเดิมทุกครั้งที่ retry*
   -> ห้ามสร้าง reference ใหม่ตอน retry นั่นคือวิธีสร้างการจ่ายซ้ำที่ rail

2. TIMEOUT จาก p99 ที่ rail ระบุไว้ในเอกสาร ไม่ใช่จากเลขกลม
   แยก connect (สั้น) จาก read timeout

3. RETRY เฉพาะ connection error และ code ที่ retryable เท่านั้น
   พร้อม backoff + full jitter, จำกัดจำนวนครั้ง, และมี retry budget
   *ห้าม retry การปฏิเสธเชิงธุรกิจ*

4. CIRCUIT BREAKER ต่อ rail
   ตอนเปิด: เข้าคิว payment ใหม่แล้วบอกลูกค้าตามจริง
   ("ธนาคารนี้ใช้งานไม่ได้ชั่วคราว") หรือ route ไป rail สำรอง
   ถ้ามีและร้านรองรับ

5. BULKHEAD: rail adapter มี connection pool และ worker pool ของตัวเอง
   rail ที่ช้าต้องไม่กิน thread ที่ใช้ serve การอ่านยอด

6. RESPONSE VALIDATION: ตรวจว่า response ของ rail อ้างถึง
   *reference ของเรา* และ *จำนวนของเรา* ก่อนจะเชื่อมัน
   ที่ไม่ตรงให้ไป quarantine ไม่ใช่ไป ledger

7. UNKNOWN: ตอน timeout ห้ามเดา
   mark UNKNOWN, ให้ status reconciler ปิดสถานะ,
   และ *ถือเงินลูกค้าไว้เป็น hold ไม่ใช่ debit* จนกว่าจะปิดสถานะ
   เพื่อให้เงินไม่ถูกใช้ซ้ำและไม่ดูเหมือนถูกยึดไป

(ข) 6 ฉากของการจ่ายซ้ำ และการป้องกันของแต่ละฉาก

ฉากการป้องกัน
ลูกค้ากด "จ่าย" 2 ครั้งในแอปclient ส่ง Idempotency-Key เดิม สำหรับเจตนาเดียวกันของผู้ใช้ (สร้างตอนเปิดหน้ายืนยัน ไม่ใช่ต่อการกด) · unique constraint ฝั่ง server คือตัวกันจริง
แอป retry หลัง network timeoutkey เดิม → เล่น response ที่เก็บไว้ซ้ำ
Static QR ถูกสแกน 2 ครั้งในไม่กี่วินาที (ลูกค้าคิดว่าล้มเหลว)velocity rule: (ลูกค้า, ร้าน, จำนวน) เดียวกันภายใน N วินาที ต้องยืนยันชัดเจนใน UI ("คุณจ่ายร้านนี้ 150 บาทไป 20 วินาทีที่แล้ว จ่ายอีกครั้ง?") — เป็นการตัดสินใจเชิง product ที่มีมิติ fraud ต้องเอา threshold จาก Risk
เรา retry ไป rail หลัง timeout ที่ครั้งแรกสำเร็จไปแล้วrail reference เดิมทุกครั้ง → rail dedupe ให้ · บวกการ query status ก่อน retry ถ้า rail มีให้
Rail ส่ง webhook/callback เดิม 2 ครั้งdedupe ด้วย event ID ของ rail · handler ต้อง idempotent · ห้าม post ledger entry จาก webhook โดยไม่เช็ค state ที่มีอยู่
Instance 2 ตัวของเราประมวลผล payment เดียวกันในคิวFOR UPDATE SKIP LOCKED หรือ broker ที่มี visibility timeout บวก handler ที่ idempotent — สมมติว่ามันจะเกิดขึ้น

(ค) Refund ไม่ใช่ "undo"

Refund เป็น aggregate ของตัวเอง ตั้งแต่วันแรก

Refund คือ transaction แยกที่มี lifecycle ของตัวเอง, idempotency key ของตัวเอง, การเรียก rail ของตัวเอง, ledger entry ของตัวเอง (ที่อ้างถึง transaction เดิม), การจัดการค่าธรรมเนียมของตัวเอง, และจังหวะ settlement ของตัวเอง

มันล้มได้ และมันเป็นบางส่วนได้

การออกแบบ refund เป็นการเปลี่ยน status ของ payment เดิมคือความผิดพลาด ที่คุณจะจ่ายตอนเกิดข้อพิพาทครั้งแรก เพราะมันทำลายประวัติที่ข้อพิพาทต้องพึ่ง

model มันเป็น aggregate ของตัวเองตั้งแต่ต้น แม้ v1 จะรองรับแค่ refund เต็มจำนวน

S2.7 · Failure mode

Failureพฤติกรรมการตรวจจับ
Rail ล่มหรืออยู่ใน maintenancecircuit เปิด · บอกลูกค้าตามจริง · payment เข้าคิวหรือถูกปฏิเสธ (เป็นการตัดสินใจเชิงธุรกิจ — การปฏิเสธมักถูกต้องที่เคาน์เตอร์ เพราะ payment ที่เข้าคิวโดยร้านมองไม่เห็นแย่กว่าการบอกชัดๆ ว่าให้ใช้วิธีอื่น)circuit state, rail error rate, scheme status feed
Rail ช้า (2 s → 15 s)bulkhead กักไว้ · product ที่เหลือยังสุขภาพดี · UX แสดงกำลังดำเนินการrail p99; adapter queue depth — นี่คือ gray failure ให้ alert ที่ latency ไม่ใช่แค่ error
Push ไม่ถึงเครื่องร้านแอปร้าน poll เป็น fallback · webhook retry แบบ backoffสัดส่วนของผลลัพธ์ที่ค้นพบด้วย poll เทียบกับที่ได้จาก push
Endpoint webhook ของร้านล่มretry แบบ backoff ในหน้าต่างที่จำกัด แล้ว DLQ + alert ที่ร้านเห็นใน dashboardwebhook failure rate ต่อร้าน
Settlement ซ้ำในไฟล์ของ railreconciliation break → quarantine → คนแก้ · ห้าม auto-post การแก้ไขไปยังยอดลูกค้าreconciliation รายวัน
Clock skew ระหว่างเรากับ railห้ามใช้ timestamp ในการ match — match ด้วย reference และจำนวนNTP monitoring; รายงานรายการที่ match ด้วย reference ไม่ได้

Scenario 3 · OTP และ notification service

บทเรียน: priority lane และการที่ dependency ระดับรองต้องไม่อยู่ใน critical path

S3.1 · Assess scale

1M DAU: login 400k/วัน, ยืนยันธุรกรรม 500k/วัน
ปริมาณ OTP ~500k/วัน -> 6/s average
แต่ LOGIN พีคที่ 08:00-09:00 และ 19:00-21:00: สมมติ 20x -> ~120/s

Marketing campaign: push 1 ล้านใบในครั้งเดียว
ถ้าส่งผ่าน pipeline เดียวกับ OTP ค่า p95 ของ OTP จะกลายเป็นหลายนาที
--> นี่คือ incident จริงที่พบบ่อยที่สุดในระบบนี้
--> PRIORITY LANE ที่แยกกันเป็น REQUIREMENT ไม่ใช่ optimization

ค่า SMS ครอบงำงบประมาณ: ที่ประมาณ 0.25-0.75 บาท/ข้อความ (illustrative)
500k/วัน เป็นบรรทัดค่าใช้จ่ายรายเดือนที่ใหญ่มาก
การควบคุมต้นทุนจึงเป็น requirement เชิงสถาปัตยกรรม:
  - push ก่อน SMS เป็น fallback (push แทบฟรี)
  - OTP 1 ใบต่อ (user, purpose) ต่อ 60 s
  - เพดานแข็งต่อวันต่อ user และต่อ IP
  - alert ที่ความผิดปกติของต้นทุนต่อวัน ไม่ใช่รอตอนได้ใบแจ้งหนี้

S3.2 · Contracts

POST /v1/otp/request
{ "user_id": "usr_1", "purpose": "LOGIN", "channel_hint": "PUSH" }
{
  "otp_id": "otp_01J9XYZ",
  "expires_at": "2026-08-08T04:05:00Z",
  "resend_after_s": 60,
  "masked_destination": "08X-XXX-1234"
}

3 ข้อที่ response นี้ต้องทำ

  • ห้ามคืน code เด็ดขาด
  • ห้ามคืนเบอร์โทรเต็ม
  • คืน shape เดียวกันไม่ว่าผู้ใช้จะมีอยู่หรือไม่ ไม่อย่างนั้น endpoint นี้กลายเป็น oracle สำหรับ enumerate ผู้ใช้
POST /v1/otp/verify
{ "otp_id": "otp_01J9XYZ", "code": "482913" }
{ "code": "INVALID_OR_EXPIRED", "attempts_remaining": 2 }

error เดียวสำหรับ ผิด/หมดอายุ/ใช้แล้ว

ห้ามแยกแยะ — การบอกว่า "code หมดอายุ" ต่างจาก "code ผิด" ให้ข้อมูลกับผู้โจมตี

S3.3 · Data model

otp_requests(
  otp_id, user_id, purpose, channel, destination_hash,
  code_hash,            -- HASH พร้อม salt ต่อ record ห้ามเก็บ plaintext
  expires_at,           -- 3-5 นาที
  attempts INT,         -- สูงสุด 3-5 แล้ว invalidate
  consumed_at,          -- ใช้ครั้งเดียว บังคับแบบ atomic
  created_at, ip_hash, device_id
)
  INDEX (user_id, purpose, created_at DESC);  -- resend + velocity check

notifications(notification_id, user_id, channel, template, priority,
              dedupe_key, status, provider, provider_msg_id, attempts,
              created_at, sent_at, delivered_at, failed_reason)
  UNIQUE (dedupe_key)                          -- กันการส่งซ้ำ
  INDEX (status, priority, created_at) WHERE status = 'QUEUED';

user_channel_prefs(user_id, channel, consent, consent_source,
                   consent_at, quiet_hours, locale);
-- consent เป็น compliance artefact: บันทึกว่าเขายินยอม *อะไร*, *เมื่อไหร่*,
-- และผ่าน flow ไหน · การส่งเชิง marketing *ต้อง* เช็คมัน

กฎความปลอดภัยของ OTP — ทุกข้อนี้เคยถูกใช้โจมตีจริงมาแล้ว

  • เก็บเฉพาะ hash ของ code พร้อม salt ต่อ record — ฐานข้อมูลที่รั่ว หรือ debug log ต้องไม่เปิดเผย code ที่ยังใช้ได้ · ห้าม log code, ห้ามใส่ใน error message, ห้ามใส่ใน analytics event
  • เทียบแบบ constant time และนับ attempt ฝั่ง server · invalidate หลังล้มเหลว 3–5 ครั้ง — ไม่อย่างนั้น code 6 หลัก brute-force ได้ง่ายมาก
  • Consume แบบ atomic UPDATE ... SET consumed_at = now() WHERE otp_id = ? AND consumed_at IS NULL แล้วเช็ค affected row count · verify 2 ใบพร้อมกันต้องไม่สำเร็จทั้งคู่
  • Rate limit หลายมิติ: ต่อ user, ต่อเบอร์, ต่อ IP, ต่อ device, และระดับ global — ผู้โจมตีจะหมุนไปใช้มิติที่คุณลืม
  • ผูก OTP กับ purpose และ session — code ที่ออกเพื่อ "login" ต้องไม่ authorize "เปลี่ยนเบอร์โทร" · ความสับสนนี้เป็นช่องทางยึดบัญชีจริง
  • ห้ามรั่วว่ามีบัญชีอยู่หรือไม่ — response เหมือนกันและ timing เหมือนกัน
  • จำกัดต้นทุน — การ resend ไม่จำกัดบน endpoint สาธารณะเป็นทั้งช่องทาง fraud (SMS pumping — ผู้โจมตีได้รายได้จาก traffic ของคุณ) และเป็นความเสียหายทางการเงินตรงๆ · บังคับเพดานต่อ user ต่อวัน และ alert ที่ความผิดปกติของปริมาณตาม prefix ปลายทาง
  • เลือกปัจจัยที่แข็งกว่าเมื่อทำได้ — push approval ที่ผูกกับอุปกรณ์ หรือ passkey และถือว่า SMS OTP เป็น fallback ไม่ใช่เป้าหมาย · SMS ถูกดักได้และเสี่ยง SIM swap — นั่นเป็นคำกล่าวเรื่องความเสี่ยง ไม่ใช่ความชอบด้าน design

S3.4 · Architecture: priority lane

แยกคิว แยก worker pool แยกงบ rate limit ของ provider

campaign 1 ล้านข้อความจึงไม่สามารถหน่วง OTP ได้ในทางกายภาพ

S3.5 · Deep dive

2 SMS provider ตั้งแต่วันแรก — การส่ง SMS ในทุกตลาดแปรผันตามผู้ให้บริการมือถือ, เวลาของวัน และ routing ของ provider เอง ให้คะแนน provider แต่ละรายอย่างต่อเนื่องด้วย delivery rate และ latency ต่อ prefix ของ operator แล้ว route ตามนั้น เมื่อ delivery rate ของ A ไปยัง operator หนึ่งตก ให้ย้าย traffic อัตโนมัติ — นี่คือการลงทุนทางวิศวกรรมที่ให้ผลตอบแทนสูงสุดใน service นี้ และเป็นคำตอบของคำถาม "ถ้า provider ล่มจะทำอย่างไร"

Push ก่อน SMS เป็น fallback — ถ้าผู้ใช้มีอุปกรณ์ที่ลงทะเบียนและสุขภาพดี ส่ง in-app approval push ถ้าไม่ตอบรับใน ~10 s ให้ตกไป SMS วิธีนี้ลดต้นทุนได้มากและปลอดภัยกว่า ต้องมี device registry ที่แม่นและจัดการ token ที่หมดอายุ — token หมดอายุได้และ provider จะบอกคุณ ถ้าไม่ประมวลผล callback นั้น delivery rate ของคุณจะเน่าอย่างเงียบๆ

Delivery receipt เป็น asynchronous และไม่น่าเชื่อถือ — "ส่งแล้ว" ไม่ใช่ "ถึงแล้ว" ติดตามทั้งคู่, alert ที่ช่องว่างระหว่าง 2 ค่า, และห้ามบอกผู้ใช้ว่า "เราส่งไปแล้ว" เมื่อ provider คืน error ให้มีเส้นทาง "ไม่ได้รับรหัส?" ที่ซื่อสัตย์และมี rate limit

Quiet hours และ consent บังคับใน pipeline สำหรับ bulk และข้ามอย่างชัดแจ้งสำหรับ critical

ทำให้การข้ามเป็นคุณสมบัติของ message class ไม่ใช่ flag ที่ caller ตั้งได้

ไม่อย่างนั้นทุกทีมจะติดป้ายว่า message ของตัวเอง "critical" การเข้าถึง critical lane ควรเป็น allow-list ที่มีคนรีวิว

Template rendering และ localization อยู่ที่เดียว พร้อมบันทึก template ID และ version บนทุก message ที่ส่ง — เมื่อมีคนถามว่า "เราส่งอะไรให้ลูกค้าคนนี้วันที่ 3 มีนาคม" คุณต้องตอบด้วย เนื้อหาที่ render แล้ว ไม่ใช่ชื่อ template

S3.6 · Failure mode

Failureพฤติกรรมการตรวจจับ
SMS provider หลัก degrade สำหรับ operator หนึ่งhealth score ตก → traffic ย้ายไป provider B อัตโนมัติdelivery rate ต่อ provider ต่อ prefix ของ operator
Marketing blast เข้าคิวbulk lane throttle · critical lane ไม่กระทบqueue depth และอายุต่อ lane
ความพยายาม brute-force OTPattempt counter invalidate code · rate limit หลายมิติทำงาน · security alertfailed-verify rate ต่อ user/IP; anomaly ของปริมาณ request
SMS pumping attack (ผู้โจมตีหาเงินจากการส่งของคุณ)เพดานต่อ user และต่อ prefix · block prefix ปลายทางที่ผิดปกติต้นทุนต่อชั่วโมง และการกระจายของ prefix ปลายทาง — alert ทั้ง 2 ตัว
Push token ใช้ไม่ได้ (ถอนแอปไปแล้ว)callback ของ provider mark token ตาย → fallback SMS → ล้าง registryinvalid-token rate; record อุปกรณ์ที่ค้าง
Notification service ล่มทั้งตัวการเคลื่อนย้ายเงินต้องไม่พึ่งมัน — transfer ยังสำเร็จ · notification เข้าคิวใน outbox และส่งช้าconsumer lag; อายุของ outbox

หลักการออกแบบเบื้องหลัง scenario นี้ทั้งอัน

Notification รู้สึกเหมือนฟีเจอร์ข้างที่สถานะต่ำ จึงมักถูกสร้างเป็น wrapper บางๆ รอบ SDK ของ provider แล้วถูกเรียกแบบ synchronous จาก payment path

แล้ววันหนึ่ง: SMS provider ช้าลง → thread ของ payment service block รอมัน → vendor ส่ง notification ทำให้การเคลื่อนย้ายเงินล่ม

ห้ามให้ dependency ที่ไม่ critical นั่งแบบ synchronous อยู่ใน critical path — publish event แล้วให้ notification service ไม่น่าเชื่อถือเท่าไหร่ก็ได้ตามใจมัน


Scenario 4 · Reconciliation และ reporting

บทเรียน: ระบบที่ไม่มีใครออกแบบ ทุกคนต้องมี และ auditor ถามทุกครั้ง ถ้าคุณทำงาน fintech การเชี่ยวชาญเรื่องนี้ทำให้คุณดู senior อย่างเห็นได้ชัด

S4 · Requirements

  • ทุกวัน พิสูจน์ว่า record ของเราตรงกับ record ของคู่ค้าภายนอกแต่ละราย (แต่ละ bank rail, แต่ละ card scheme, แต่ละ partner) และ record ภายในของเราตรงกันเอง
  • ทุกความต่าง ("break") ต้องถูกตรวจพบ, จัดหมวด, มอบเจ้าของ, และแก้ไขพร้อม audit trail ตรวจพบภายในวันเดียวกัน มี SLA การแก้ต่อหมวด
  • ผลิตรายงานรายวันและรายเดือนที่ธุรกิจและ regulator ต้องการ จาก snapshot ที่คงที่

S4 · Architecture

S4 · กฎที่ได้มาด้วยความเจ็บปวด

8 กฎของ reconciliation

1. Reconcile เทียบ cutoff ไม่ใช่เทียบ "ตอนนี้" เลือก timestamp (หรือ ledger entry watermark) แล้วเทียบทั้ง 2 ฝ่าย ณ จุดนั้น การเทียบกับเป้าที่เคลื่อนที่ผลิต break ที่หายเองได้และ break ที่ซ่อนตัว — ทั้ง 2 อย่างทำลายความเชื่อถือในกระบวนการ

2. เก็บทุกไฟล์ input ไว้ตลอดกาลแบบ immutable partner ส่งไฟล์ซ้ำ, ส่งไฟล์แก้ไข, และบางครั้งส่งไฟล์ชื่อเดิมที่เนื้อหาต่างกัน เก็บแต่ละ version พร้อม checksum และ ingest แบบ idempotent ด้วย checksum

3. Match ด้วย reference ห้าม match ด้วย timestamp นาฬิกาต่างกัน timezone ต่างกัน และ "settlement date" เป็นแนวคิดทางธุรกิจ ที่ไม่ใช่ created_at ของคุณ แยก effective_at (วันที่ทางธุรกิจ) ออกจาก created_at (วันที่ระบบ) ใน ledger — การตัดสินใจเรื่อง schema ข้อเดียวนี้ประหยัดความเจ็บปวดมหาศาลที่นี่

4. จัดหมวด break ตาม type เพราะแต่ละ type มีเจ้าของและวิธีแก้ต่างกัน timing (อยู่ในของเราวันนี้ อยู่ในของเขาพรุ่งนี้ — มักไม่มีพิษ และควร auto-resolve ในรอบถัดไป) · amount mismatch (การคิดค่าธรรมเนียม, การปัดเศษ, FX) · missing ฝั่งเรา (เราทำ callback หาย — เป็น bug ทางวิศวกรรม) · missing ฝั่งเขา (record ของเราอาจผิด หรือไฟล์ของเขาไม่ครบ) · duplicate ติดตามจำนวน break ตาม type ตามเวลา — แนวโน้มขาขึ้นในหมวดหนึ่งคือ bug report

5. ห้ามให้ job อัตโนมัติปรับยอดเงินลูกค้า Reconciliation เสนอ · คนที่มีอำนาจตัดสิน ผ่านเส้นทางที่ควบคุม พร้อมการอนุมัติแบบ 4 ตา · ทุกการปรับปรุงต้องมี reason code, ผู้อนุมัติ, และ ledger entry ที่ immutable — เป็นทั้งข้อกำหนดด้าน control และความรอบคอบพื้นฐาน ลูป auto-adjust ที่มี bug ดูดเงินออกจากบัญชีได้เร็วกว่าที่ใครจะสังเกตทัน

6. รายงานต้องทำซ้ำได้ การรันรายงานเดิมสำหรับช่วงเวลาเดิมต้องได้ตัวเลขเดิมในอีก 1 ปี นั่นหมายถึงต้องรายงานจากข้อมูล immutable, append-only, ณ cutoff ไม่ใช่จากตารางที่แก้ได้ ถ้าตัวเลขเปลี่ยนหลังเผยแพร่ นั่นต้องเป็นเหตุการณ์ที่มองเห็นได้และมีคำอธิบาย

7. แยก analytical workload ในทางกายภาพ Reconciliation และ reporting scan ข้อมูลจำนวนมาก รันบน replica, archive tier หรือ columnar store — ห้ามรันบน OLTP primary ที่กำลังรับ payment

8. Alert ที่การไม่มีงาน "ไม่ได้รับไฟล์ settlement ภายใน 09:00" และ "งาน reconciliation ไม่จบ" คือ alert ที่จับความล้มเหลวแบบเงียบได้ reconciliation ที่เจอ break 0 รายการเพราะประมวลผล 0 แถว คือ dashboard สีเขียวที่อันตรายที่สุดในบริษัท

[!TIP] วิธีคุยเรื่อง reconciliation กับผู้บริหาร Reconciliation มักได้งบช้าเพราะดูเหมือนงาน back office การตีกรอบที่ทำให้ได้งบคือ:

"Reconciliation คือวิธีที่เราตรวจพบว่าซอฟต์แวร์ของเราเองผิด ก่อนที่ลูกค้าหรือ regulator จะมาบอกเรา match rate ของมันคือตัววัดสุขภาพโดยตรงของทุก integration ที่เราเป็นเจ้าของ"

แล้วเอา daily match rate และ จำนวน break ที่ยังเปิดอยู่ ไปวางบน dashboard เดียวกับ uptime

เมื่อผู้บริหารเห็นจำนวน break มีแนวโน้มขึ้น มันจะกลายเป็นความสำคัญทางวิศวกรรม — และคุณจะเจอ bug จริงในเดือนแรก


Lab 18 — Scenario sprint (ครึ่งวัน)

รูปแบบ: 3 รอบ รอบละ 60 นาที สลับบทบาท ในแต่ละรอบมี architect 1 คน, business stakeholder 1 คน (ถือ constraint ที่ซ่อนไว้ ซึ่งจะเปิดเผยเฉพาะเมื่อถูกถามคำถามที่ถูก), และ reviewer 1 คน (ต้องหารูให้ได้ 3 รู)

Brief (เลือก 3 อัน):

#Brief
aจ่ายเงินเดือนทันทีให้ผู้ประกอบการ 5,000 รายในวันที่ 25 ของทุกเดือน
bฟีเจอร์ "หารกัน" แบบ group payment ที่คำขอมีวันหมดอายุ
cSettlement ให้ merchant แบบ T+1 พร้อมค่าธรรมเนียมและการหักภาษี
dCard-tokenization service ที่กันข้อมูลบัตรออกจากขอบเขตหลักของคุณ
eการ export statement ให้ลูกค้าย้อนหลัง 7 ปี
fFraud rules engine ที่ต้องตัดสินภายใน 100 ms

Constraint ที่ซ่อนไว้ ให้ stakeholder เปิดเผยเฉพาะเมื่อถูกถาม (เลือก 1 ข้อต่อรอบ):

  • ธนาคาร partner มี maintenance window 4 ชั่วโมงทุกวันอาทิตย์
  • Regulator ต้องการรายงานเฉพาะภายใน 24 ชั่วโมงหลังสิ้นเดือน
  • Merchant รายเดียวคิดเป็น 40% ของปริมาณ
  • แอปมือถืออัปเดตไม่ได้เป็นเวลา 6 สัปดาห์
  • ข้อมูลต้องไม่ออกนอกประเทศ

Deliverable ต่อรอบ: ตาราง requirement, estimation, diagram 1 ใบ, failure table, และ ADR 1 ฉบับ

คำถาม debrief ที่สำคัญที่สุด

"constraint ที่ซ่อนไว้ข้อไหนที่จะทำให้ design ของคุณใช้ไม่ได้เลย ถ้าคุณไม่ได้ถาม"

นั่นคือคำถามที่คุณต้องเรียนให้ถามได้ในวันแรกของทุกโปรเจกต์จริง

[!NOTE] สรุปบทนี้ ปริมาณธุรกรรมของ e-wallet เล็ก — build เพื่อ correctness ไม่ใช่เพื่อ scale · UNIQUE constraint บวกการเก็บ response คือ idempotency ที่ทำงานจริง · timeout ไม่ใช่หลักฐานว่าล้มเหลว มันคือ UNKNOWN · snapshot ปลอดภัยเพราะ delta ครอบคลุมมันไว้ · refund เป็น aggregate ของตัวเอง · priority lane แยกทำให้ campaign 1 ล้าน message หน่วง OTP ไม่ได้ · reconciliation เทียบ cutoff, match ด้วย reference, และ เสนอ ไม่ใช่ ตัดสิน