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

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 แบ่ง​เป็น​สอง​เส้นทาง​ที่​ไม่​ตัด​กัน:

  1. ฝั่ง​เขียน — client ส่ง command ที่​สื่อ business intent ชัดเจน เช่น “จอง​ห้อง​พัก” ไม่ใช่ “set ReservationStatus = Reserved” command handler โหลด aggregate, เรียก business logic, ตรวจ invariant แล้ว persist ลง write store จาก​นั้น publish domain event ออก​ไป​เพื่อ​แจ้ง​ว่า​มี​การ​เปลี่ยนแปลง
  2. การ sync read model — ถ้า read model แยก store จาก write model ตัว projector (event handler) จะ​รับ event นั้น​แล้ว​อัปเดต read view ให้​ตรง​กับ​สถานะ​ล่าสุด ขั้นตอน​นี้​ทำให้​ระบบ​เป็น​แบบ eventually consistent — read model อาจ​ตาม​หลัง write model อยู่​เสี้ยว​วินาที​ถึง​ไม่​กี่​วินาที
  3. ฝั่ง​อ่าน — 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 จริง ๆ

// ---------- ฝั่งเขียน (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 ที่​ต้อง​ตรวจสอบ​สิทธิ์​ทั้ง​สอง​ฝั่ง​แยก​กัน