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 ไม่ใช่ สถาปัตยกรรมระดับสูงสุดของทั้งระบบ มันเป็น pattern จัดการความซับซ้อนที่เราเลือกใช้ “เฉพาะ” กับ Bounded Context ที่ presentation model ไม่สอดคล้องกับโครงสร้างของ transactional model — app เว็บส่วนใหญ่มีช่องว่างระหว่าง query กับ command อยู่แล้ว CQRS เพียงแยกสองด้านนี้ออกเพื่อให้ปรับแต่งแต่ละด้านได้อิสระ
ปัญหาของการใช้ model เดียวรับ2 context
หัวข้อที่มีชื่อว่า “ปัญหาของการใช้ model เดียวรับ2 context”รูปที่ 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 ก้อนเล็กๆ ที่ออกแบบมาเพื่อความถูกต้องและสะท้อนกฎของธุรกิจ

ปัญหาคือ ความต้องการด้านรายงานมักไม่ตรงกับโครงสร้างของ 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 รับใช้ทั้งการแสดงผลและกฎธุรกิจ รูปร่างของ 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 ทั้งสองฝั่งใช้ 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: งานตามกฎธุรกิจ
หัวข้อที่มีชื่อว่า “ฝั่ง Command: งานตามกฎธุรกิจ”ฝั่ง command สนใจเรื่องเดียว — การรักษากฎของ domain มันคือ domain logic ที่ทำให้ business task สำเร็จ เมื่อมองแวบแรกฝั่งนี้เหมือน layered architecture ทั่วไป (ดูรูปที่ 24-3 ในหนังสือ) แต่ต่างตรงที่ ฝั่ง command ไม่รองรับการ query ผลลัพธ์ที่ส่งกลับไปยัง client เป็นเพียง “การยืนยัน” (acknowledgement) ว่างานสำเร็จหรือไม่เท่านั้น
สร้าง model ของ “เจตนา” อย่างชัดแจ้ง
หัวข้อที่มีชื่อว่า “สร้าง model ของ “เจตนา” อย่างชัดแจ้ง”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 เท่านั้น
// 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; }}model ที่ปลอดจากภาระการแสดงผล
หัวข้อที่มีชื่อว่า “model ที่ปลอดจากภาระการแสดงผล”ลองนึกถึงหน้าจอ dashboard ของลูกค้าที่ดึงข้อมูลจากหลาย aggregate (รูปที่ 24-4 ในหนังสือ) ถ้าเราออกแบบ Customer ให้ตรงกับหน้าจอนั้น เราจะได้ aggregate ก้อนยักษ์แบบนี้:
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 handler)
หัวข้อที่มีชื่อว่า “ตัวจัดการคำสั่ง (command handler)”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 หรือรายงานสถานะ
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); // คืนแค่ผลสำเร็จ ไม่คืนข้อมูล }}ในระบบ e-commerce command คือเจตนาของผู้ใช้ที่ชัดเจน เช่น CustomerWantsToRedeemAGiftCertificate หรือ PlaceOrder — เป็นกริยา ปัจจุบันกาล สิ่งที่ command handler ตอบกลับมีแค่ “รับคำสั่งแล้ว มีเลขที่ order = …” ไม่ใช่ข้อมูลตะกร้าทั้งหมด ส่วนการ “อ่าน” รายละเอียด order ปล่อยให้ฝั่ง query รับผิดชอบ
ฝั่ง Query: การรายงานบน domain
หัวข้อที่มีชื่อว่า “ฝั่ง Query: การรายงานบน domain”สถาปัตยกรรมฝั่ง 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 รายงานตรงเข้า data model
หัวข้อที่มีชื่อว่า “map รายงานตรงเข้า data model”ฝั่งอ่าน 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):
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; }}Materialized view ที่สร้างจาก Domain Event
หัวข้อที่มีชื่อว่า “Materialized view ที่สร้างจาก Domain Event”เราแยกอ่าน/เขียนให้ลึกขึ้นได้อีก ด้วยการใช้ data schema ที่ต่างกัน — สร้าง read model จาก Domain EventDomain Eventสิ่งที่ “เกิดขึ้นแล้ว” ใน domain ซึ่งส่วนอื่นสนใจ แทนด้วยอ็อบเจ็กต์ที่เปลี่ยนแปลงไม่ได้ ตั้งชื่อเป็นอดีต เช่น CargoWasRouted ใช้สื่อสารข้าม AggregateTactical Design ที่ถูกยกขึ้นโดย command แล้วใช้ event เหล่านั้น build materialized view (รูปที่ 24-6 ในหนังสือ) read data model จึงถูก denormalize และ optimize เพื่อการ query รวมถึงเก็บค่าที่คำนวณไว้ล่วงหน้า
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 เดียวกันได้}การอัปเดต read model จาก domain event เกิดขึ้นใน transaction เดียวกันและฐานข้อมูลเดียวกันได้ ไม่จำเป็นต้องแยก process หรือทำแบบ async — นั่นเป็นเรื่องของการ scale ที่จะพูดถึงทีหลัง
ความเข้าใจผิดเกี่ยวกับ CQRS
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดเกี่ยวกับ CQRS”มีตำนานเกี่ยวกับ CQRS อยู่มากมายบนอินเทอร์เน็ต แต่อย่างที่ได้อ่านมา แท้จริงแล้วมันเรียบง่าย แก่นของมันคือ “ใช้ model เฉพาะให้เหมาะกับแต่ละ context” เท่านั้นเอง
-
“CQRS ยาก” — ไม่จริง โดยพื้นฐานมันคือการนำ Single Responsibility Principle (SRP) มาใช้ที่ระดับ domain model มันเป็น “การเปลี่ยนมุมมอง” มากกว่าจะเป็นชุด pattern อันซับซ้อน
-
“CQRS ต้องเป็น eventually consistentEventual Consistencyความถูกต้องที่ “ตามมาทีหลัง” ไม่ใช่ทันที ใช้กับการอัปเดตข้าม Aggregate ผ่าน Domain Event แทนการบังคับให้ตรงกันในทันทีภายใน transaction เดียวTactical Design” — ไม่จริง คุณใช้ฐานข้อมูลและ transaction เดียวกันอัปเดต read model ได้ ให้ เริ่มจาก immediately consistent ก่อน แล้วค่อยขยับไป eventual consistency เมื่อเจอปัญหา performance จริงๆ
-
“model ต้องเป็น event sourcedEvent Sourcingเก็บทุกการเปลี่ยนสถานะเป็น event ที่เปลี่ยนไม่ได้ แล้วสร้างสถานะปัจจุบันด้วยการเล่น event ซ้ำ มัก “ต้องใช้คู่กับ CQRS” (Greg Young) มี event stream ต่อ1 AggregateArchitecture” — ไม่จริง event sourcing ช่วยให้ build read model ง่ายขึ้น (สร้าง projection อะไรก็ได้จาก event ในอดีต) แต่ไม่ใช่ข้อบังคับของ CQRS
-
“command ต้องเป็น asynchronous” — ไม่จริง async เหมาะกับ domain ที่ผู้ใช้หลายคนแก้ข้อมูลชุดเดียวกัน (collaborative) แต่ไม่ใช่ข้อบังคับ
-
“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 ก็ได้
Pattern เพื่อ scale app ของคุณ
หัวข้อที่มีชื่อว่า “Pattern เพื่อ scale app ของคุณ”CQRS ช่วยให้ app ทำงานได้ดีภายใต้โหลดหนัก เพราะฝั่งอ่านและฝั่งเขียนถูกแยกจากกัน เราจึง scale แต่ละด้านได้อิสระ และเลือก data store ที่เหมาะกับแต่ละด้านได้ — เช่น write store ที่จัดการ aggregate ล้วนๆ อาจใช้ document database หรือ key-value store ส่วน read model อาจใช้ relational database หรือ caching store
ทุกการ trade-off เพื่อ scale ส่งผลต่อ user experience และนั่นส่งผลต่อธุรกิจ ตาม CAP theorem คุณเลือกได้แค่สองในสาม: consistency, availability, partition tolerance การยอมสละ immediate consistency ไปสู่ eventual consistency จึงเป็นการตัดสินใจที่ฝ่ายธุรกิจต้องเข้าใจและยอมรับ
scale ฝั่งอ่าน: eventually consistent read model
หัวข้อที่มีชื่อว่า “scale ฝั่งอ่าน: eventually consistent read model”ถ้า app ของคุณมีโหลดฝั่งอ่านมากกว่าฝั่งเขียนมาก การมี read store แบบ eventually consistent ช่วยเพิ่ม availability และ performance ได้ คุณเก็บ read model ไว้คนละฐานข้อมูลกับฝั่งเขียน หรือ replicate ไปหลายที่ก็ได้ แต่คุณต้อง publish การเปลี่ยนสถานะจากฝั่งเขียนไปฝั่งอ่าน เพื่อให้ฝั่งอ่าน denormalize และเก็บในรูปที่เหมาะกับการ query

ในรูปที่ 24-7 มี queue คั่นกลาง: domain event ที่ยกขึ้นเมื่อสถานะใน domain model เปลี่ยน จะถูกใส่ queue แล้วฝั่งอ่านประมวลผล event นั้นเพื่ออัปเดต read store ของตัวเอง — read store จึง “ตามหลัง” ฝั่งเขียนอยู่เล็กน้อย นั่นคือความหมายของ eventually consistent
ผลกระทบต่อ UX คือผู้ใช้ที่เพิ่งทำ action แล้วรีเฟรชหน้าจออาจเห็นข้อมูลเก่า ดังนั้นควร แจ้งผู้ใช้ตรงๆ ว่าข้อมูลอาจยังไม่อัปเดตล่าสุด เพื่อให้เขาตัดสินใจบนความเข้าใจที่ถูกต้อง
รวมหลาย Bounded Context ด้วย read model
หัวข้อที่มีชื่อว่า “รวมหลาย Bounded Context ด้วย read model”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 ในหนังสือ)
scale ฝั่งเขียน: asynchronous command
หัวข้อที่มีชื่อว่า “scale ฝั่งเขียน: asynchronous command”ถ้าคุณมี collaborative domain ที่ผู้ใช้จำนวนมากแก้ entity ชุดเดียวกัน การจัดการ business task แบบ out-of-process ช่วยให้ scale รับโหลดสูงได้ application layer รับคำขอจาก client แต่แทนที่จะทำทันที กลับใส่ลง message queue แล้วประมวลผลทีหลัง — มันตอบได้แค่ว่า “รับคำขอแล้ว” ส่วนผลจริงต้องแจ้ง client ทางอื่น เช่น อีเมล หรือให้ผู้ใช้มาเช็กสถานะเอง (รูปที่ 24-10 ในหนังสือ)
การจองขนส่ง (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 ได้
เมื่อส่ง 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:
- CQRS — คำอธิบายอ้างอิงของ pattern หลักในบทนี้ ครอบคลุมการแยก command/query และเงื่อนไขที่ควรใช้
- Domain Events — กลไกที่ใช้ build materialized read model และเชื่อมฝั่ง command กับฝั่ง query เข้าด้วยกันแบบหลวมๆ
- Repository — เหตุผลที่ repository บวมเมื่อรับใช้ทั้ง2 context และทำไมฝั่ง query แทบไม่ต้องใช้มันเลย
เช็กความเข้าใจ — บทที่ 24
ข้อ 1 / 4แก่นของ CQRS คืออะไร?