Thứ Hai đầu tuần, team analytics báo dashboard doanh thu load mất 12 giây. Mở slow query log ra thấy một con query JOIN 5 bảng, aggregate theo ngày, filter theo region và product category, chạy full table scan trên bảng orders có 40 triệu row. Cùng lúc đó, checkout flow cũng dùng chung bảng orders để insert đơn hàng mới và validate inventory. Hai workload hoàn toàn khác nhau, một bên cần đọc nhanh trên tập dữ liệu lớn đã denormalize, một bên cần ghi nhanh với business rule chặt chẽ, nhưng đang chia sẻ cùng một database, cùng schema, cùng connection pool.

Giải pháp đầu tiên ai cũng nghĩ tới: thêm index. Index giúp query đọc nhanh hơn nhưng làm write chậm đi vì mỗi INSERT/UPDATE phải cập nhật thêm index. Thêm read replica? Giảm tải read khỏi primary, nhưng schema vẫn là schema chuẩn hoá cho write, query report vẫn phải JOIN nhiều bảng. Tạo materialized view? Giúp được phần nào nhưng refresh view lại tạo load trên database, và schema của view vẫn bị ràng buộc bởi schema gốc.

Đến một điểm, bạn nhận ra rằng vấn đề không phải thiếu index hay thiếu replica, vấn đề là mô hình dữ liệu phục vụ write và mô hình dữ liệu phục vụ read có yêu cầu trái ngược nhau. Write model cần normalize để tránh anomaly, enforce foreign key, validate constraint. Read model cần denormalize để query nhanh, không JOIN, trả đúng shape mà UI cần. CQRS, Command Query Responsibility Segregation, sinh ra để giải quyết mâu thuẫn này.


CQRS là gì, và không phải gì

Hiểu nôm na: CQRS giống như kho hàng có hai khu vực riêng biệt, khu nhập hàng (write) kiểm tra kỹ chất lượng, số lượng, sắp xếp đúng chỗ; khu xuất hàng (read) chỉ lo lấy đúng mặt hàng ra nhanh nhất. Hai khu tối ưu cho hai mục tiêu khác nhau, không cần cùng quy trình.

Ý tưởng cốt lõi của CQRS đơn giản đến mức nhiều người nghe xong nghĩ “vậy thôi à?”: tách mô hình xử lý command (ghi) và mô hình xử lý query (đọc) thành hai thứ riêng biệt. Command thay đổi trạng thái hệ thống, tạo đơn hàng, cập nhật profile, huỷ subscription. Query đọc trạng thái, hiển thị dashboard, liệt kê đơn hàng, tìm kiếm sản phẩm. Trong CQRS, hai nhóm thao tác này đi qua hai đường dẫn code khác nhau, có thể dùng hai mô hình dữ liệu khác nhau, và ở mức cao nhất có thể dùng hai database khác nhau.

Điều quan trọng cần nhấn mạnh: CQRS không bắt buộc hai database riêng. Đây là hiểu lầm phổ biến nhất. Ở dạng đơn giản nhất, CQRS chỉ là tách code handler cho command và query trong cùng một ứng dụng, cùng một database. Không có message queue, không có event bus, không có eventual consistency. Chỉ là tổ chức code rõ ràng hơn: phía command enforce business rule, phía query tối ưu cho đọc.

CQRS cũng không phải Event Sourcing. Hai khái niệm này hay đi cùng nhau nhưng hoàn toàn độc lập. Bạn có thể dùng CQRS mà write model vẫn là PostgreSQL bình thường với UPDATE/INSERT, không cần event store, không cần replay event. Ngược lại, bạn có thể dùng Event Sourcing mà không tách read/write model (dù rất hiếm trong thực tế vì event store không tối ưu cho query).

flowchart LR Client([Client]) subgraph Application CH[Command Handler] QH[Query Handler] end subgraph "Write Side" WM[(Write Model
normalized)] end subgraph "Read Side" RM[(Read Model
denormalized)] end Client -->|"command
(create order)"| CH Client -->|"query
(get dashboard)"| QH CH --> WM WM -->|"sync / async
projection"| RM QH --> RM

Sơ đồ trên minh hoạ flow cơ bản: command đi vào write model, query đi vào read model, và có một cơ chế nào đó (đồng bộ hoặc bất đồng bộ) để write model cập nhật read model. Cơ chế đó, projection, là trung tâm của mọi triển khai CQRS.


Ba mức độ CQRS

Không nhất thiết phải nhảy thẳng vào kiến trúc phức tạp nhất. CQRS có thể áp dụng theo mức độ tăng dần, và ở mỗi mức bạn nhận thêm lợi ích nhưng cũng nhận thêm complexity. Hiểu rõ từng mức giúp chọn đúng mức phù hợp với bài toán hiện tại thay vì over-engineer.

Cùng database, khác mô hình code

Đây là mức đơn giản nhất và cũng là nơi nên bắt đầu. Cùng một database PostgreSQL, nhưng phía command dùng domain model giàu business logic (validate invariant, enforce rule), còn phía query dùng DTO/view model tối ưu cho từng màn hình cụ thể. Command handler gọi repository pattern với aggregate root, query handler chạy SQL trực tiếp (hoặc qua view) trả về đúng shape mà API cần.

Lợi ích rõ ràng nhất: query không bị ràng buộc bởi domain model. Thay vì load aggregate Order với toàn bộ OrderItem, tính toán derived field trong code, rồi map sang DTO, query handler chạy một câu SQL đã tối ưu, có thể dùng GROUP BY, window function, join denormalized view, trả về đúng cột mà frontend cần. Performance tốt hơn nhiều vì database engine xử lý aggregation hiệu quả hơn application code.

Ở mức này, consistency là strong, command ghi xong, query đọc ngay thấy dữ liệu mới. Không có eventual consistency, không có stale data. Phù hợp cho hầu hết ứng dụng CRUD có vài query phức tạp cần tối ưu riêng. Nếu bạn đang ở giai đoạn này và hệ thống chạy tốt, đừng nhảy lên mức tiếp theo chỉ vì “CQRS phải có hai database”.

Tách read database riêng

Khi query load quá nặng mà read replica thông thường không đủ, vì schema chuẩn hoá không phù hợp cho query pattern, bạn tách hẳn một database riêng cho read. Write model vẫn dùng PostgreSQL chuẩn hoá. Read model có thể là PostgreSQL khác với schema denormalized, hoặc Elasticsearch cho full-text search, hoặc Redis cho lookup nhanh, hoặc ClickHouse cho analytics.

Cơ chế đồng bộ giữa write DB và read DB là bài toán trung tâm ở mức này. Có ba cách phổ biến. Cách thứ nhất là dual write: command handler ghi cả write DB và read DB trong cùng transaction. Đơn giản nhưng fragile, nếu ghi read DB fail sau khi ghi write DB thành công, hai bên lệch nhau. Distributed transaction (2PC) giải quyết nhưng chậm và phức tạp. Cách thứ hai là Change Data Capture (CDC): dùng Debezium hoặc tương tự đọc WAL/binlog của write DB, stream changes sang read DB. Reliable hơn dual write vì dựa trên commit log của database, không mất event. Cách thứ ba là domain event: command handler publish event sau khi ghi write DB, consumer nhận event và cập nhật read DB. Linh hoạt nhất nhưng cần message broker (Kafka, RabbitMQ) và phải xử lý idempotency ở consumer.

Ở mức này, bạn chấp nhận eventual consistency, có khoảng thời gian (thường vài trăm millisecond đến vài giây) mà read model chưa phản ánh thay đổi mới nhất từ write model. Đây không phải bug, đây là trade-off có chủ đích. Nhưng nó ảnh hưởng đến UX và cần xử lý cẩn thận, phần sau sẽ nói kỹ hơn.

Event-sourced write, projected read

Mức cao nhất: write side không lưu trạng thái hiện tại mà lưu chuỗi event đã xảy ra, OrderCreated, ItemAdded, PaymentReceived, OrderShipped. Trạng thái hiện tại được tính bằng cách replay toàn bộ event từ đầu. Read model được xây dựng bằng cách project (chiếu) event stream thành các view tối ưu cho query.

Mức này cho bạn audit trail hoàn chỉnh (mọi thay đổi đều là event, không bao giờ mất), khả năng rebuild read model từ đầu khi cần thay đổi schema read, và khả năng tạo nhiều read model khác nhau từ cùng event stream. Nhưng complexity tăng rất nhiều: event schema evolution, snapshot để tránh replay chậm, idempotent projection, ordering guarantee, mỗi thứ đều là bài toán riêng.

Đây là mức mà chỉ nên áp dụng khi có yêu cầu thực sự, audit requirement nghiêm ngặt (fintech, healthcare), hoặc read model cần rebuild thường xuyên khi business requirement thay đổi, hoặc nhiều bounded context cần consume cùng event stream để build read model riêng. Nếu không có những yêu cầu này, hai mức trước đã đủ.


Write model, nơi business rule sống

Write model trong CQRS có một trách nhiệm duy nhất: đảm bảo mọi thay đổi trạng thái hệ thống đều hợp lệ theo business rule. Nó không quan tâm dữ liệu hiển thị thế nào trên UI, không quan tâm query nào cần chạy nhanh. Nó chỉ quan tâm: “command này có hợp lệ không? Invariant có bị vi phạm không?”

Command đi vào write model qua command handler. Handler validate input, load aggregate (hoặc entity), gọi method trên aggregate để thực thi business logic, rồi persist trạng thái mới. Aggregate là ranh giới consistency, mọi invariant trong aggregate được đảm bảo trong cùng transaction.

Ví dụ: command PlaceOrder đi vào handler. Handler kiểm tra user tồn tại, kiểm tra inventory đủ, tính giá sau discount, validate tổng tiền hợp lệ. Nếu mọi thứ OK, tạo Order aggregate với trạng thái Placed, persist vào write DB, publish event OrderPlaced. Nếu inventory không đủ, trả lỗi ngay, không ghi gì hết.

Thiết kế write model theo nguyên tắc command trả về success/failure, không trả về dữ liệu để hiển thị. Command PlaceOrder trả về orderId (để client biết ID đơn hàng mới) và status, không trả về toàn bộ order detail với tên sản phẩm, ảnh, giá formatted. Dữ liệu hiển thị là việc của query, client dùng orderId gọi query riêng để lấy order detail từ read model. Nguyên tắc này giữ write model lean và focused.

Một lỗi thiết kế phổ biến: command handler query dữ liệu từ read model để validate. Ví dụ: trước khi tạo order, handler query read model để kiểm tra “user đã có bao nhiêu order trong tháng này?” (cho business rule giới hạn order/tháng). Vấn đề: read model có thể stale, user vừa tạo order 1 giây trước nhưng read model chưa cập nhật, handler cho phép order mới, vi phạm business rule. Command handler chỉ nên dựa vào write model để validate, nếu cần count order, query write DB, không phải read DB.


Read model, tối ưu cho từng màn hình

Read model tồn tại vì một lý do duy nhất: trả dữ liệu nhanh nhất có thể cho một use case cụ thể. Không có ràng buộc normalize, không có foreign key, không cần đảm bảo integrity qua constraint. Read model là “bản sao đã chế biến” của dữ liệu, tối ưu cho đọc.

Một hệ thống có thể có nhiều read model cho cùng dữ liệu gốc. Bảng orders trong write model có thể sinh ra: read model order_list_view denormalized cho trang danh sách đơn hàng (có sẵn tên khách hàng, tổng tiền, trạng thái, không cần JOIN), read model order_analytics aggregate theo ngày/region cho dashboard, read model order_search index trong Elasticsearch cho tìm kiếm full-text. Mỗi read model phục vụ một use case, tối ưu cho use case đó.

Denormalize không có nghĩa là “copy paste dữ liệu bừa bãi”. Read model vẫn cần thiết kế có chủ đích: field nào cần, index nào cần, query pattern nào phải nhanh. Sai lầm phổ biến là tạo read model “đa năng” phục vụ mọi query, kết cục nó giống write model với vài cột thêm, không tối ưu cho gì cả.

Query handler đọc read model cũng nên đơn giản: nhận query parameters, chạy SQL (hoặc Elasticsearch query, Redis GET), trả về DTO. Không có business logic trong query handler, không validate, không tính toán phức tạp, không side effect. Nếu bạn thấy query handler có if condition phức tạp hoặc gọi external service, có thể dữ liệu trong read model chưa được precompute đúng mức.


Projection, cầu nối giữa write và read

Projection là quá trình biến đổi dữ liệu từ write model thành read model. Đây là phần ít glamorous nhất của CQRS nhưng quyết định thành bại của toàn bộ kiến trúc. Projection chạy sai, read model sai, mọi query đều trả kết quả sai, và user nhìn thấy dữ liệu sai trên UI.

Cách projection hoạt động

Ở dạng đơn giản nhất: sau khi command handler ghi write DB, một process (đồng bộ hoặc bất đồng bộ) đọc thay đổi và cập nhật read DB.

Đồng bộ: command handler ghi write DB, trong cùng request đó cập nhật read DB, rồi trả response cho client. Ưu điểm: read model luôn nhất quán với write model, client đọc ngay thấy dữ liệu mới. Nhược điểm: latency của command tăng vì phải đợi cả read DB cập nhật xong, và nếu read DB chậm hoặc lỗi thì command cũng fail, coupling cao.

Bất đồng bộ: command handler ghi write DB, publish event (hoặc CDC stream), rồi trả response ngay cho client. Consumer nhận event và cập nhật read DB ở thời điểm sau. Ưu điểm: command nhanh, write và read decouple, read DB lỗi không ảnh hưởng command. Nhược điểm: eventual consistency, client có thể đọc dữ liệu cũ ngay sau khi ghi.

sequenceDiagram participant C as Client participant CH as Command Handler participant WDB as Write DB participant EB as Event Bus / CDC participant P as Projector participant RDB as Read DB participant QH as Query Handler C->>CH: PlaceOrder command CH->>WDB: INSERT order WDB-->>CH: OK CH->>EB: publish OrderPlaced CH-->>C: 201 Created (orderId) Note over EB,P: async processing EB->>P: OrderPlaced event P->>RDB: UPSERT order_list_view C->>QH: GET /orders/{id} QH->>RDB: SELECT from order_list_view RDB-->>QH: order data QH-->>C: order detail

Idempotency trong projection

Event có thể được deliver nhiều lần, message broker retry khi consumer không ack, CDC replay khi connector restart. Nếu projector không idempotent, cùng event xử lý hai lần có thể tạo dữ liệu sai: counter tăng gấp đôi, row duplicate, trạng thái nhảy lung tung.

Cách xử lý phổ biến nhất: mỗi event có event_id duy nhất, projector lưu last_processed_event_id hoặc dùng bảng processed_events để dedup. Trước khi xử lý event, kiểm tra đã xử lý chưa. Nếu rồi, skip. Nếu chưa, xử lý và ghi event_id vào bảng dedup trong cùng transaction với update read model. Pattern đơn giản nhưng bắt buộc, thiếu idempotency là bug chắc chắn lộ trên production, chỉ là sớm hay muộn.

Rebuild projection

Một trong những lợi ích lớn nhất của CQRS (đặc biệt khi kết hợp Event Sourcing): có thể xoá toàn bộ read model và build lại từ đầu. Khi business requirement thay đổi và read model cần thêm field, thay đổi cách aggregate, hoặc thậm chí đổi storage engine (từ PostgreSQL sang Elasticsearch), bạn viết projector mới, replay toàn bộ event/change log, build read model mới từ scratch.

Nhưng rebuild không phải miễn phí. Với event store có hàng trăm triệu event, replay mất hàng giờ hoặc hàng ngày. Trong thời gian rebuild, read model cũ vẫn phải serve traffic (stale nhưng available) hoặc service phải chấp nhận downtime cho query đó. Snapshot giúp rút ngắn thời gian replay nhưng thêm complexity quản lý snapshot.

Mình từng thấy team rebuild projection cho dashboard analytics, 200 triệu event, replay mất 6 tiếng trên máy khá mạnh. Trong 6 tiếng đó, dashboard hiển thị dữ liệu cũ với banner “Data đang được cập nhật, có thể chưa chính xác”. Không lý tưởng, nhưng chấp nhận được cho use case analytics. Nếu đây là dữ liệu checkout real-time thì khác, cần chiến lược rebuild không downtime (blue-green projection, build read model mới song song rồi switch traffic).


Eventual consistency, sống chung với dữ liệu “chưa mới nhất”

Khi tách read DB riêng với projection bất đồng bộ, bạn chấp nhận eventual consistency: có khoảng thời gian mà read model chưa phản ánh write mới nhất. Khoảng thời gian này thường vài trăm millisecond đến vài giây trong điều kiện bình thường, nhưng có thể lên phút hoặc hơn khi system overload, consumer lag, hoặc read DB chậm.

Eventual consistency không phải trạng thái bất thường, nó là đặc tính thiết kế có chủ đích. Nhưng nó ảnh hưởng đến UX theo cách mà developer phải xử lý cẩn thận, không thể bỏ qua và hy vọng user không để ý.

Read-your-own-writes

Kịch bản phổ biến nhất: user submit form tạo đơn hàng, server trả thành công, UI redirect sang trang “đơn hàng của tôi”, nhưng đơn hàng mới không xuất hiện trong danh sách vì read model chưa kịp cập nhật. User hoảng, nhấn F5, vẫn không thấy, nhấn nút tạo đơn lần nữa, giờ có hai đơn hàng.

Read-your-own-writes là pattern giải quyết vấn đề này: user phải luôn thấy được thay đổi mà chính họ vừa tạo ra, dù read model chưa eventual consistent cho user khác.

Cách triển khai phổ biến nhất: sau khi command thành công, server trả về version hoặc timestamp của write. Client gửi kèm version trong query tiếp theo. Query handler so sánh, nếu read model đã có version >= yêu cầu, trả kết quả bình thường. Nếu read model chưa có, có vài lựa chọn: đợi (poll read model vài lần với backoff), hoặc fallback đọc trực tiếp write DB cho request đó, hoặc trả kết quả hiện có kèm indicator “đang cập nhật”.

Cách khác đơn giản hơn: sau khi command thành công, UI không query lại từ server mà optimistic update trên client, thêm đơn hàng vào danh sách trên UI ngay, dùng dữ liệu từ response của command. Lần query tiếp theo (refresh trang hoặc poll) sẽ lấy dữ liệu thật từ read model. Cách này phổ biến trong SPA và hoạt động tốt cho hầu hết use case.

Stale data trên UI, có luôn là vấn đề?

Không phải mọi stale data đều gây hại. Dashboard analytics hiển thị dữ liệu chậm 30 giây so với real-time, user có quan tâm không? Hầu hết là không. Trang danh sách sản phẩm chậm 5 giây cập nhật tồn kho, có sao không? Tuỳ. Nếu sản phẩm sắp hết hàng và user đặt xong mới báo “hết hàng” thì UX tệ. Nếu tồn kho hàng nghìn thì 5 giây delay không ảnh hưởng gì.

Eventual consistency ảnh hưởng khác nhau tuỳ use case. Mình phân loại theo mức nhạy cảm: cao (payment status, inventory check, account balance, cần consistency chặt hoặc read-your-own-writes), trung bình (order list, notification count, eventual OK nhưng cần read-your-own-writes), thấp (analytics dashboard, search result, recommendation, eventual vài giây hoặc vài phút đều chấp nhận được).

Phân loại này giúp quyết định: use case nào cần strong consistency (đọc write DB trực tiếp hoặc đồng bộ projection), use case nào chấp nhận eventual (bất đồng bộ projection). Không nhất thiết toàn hệ thống phải cùng mức consistency, mix được.


Khi nào CQRS có lợi

CQRS không phải silver bullet, và cũng không phải pattern nên áp dụng mặc định. Nó giải quyết một nhóm vấn đề cụ thể, nếu bạn không gặp vấn đề đó thì CQRS chỉ thêm complexity mà không có lợi.

Read/write ratio lệch nặng

Hệ thống mà read traffic gấp 10-100 lần write traffic, e-commerce catalog, content platform, social feed. Tách read model cho phép scale read side độc lập: thêm replica, cache aggressively, dùng storage engine tối ưu cho đọc. (Trong thực tế, lý do tách read model thường là: DBA nhìn vào slow query log rồi nói “bảng này không thể thêm index được nữa”, lúc đó CQRS trở thành giải pháp thực sự thay vì lý thuyết.) Write side giữ nhỏ gọn, focus vào business logic.

Query pattern phức tạp và đa dạng

Khi cùng dữ liệu cần phục vụ nhiều kiểu query rất khác nhau, danh sách đơn hàng cho user, dashboard tổng hợp cho manager, search full-text cho support team, analytics cho data team, một schema normalize không thể tối ưu cho tất cả. Nhiều read model, mỗi cái tối ưu cho một use case, giải quyết triệt để.

Scaling write và read độc lập

Write cần database với ACID transaction mạnh (PostgreSQL, MySQL). Read cần throughput cao, có thể chấp nhận eventually consistent (Elasticsearch, Redis, ClickHouse). CQRS cho phép chọn storage engine phù hợp cho từng side thay vì ép cả hai vào cùng một database.

Team structure phù hợp

Khi team đủ lớn để có người chuyên backend domain logic (write side) và người chuyên API/query performance (read side), CQRS giúp hai nhóm làm việc song song mà ít conflict. Một người thay đổi business rule ở command handler, người khác tối ưu query handler và read model index, không chạm code của nhau.


Khi nào CQRS là over-engineering

CRUD đơn giản

Ứng dụng quản lý nội bộ, admin panel, CMS nhỏ, entity ít, business rule đơn giản, traffic thấp. Mô hình CRUD truyền thống (một model, một database, một repository) đã đủ. Thêm CQRS vào đây là thêm boilerplate code, thêm abstraction layer, mà không nhận lại lợi ích gì. Mỗi entity giờ cần command handler, query handler, có thể cần projector, cho một thứ mà trước đó chỉ cần một controller method.

Team nhỏ, early-stage

Startup 3 người, product chưa rõ direction, domain model thay đổi liên tục. Mỗi lần thay đổi schema write model, phải sửa projection, sửa read model, sửa query handler, effort nhân ba so với CRUD. Ở giai đoạn này, tốc độ iterate quan trọng hơn kiến trúc hoàn hảo. Dùng CQRS khi bạn đã có đủ traffic và complexity để justify, không phải ngày đầu.

Strong consistency là bắt buộc ở mọi nơi

Nếu business requirement yêu cầu mọi read phải trả dữ liệu mới nhất tuyệt đối, ví dụ hệ thống giao dịch tài chính real-time nơi stale balance là vi phạm pháp luật, thì CQRS với eventual consistency không phù hợp. Bạn có thể dùng CQRS mức 1 (cùng DB, khác model) nhưng lợi ích giảm đáng kể vì mất khả năng tách DB và scale độc lập.

Không ai maintain projection

Projection không phải “set and forget”. Khi write model schema thay đổi (thêm field, đổi type), projection phải cập nhật theo. Khi read model cần field mới, projection phải backfill. Khi projection lag hoặc lỗi, cần monitoring và alert. Nếu team không có bandwidth để maintain projection pipeline, monitoring, on-call cho projection lag, debug data inconsistency giữa write và read model, thì CQRS với separate DB sẽ sớm trở thành nguồn incident.


CQRS và Event Sourcing

Hai pattern này hay đi cùng nhau vì Event Sourcing tạo ra stream of events một cách tự nhiên, và events đó chính là input lý tưởng cho projection. Write side lưu event (OrderPlaced, ItemAdded, OrderCancelled), projection consume event stream và build read model. Khi cần thay đổi read model, replay event từ đầu.

Nhưng Event Sourcing thêm complexity đáng kể mà không phải lúc nào cũng cần. Event schema evolution, khi event format thay đổi theo thời gian, là bài toán khó. Snapshot management, để tránh replay hàng triệu event mỗi lần load aggregate, thêm code và storage. Debugging khó hơn vì trạng thái hiện tại không tồn tại explicit mà phải derive từ event stream.

Lời khuyên thực tế: bắt đầu CQRS với write model thông thường (PostgreSQL, UPDATE/INSERT). Nếu sau đó bạn cần audit trail chi tiết, khả năng “what-if” replay, hoặc nhiều bounded context cần consume cùng event stream, lúc đó mới cân nhắc Event Sourcing cho write side. Không nhảy vào Event Sourcing chỉ vì “CQRS hay đi kèm Event Sourcing”. Mỗi thứ giải quyết bài toán riêng.


Triển khai thực tế, bắt đầu từ đâu

Bước một: tách handler trong cùng codebase

Không cần hai database, không cần message queue. Chỉ cần tổ chức code: thư mục commands/ chứa command handler, thư mục queries/ chứa query handler. Command handler gọi domain model, persist qua repository. Query handler chạy SQL trực tiếp, trả DTO. Cùng database, cùng deploy, cùng process.

Bước này đã cho lợi ích: query handler không bị domain model “cản trở”, bạn viết SQL tối ưu mà không cần map qua ORM entity. Command handler tập trung vào business logic mà không bị query requirement ảnh hưởng. Code dễ navigate hơn: muốn biết business rule, vào commands/. Muốn tối ưu query, vào queries/.

Bước hai: thêm read model denormalized

Khi query bắt đầu chậm vì JOIN phức tạp trên schema normalize, tạo bảng (hoặc materialized view) denormalized cho query cụ thể. Update bảng này đồng bộ, trigger, hoặc application code trong command handler sau khi ghi write model. Vẫn cùng database, vẫn strong consistency.

Đây là lúc phần lớn ứng dụng nên dừng lại nếu performance đã đủ tốt. Materialized view hoặc bảng denormalized trong cùng PostgreSQL giải quyết hầu hết query performance issue mà không cần architecture phức tạp hơn.

Bước ba: tách database khi thực sự cần

Khi bảng denormalized trong cùng DB không đủ, vì cần Elasticsearch cho search, ClickHouse cho analytics, hoặc read replica không scale đủ, lúc đó mới tách. Thêm CDC hoặc event bus, viết projector, setup monitoring cho projection lag, xử lý idempotency, design cho eventual consistency.

Mỗi bước tăng complexity đáng kể. Đừng nhảy bước. Mình từng thấy team setup Kafka + Elasticsearch + custom projector cho ứng dụng mà PostgreSQL với vài index và materialized view đã đủ nhanh. Kết quả: 6 tháng đầu dành cho infrastructure thay vì tính năng, projection bug liên tục, data inconsistency phải debug hàng tuần. Rollback về “CQRS mức 1” tiết kiệm cả team hàng trăm giờ.


Anti-pattern phổ biến

CQRS mọi nơi

Không phải mọi bounded context đều cần CQRS. Service quản lý user profile, CRUD đơn giản, traffic vừa, query không phức tạp, dùng CQRS là overkill. Service checkout, business rule phức tạp, query dashboard nặng, read/write ratio lệch, CQRS hợp lý. Áp dụng CQRS có chọn lọc theo bounded context, không phải blanket policy cho toàn hệ thống.

Ignore eventual consistency trên UI

Team triển khai CQRS với separate read DB, nhưng frontend code vẫn giả định strong consistency, submit form xong redirect sang list page, expect dữ liệu mới xuất hiện ngay. User thấy dữ liệu cũ, báo bug, developer đổ lỗi “eventual consistency mà”. Eventual consistency là đặc tính kỹ thuật, nhưng UI phải xử lý nó, optimistic update, loading state, read-your-own-writes, không phải đẩy cho user chấp nhận.

Query handler có business logic

Query handler bắt đầu có if kiểm tra quyền, tính toán field, filter theo business rule, dần dần nó trở thành bản copy của domain logic trong command handler. Khi business rule thay đổi, phải sửa hai nơi. Giải pháp: precompute mọi thứ vào read model ở projection. Query handler chỉ SELECT và trả về, không logic, không transform, không filter phức tạp. Nếu read model thiếu field, fix ở projection, không phải ở query handler.

Projection không có monitoring

Projection lag 5 phút mà không ai biết cho đến khi user phàn nàn “data cũ”. Projection fail silent, consumer crash, restart, miss vài event, read model lệch mà không alert. Cần metric cho projection: lag time (thời gian từ event publish đến event processed), error rate, throughput. Alert khi lag vượt SLO. Đây không phải “nice to have”, thiếu monitoring cho projection thì bạn đang vận hành một hệ thống mà bạn không biết nó có đang đúng hay không.

Dual write không có outbox

Command handler ghi write DB thành công rồi publish event qua message broker, nhưng nếu publish fail sau khi DB commit, event mất, read model không bao giờ cập nhật. Pattern Transactional Outbox giải quyết: thay vì publish trực tiếp, command handler ghi event vào bảng outbox trong cùng transaction với write DB. Một process riêng (poller hoặc CDC trên bảng outbox) đọc outbox và publish ra message broker. Không mất event vì event nằm trong cùng transaction với business data.


CQRS có lợi khi write model và read model có yêu cầu thực sự trái ngược nhau, đừng áp dụng chỉ vì nghe tên ngầu. Bắt đầu với cùng database khác handler, lên mức tách database chỉ khi performance thực sự cần và team có bandwidth maintain projection pipeline; thiếu monitoring projection là vận hành mù.