CQRS
แยก model อ่าน (query) ออกจาก model เขียน (command)
ในสถาปัตยกรรม CRUD แบบดั้งเดิม ทั้งการอ่านและเขียนข้อมูลมักใช้ “model เดียวกัน” ผ่าน entity หรือ ORM class ชุดเดียว วิธีนี้เรียบง่ายและเหมาะกับงาน CRUD พื้นฐาน แต่เมื่อระบบโตขึ้น ความไม่สมมาตรระหว่างการอ่านกับการเขียนจะเริ่มเป็นปัญหา — field ที่จำเป็นตอนแก้ไขข้อมูลอาจไม่จำเป็นเลยตอนแสดงผล, query ที่ต้อง join หลายตารางเพื่อแสดง dashboard จะรันช้าลงเรื่อย ๆ, transaction เขียนพร้อมกันหลายจุดทำให้เกิด lock contention และการดูแล security ก็ยากขึ้นเมื่อ entity เดียวถูกทั้งอ่านและเขียนพร้อมกัน
CQRS (Command Query Responsibility Segregation) แก้ปัญหานี้ด้วยแนวคิดหลัก: แยก model และ path การประมวลผลสำหรับ “เขียน” (command) ออกจาก “อ่าน” (query) โดยเด็ดขาด ฝั่งเขียนมี business logic, validation และ invariant ครบถ้วน optimize เพื่อความถูกต้องของข้อมูล ส่วนฝั่งอ่านไม่มี logic ใด ๆ เลย มีหน้าที่แค่คืนค่า DTO ที่ optimize มาเพื่อการแสดงผลโดยเฉพาะ — ทั้งสองฝั่งจะพัฒนา, scale และแม้แต่เลือกเทคโนโลยีเก็บข้อมูลแยกจากกันได้อย่างอิสระ
แนวคิดนี้มีรากมาจาก Command-Query Separation (CQS) ของ Bertrand Meyer ที่ระบุว่าแต่ละ method ควรเป็น command (ทำ side effect) หรือ query (คืนค่า) อย่างใดอย่างหนึ่งเท่านั้น ไม่ใช่ทั้งสองอย่างพร้อมกัน Greg Young ยกระดับแนวคิดนี้จากระดับ method ขึ้นมาเป็นระดับสถาปัตยกรรม โดยแยกทั้ง “model” ของอ่านและเขียนออกจากกัน ไม่ใช่แค่แยก method
โครงสร้าง
หัวข้อที่มีชื่อว่า “โครงสร้าง”classDiagram
class Client
class CreateOrderCommand {
+CustomerId
+Items
}
class GetOrderByIdQuery {
+OrderId
}
class ICommandHandler["ICommandHandler (interface)"] {
+Handle()
}
class IQueryHandler["IQueryHandler (interface)"] {
+Handle()
}
class CreateOrderHandler {
+Handle()
}
class GetOrderByIdHandler {
+Handle()
}
class WriteModel
class ReadModel
Client --> CreateOrderCommand
Client --> GetOrderByIdQuery
CreateOrderHandler ..|> ICommandHandler
GetOrderByIdHandler ..|> IQueryHandler
CreateOrderHandler --> WriteModel
GetOrderByIdHandler --> ReadModel
ฝั่ง command (CreateOrderCommand) ถูกจับคู่กับ handler ที่ implement ICommandHandler ซึ่งทำงานผ่าน write model ที่มี business logic และ validation เต็มรูปแบบ ส่วนฝั่ง query (GetOrderByIdQuery) ถูกจับคู่กับ handler ที่ implement IQueryHandler ซึ่งอ่านจาก read model ที่ไม่มี logic ใด ๆ — เป็นเพียง projection หรือ view ที่ optimize ไว้สำหรับแสดงผล ในระบบจริง write model และ read model อาจอยู่ใน database เดียวกันคนละ schema (basic CQRS) หรืออยู่คนละ data store ไปเลย เช่น write ลง relational database ส่วน read เก็บเป็น document ที่ denormalize แล้ว (advanced CQRS)
การทำงาน
หัวข้อที่มีชื่อว่า “การทำงาน”การไหลของ request แบ่งเป็นสองเส้นทางที่ไม่ตัดกัน:
- ฝั่งเขียน — client ส่ง command ที่สื่อ business intent ชัดเจน เช่น “จองห้องพัก” ไม่ใช่ “set ReservationStatus = Reserved” command handler โหลด aggregate, เรียก business logic, ตรวจ invariant แล้ว persist ลง write store จากนั้น publish domain event ออกไปเพื่อแจ้งว่ามีการเปลี่ยนแปลง
- การ sync read model — ถ้า read model แยก store จาก write model ตัว projector (event handler) จะรับ event นั้นแล้วอัปเดต read view ให้ตรงกับสถานะล่าสุด ขั้นตอนนี้ทำให้ระบบเป็นแบบ eventually consistent — read model อาจตามหลัง write model อยู่เสี้ยววินาทีถึงไม่กี่วินาที
- ฝั่งอ่าน — client ส่ง query, query handler อ่านตรงจาก read store (ไม่ผ่าน business logic ใด ๆ) แล้วคืน DTO ที่พร้อมใช้แสดงผลทันที
sequenceDiagram
participant UI
participant CommandHandler
participant WriteDB
participant EventBus
participant Projector
participant ReadDB
participant QueryHandler
UI->>CommandHandler: CreateOrderCommand
CommandHandler->>WriteDB: บันทึก aggregate
CommandHandler->>EventBus: publish OrderCreated
EventBus->>Projector: OrderCreated
Projector->>ReadDB: อัปเดต read view
UI->>QueryHandler: GetOrderByIdQuery
QueryHandler->>ReadDB: อ่าน view
QueryHandler-->>UI: OrderDetailsDto
สังเกตว่า query ในขั้นตอนที่ 3 ไม่ได้รอ projector เสร็จก่อน — ถ้า query มาถึงก่อน projector อัปเดต read view เสร็จ ผู้ใช้อาจเห็นข้อมูลเก่าชั่วขณะ นี่คือ trade-off หลักที่ทีมต้องยอมรับเมื่อเลือกแยก data store จริง ๆ
ตัวอย่าง code
หัวข้อที่มีชื่อว่า “ตัวอย่าง code”// ---------- ฝั่งเขียน (Write side) ----------public record CreateOrderCommand(int CustomerId, List<OrderLineDto> Items) : IRequest<int>;
public class CreateOrderCommandHandler : IRequestHandler<CreateOrderCommand, int>{ private readonly IOrderRepository _repository; private readonly IEventBus _eventBus;
public CreateOrderCommandHandler(IOrderRepository repository, IEventBus eventBus) { _repository = repository; _eventBus = eventBus; }
public async Task<int> Handle(CreateOrderCommand request, CancellationToken ct) { // สร้าง aggregate ฝั่งเขียน พร้อม business logic และ validation เต็มรูปแบบ var order = Order.Create(request.CustomerId, request.Items);
await _repository.AddAsync(order, ct); await _repository.UnitOfWork.SaveChangesAsync(ct);
// แจ้ง event ให้ฝั่งอ่านไป sync read model แบบ eventually consistent await _eventBus.PublishAsync(new OrderCreatedEvent(order.Id, order.CustomerId), ct);
return order.Id; }}
// ---------- ฝั่งอ่าน (Read side) ----------public record GetOrderByIdQuery(int Id) : IRequest<OrderDetailsDto>;
public class OrderDetailsDto{ public int Id { get; init; } public string CustomerName { get; init; } = string.Empty; public decimal TotalAmount { get; init; } public string Status { get; init; } = string.Empty;}
public class GetOrderByIdQueryHandler : IRequestHandler<GetOrderByIdQuery, OrderDetailsDto>{ private readonly IDbConnection _readConnection; // เช่น Dapper บน read replica หรือ read store แยกต่างหาก
public GetOrderByIdQueryHandler(IDbConnection readConnection) { _readConnection = readConnection; }
public Task<OrderDetailsDto> Handle(GetOrderByIdQuery request, CancellationToken ct) { // ไม่มี business logic ใด ๆ — ดึงจาก view ที่ optimize ไว้สำหรับอ่านโดยเฉพาะ const string sql = @" SELECT o.Id, c.Name AS CustomerName, o.TotalAmount, o.Status FROM OrderReadView o JOIN Customer c ON c.Id = o.CustomerId WHERE o.Id = @Id";
return _readConnection.QuerySingleAsync<OrderDetailsDto>(sql, new { request.Id }); }}
// ---------- projector ที่ sync read model จาก event ----------public class OrderCreatedProjector : IEventHandler<OrderCreatedEvent>{ private readonly IDbConnection _readConnection;
public OrderCreatedProjector(IDbConnection readConnection) => _readConnection = readConnection;
public Task HandleAsync(OrderCreatedEvent @event, CancellationToken ct) { // สร้าง/อัปเดตแถวใน read view ให้ตรงกับ write model ล่าสุด const string sql = @" INSERT INTO OrderReadView (Id, CustomerId, TotalAmount, Status) VALUES (@OrderId, @CustomerId, 0, 'Pending')";
return _readConnection.ExecuteAsync(sql, new { @event.OrderId, @event.CustomerId }); }}เมื่อไหร่ควรใช้
หัวข้อที่มีชื่อว่า “เมื่อไหร่ควรใช้”- domain ซับซ้อน มี business logic ฝั่งเขียนเยอะ และต่างจากรูปแบบการแสดงผลฝั่งอ่านมาก
- โหลดอ่านกับเขียนไม่สมดุลอย่างชัดเจน (ส่วนใหญ่อ่านมากกว่าเขียนมาก) และต้องการ scale สองฝั่งแยกกัน
- ระบบ collaborative ที่หลายคนแก้ไขข้อมูลชุดเดียวกันพร้อมกัน — command ที่ granular ช่วยลด merge conflict
- UI แบบ task-based ที่สื่อ business intent เป็นขั้นตอน (เช่น wizard) มากกว่าการ set field ตรง ๆ
- ต้องการแยกทีมพัฒนา — ทีมหนึ่งดูแล business logic ฝั่งเขียน อีกทีมดูแล read model/UI
- ใช้ร่วมกับ Event Sourcing เพื่อ rebuild read model ใหม่ได้จาก event stream เมื่อ schema ฝั่งอ่านเปลี่ยน
- ระบบต้อง integrate กับ subsystem อื่นและต้องการ isolate failure ไม่ให้ลามทั้งระบบ
เมื่อไหร่ไม่ควรใช้
หัวข้อที่มีชื่อว่า “เมื่อไหร่ไม่ควรใช้”- domain หรือ business rule เรียบง่าย CRUD ธรรมดาก็เพียงพอแล้ว
- ทีมเล็ก ไม่มีความจำเป็นต้อง scale อ่าน/เขียนแยกกัน
- ระบบต้องการ strong consistency ทันทีทุกจุด ทนต่อ eventual consistency ไม่ได้
- คิดจะใช้ทั่วทั้งระบบ (system-wide) แทนที่จะจำกัดเฉพาะ bounded context ที่จำเป็นจริง ๆ — Martin Fowler เตือนว่านี่คือสาเหตุหลักที่ CQRS “เพิ่มความซับซ้อนแบบเสี่ยง” ให้กับระบบ
- ทีมยังไม่พร้อมรับความซับซ้อนของ messaging, event handling และการ sync ระหว่าง store
ข้อดีและข้อเสีย
หัวข้อที่มีชื่อว่า “ข้อดีและข้อเสีย”| ด้าน | ข้อดี | ข้อเสีย |
|---|---|---|
| ประสิทธิภาพ | scale อ่าน/เขียนแยกอิสระ, query เร็วขึ้นด้วย read model ที่ denormalize ไว้แล้ว | ต้องดูแล infrastructure เพิ่ม (2 data store, message bus) |
| ความเข้าใจ code | แต่ละ model เรียบง่ายลง เข้าใจง่ายในจุดของตัวเอง | ระบบโดยรวมซับซ้อนขึ้นมาก โดยเฉพาะเมื่อรวมกับ Event Sourcing |
| ความสอดคล้องข้อมูล | ลด lock contention ระหว่างอ่าน-เขียน | eventual consistency — ผู้ใช้อาจเห็นข้อมูลเก่าชั่วขณะหนึ่ง |
| การทำงานเป็นทีม | แยกทีมเขียน/อ่านทำงานคู่ขนานได้ | ต้องมี discipline สื่อสาร contract (event, DTO) ระหว่างสองฝั่งให้ตรงกัน |
| ความปลอดภัย | จำกัดสิทธิ์เขียนให้เฉพาะ path ที่ผ่าน command ได้ชัดเจน | เพิ่ม surface area ที่ต้องตรวจสอบสิทธิ์ทั้งสองฝั่งแยกกัน |