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 ด้านล่างดึง 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, };}import asyncio
async def get_order_detail(order_id: str) -> dict: order = await order_svc.get_order(order_id)
# The order tells us which customer and shipment to fetch. customer, shipment = await asyncio.gather( customer_svc.get_customer(order["customer_id"]), shipping_svc.get_shipment(order["id"]), )
# In-memory join. return { "order_id": order["id"], "total": order["total"], "customer_name": customer["name"], "customer_tier": customer["tier"], "delivery_status": shipment["status"], "eta": shipment["eta"], }func GetOrderDetail(ctx context.Context, orderID string) (OrderDetail, error) { order, err := orderSvc.GetOrder(ctx, orderID) if err != nil { return OrderDetail{}, err }
// Fetch customer and shipment in parallel. var customer Customer var shipment Shipment g, gctx := errgroup.WithContext(ctx) g.Go(func() (err error) { customer, err = customerSvc.GetCustomer(gctx, order.CustomerID) return }) g.Go(func() (err error) { shipment, err = shippingSvc.GetShipment(gctx, order.ID) return }) if err := g.Wait(); err != nil { return OrderDetail{}, err }
// In-memory join. return OrderDetail{ OrderID: order.ID, Total: order.Total, CustomerName: customer.Name, CustomerTier: customer.Tier, DeliveryStatus: shipment.Status, ETA: shipment.ETA, }, nil}async fn get_order_detail(order_id: &str) -> Result<OrderDetail, Error> { let order = order_svc::get_order(order_id).await?;
// Fetch customer and shipment concurrently. let (customer, shipment) = tokio::try_join!( customer_svc::get_customer(&order.customer_id), shipping_svc::get_shipment(&order.id), )?;
// In-memory join. Ok(OrderDetail { order_id: order.id, total: order.total, customer_name: customer.name, customer_tier: customer.tier, delivery_status: 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 layer | eventual consistency — ข้อมูลจาก service ต่าง ๆ อาจไม่ sync |
| query ข้าม service ได้โดยไม่ต้อง join database | error ใน 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 แทน