ข้ามไปยังเนื้อหา

API Composition

แต่ละ service ของคุณเป็นเจ้าของ database ส่วนตัว หน้ารายละเอียด order เลยกลายเป็นปัญหา เพราะต้องใช้ทั้งตัว order เองจาก Order service ชื่อกับ tier ของผู้ซื้อจาก Customer service และสถานะการจัดส่งจาก Shipping service ถ้าเป็น monolith นี่คือ join สามตารางธรรมดา แต่ตอนนี้ตารางทั้งสามอยู่คนละ database ที่ไม่มี query เดียวเอื้อมถึงได้

หน้านี้ก็ยังต้อง render อยู่ดี คุณต้องรวบรวมข้อมูลจากหลาย service และนำเสนอเป็นผลลัพธ์รวมหนึ่งเดียว แต่คุณไม่มีฐานข้อมูลร่วมให้ join ข้ามได้ และคุณก็ไม่อยากประดิษฐ์ชั้นการจัดเก็บใหม่ทั้งชั้นขึ้นมาเพียงเพื่อตอบ query เดียว

คุณจะ implement query ที่ข้อมูลกระจายอยู่ตามหลาย service ได้อย่างไร โดยไม่ทำลายขอบเขต Database per Service และไม่ over-engineer?

ทางแก้คือใส่ API Composer เข้ามา ที่เป็น component เล็ก ๆ มักอยู่ใน API gateway หรือ backend-for-frontend มีหน้าที่เดียวคือตอบ query นี้ โดยเรียกไปยังแต่ละ service ที่ถือคำตอบคนละส่วน รวบรวม response แล้ว join เข้าด้วยกันใน memory ก่อนคืนผลลัพธ์รวมกลับไป

composer ไม่ถือข้อมูลของตัวเองเลย ทำหน้าที่ประสานงานล้วน ๆ คือกระจาย request ออกไป ซึ่งควรยิงแบบขนานเพราะแต่ละการเรียกเป็นอิสระต่อกัน แล้วค่อย merge ผลลัพธ์ เมื่อการเรียกทั้งสามไม่ต้องรอกัน การยิงพร้อมกันจะทำให้ latency ใกล้เคียงกับการเรียกที่ช้าที่สุดเพียงตัวเดียว ไม่ใช่ผลรวมของทั้งสาม

flowchart LR
  Client[Client] --> Composer[API Composer]
  Composer -->|getOrder| OrderSvc[Order Service]
  Composer -->|getCustomer| CustomerSvc[Customer Service]
  Composer -->|getShipment| ShippingSvc[Shipping Service]
  OrderSvc --> ODB[(Orders DB)]
  CustomerSvc --> CDB[(Customers DB)]
  ShippingSvc --> SDB[(Shipping DB)]
  Composer -->|merged result| Client
composer กระจายการเรียกออกไปยังแต่ละ service ที่เป็นเจ้าของ แล้ว join response เข้าด้วยกันใน memory

composer ด้านล่างดึง order, customer และ shipment แบบขนาน แล้วเย็บเข้าด้วยกันเป็น view object เดียว การ merge เป็น in-memory join ที่ใช้ ID ที่ order ถืออยู่แล้วเป็นกุญแจ

async function getOrderDetail(orderId: string) {
const order = await orderSvc.getOrder(orderId);
// The order tells us which customer and shipment to fetch.
const [customer, shipment] = await Promise.all([
customerSvc.getCustomer(order.customerId),
shippingSvc.getShipment(order.id),
]);
// In-memory join.
return {
orderId: order.id,
total: order.total,
customerName: customer.name,
customerTier: customer.tier,
deliveryStatus: shipment.status,
eta: shipment.eta,
};
}

สิ่งที่คุณได้รับ:

  • เรียบง่าย ไม่ต้องเพิ่มที่จัดเก็บใหม่ ไม่ต้องมี event pipeline ถ้าเรียก service ได้ ก็ compose ได้ทันที นี่คือตัวเลือกแรกที่ควรหยิบสำหรับ query ข้าม service
  • ข้อมูลสดเสมอ เพราะอ่านสด ๆ จากเจ้าของข้อมูลแต่ละรายตอน request ผลลัพธ์จึงสะท้อนสถานะล่าสุด ไม่มี replication lag

สิ่งที่คุณต้องจ่าย:

  • in-memory join ไม่มีประสิทธิภาพ การ join ผลลัพธ์ก้อนใหญ่ใน memory ของแอปพลิเคชันช้ากว่าให้ database ทำด้วย index มาก query ที่ต้อง filter หรือ sort ข้าม service เช่น “order มูลค่าสูงสุด 20 รายการของลูกค้า gold tier” อาจบังคับให้ composer ดึงข้อมูลมหาศาลจากทุก service มาแล้วทิ้งเกือบหมด
  • availability เป็นผลคูณของส่วนต่าง ๆ. composer ต้องการให้ทุก service ที่เรียกตอบกลับ ถ้าตัวใดล่มหรือช้า query ทั้งหมดก็จะแย่ตามไปด้วย — เว้นแต่คุณจะเพิ่ม timeout และการจัดการ partial-result
  • consistency ลดลง. ชิ้นส่วนต่าง ๆ ถูกอ่านในช่วงเวลาที่ต่างกันเล็กน้อย ดังนั้น view รวมจึงอาจผสมสถานะที่ไม่เคยมีอยู่ด้วยกันในขณะเดียวกันเลย

เมื่อ in-memory join กลายเป็นคอขวด — ข้อมูลใหญ่ การ filter ซับซ้อน หรือเป้าหมาย latency ที่เข้มงวด — ให้ยกระดับไปสู่ read model ที่ดูแลไว้ด้วย CQRS ซึ่ง pre-join ข้อมูลไว้ล่วงหน้า

  • Database per Service — ขอบเขตที่เปลี่ยน join ให้กลายเป็น composition
  • CQRS — ทางเลือกที่หนักกว่าสำหรับ query ที่แพงเกินกว่าจะ compose ตอน request
ข้อดีข้อแลกเปลี่ยน
ไม่ต้องเปลี่ยน service — compose ที่ application layereventual consistency — ข้อมูลจาก service ต่าง ๆ อาจไม่ sync
query ข้าม service ได้โดยไม่ต้อง join databaseerror ใน service หนึ่งทำให้ composite response พัง
แต่ละ service ยังคุม data ของตัวเองได้เต็มin-memory join ช้าถ้า dataset ใหญ่
implement ได้ที่ API Gateway หรือ BFFต้อง handle partial failure — บาง service ตอบ บาง service ไม่ตอบ

Unbounded Composition — compose service มากเกินไปใน request เดียว อาการ:

  • request เดียวเรียก 10+ service แบบ parallel
  • latency รวมสูงเพราะ slowest service เป็นตัวกำหนด
  • error rate สูงเพราะโอกาส failure เพิ่มตาม service count

Composition กลาง Orchestrator — มี orchestrator service ที่รู้ business logic ของทุก service อาการ:

  • orchestrator เป็น god service ที่รู้มากเกินไป
  • ทุก feature ใหม่ต้องแก้ orchestrator

💡 ตัวอย่างจากของจริง

Amazon:

  • หน้า product detail page รวมข้อมูลจากหลาย service: pricing, inventory, review, recommendation
  • ใช้ parallel fan-out แล้ว compose ที่ BFF layer
  • service ไหนตอบช้า → ใช้ cached version แทน
API Composer ทำอะไร?
ทำไม composer จึงควรยิงการเรียกแบบขนานเมื่อแต่ละการเรียกเป็นอิสระต่อกัน
ข้อเสียหลักของ API Composition สำหรับ query ขนาดใหญ่หรือที่ filter หนักคืออะไร?