Animated Architecture Patterns

Saga (Choreography)

service ต่าง ๆ ตอบสนองต่อ event ของกันและกันไปจนจบ workflow โดยไม่มี coordinator กลาง

หรือที่เรียกกันว่า Choreography-based saga, Choreographed saga

Tracking view subscribes to every event, groups them by order ID waiting for events order #1044 PLACED PAID STUCK OrderPlaced PaymentCompleted nothing since timeout no event for 30 s message broker topic: order-events listens for ShipmentBooked ShipmentBooked PaymentRefunded PaymentRefunded publishes OrderPlaced OrderPlaced listens for OrderPlaced OrderPlaced StockReservationFailed StockReservationFailed publishes PaymentCompleted PaymentCompleted PaymentRefunded PaymentRefunded publishes ShipmentBooked ShipmentBooked listens for StockReserved StockReserved publishes StockReserved StockReserved StockReservationFailed StockReservationFailed listens for PaymentCompleted PaymentCompleted Orders keeps the order status #1042 #1043 #1044 PENDING CONFIRMED CANCELLED Payments charges the card refunds the charge #1042 #1043 #1044 charged $80 refunded $80 Shipping books a courier #1042 booked Inventory reserves stock out of stock ● consumer down #1042 reserved 1 in stock: 0 not consumed OrderPlaced PaymentCompleted StockReserved ShipmentBooked StockReservationFailed PaymentRefunded #1044 #1044 #1044 #1044

ขั้นที่ 1 · event เป็นตัวเริ่ม Orders commit order ใหม่ในสถานะ PENDING ลง database ของตัวเอง แล้ว publish OrderPlaced ไปที่ topic มันทำแค่นั้นเอง: ไม่ส่ง command อะไร และไม่รู้ด้วยว่าใครจะตอบสนอง service อื่น ๆ ต่างก็ subscribe event ที่ตัวเองสนใจไว้แล้ว และ Payments คือตัวที่รอฟัง event นี้อยู่

ขั้นที่ 2 · ตอบสนองแล้ว publish ต่อ Payments consume OrderPlaced ตัดเงินจากบัตรใน local transaction แล้ว publish PaymentCompleted พอได้ event นั้น Inventory ก็จอง stock แล้ว publish StockReserved Shipping ก็รับต่อด้วยการจอง courier แล้ว publish ShipmentBooked และ Orders ก็ตอบสนองด้วยการ mark order เป็น CONFIRMED ไม่มี component ไหนเป็นคนสั่งลำดับนี้: workflow มีอยู่แค่ในรูปของสาย subscription ที่ต่อกันเท่านั้น

ขั้นที่ 3 · ความล้มเหลวก็เป็น event ใน order #1043 ตัดเงินจากบัตรไปแล้ว แต่ Inventory ไม่มี stock เหลือให้จอง เลย publish StockReservationFailed Payments ก็รอฟัง event นี้อยู่เหมือนกัน: มันคืนเงินที่ตัดไปใน local transaction ใหม่ แล้ว publish PaymentRefunded ส่วน Orders ก็ตอบสนองด้วยการ mark order เป็น CANCELLED แต่ละ service ย้อนขั้นของตัวเอง ไม่มี global rollback

ขั้นที่ 4 · ไม่มีใครเห็น flow ทั้งหมด flow นี้ไม่ได้เขียนไว้ที่ไหนเลย พอ consumer ของ Inventory ล่ม order #1044 ก็หยุดค้างอยู่หลังตัดเงินเฉย ๆ และไม่มี service ไหนรายงาน ทีมต่าง ๆ ก็เลยเติมสิ่งที่แพตเทิร์นนี้ไม่ได้ให้มา: ทุก event พก order ID ไว้เป็น correlation ID, tracking view จะ subscribe topic แล้วประกอบสถานะของแต่ละ saga ขึ้นมาจาก event ของมัน และ timeout จะ flag saga ที่เงียบหายไปกลางทาง พอขั้นตอนกับทางแยกเยอะขึ้น subscription ต่าง ๆ ก็ไล่ตามยาก ถึงตอนนั้นใช้ orchestrator จะคุ้มกว่า

ปัญหา

การสั่ง order หนึ่งครั้งเปลี่ยนข้อมูลที่ service สี่ตัวเป็นเจ้าของ แต่ละตัวมี database ของตัวเอง: Orders บันทึก order, Payments ตัดเงินจากบัตร, Inventory จอง stock และ Shipping จอง courier ไม่มี local transaction ไหนครอบ database สี่ตัวได้ ส่วน distributed transaction ด้วย two-phase commit ต้องให้ทุก participant รองรับมันและติดต่อได้ในเวลาเดียวกัน แล้วยังถือ lock ไว้ในทุกตัวจนกว่า coordinator จะตัดสินใจ saga เลยเลือกทิ้ง transaction ก้อนเดียวไปแทน: แต่ละ service commit local transaction ของตัวเอง และขั้นไหนที่ต้องย้อนกลับก็ย้อนด้วย transaction ใหม่

แบบนี้ยังเหลือคำถามหนึ่งข้อ: ใครเป็นคนตัดสินว่าจะเกิดอะไรต่อ orchestrator ทำได้ ด้วยการส่ง command ให้แต่ละ service แล้วรอ reply แต่มันคืออีก component ที่ต้องสร้างและดูแลให้ใช้งานได้ตลอด มันต้องรู้จักทุก participant และทุกการเปลี่ยนแปลงของ flow ต้องผ่านคนที่เป็นเจ้าของมัน สำหรับสามสี่ขั้น นี่อาจเป็นเครื่องไม้เครื่องมือที่มากเกินกว่าปัญหาต้องการ

ทำงานยัง⁠ไง

saga คือลำดับของ local transaction และทุกตัวที่อาจต้องย้อนกลับจะมี compensating transaction ของมัน Hector Garcia-Molina กับ Kenneth Salem เสนอแนวคิดนี้ในปี 1987 สำหรับ long-lived transaction ภายใน database เดียว โดยมีคำสัญญาง่าย ๆ: ทุก transaction ของ saga ทำเสร็จครบ หรือไม่ก็ตัวที่ทำเสร็จไปแล้วจะถูก compensate

ในแบบ choreography ไม่มีใครเป็นคนรันลำดับนั้น ทุก service ทำตามกฎเดียวกัน: รอฟัง event ที่ตัวเองสนใจ ตอบสนอง ด้วย local transaction ใน database ของตัวเอง แล้ว publish event ที่บอกว่าเกิดอะไรขึ้น event เดินทางผ่าน message broker ทำให้ service ไม่เคยเรียกกันตรง ๆ flow ของ order ทั้งหมดใน diagram ก็คือกฎแบบนี้เจ็ดข้อ:

Service รอฟัง Local transaction แล้ว publish
Orders request ของลูกค้า บันทึก order เป็น PENDING OrderPlaced
Payments OrderPlaced ตัดเงินจากบัตร PaymentCompleted
Inventory PaymentCompleted จอง stock StockReserved หรือ StockReservationFailed
Shipping StockReserved จอง courier ShipmentBooked
Orders ShipmentBooked mark order เป็น CONFIRMED
Payments StockReservationFailed คืนเงินที่ตัดไป PaymentRefunded
Orders PaymentRefunded mark order เป็น CANCELLED

อ่านจากบนลงล่าง ตารางนี้ก็คือ workflow แต่ในระบบที่รันจริงไม่มีตารางแบบนี้อยู่เลย: แต่ละแถวอยู่ในโค้ดของคนละ service และ broker ก็รู้แค่ว่าใคร subscribe event ไหน

  • Event ไม่ใช่ command event บอกข้อเท็จจริงในรูปอดีต (OrderPlaced) และคน publish ไม่รู้ว่าใครจะตอบสนอง หรือจะมีใครตอบสนองหรือเปล่า ส่วน command (ChargePayment) ขอให้ service ตัวใดตัวหนึ่งทำอะไรบางอย่างและคาดหวังผลลัพธ์ choreography ใช้แค่ event ทำให้การตัดสินใจว่าจะทำอะไรเป็นของฝั่งผู้รับ: Payments ตัดเงินเพราะ Payments ตัดสินใจเองว่า order ที่ถูกสั่งเป็นเหตุผลให้ตัดเงิน
  • ความล้มเหลวก็ถูก publish เหมือนอย่างอื่น ถ้า Inventory จองไม่ได้ ตัว local transaction ของมันจะไม่เขียนอะไรเลย แล้วมันก็ publish StockReservationFailed ส่วน Payments ที่ตัดเงินจากบัตรไปแล้วก็รอฟัง event นี้อยู่ และ compensate ขั้นของตัวเอง แล้ว PaymentRefunded ที่มัน publish ก็คือสิ่งที่ Orders ตอบสนอง ไม่มีใครส่ง command "undo" และไม่มีอะไรถูก rollback แบบ global เลย แม้แต่ลำดับของ compensation ก็เกิดขึ้นเองจาก subscription
  • Compensation คือ action ทางธุรกิจ ไม่ใช่การ undo การตัดเงินยังอยู่ใน payments ledger และมีรายการคืนเงินบันทึกไว้ข้าง ๆ compensation ทำให้ธุรกิจกลับมา consistent ไม่ใช่คืนข้อมูลกลับเป็นสถานะเดิม และมันก็คือ local transaction อีกตัวที่ publish event อีกตัว
  • ผู้เรียกไม่ต้องรอ ปกติ Orders ตอบกลับทันทีที่ order เป็น PENDING แล้ว client ก็รู้ผลทีหลัง ด้วยการ poll สถานะของ order หรือผ่าน notification เช่น webhook หรือ message ทาง WebSocket

ใช้ตอนไหนดี

  • business transaction หนึ่งครอบ service ไม่กี่ตัว แต่ละตัวมี database ของตัวเอง และ flow สั้นและส่วนใหญ่เป็นเส้นตรง ทั้งแนวทางของ AWS และ Azure แนะนำ choreography สำหรับ saga ง่าย ๆ ที่มี participant ไม่กี่ตัว
  • participant เป็นของคนละทีมหรือคนละ bounded context ที่ควรผูกกันหลวม ๆ และ event เป็นข้อเท็จจริงของ domain ที่ consumer อื่น (analytics, notification, search) ก็อยากได้อยู่แล้ว
  • มี event backbone อยู่แล้ว และ coordinator จะกลายเป็นอีก component ที่ทุก order ต้องพึ่ง
  • ทุกขั้นที่อาจมีความล้มเหลวตามมา มีวิธีย้อนทางธุรกิจที่มีความหมาย: คืนเงิน ปล่อย stock ยกเลิก

อย่าใช้:

  • กับไม่กี่ขั้นภายใน service เดียว ถ้าข้อมูลอยู่ใน database เดียวได้ ใช้ local transaction ตัวเดียวก็ง่ายกว่าและให้ isolation จริง ๆ
  • กับขั้นเยอะ ๆ ทางแยก timer หรือการอนุมัติจากคน ให้ใช้ orchestration: นิยามของ flow ที่เดียวที่อ่าน ทำ version, test และ monitor ได้
  • ถ้าต้องมีคนบอกได้ตลอดว่า order อยู่ตรงไหนแล้ว และคุณยังไม่พร้อมจะสร้างระบบติดตามที่อธิบายไว้ด้านล่าง
  • ถ้าสถานะระหว่างทางต้องไม่มีใครเห็นเลย ไม่มี saga ไหนให้ isolation

ได้อะไร เสียอะไร

Choreography Orchestration
ใครรู้จัก flow ไม่มีใคร: มันเกิดขึ้นจาก subscription ของแต่ละ service orchestrator: นิยามเดียวในที่เดียว
Message event: ข้อเท็จจริง และผู้รับตัดสินใจเองว่าจะทำอะไร command และ reply: ผู้ส่งเป็นคนตัดสินใจ
Coupling service พึ่ง event ของกันและกัน และบ่อยครั้งเป็นวง participant ไม่รู้จักกันเลย แต่ orchestrator รู้จักทุกตัว
Visibility ต้องสร้างเอง: correlation ID, tracing, tracking view state ของ saga ก็คือข้อมูลของ orchestrator เอง
Control ไม่มี coordinator ที่จะล่มหรือกลายเป็นคอขวด (แต่ broker ยังใช้ร่วมกันอยู่) ส่วน retry และ timeout ต้องมีในทุก service retry, timeout และ compensation อยู่ในที่เดียว และที่นั่นต้อง highly available
การเปลี่ยนแปลง listener ใหม่ไม่ต้องแก้ที่อื่น แต่ขั้นใหม่หรือการย้ายขั้นต้องแตะหลาย service แก้นิยามที่เดียว ส่วน participant ปกติไม่ต้องเปลี่ยน
  • ไม่มีใครเห็นภาพรวม flow ไม่ได้อยู่ในโค้ดชิ้นไหนเลย Martin Fowler เตือนว่าถ้ามีสาย event notification ต่อกันยาว ๆ คุณอาจต้องสังเกตระบบที่รันจริงถึงจะรู้ว่า flow เป็นยัง⁠ไง ถ้า saga หยุดกลางทาง อย่าง order #1044 ใน step 4 ก็ไม่มี service ไหนอยู่ในตำแหน่งที่จะสังเกตเห็น
  • Dependency วนเป็นวงกลม Orders consume event จาก Payments และ Shipping ส่วน Payments consume event จาก Orders และ Inventory แล้วก็วนไปเรื่อย ๆ รอบวง ทั้ง Azure และ AWS ระบุว่า dependency แบบวนระหว่าง participant คือความเสี่ยงของ choreography
  • ทุกกรณีล้มเหลวเพิ่ม subscription ใน diagram มีแค่ Inventory ที่ล้มเหลว ถ้าการจอง courier ล้มเหลวได้ด้วย Inventory ก็ต้องรอฟัง event นั้นแล้วปล่อย stock และ Payments ก็ต้องรอฟังการปล่อย stock หรือรอฟังความล้มเหลวนั้นเอง แล้วคืนเงิน ประเภทของ event และจำนวน listener โตเร็วกว่าจำนวนขั้น
  • การย้ายขั้นคือการเปลี่ยนแปลงข้ามทีม ถ้าจะจอง stock ก่อนตัดเงิน Inventory ต้องรอฟัง OrderPlaced แล้ว Payments ต้องรอฟัง StockReserved ส่วน Shipping ต้องรอฟัง PaymentCompleted: service สามตัวต้องเปลี่ยนและ deploy พร้อมกัน Bernd Ruecker กับ Martin Schimak ใช้ตัวอย่างนี้เลยเพื่อค้านสาย event ที่ยาว
  • Event ที่จริง ๆ คือ command ปลอมตัวมา ถ้า Orders publish event แค่เพื่อให้ Payments ตัดเงิน มันกำลังส่งสิ่งที่ Fowler เรียกว่า "passive-aggressive command": coupling ยังอยู่ แค่มองเห็นยากขึ้น ถ้า service หนึ่งต้องการให้อีกตัวทำอะไร ก็ใช้ command บอกไปตรง ๆ
  • ไม่มี isolation saga เป็น atomic ในที่สุด consistent และ durable แต่ request อื่นจะเห็นสถานะระหว่างทางของมัน: stock ที่ถูกจองไว้ให้ order ที่กำลังจะถูกยกเลิก หรือเงินที่ตัดไปแล้วจะถูกคืน แบบนี้ทำให้เกิด lost update, dirty read และ fuzzy (non-repeatable) read ได้ วิธีรับมือที่ใช้กันบ่อยคือ semantic lock (ตัวบอกระดับ application เช่นสถานะ PENDING ของ order ที่บอกคนอื่นว่า record นี้กำลังเปลี่ยนอยู่), update แบบ commutative, pessimistic view (เรียง saga ใหม่ให้ update ที่เสี่ยงไปอยู่ในขั้นที่ retry ได้), การอ่านค่าใหม่ก่อน update, version file (log ของ operation บน record เพื่อให้ apply ได้ตามลำดับที่ถูกต้อง) และการเลือกกลไกตามความเสี่ยงทางธุรกิจของแต่ละ request
  • Eventual consistency จนกว่า saga จะจบ order จะเป็น PENDING และ API กับหน้าจอต้องแสดงเรื่องนี้ตามจริง
  • Timeout และ retry เป็นงานของทุกคน เพราะไม่มี coordinator แต่ละ service ต้องทำ retry, timeout และการจัดการ compensation ของตัวเอง AWS บอกว่าการทำเรื่องพวกนี้ให้ถูกต้องข้ามหลาย component ยากกว่าทำที่ orchestrator
  • End-to-end test ต้องรันทุกอย่าง จะลอง saga ทั้งก้อนได้ก็ต่อเมื่อทุก service ของมันและ broker ทำงานอยู่

ข้อควรรู้ตอนลงมือทำ

  • Publish ให้เชื่อถือได้ service ที่ commit แล้วค่อย publish อาจ crash ระหว่างนั้นได้ และ saga ที่ทำ event หายจะหยุดไปเฉย ๆ โดยไม่มี error ที่ไหนเลย ให้เขียน event ใน local transaction เดียวกับการเปลี่ยน state ด้วย transactional outbox หรือให้ event เป็น state เองไปเลย แบบ event sourcing

  • Consume แบบ idempotent การส่งที่เชื่อถือได้ก็คือการส่งแบบ at-least-once: broker ส่งซ้ำสิ่งที่ยังไม่ถูก acknowledge และ outbox relay ก็ publish ซ้ำหลัง crash สักวันหนึ่งทุก handler เลยต้องเจอ event ซ้ำสองครั้ง ให้แต่ละตัวเป็น idempotent consumer: StockReservationFailed ตัวที่สองต้องไม่ทำให้เกิดการคืนเงินครั้งที่สอง

  • รักษาลำดับ event ของ order เดียวกัน ใช้ order ID เป็น message key, session หรือ group ตัว Kafka เขียน event ที่ key เดียวกันลง partition เดียวกัน และส่ง partition ตามลำดับที่เขียน ส่วน Azure Service Bus มี session และ SQS FIFO queue เรียงลำดับเคร่งครัดภายใน message group เดียวกัน แต่ไม่มีตัวไหนเรียงลำดับ event ข้าม topic หรือข้าม queue และ consumer ที่ scale out โดยไม่มี key แบบนี้จะประมวลผล event ผิดลำดับ เลยควรใส่ version หรือ sequence number ไว้ทุกที่ที่ event เก่าจะทำความเสียหายได้

  • topic เดียวหรือหลาย topic diagram ใช้ topic เดียวสำหรับทั้ง saga ทำให้ event ทั้งหมดของ order หนึ่งอยู่ใน stream เดียวที่เรียงลำดับ การมี topic ต่อ service หรือต่อประเภท event ก็พบบ่อยพอกัน แบบนั้นแยกความเป็นเจ้าของได้ชัดกว่า แต่เสียลำดับระหว่าง topic ไป

  • คาดไว้เลยว่า event มาได้ในทุกสถานะ handler จะเจอ event ซ้ำ, event ที่มาช้า และ event ของ order ที่มันไม่มีบันทึกอยู่ ให้มันเช็กข้อมูลของตัวเองก่อนแล้วค่อยตัดสินใจ: StockReservationFailed ของ order ที่ไม่เคยถูกตัดเงินก็ไม่ต้องคืนเงิน

  • เรียงขั้นตามความยากในการย้อนกลับ แนวทางของ Azure แบ่งขั้นเป็นสามแบบ: transaction แบบ compensable ที่ย้อนกลับได้, pivot ที่พอผ่านไปแล้ว saga ต้องรันไปจนจบ และ transaction แบบ retryable ที่ตามหลัง pivot และ idempotent ทำให้ทำซ้ำได้จนกว่าจะสำเร็จ ให้เอาการตรวจที่มีโอกาสล้มเหลวสูงสุดไว้ก่อน และอะไรที่ย้อนกลับไม่ได้ (พัสดุที่ส่งให้ courier แล้ว, email) ไว้ท้ายสุด ใน diagram ตัดเงินจากบัตรก่อนเพื่อให้มีอะไรให้ compensate แต่ flow จริงจะจอง stock ก่อน หรือ authorise บัตรไว้ตั้งแต่ต้นแล้วค่อย capture ตอนท้าย ทำให้การคืนเงินกลายเป็นแค่การปล่อย hold

  • Compensation ต้องไม่ยอมแพ้ compensation ล้มเหลวได้เหมือนขั้นอื่น ให้มัน idempotent แล้ว retry และถ้า retry ไม่ช่วย ก็ alert คนพร้อมรายละเอียดมากพอให้ทำงานที่เหลือให้จบด้วยมือได้

  • ใส่ correlation ID ในทุก event order ID หรือ saga ID แยกต่างหาก คือสิ่งที่ทำให้ log, trace และ tracking view ผูก event ของ saga เดียวกันเข้าด้วยกันได้ ส่วน event ID ที่ไม่ซ้ำกันที่อยู่ข้าง ๆ คือสิ่งที่ consumer ใช้ตัดตัวซ้ำ:

    {
      "eventId": "e-7c1",
      "type": "PaymentCompleted",
      "orderId": 1044,
      "occurredAt": "2026-10-02T09:15:04Z",
      "data": { "amount": 80, "currency": "USD" }
    }
    
  • Trace มัน ส่งต่อ trace context ไปใน message header เพื่อให้ trace เดียวตาม saga ข้าม broker ไปได้: ดู distributed tracing ปกติ trace จะถูก sample ทำให้มันช่วย debug saga ทีละตัวได้ แต่บอกไม่ได้ว่าทุก order อยู่ตรงไหน

  • มี state view consumer ตัวเล็ก ๆ subscribe ทุก event ของ saga แล้วเก็บแถวละหนึ่ง order: event ที่เห็นแล้ว ตัวล่าสุด และเวลาที่มันมาถึง มันไม่ต้องรู้อะไรเกี่ยวกับ flow นอกจากว่า event ไหนเปิดและปิด saga ส่วน Bernd Ruecker ไล่ดูทางเลือกต่าง ๆ ตั้งแต่ store ของทุก event ที่ query ได้ ไปจนถึง workflow engine ที่แค่รอฟังเฉย ๆ พอ view เริ่มลงมือทำอะไร เช่นยกเลิก order ที่ timeout มันก็เริ่ม orchestrate แล้ว และนั่นอาจเป็นจังหวะที่ควรย้าย flow ไปไว้ที่ orchestrator

  • ให้ทุก saga มี deadline ขั้นหนึ่งไม่ได้ล้มเหลวเสมอไป บางทีมันก็แค่ไม่ตอบเลย ให้ alert ทุก saga ที่ยังไม่เห็น event ปิดภายใน deadline ของมัน ถ้า timeout ควรยกเลิก order ด้วย ก็ให้ service ที่เริ่ม saga เป็นเจ้าของเรื่องนี้: Orders ทำให้ order ที่เป็น PENDING นานเกินไปหมดอายุ แล้ว publish event ที่ตัวอื่นใช้เป็นสัญญาณให้ compensate แต่ timeout ไม่ได้พิสูจน์ว่าล้มเหลว ShipmentBooked ที่มาช้าเลยยังมาถึงได้ และพอมาถึงก็ต้อง compensate ด้วย

  • คอยดู dead-letter queue event ที่ consumer ทำไม่ผ่านซ้ำ ๆ จะถูกพักไว้ใน dead-letter queue และ saga ของมันก็รออยู่ตรงนั้นด้วย ให้ alert ทุกครั้งที่มีอะไรเข้ามา แล้วใช้ correlation ID หา order

  • ถือว่า event เป็น public contract event หนึ่งมี consumer ได้หลายตัว และคน publish ก็ไม่รู้จักทุกตัว การเปลี่ยนรูปร่างของมันเลยอาจทำให้ service ที่ไม่มีใครนึกถึงพัง ให้ evolve schema ทีละขั้นแบบ backward-compatible และใช้ schema registry บังคับเรื่องนี้ได้

  • Test แต่ละกฎ แล้วค่อย test saga ทั้งก้อนสักสองสามตัว สำหรับทุกแถวในตารางข้างบน ให้ตั้ง service ไว้ในสถานะที่รู้แน่ ส่ง event เข้าไป แล้ว assert ที่ database ของมันและที่ event ที่มัน publish จากนั้นส่ง event ซ้ำสองครั้ง และส่ง event ผิดลำดับ เช็ก schema ของ event ด้วย contract test ระหว่างคน publish แต่ละตัวกับ consumer ของมัน ส่วน end-to-end test ให้มีน้อย ๆ: happy path และอีกหนึ่งตัวต่อหนึ่งเส้นทาง compensation

  • ผสมทั้งสองแบบ การเลือกทำเป็นราย flow ไม่ใช่ทั้งระบบ วิธีแบ่งที่เจอบ่อยคือใช้ choreography ระหว่าง bounded context และใช้ orchestrator ภายใน context เดียว ส่วน flow ที่โตจนสาย event รับไม่ไหว ก็ส่งต่อให้ orchestrator ที่รอฟัง event เดิมและตอบด้วย command ได้