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

CQRS: สถาปัตยกรรม​ภายใน Bounded Context

CQRSCQRSCommand Query Responsibility Segregation — แยก model สำหรับ “เขียน/แก้ไข” (command) ออก​จาก model สำหรับ “อ่าน” (query) เหมาะ​กับ domain ซับซ้อน แต่​เพิ่ม​ความ​ซับซ้อน ใช้​อย่าง​ระมัดระวังArchitecture (Command Query Responsibility Segregation) เป็น pattern ที่​เรียบ​ง่าย​อย่าง​น่า​ประหลาด​ใจ หัวใจ​ของ​มัน​คือ​ประโยค​เดียว — แยก domain model ออก​เป็น​สอง​ส่วน: model สำหรับ “เขียน” (write model หรือ transactional model) และ model สำหรับ “อ่าน” (read model)

เหตุผล​ของ​การ​แยก​คือ เพื่อ​ให้​แต่ละ model รับใช้ context ของ​ตัวเอง​ได้​อย่าง​เต็ม​ที่​โดย​ไม่​ต้อง​ประนีประนอม 2 context ที่​ขัด​กัน​คือ การ​รายงาน​สถานะ​ของ domain (ฝั่ง​อ่าน) และ การ​ทำงาน​ตาม​กฎ​ธุรกิจ (ฝั่ง​เขียน) เมื่อ​เรา​ฝืน​ใช้ model เดียวกัน​รับ​ทั้ง​สอง​งาน​ใน Bounded ContextBounded Contextขอบเขต​ที่​ชัดเจน (มัก​เป็น​ระบบ​ย่อย​หรือ​ทีม​หนึ่ง) ที่ model หนึ่ง​ใช้ได้​และ​มี​ความหมาย​แน่นอน ภายใน​ขอบเขต​นี้ Ubiquitous Language สอดคล้อง​กัน 100%Strategic Design ที่​มี​ทั้ง​ความ​ต้องการ​ด้าน​การ​แสดง​ผล​ที่​ซับซ้อน​และ domain logic ที่​เข้มข้นRich Domain Modelmodel domain ที่ “รวย​พฤติกรรม” — object ที่​เก็บ​ทั้ง​ข้อมูล​และ business logic ไว้​ด้วย​กัน เป็น​คู่​ตรง​ข้าม​ของ Anemic Domain Model (ที่​มี​แต่ getter/setter แล้ว​ผลัก logic ไป​ไว้​ที่​อื่น) tactical patterns ของ DDD จะ​คุ้ม​ค่า​ก็​ต่อ​เมื่อ model “รวย” พอ​เช่น​นี้Tactical Design ผลลัพธ์​มัก​เป็น model ที่​บวม​เกิน​จริง สูญเสีย​ความ​ถูกต้อง​เชิง​แนวคิด สร้าง​ความ​สับสน​ให้ domain expert และ​กลาย​เป็น​ฝัน​ร้าย​ใน​การ​ดูแล​รักษา

CQRS ใน​ประโยค​เดียว

CQRS ไม่ใช่ สถาปัตยกรรม​ระดับ​สูงสุด​ของ​ทั้ง​ระบบ มัน​เป็น pattern จัดการ​ความ​ซับซ้อน​ที่​เรา​เลือก​ใช้ “เฉพาะ” กับ Bounded Context ที่ presentation model ไม่​สอดคล้อง​กับ​โครงสร้าง​ของ transactional model — app เว็บ​ส่วน​ใหญ่​มี​ช่องว่าง​ระหว่าง query กับ command อยู่​แล้ว CQRS เพียง​แยก​สอง​ด้าน​นี้​ออก​เพื่อ​ให้​ปรับ​แต่ง​แต่ละ​ด้าน​ได้​อิสระ


รูป​ที่ 24-1 แสดง​สถาปัตยกรรม​แบบ Layered ArchitectureLayered Architectureการ​แยก code เป็น​ชั้น (UI, Application, Domain, Infrastructure) โดย​รวม code ที่​เกี่ยว​กับ domain ไว้​ชั้น​เดียว​และ​แยก​ออก​จาก​ชั้น​อื่น เป้าหมาย​คือ​การ​แยก​ส่วน (isolation)Architecture ทั่วไป​ของ Bounded Context หนึ่ง ตรง​ใจกลาง​คือ domain model ที่​ถูก​สร้าง​มา​เพื่อ​บังคับ​ใช้ InvariantInvariantกฎ​ทาง​ธุรกิจ​ที่ “ต้อง​เป็น​จริง​เสมอ” ภายใน Aggregate เช่น ยอด​รวม​คำ​สั่ง​ซื้อ​ต้อง​เท่ากับ​ผล​รวม​ของ​รายการ​สินค้า — ต้อง​ถูกต้อง​ภายใน transaction เดียวTactical Design ของ domain ขณะ​ทำงาน​เชิง transaction โดย model ประกอบ​ด้วย AggregateAggregateกลุ่ม​ของ Entity และ Value Object ที่​ถูก​มอง​เป็น​หนึ่ง​หน่วย​เดียว​เพื่อ​รักษา​ความ​ถูกต้อง​ของ​ข้อมูล มี​ขอบเขต​ชัดเจน และ​เป็น​หน่วย​ของ transactionTactical Design ก้อน​เล็กๆ ที่​ออกแบบ​มา​เพื่อ​ความ​ถูกต้อง​และ​สะท้อน​กฎ​ของ​ธุรกิจ

รูปที่ 24-1: model เดียวรับใช้ทั้งฝั่งอ่านและฝั่งเขียน — ฝั่งบน “Handling Business Tasks” โหลด aggregate มาทำงานตามกฎ ฝั่งล่าง “Reporting on the Domain” โหลด aggregate ชุดเดียวกันมาแปลงเป็น view model

ปัญหา​คือ ความ​ต้องการ​ด้าน​รายงาน​มัก​ไม่​ตรง​กับ​โครงสร้าง​ของ aggregate ฝั่ง​อ่าน​จึง​ต้อง​โหลด aggregate หลาย​ตัว​มา​ประกอบ​เป็น view model ทั้ง​ที่​ใช้​ข้อมูล​เพียง​เศษ​เสี้ยว​ของ​แต่ละ aggregate การ​สร้าง view แบบ​นี้​ซับซ้อน​ขึ้น​เรื่อยๆ ดูแล​ยาก และ​ใน​กรณี​เลว​ร้าย​ที่สุด​ก็​ฉุด​ให้​ทั้ง​ระบบ​ช้า​ลง

เพื่อ​รองรับ​การ​สร้าง view เรา​เริ่ม​ทำ​สิ่ง​ที่​ทำลาย domain model ที​ละ​นิด:

  • domain model ต้อง​เปิดเผย​สถานะ​ภายใน และ​ต้อง​แปะ property เพื่อ​การ​แสดง​ผล​ที่​ไม่​เกี่ยว​กับ invariant เลย
  • RepositoryRepositoryตัว​ที่​ทำให้​เรา​เข้าถึง Aggregate ราวกับ​เป็น collection ใน​หน่วย​ความ​จำ ซ่อน​รายละเอียด​ฐาน​ข้อมูล มี1 repository ต่อ1 Aggregate RootTactical Design บวม​ด้วย method พิเศษ​มากมาย​เพื่อ​รองรับ paging, querying และ free-text search
  • เพราะ​ฝั่ง​อ่าน​ถูก​ใช้​บ่อย​กว่า​ฝั่ง​เขียน เรา​จึง​ไป merge aggregate และ​เปิด lazy loading เพื่อ​เลี่ยง​การ​ดึง​ข้อมูล​ที่​ไม่​จำเป็น

สุดท้าย​เรา​ก็ได้ model เดียว​ที่​เต็ม​ไป​ด้วย​การ​ประนีประนอม จน​ทำได้​ไม่​ดี​ทั้ง​ฝั่ง​อ่าน​และ​ฝั่ง​เขียน

กับดัก: model ที่​หน้าตา​เหมือน UI

เมื่อ model รับใช้​ทั้ง​การ​แสดง​ผล​และ​กฎ​ธุรกิจ รูปร่าง​ของ aggregate จะ​ค่อยๆ บิด​จาก “สิ่ง​ที่​ปกป้อง invariant” กลาย​เป็น “สิ่ง​ที่ match หน้า​จอ” จน​เกิด aggregate ก้อน​ยักษ์​ที่​รับผิดชอบ​ทุก​อย่าง​เกี่ยว​กับ entity หนึ่งๆ — นี่​คือ code smell ที่​เรียก​ว่า god object


สถาปัตยกรรม​ที่​ดี​กว่า​สำหรับ Bounded Context ที่​ซับซ้อน

หัวข้อ​ที่​มีชื่อ​ว่า “สถาปัตยกรรม​ที่​ดี​กว่า​สำหรับ Bounded Context ที่​ซับซ้อน”

รูป​ที่ 24-2 คือ​สถาปัตยกรรม​ที่​ใช้ CQRS — จัดการ2 context ที่​ขัด​กัน (อ่าน vs เขียน) แยก​กัน ด้วย​การ​ให้ 2 model แทนที่​จะ​เป็น​หนึ่ง แต่ละ model จึง​ปรับ​ให้​เหมาะ​กับ context ของ​ตัวเอง​ได้​โดย​ยัง​รักษา​ความ​ถูกต้อง​เชิง​แนวคิด​ไว้ พูด​อีก​แบบ คุณ​กำลัง​นำ​แนวคิด Bounded Context มา​ใช้​ใน “ระดับ​ที่​ลึก​ลง​ไป​อีก​ชั้น” — ผูก model หนึ่ง​ไว้​กับ read context และ​อีก model หนึ่ง​ไว้​กับ write context

รูปที่ 24-2: CQRS แยก domain layer ออกเป็น read model และ write model โดย application service ฝั่งหนึ่งจัดการ business task อีกฝั่งจัดการ report request

ใช้ data store เดียว​ก็ได้

ใน​รูป​ที่ 24-2 ทั้ง​สอง​ฝั่ง​ใช้ data store เดียวกัน — นี่​ไม่ใช่​ข้อ​บังคับ คุณ​จะ​ใช้ data store แยก​สำหรับ​ฝั่ง​อ่าน​เพื่อ scale ก็ได้ แต่ “ไม่​จำเป็น” ต้อง​ทำ​ตั้งแต่​แรก เริ่ม​จาก​ง่าย​ก่อน​เสมอ

flowchart TB
  Client((Client))
  subgraph CmdSide["ฝั่ง Command (เขียน)"]
    CmdAS[Application Service<br/>จัดการ business task]
    WM[Domain Model<br/>Write Model]
  end
  subgraph QrySide["ฝั่ง Query (อ่าน)"]
    QryAS[Application Service<br/>จัดการ report request]
    RM[View Model<br/>Read Model]
  end
  Client -->|command: ทำงานตามกฎ| CmdAS --> WM
  Client -->|query: ขอรายงาน| QryAS --> RM
  WM --- DB[(Data Store)]
  RM --- DB

ฝั่ง command สนใจ​เรื่อง​เดียว — การ​รักษา​กฎ​ของ domain มัน​คือ domain logic ที่​ทำให้ business task สำเร็จ เมื่อ​มอง​แวบ​แรก​ฝั่ง​นี้​เหมือน layered architecture ทั่วไป (ดู​รูป​ที่ 24-3 ใน​หนังสือ) แต่​ต่าง​ตรง​ที่ ฝั่ง command ไม่​รองรับ​การ query ผลลัพธ์​ที่​ส่ง​กลับ​ไป​ยัง client เป็น​เพียง “การ​ยืนยัน” (acknowledgement) ว่า​งาน​สำเร็จ​หรือ​ไม่​เท่านั้น

CommandCommandคำ​สั่ง​ที่​แทน business task หรือ use case หนึ่ง​ของ​ระบบ อยู่​ใน application layer เป็น DTO ง่าย ๆ ที่​เผย​เจตนา​ของ​ผู้​ใช้ ตั้ง​ชื่อ​เป็นกริยา​และ​เขียน​เป็น​ปัจจุบัน​กาล (ฉัน​ต้องการ​จะ​ทำ​สิ่ง​นี้) ตรง​ข้าม​กับ Domain Event ที่​เขียน​เป็น​อดีตกาลArchitecture คือ business task หรือ use case หนึ่ง​ของ​ระบบ และ​อยู่​ใน application layerApplication Serviceบริการ​ที่ “ประสาน​งาน” use case — จัดการ transaction, ความ​ปลอดภัย, การ​บันทึก, แปลง DTO — แต่​ไม่มี​กฎ​ธุรกิจ​อยู่​ใน​ตัว​มัน​เองTactical Design เรา​ควร​เขียน command ด้วย​ภาษา​ของ​ธุรกิจ — แต่​จำ​ไว้​ว่า​นี่ ไม่ใช่ Ubiquitous LanguageUbiquitous Languageภาษา​กลาง​ที่​ทุก​คนใน​ทีม (ทั้ง​นัก​พัฒนา​และ​ผู้เชี่ยวชาญ​ธุรกิจ) ใช้​ร่วม​กัน อิง​กับ domain model โดยตรง คำ​เดียวกัน​ต้อง​หมาย​ถึง​สิ่ง​เดียวกัน และ​สะท้อน​ลง​ใน codeStrategic Design โดยตรง มัน​คือ​ภาษา​ที่​จับ “กระบวนการ​ธุรกิจ (business process)” ไม่ใช่​คำ​ศัพท์​ของ domain model หาก​คุณ​ทำงาน​แบบ BDD ตัว command มัก​ผุด​ออก​มา​จาก use case และ user story ที่​คุณ​เขียน

กฎ​สำคัญ​คือ ตั้ง​ชื่อ command เป็นกริยา (verb) ไม่ใช่​คำ​นาม และ​เขียน​เป็น ปัจจุบัน​กาล (ฉัน​ต้องการ​จะ​ทำ​สิ่ง​นี้) ต่าง​จาก Domain EventDomain Eventสิ่ง​ที่ “เกิด​ขึ้น​แล้ว” ใน domain ซึ่ง​ส่วน​อื่น​สนใจ แทน​ด้วย​อ็อบเจ็กต์ที่​เปลี่ยนแปลง​ไม่​ได้ ตั้ง​ชื่อ​เป็น​อดีต เช่น CargoWasRouted ใช้​สื่อสาร​ข้าม AggregateTactical Design ที่​เขียน​เป็น​อดีตกาล (สิ่ง​นี้​เกิด​ขึ้น​แล้ว) ตัว command เอง​เป็น​เพียง DTO ง่ายๆ ที่​มี validation ระดับ parameter เท่านั้น

CustomerWantsToRedeemAGiftCertificate.cs
// command = DTO ที่เผยเจตนาของผู้ใช้ผ่านชื่อ class (กริยา + ปัจจุบันกาล)
public class CustomerWantsToRedeemAGiftCertificate
{
public CustomerWantsToRedeemAGiftCertificate(Guid accountId, string giftCertificate)
{
AccountId = accountId;
GiftCertificate = giftCertificate;
}
public Guid AccountId { get; private set; }
public string GiftCertificate { get; private set; }
}

ลอง​นึกถึง​หน้า​จอ dashboard ของ​ลูกค้า​ที่​ดึง​ข้อมูล​จาก​หลาย aggregate (รูป​ที่ 24-4 ใน​หนังสือ) ถ้า​เรา​ออกแบบ Customer ให้​ตรง​กับ​หน้า​จอ​นั้น เรา​จะ​ได้ aggregate ก้อน​ยักษ์​แบบ​นี้:

Customer.cs (anti-pattern)
public class Customer
{
// aggregate ถูกบิดให้ match UI ไม่ใช่ invariant — กลายเป็น god object
public ContactDetails ContactDetails { get; private set; }
public LoyaltyStatus LoyaltyStatus { get; private set; }
public Money GiftCertBalance { get; private set; }
public IEnumerable<Address> AddressBook { get; private set; }
}

มัน​สร้าง view ได้​ง่าย​ก็​จริง (แค่​โหลด aggregate เต็มๆ แล้ว map เป็น view model) แต่ aggregate ก้อน​นี้​ใหญ่​เกิน​ไป​และ​รับผิดชอบ​ทุก​อย่าง​เกี่ยว​กับ​ลูกค้า เพราะ​มัน​ถูก​ออกแบบ​รอบ “หน้า​จอ​รายงาน” แทนที่​จะ​เป็น “กระบวนการ​ธุรกิจ​ของ domain”

เมื่อ​ใช้ CQRS เรา​ปลด model ฝั่ง​เขียน​ออก​จาก​ภาระ​การ​แสดง​ผล ใน write model ไม่มี​ประโยชน์​ที่​จะ​สร้าง​แนวคิด Customer ก้อน​เดียว​รวบ​ทุก​อย่าง แต่​จะ​แยก​เป็น aggregate ที่​ดูแล​กฎ loyalty หนึ่ง​ตัว, aggregate สำหรับ​รายละเอียด​ลูกค้า​อีก​ตัว, และ aggregate สำหรับ​ยอด gift certificate อีก​ตัว — Ubiquitous Language จะ​สอด​รับ​กับ “กระบวนการ​ธุรกิจ” ของ app ได้​ดี​กว่า​หน้า​จอ UI เพราะ domain expert พูด​ถึง​กฎ​และ​กระบวนการ​ทำงาน ไม่​ได้​พูด​ถึง UI

ผล​คือ write model เล็ก​ลง โฟกัส​ที่​กระบวนการ​ธุรกิจ, aggregate กระชับ​ขึ้น, และ repository เรียบ​ง่าย​ลง​มหาศาล เพราะ​การ​ดึง aggregate ถูก​จำกัด​ให้​ทำ​ผ่าน ID เท่านั้น ไม่​ต้อง​มี method paging/sorting รุงรัง

Command HandlerCommand Handlerapplication service ชนิด​หนึ่ง​ที่​รับ command มา​ประมวล​ผล​และ​ประสาน​งาน​ให้ task สำเร็จ — delegate ไป​ยัง domain model, บันทึก/ดึง​ข้อมูล, เรียก infrastructure service — แต่​คืน​ได้​เพียง acknowledgement ว่า​สำเร็จ​หรือ​ล้มเหลว ห้าม​ใช้ query หรือ​รายงาน​สถานะArchitecture คือ application service ชนิด​หนึ่ง มัน​รับ command มา​ประมวล​ผล​และ​ประสาน​งาน​ให้ task สำเร็จ — อาจ delegate ไป​ยัง domain model, บันทึก/ดึง​ข้อมูล, หรือ​เรียก infrastructure service เช่น อีเมล​หรือ payment gateway แต่ มัน​คืน​ได้​แค่ acknowledgement ว่า​สำเร็จ​หรือ​ล้มเหลว ห้าม​ใช้​มัน query หรือ​รายงาน​สถานะ

CreateOrUpdateCategoryHandler.cs
public class CreateOrUpdateCategoryHandler
{
public ICommandResult Execute(CreateOrUpdateCategoryCommand command)
{
var category = new Category {
CategoryId = command.CategoryId,
Name = command.Name,
Description = command.Description
};
if (category.CategoryId == 0) categoryRepository.Add(category);
else categoryRepository.Update(category);
unitOfWork.Commit();
return new CommandResult(true); // คืนแค่ผลสำเร็จ ไม่คืนข้อมูล
}
}
🛒 Command ใน​ร้าน​ค้า​ออนไลน์

ใน​ระบบ e-commerce command คือ​เจตนา​ของ​ผู้​ใช้​ที่​ชัดเจน เช่น CustomerWantsToRedeemAGiftCertificate หรือ PlaceOrder — เป็นกริยา ปัจจุบัน​กาล สิ่ง​ที่ command handler ตอบ​กลับ​มี​แค่ “รับคำ​สั่ง​แล้ว มี​เลข​ที่ order = …” ไม่ใช่​ข้อมูล​ตะกร้า​ทั้งหมด ส่วน​การ “อ่าน” รายละเอียด order ปล่อย​ให้​ฝั่ง query รับผิดชอบ


สถาปัตยกรรม​ฝั่ง query (รูป​ที่ 24-5 ใน​หนังสือ) สนใจ​เรื่อง​เดียว​คือ การ​รายงาน​บน domain สิ่ง​ที่​ฝั่ง​นี้​คืน​คือ DTO view model ง่ายๆ ที่​ออกแบบ​มา​ให้​พอดี​กับ view แต่ละ​หน้า

จุด​สำคัญ: ฝั่ง query ไม่​จำเป็น​ต้อง​ใช้ domain model ของ​ฝั่ง command เลย เพราะ view สร้าง​ได้​โดยตรง​จาก data store และ​ไม่​ต้อง​มี abstraction เหนือ​ชั้น persistence ดังนั้น RepositoryRepositoryตัว​ที่​ทำให้​เรา​เข้าถึง Aggregate ราวกับ​เป็น collection ใน​หน่วย​ความ​จำ ซ่อน​รายละเอียด​ฐาน​ข้อมูล มี1 repository ต่อ1 Aggregate RootTactical Design ใน​บริบท​นี้​แทบ​ไม่มี​ประโยชน์ — เพียง​ใช้ persistence framework หรือ library เบาๆ อย่าง ADO.NET หรือ micro-ORM ที่ map raw data เป็น DTO ได้​รวดเร็ว​ก็​เพียงพอ

ฝั่ง​อ่าน map คำขอ​รายงาน (ใน​รูป​ของ view model) ตรง​เข้า​กับ data model โดย ข้าม command model ไป​เลย เรา​สามารถ​สร้าง view ใน data model สำหรับ​แต่ละ​หน้า​จอ/รายงาน ได้​เป็น presummarized view ที่​ดึง​เร็ว, map ง่าย, และ​รองรับ paging/sorting/free-text search ได้​สบาย

ถ้า​ฝั่ง command ไม่​ได้ pre-compute ค่าที่​ต้องการ​ไว้ ฝั่ง query ก็​ยัง​เรียก​ใช้ Domain ServiceDomain Serviceบริการ​ที่​ถือ “ตรรกะ​ธุรกิจ” ซึ่ง​ไม่​เข้า​กับ Entity หรือ Value Object ตัว​ใด​ตัว​หนึ่ง​โดย​ธรรมชาติ เช่น การ​โอน​เงิน​ที่​เกี่ยวข้อง​กับ​สอง​บัญชี — ไร้​สถานะ (stateless)Tactical Design หรือ specification เพื่อ​คำนวณ​ค่า​ตรง​นั้น​ได้ (เพราะ read model ยัง​อยู่​ใน domain layer):

OrderQuery.cs
public class OrderQuery
{
public OrderViewModel Generate(Guid customerId)
{
var customer = _customerRepository.FindBy(customerId);
var view = MapCustomerViewModelFrom(customer);
// reuse specification เพื่อคำนวณค่าที่ data store ไม่ได้เก็บไว้ล่วงหน้า
view.IsInfluential = _influentialSpec.SatisfiedBy(customer);
return view;
}
}

เรา​แยก​อ่าน/เขียน​ให้​ลึก​ขึ้น​ได้​อีก ด้วย​การ​ใช้ data schema ที่​ต่าง​กัน — สร้าง read model จาก Domain EventDomain Eventสิ่ง​ที่ “เกิด​ขึ้น​แล้ว” ใน domain ซึ่ง​ส่วน​อื่น​สนใจ แทน​ด้วย​อ็อบเจ็กต์ที่​เปลี่ยนแปลง​ไม่​ได้ ตั้ง​ชื่อ​เป็น​อดีต เช่น CargoWasRouted ใช้​สื่อสาร​ข้าม AggregateTactical Design ที่​ถูก​ยก​ขึ้น​โดย command แล้ว​ใช้ event เหล่า​นั้น build materialized view (รูป​ที่ 24-6 ใน​หนังสือ) read data model จึง​ถูก denormalize และ optimize เพื่อ​การ query รวม​ถึง​เก็บ​ค่าที่​คำนวณ​ไว้​ล่วงหน้า

ModifyCategoryHandler.cs
public void Execute(ModifyCategoryCommand command)
{
var category = _catalogueRepository.FindBy(command.Id);
// เมื่อ command ทำงานเสร็จ มันยก domain event แล้วเอามาอัปเดต read model
using (DomainEvents.Register<CategoryUpdated>(onCategoryUpdated))
{
category.Update(command);
}
}
private void onCategoryUpdated(CategoryUpdated @event)
{
_catalogueViewModel.Update(@event); // อัปเดต view model — ทำใน transaction เดียวกันได้
}
ทำใน transaction เดียวกัน​ได้

การ​อัปเดต read model จาก domain event เกิด​ขึ้น​ใน transaction เดียวกัน​และ​ฐาน​ข้อมูล​เดียวกัน​ได้ ไม่​จำเป็น​ต้อง​แยก process หรือ​ทำ​แบบ async — นั่น​เป็น​เรื่อง​ของ​การ scale ที่​จะ​พูด​ถึง​ทีหลัง


มี​ตำนาน​เกี่ยว​กับ CQRS อยู่​มากมาย​บน​อินเทอร์เน็ต แต่​อย่าง​ที่​ได้​อ่าน​มา แท้จริง​แล้ว​มัน​เรียบ​ง่าย แก่น​ของ​มัน​คือ “ใช้ model เฉพาะ​ให้​เหมาะ​กับ​แต่ละ context” เท่านั้น​เอง

ห้า​ตำนาน​ที่​ต้อง​ลืม
  1. “CQRS ยาก” — ไม่​จริง โดย​พื้นฐาน​มัน​คือ​การนำ Single Responsibility Principle (SRP) มา​ใช้​ที่​ระดับ domain model มัน​เป็น “การ​เปลี่ยน​มุมมอง” มากกว่า​จะ​เป็น​ชุด pattern อัน​ซับซ้อน

  2. “CQRS ต้อง​เป็น eventually consistentEventual Consistencyความ​ถูกต้อง​ที่ “ตาม​มา​ทีหลัง” ไม่ใช่​ทันที ใช้​กับ​การ​อัปเดต​ข้าม Aggregate ผ่าน Domain Event แทน​การ​บังคับ​ให้​ตรง​กัน​ใน​ทันที​ภายใน transaction เดียวTactical Design — ไม่​จริง คุณ​ใช้​ฐาน​ข้อมูล​และ transaction เดียวกัน​อัปเดต read model ได้ ให้ เริ่ม​จาก immediately consistent ก่อน แล้ว​ค่อย​ขยับ​ไป eventual consistency เมื่อ​เจอ​ปัญหา performance จริงๆ

  3. “model ต้อง​เป็น event sourcedEvent Sourcingเก็บ​ทุก​การ​เปลี่ยน​สถานะ​เป็น event ที่​เปลี่ยน​ไม่​ได้ แล้ว​สร้าง​สถานะ​ปัจจุบัน​ด้วย​การ​เล่น event ซ้ำ มัก “ต้อง​ใช้​คู่​กับ CQRS” (Greg Young) มี event stream ต่อ1 AggregateArchitecture — ไม่​จริง event sourcing ช่วย​ให้ build read model ง่าย​ขึ้น (สร้าง projection อะไร​ก็ได้​จาก event ใน​อดีต) แต่​ไม่ใช่​ข้อ​บังคับ​ของ CQRS

  4. “command ต้อง​เป็น asynchronous” — ไม่​จริง async เหมาะ​กับ domain ที่​ผู้​ใช้​หลาย​คน​แก้​ข้อมูล​ชุด​เดียวกัน (collaborative) แต่​ไม่ใช่​ข้อ​บังคับ

  5. “CQRS ทำงาน​ได้​กับ messaging system เท่านั้น” — ไม่​จริง ถ้า​คุณ​ไม่​ได้​ทำ async หรือ eventually consistent การ​ใส่ messaging framework เข้า​มา​ก็​เป็น​แค่​ความ​ซับซ้อน​ที่​ไม่​จำเป็น

CQRS ไม่​ได้​กำหนด framework, หลาย​ฐาน​ข้อมูล หรือ design pattern ใดๆ มัน​บอก​แค่​ว่า2 context ควร​ถูก​จัดการ​แยก​กัน​เพื่อ​ประสิทธิภาพ​ที่​ดี​กว่า

นอกจาก​นี้ การ​ใช้ domain event เพื่อ build materialized read model ก็​ไม่ใช่​สิ่ง​จำเป็น — คุณ​จะ build read view จาก relational data model ของ​ฝั่ง​เขียน​ก็ได้ หรือ​ใช้ memento pattern จาก​บท​เรื่อง Repository ก็ได้


CQRS ช่วย​ให้ app ทำงาน​ได้​ดี​ภาย​ใต้​โหลด​หนัก เพราะ​ฝั่ง​อ่าน​และ​ฝั่ง​เขียน​ถูก​แยก​จาก​กัน เรา​จึง scale แต่ละ​ด้าน​ได้​อิสระ และ​เลือก data store ที่​เหมาะ​กับ​แต่ละ​ด้าน​ได้ — เช่น write store ที่​จัดการ aggregate ล้วนๆ อาจ​ใช้ document database หรือ key-value store ส่วน read model อาจ​ใช้ relational database หรือ caching store

การ scale คือ​การ​ตัดสิน​ใจ​ทาง​ธุรกิจ ไม่ใช่​แค่​เทคนิค

ทุก​การ trade-off เพื่อ scale ส่ง​ผล​ต่อ user experience และ​นั่น​ส่ง​ผล​ต่อ​ธุรกิจ ตาม CAP theorem คุณ​เลือก​ได้​แค่​สอง​ใน​สาม: consistency, availability, partition tolerance การ​ยอม​สละ immediate consistency ไป​สู่ eventual consistency จึง​เป็นการ​ตัดสิน​ใจ​ที่​ฝ่าย​ธุรกิจ​ต้อง​เข้าใจ​และ​ยอมรับ

ถ้า app ของ​คุณ​มี​โหลด​ฝั่ง​อ่าน​มากกว่า​ฝั่ง​เขียน​มาก การ​มี read store แบบ eventually consistent ช่วย​เพิ่ม availability และ performance ได้ คุณ​เก็บ read model ไว้​คนละ​ฐาน​ข้อมูล​กับ​ฝั่ง​เขียน หรือ replicate ไป​หลาย​ที่​ก็ได้ แต่​คุณ​ต้อง publish การ​เปลี่ยน​สถานะ​จาก​ฝั่ง​เขียน​ไป​ฝั่ง​อ่าน เพื่อ​ให้​ฝั่ง​อ่าน denormalize และ​เก็บ​ใน​รูป​ที่​เหมาะ​กับ​การ query

รูปที่ 24-7: read model แบบ eventually consistent — มี message queue คั่นระหว่างฝั่งเขียนกับฝั่งอ่าน domain event ถูกยกขึ้นจากการเปลี่ยนสถานะ แล้วฝั่งอ่านดึงไปอัปเดต read store จนค่อยๆ ตามฝั่งเขียนทัน

ใน​รูป​ที่ 24-7 มี queue คั่น​กลาง: domain event ที่​ยก​ขึ้น​เมื่อ​สถานะ​ใน domain model เปลี่ยน จะ​ถูก​ใส่ queue แล้ว​ฝั่ง​อ่าน​ประมวล​ผล event นั้น​เพื่อ​อัปเดต read store ของ​ตัวเอง — read store จึง “ตาม​หลัง” ฝั่ง​เขียน​อยู่​เล็กน้อย นั่น​คือ​ความหมาย​ของ eventually consistent

ผลกระทบ​ต่อ UX คือ​ผู้​ใช้​ที่​เพิ่ง​ทำ action แล้ว​รีเฟรช​หน้า​จอ​อาจ​เห็น​ข้อมูล​เก่า ดังนั้น​ควร แจ้งผู้ใช้ตรงๆ ว่า​ข้อมูล​อาจ​ยัง​ไม่​อัปเดต​ล่าสุด เพื่อ​ให้​เขา​ตัดสิน​ใจ​บน​ความ​เข้าใจ​ที่​ถูกต้อง

read model ยัง​ใช้​รวบ view จาก​ทั่ว​ทั้ง​องค์กร​เพื่อ​ทำให้การ render รายงาน​ง่าย​ขึ้น​ได้ — Bounded Context อื่น​ที่​เปิด​รายงาน​ผ่าน RESTful URL หรือ messaging ถูก consume เข้า​มา​เก็บ​ใน read store เพื่อ​ประกอบ​เป็น composite UI หน้า​เดียว (รูป​ที่ 24-8 ใน​หนังสือ)

อีก​วิธี​ที่​ง่าย​มาก: ถ้า​คุณ​มี copy ของ transactional database ที่ replicate ผ่าน log shipping อยู่​แล้ว นั่น​ก็​เป็นการ​แยก read/write รูปแบบ​หนึ่ง ใช้​มัน​เป็น reporting database ได้​เลย (รูป​ที่ 24-9 ใน​หนังสือ)

ถ้า​คุณ​มี collaborative domain ที่​ผู้​ใช้​จำนวน​มาก​แก้ entity ชุด​เดียวกัน การ​จัดการ business task แบบ out-of-process ช่วย​ให้ scale รับ​โหลด​สูง​ได้ application layer รับคำ​ขอ​จาก client แต่​แทนที่​จะ​ทำ​ทันที กลับ​ใส่​ลง message queue แล้ว​ประมวล​ผล​ทีหลัง — มัน​ตอบ​ได้​แค่​ว่า “รับคำ​ขอ​แล้ว” ส่วน​ผล​จริง​ต้อง​แจ้ง client ทาง​อื่น เช่น อีเมล หรือ​ให้​ผู้​ใช้​มา​เช็ก​สถานะ​เอง (รูป​ที่ 24-10 ใน​หนังสือ)

🚢 Async command ใน​ระบบ​ขนส่ง

การ​จอง​ขนส่ง (book a cargo) ใน collaborative domain ที่​หลาย​ทีม​แก้​ข้อมูล​พร้อม​กัน​เหมาะ​กับ async command มาก — ระบบ​ตอบ​กลับ​เพียง “รับคำ​ขอ​จอง​แล้ว นี่​คือ Tracking IDTracking IDรหัส​ติดตาม​ที่​ไม่​ซ้ำ​ของ Cargo ใน​ตัวอย่าง​การ​ขนส่ง ใช้​เป็น identity ของ Cargo ตั้งแต่​ตอน​จอง​จน​ส่ง​มอบTactical Design ของ​คุณ” แล้ว​จึง​ประมวล​ผล​การ​วาง แผน​เดินทางItineraryValue Object ที่​เป็น “แผนการ​เดินทาง​จริง” ของ Cargo ประกอบ​ด้วย​ลำดับ​ของ Leg (ช่วง​การ​ขนส่ง​แต่ละ​ช่วง)Tactical Design แบบ out-of-process ทีหลัง ลูกค้า​รู้​ว่า​ต้อง​รอ และ​มา​เช็ก​สถานะ​ผ่าน tracking ID ได้

Async ต้อง validate ให้​พอ

เมื่อ​ส่ง command แบบ async คุณ​จะ​ไม่​ได้​รับ acknowledgement ทันที​ว่า​สำเร็จ​หรือ​ไม่ ดังนั้น application service จึง​ต้อง validate คำขอ​ให้​รัดกุม​เพียงพอ — อาจ​ถึง​ขั้น​ใช้ view store ตรวจสอบ invariant ก่อน​ใส่ queue — เพื่อ​ให้​มั่นใจ​ว่า​หาก​คำขอ​ล้มเหลว มัน​ล้ม​ด้วย “เหตุผล​ทาง​ธุรกิจ” ไม่ใช่​เพราะ parameter ขาด​หาย​หรือ​ผิดพลาด

เมื่อ domain ทั้ง​โหลด​อ่าน​และ​เขียน​หนัก คุณ​ผสาน​ทั้ง​สอง​เทคนิค — eventually consistent read model คู่​กับ async write — เพื่อ scale ทั้ง​สอง​ด้าน​พร้อม​กัน​ได้ (รูป​ที่ 24-11 ใน​หนังสือ)

หัวใจ​ของ​บท​นี้

CQRS = การนำ Single Responsibility Principle มา​ใช้​ที่​ระดับ model — สร้าง2 model แทนที่​จะ​มี​เพียง​หนึ่ง ใช้ “เฉพาะ” กับ Bounded Context ที่ presentation ขัด​กับ transaction เท่านั้น ไม่ใช่​ทุก context ส่วน messaging, eventual consistency, domain event และ event sourcing ล้วน​เป็น “ส่วน​เสริม” เพื่อ​การ scale ไม่ใช่​เงื่อนไข​บังคับ


🔗 อ้างอิง​เพิ่มเติม​ใน DevIQ

เจาะ​ลึก​แนวคิด​ใน​บท​นี้​ต่อ​ได้ที่​คลัง​อ้างอิง DevIQ:

  • CQRS — คำ​อธิบาย​อ้างอิง​ของ pattern หลัก​ใน​บท​นี้ ครอบคลุม​การ​แยก command/query และ​เงื่อนไข​ที่​ควร​ใช้
  • Domain Events — กลไก​ที่​ใช้ build materialized read model และ​เชื่อม​ฝั่ง command กับ​ฝั่ง query เข้า​ด้วย​กัน​แบบหลวมๆ
  • Repository — เหตุผล​ที่ repository บวม​เมื่อ​รับใช้​ทั้ง2 context และ​ทำไม​ฝั่ง query แทบ​ไม่​ต้อง​ใช้​มัน​เลย

เช็กความเข้าใจ — บทที่ 24

ข้อ 1 / 4

แก่นของ CQRS คืออะไร?