CQRS từ A đến Z (Command Query Responsibility Segregation)
Admin · Cập nhật 19/07/2026
CQRS từ A đến Z: tách Command/Query, kiến trúc tổng quan, kết hợp Clean Architecture và Event Sourcing, ưu nhược điểm cùng best practices và anti-pattern.
Chuyên mục: Software Architecture
Mục lục
- CQRS là gì?
- Bài toán CQRS giải quyết
- Command và Query
- Kiến trúc tổng quan
- Ví dụ thực tế
- Áp dụng với Clean Architecture
- CQRS và Event Sourcing
- Ưu điểm
- Nhược điểm
- Best Practices
- Anti-Patterns
- Checklist
- FAQ
CQRS là gì?
CQRS (Command Query Responsibility Segregation) là một mẫu kiến trúc tách thao tác ghi dữ liệu (Command) và thao tác đọc dữ liệu (Query) thành hai luồng độc lập.
Ý tưởng cốt lõi:
- Command: Thay đổi trạng thái hệ thống (Create, Update, Delete).
- Query: Chỉ đọc dữ liệu, không làm thay đổi trạng thái.
Điều này giúp tối ưu hóa từng luồng theo nhu cầu thực tế.
Bài toán
Trong nhiều hệ thống lớn:
- Ghi dữ liệu cần kiểm tra nghiệp vụ, transaction và bảo toàn tính nhất quán.
- Đọc dữ liệu cần tốc độ cao, có thể tổng hợp từ nhiều nguồn.
Nếu sử dụng cùng một mô hình cho cả đọc và ghi, mã nguồn sẽ ngày càng phức tạp và khó mở rộng.
Command và Query
Command
Ví dụ:
- CreateUser
- UpdateProfile
- DeletePost
- TransferMoney
Command:
- Có thể thay đổi dữ liệu.
- Thường không trả về đối tượng đầy đủ.
- Có thể chạy trong transaction.
Query
Ví dụ:
- GetUserById
- SearchPost
- GetDashboardStatistics
Query:
- Chỉ đọc dữ liệu.
- Không được thay đổi trạng thái.
- Có thể tối ưu bằng cache hoặc read model.
Kiến trúc tổng quan
Presentation
/ \
/ \
Command API Query API
| |
Command Handler Query Handler
| |
Domain Read Database
|
Write Database
Ví dụ
Command
await createUserHandler.execute(command);
Query
const profile = await getUserProfileHandler.execute(query);
Chi tiết một Command Handler
Command là một đối tượng dữ liệu mô tả ý định thay đổi hệ thống, còn Handler chứa logic thực thi ý định đó. Tách hai phần này giúp mỗi hành động ghi được đóng gói rõ ràng và dễ kiểm thử:
// Command: chỉ là dữ liệu, không chứa logic
export interface CreateUserCommand {
email: string;
fullName: string;
password: string;
}
// Handler: thực thi command, dùng Repository để ghi
export class CreateUserHandler {
constructor(private userRepository: UserRepository) {}
async execute(command: CreateUserCommand): Promise<{ id: string }> {
const existing = await this.userRepository.findByEmail(command.email);
if (existing) {
throw new Error("Email đã tồn tại");
}
const passwordHash = await hashPassword(command.password);
const user = await this.userRepository.create({
email: command.email,
fullName: command.fullName,
passwordHash,
});
// Command thường chỉ trả về định danh, không trả về toàn bộ đối tượng
return { id: user.id };
}
}
Chi tiết một Query Handler
Query Handler không đi qua Domain phức tạp mà thường đọc thẳng dữ liệu và trả về đúng hình dạng (shape) mà giao diện cần — gọi là Read Model. Read Model có thể gộp nhiều bảng, bỏ qua các trường không dùng để tối ưu tốc độ:
export interface GetUserProfileQuery {
userId: string;
}
export interface UserProfileReadModel {
id: string;
fullName: string;
email: string;
totalOrders: number;
}
export class GetUserProfileHandler {
constructor(private prisma: PrismaClient) {}
async execute(query: GetUserProfileQuery): Promise<UserProfileReadModel | null> {
const user = await this.prisma.user.findUnique({
where: { id: query.userId },
select: {
id: true,
fullName: true,
email: true,
_count: { select: { orders: true } },
},
});
if (!user) return null;
return {
id: user.id,
fullName: user.fullName,
email: user.email,
totalOrders: user._count.orders,
};
}
}
Lưu ý sự khác biệt: Command đi qua Repository và có thể kèm quy tắc nghiệp vụ, còn Query đọc trực tiếp và tối ưu cho hiển thị.
CQRS kết hợp Clean Architecture
Presentation
│
Application
├── Commands
├── Queries
├── Handlers
│
Domain
│
Infrastructure
Command Handler sử dụng Repository để ghi dữ liệu.
Query Handler có thể đọc trực tiếp từ database hoặc read model.
Ví dụ thực tế với Next.js + Prisma
Trong một dự án Next.js (App Router), bạn có thể áp dụng CQRS mà không cần hạ tầng phức tạp: chỉ cần tách các Route Handler ghi và đọc thành hai họ Command/Query dùng chung một cơ sở dữ liệu.
Route Handler cho phía ghi (POST) gọi Command Handler:
// app/api/users/route.ts
import { NextResponse } from "next/server";
import { z } from "zod";
const schema = z.object({
email: z.string().email(),
fullName: z.string().min(1),
password: z.string().min(6),
});
export async function POST(request: Request) {
const body = await request.json();
const parsed = schema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(parsed.error.flatten(), { status: 400 });
}
try {
const result = await createUserHandler.execute(parsed.data);
return NextResponse.json(result, { status: 201 });
} catch {
return NextResponse.json({ message: "Lỗi server" }, { status: 500 });
}
}
Route Handler cho phía đọc (GET) gọi Query Handler và trả về Read Model đã tối ưu:
// app/api/users/[id]/route.ts
import { NextResponse } from "next/server";
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
const profile = await getUserProfileHandler.execute({ userId: id });
if (!profile) {
return NextResponse.json({ message: "Không tìm thấy" }, { status: 404 });
}
return NextResponse.json(profile);
}
Ở giai đoạn đầu, cả hai phía dùng chung một database — bạn vẫn hưởng lợi từ việc code đọc và ghi tách bạch, dễ đọc và dễ kiểm thử. Khi lưu lượng đọc tăng mạnh, bạn có thể chuyển các Query sang một Read Replica hoặc một read model được đánh chỉ mục riêng mà không cần đụng đến phía Command.
Command và Query khác nhau thế nào?
| Tiêu chí | Command (ghi) | Query (đọc) |
|---|---|---|
| Mục đích | Thay đổi trạng thái | Đọc dữ liệu |
| Có thể sửa dữ liệu? | Có | Không (phải là read-only) |
| Đi qua Domain/Repository | Thường có | Có thể đọc thẳng DB |
| Giá trị trả về | Định danh hoặc void | Read Model đầy đủ cho UI |
| Transaction | Cần khi ghi nhiều bảng | Không cần |
| Tối ưu bằng cache | Hiếm khi | Rất thường xuyên |
CQRS và Event Sourcing
CQRS không bắt buộc phải dùng Event Sourcing.
Tuy nhiên hai mô hình thường được kết hợp:
- Command sinh Event.
- Event lưu vào Event Store.
- Read Model được cập nhật từ Event.
Điều này rất phù hợp với các hệ thống cần lịch sử thay đổi đầy đủ.
Ưu điểm
- Tách biệt đọc và ghi rõ ràng.
- Dễ mở rộng từng luồng.
- Tối ưu hiệu năng truy vấn.
- Phù hợp hệ thống lớn.
Nhược điểm
- Kiến trúc phức tạp hơn CRUD truyền thống.
- Tăng số lượng lớp và Handler.
- Có thể phải đồng bộ dữ liệu giữa Write Model và Read Model.
Khi nào nên dùng?
- Hệ thống có lượng đọc lớn.
- Nghiệp vụ phức tạp.
- Ứng dụng tài chính.
- Thương mại điện tử.
- AI SaaS nhiều dashboard.
Khi nào không nên dùng?
- MVP nhỏ.
- CRUD đơn giản.
- Ứng dụng nội bộ ít nghiệp vụ.
Best Practices
- Một Command chỉ thực hiện một hành động.
- Một Query chỉ phục vụ một nhu cầu đọc.
- Không ghi dữ liệu trong Query.
- Không đọc dữ liệu phức tạp trong Command nếu không cần thiết.
- Kết hợp Repository Pattern và Unit of Work cho Command.
Anti-Patterns
- Dùng CQRS cho mọi dự án.
- Một Handler xử lý quá nhiều nhiệm vụ.
- Query làm thay đổi dữ liệu.
- Command trả về toàn bộ đối tượng.
Sai lầm thường gặp
- Áp dụng CQRS cho CRUD đơn giản. Với một form quản lý danh mục ít nghiệp vụ, việc dựng Command/Query/Handler chỉ làm phình số lớp mà không mang lại lợi ích. CQRS chỉ đáng giá khi mô hình đọc và ghi thực sự khác nhau.
- Để Query ghi dữ liệu. Một Query "tiện tay" cập nhật
lastViewedAtsẽ phá vỡ giả định read-only, khiến việc cache hay chuyển sang read replica trở nên nguy hiểm. Mọi thay đổi trạng thái phải đi qua Command. - Nhồi nhiều hành động vào một Command. Command
UpdateUservừa đổi mật khẩu, vừa gửi email, vừa cập nhật vai trò sẽ khó test và khó tái sử dụng. Mỗi Command nên thể hiện một ý định nghiệp vụ rõ ràng. - Dùng chung một model cho cả đọc và ghi. Nếu Query trả về nguyên Entity của Domain (kèm các phương thức nghiệp vụ), tầng giao diện sẽ phụ thuộc vào Domain và khó tối ưu. Hãy trả về Read Model phẳng, chỉ chứa dữ liệu cần hiển thị.
- Quên đồng bộ Read Model khi tách database. Nếu bạn tách Read Database nhưng không cập nhật nó sau mỗi Command, người dùng sẽ thấy dữ liệu cũ. Phải có cơ chế đồng bộ (event, trigger, hoặc materialized view) và chấp nhận tính nhất quán cuối (eventual consistency).
- Coi CQRS đồng nghĩa với hai database. Nhiều đội nghĩ phải có hai DB mới là CQRS nên bỏ cuộc sớm. Thực tế bạn có thể bắt đầu với một database duy nhất và chỉ tách khi thật cần.
Checklist
- Command và Query được tách riêng.
- Handler chỉ có một trách nhiệm.
- Query không thay đổi dữ liệu.
- Command có transaction khi cần.
- Read Model được tối ưu cho truy vấn.
FAQ
CQRS có bắt buộc dùng hai cơ sở dữ liệu?
Không. Nhiều hệ thống sử dụng cùng một cơ sở dữ liệu nhưng tách logic đọc và ghi. Khi quy mô tăng, có thể tách Read Database để tối ưu hiệu năng.
CQRS có thay thế MVC không?
Không. CQRS là cách tổ chức logic nghiệp vụ. MVC là mô hình tổ chức giao diện và luồng xử lý ở tầng trình bày. Hai mô hình có thể kết hợp với nhau.
CQRS có bắt buộc dùng thư viện MediatR hay một Message Bus không?
Không. Ở nhiều dự án JavaScript/TypeScript, bạn chỉ cần các class Handler thuần và tự gọi chúng. Message Bus hay các thư viện điều phối chỉ hữu ích khi bạn muốn thêm middleware (logging, validation, transaction) chung cho mọi Handler, nhưng chúng không phải điều kiện bắt buộc của CQRS.
Read Model bị "cũ" (stale) so với Write Model thì xử lý sao?
Nếu dùng chung một database, dữ liệu luôn nhất quán ngay lập tức. Nếu tách Read Database, bạn phải chấp nhận eventual consistency: Read Model được cập nhật sau một khoảng trễ. Hãy thiết kế giao diện để dung nạp độ trễ nhỏ này (ví dụ hiển thị trạng thái "đang xử lý") và chọn cơ chế đồng bộ phù hợp với yêu cầu độ tươi của dữ liệu.
Có thể áp dụng CQRS dần dần không?
Có, và đây là cách được khuyến nghị. Bạn có thể chỉ tách những phần "nóng" như dashboard đọc nhiều hoặc nghiệp vụ ghi phức tạp, trong khi các module CRUD đơn giản vẫn giữ nguyên. Không cần chuyển toàn bộ hệ thống sang CQRS cùng lúc.
Kết luận
CQRS là lựa chọn mạnh mẽ cho các hệ thống có nghiệp vụ phức tạp hoặc lưu lượng đọc cao. Tuy nhiên, với các ứng dụng nhỏ, việc áp dụng CQRS có thể làm tăng độ phức tạp không cần thiết. Hãy cân nhắc quy mô và yêu cầu thực tế trước khi áp dụng.