TDSoftware
TDSoftware
Innovation Hub
CQRS Pattern: Hướng Dẫn Đầy Đủ Cho Developers

CQRS Pattern: Hướng Dẫn Đầy Đủ Cho Developers

15 phút đọc

CQRS Là Gì?

CQRS (Command Query Responsibility Segregation) là một design pattern tách biệt các thao tác đọc (Queries) khỏi các thao tác ghi (Commands) thành các model riêng biệt. Thay vì dùng một model cho cả đọc và ghi dữ liệu, CQRS chia ứng dụng thành hai phần riêng biệt.

Nguyên Tắc Cốt Lõi:

  • **Commands**: Thay đổi trạng thái của ứng dụng (Tạo, Sửa, Xóa)
  • **Queries**: Lấy dữ liệu mà không thay đổi gì (Thao tác đọc)

Nghĩ về nó như một nhà hàng:

  • **Command side** = Bếp (chuẩn bị món ăn, thay đổi kho)
  • **Query side** = Menu hiển thị (hiển thị món có sẵn, không thay đổi gì)

Tại Sao Cần CQRS?

Vấn Đề Với Kiến Trúc Truyền Thống

Trong ứng dụng truyền thống, chúng ta dùng cùng một model cho cả đọc và ghi:

// Cách tiếp cận truyền thống - cùng model cho mọi thứ
public class ProductService
{
    public void CreateProduct(Product product) 
    {
        // Thao tác ghi
        _dbContext.Products.Add(product);
        _dbContext.SaveChanges();
    }
    
    public Product GetProduct(int id) 
    {
        // Thao tác đọc - cùng model
        return _dbContext.Products
            .Include(p => p.Category)
            .Include(p => p.Reviews)
            .FirstOrDefault(p => p.Id == id);
    }
}

Các Vấn Đề:

1. Vấn đề hiệu suất - Join phức tạp làm chậm việc đọc

2. Khó scale - Không thể scale đọc và ghi riêng biệt

3. Xung đột khóa - Nhiều thao tác trên cùng dữ liệu gây conflict

4. Query phức tạp - Một model phục vụ nhiều mục đích trở nên phức tạp

Kiến Trúc CQRS

Kiến Trúc Cơ Bản

┌─────────────┐
│   Client    │
└──────┬──────┘
       │
       ├─────────► Commands (Ghi)
       │           ├─ TạoĐơnHàng
       │           ├─ CậpNhậtSảnPhẩm
       │           └─ XóaNgườiDùng
       │
       └─────────► Queries (Đọc)
                   ├─ LấyĐơnHàngTheoId
                   ├─ TìmKiếmSảnPhẩm
                   └─ LấyThôngTinUser

Hai Cách Tiếp Cận Chính

1. Tách Biệt Logic (Cùng Database)

┌──────────────────────────────────┐
│        Cùng Database             │
│  ┌────────────┐  ┌────────────┐ │
│  │  Commands  │  │  Queries   │ │
│  │   Logic    │  │   Logic    │ │
│  └────────────┘  └────────────┘ │
└──────────────────────────────────┘

2. Tách Biệt Vật Lý (Database Khác Nhau)

┌─────────────┐                 ┌─────────────┐
│  Database   │    Events       │  Database   │
│  Command    │ ───────────────►│   Query     │
│   (Ghi)     │                 │   (Đọc)     │
└─────────────┘                 └─────────────┘

Ví Dụ Thực Tế: Hệ Thống Đơn Hàng E-Commerce

Cùng xây dựng một ví dụ đầy đủ sử dụng C# và MediatR.

Bước 1: Cài Đặt Packages Cần Thiết

dotnet add package MediatR
dotnet add package MediatR.Extensions.Microsoft.DependencyInjection

Bước 2: Định Nghĩa Commands

Commands đại diện cho các hành động thay đổi trạng thái.

using MediatR;

// Command để tạo đơn hàng
public class CreateOrderCommand : IRequest<CreateOrderResult>
{
    public string CustomerId { get; set; }
    public List<OrderItem> Items { get; set; }
    public string ShippingAddress { get; set; }
}

public class OrderItem
{
    public int ProductId { get; set; }
    public int Quantity { get; set; }
    public decimal Price { get; set; }
}

public class CreateOrderResult
{
    public bool Success { get; set; }
    public int OrderId { get; set; }
    public string Message { get; set; }
}

Bước 3: Tạo Command Handler

Handler chứa business logic cho command.

public class CreateOrderCommandHandler 
    : IRequestHandler<CreateOrderCommand, CreateOrderResult>
{
    private readonly IOrderRepository _orderRepository;
    private readonly IInventoryService _inventoryService;
    private readonly IEventPublisher _eventPublisher;

    public CreateOrderCommandHandler(
        IOrderRepository orderRepository,
        IInventoryService inventoryService,
        IEventPublisher eventPublisher)
    {
        _orderRepository = orderRepository;
        _inventoryService = inventoryService;
        _eventPublisher = eventPublisher;
    }

    public async Task<CreateOrderResult> Handle(
        CreateOrderCommand request, 
        CancellationToken cancellationToken)
    {
        try
        {
            // 1. Kiểm tra hàng tồn kho
            foreach (var item in request.Items)
            {
                var available = await _inventoryService
                    .CheckAvailability(item.ProductId, item.Quantity);
                
                if (!available)
                {
                    return new CreateOrderResult
                    {
                        Success = false,
                        Message = $"Sản phẩm {item.ProductId} đã hết hàng"
                    };
                }
            }

            // 2. Tính tổng tiền
            var total = request.Items.Sum(i => i.Price * i.Quantity);

            // 3. Tạo entity đơn hàng
            var order = new Order
            {
                CustomerId = request.CustomerId,
                OrderDate = DateTime.UtcNow,
                TotalAmount = total,
                ShippingAddress = request.ShippingAddress,
                Status = OrderStatus.Pending,
                Items = request.Items
            };

            // 4. Lưu vào database
            await _orderRepository.CreateAsync(order);

            // 5. Publish event để cập nhật query side
            await _eventPublisher.PublishAsync(new OrderCreatedEvent
            {
                OrderId = order.Id,
                CustomerId = order.CustomerId,
                TotalAmount = order.TotalAmount,
                CreatedAt = order.OrderDate
            });

            return new CreateOrderResult
            {
                Success = true,
                OrderId = order.Id,
                Message = "Tạo đơn hàng thành công"
            };
        }
        catch (Exception ex)
        {
            // Log lỗi
            return new CreateOrderResult
            {
                Success = false,
                Message = $"Lỗi khi tạo đơn hàng: {ex.Message}"
            };
        }
    }
}

Bước 4: Định Nghĩa Queries

Queries lấy dữ liệu mà không thay đổi gì.

// Query để lấy chi tiết đơn hàng
public class GetOrderByIdQuery : IRequest<OrderDetailsDto>
{
    public int OrderId { get; set; }
}

// DTO cho kết quả query - tối ưu cho hiển thị
public class OrderDetailsDto
{
    public int OrderId { get; set; }
    public string CustomerName { get; set; }
    public string CustomerEmail { get; set; }
    public DateTime OrderDate { get; set; }
    public string Status { get; set; }
    public decimal TotalAmount { get; set; }
    public string ShippingAddress { get; set; }
    public List<OrderItemDto> Items { get; set; }
}

public class OrderItemDto
{
    public string ProductName { get; set; }
    public int Quantity { get; set; }
    public decimal Price { get; set; }
    public decimal Subtotal { get; set; }
}

Bước 5: Tạo Query Handler

Query handlers lấy dữ liệu hiệu quả mà không có business logic.

public class GetOrderByIdQueryHandler 
    : IRequestHandler<GetOrderByIdQuery, OrderDetailsDto>
{
    private readonly IReadOnlyDbContext _readDbContext;
    // Hoặc dùng Dapper cho raw SQL queries

    public GetOrderByIdQueryHandler(IReadOnlyDbContext readDbContext)
    {
        _readDbContext = readDbContext;
    }

    public async Task<OrderDetailsDto> Handle(
        GetOrderByIdQuery request, 
        CancellationToken cancellationToken)
    {
        // Câu SQL trực tiếp để hiệu suất tốt hơn
        var query = @"
            SELECT 
                o.OrderId,
                o.OrderDate,
                o.Status,
                o.TotalAmount,
                o.ShippingAddress,
                c.Name as CustomerName,
                c.Email as CustomerEmail,
                oi.ProductName,
                oi.Quantity,
                oi.Price,
                (oi.Quantity * oi.Price) as Subtotal
            FROM Orders o
            INNER JOIN Customers c ON o.CustomerId = c.CustomerId
            INNER JOIN OrderItems oi ON o.OrderId = oi.OrderId
            WHERE o.OrderId = @OrderId";

        // Dùng Dapper để lấy dữ liệu hiệu quả
        using var connection = _readDbContext.CreateConnection();
        
        var orderDictionary = new Dictionary<int, OrderDetailsDto>();
        
        var result = await connection.QueryAsync<OrderDetailsDto, OrderItemDto, OrderDetailsDto>(
            query,
            (order, item) =>
            {
                if (!orderDictionary.TryGetValue(order.OrderId, out var orderEntry))
                {
                    orderEntry = order;
                    orderEntry.Items = new List<OrderItemDto>();
                    orderDictionary.Add(order.OrderId, orderEntry);
                }
                
                orderEntry.Items.Add(item);
                return orderEntry;
            },
            new { OrderId = request.OrderId },
            splitOn: "ProductName"
        );

        return orderDictionary.Values.FirstOrDefault();
    }
}

Bước 6: Setup Trong API Controller

[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
    private readonly IMediator _mediator;

    public OrdersController(IMediator mediator)
    {
        _mediator = mediator;
    }

    // Command endpoint - Thao tác ghi
    [HttpPost]
    public async Task<IActionResult> CreateOrder(
        [FromBody] CreateOrderCommand command)
    {
        var result = await _mediator.Send(command);
        
        if (result.Success)
        {
            return Ok(result);
        }
        
        return BadRequest(result.Message);
    }

    // Query endpoint - Thao tác đọc
    [HttpGet("{id}")]
    public async Task<IActionResult> GetOrder(int id)
    {
        var query = new GetOrderByIdQuery { OrderId = id };
        var result = await _mediator.Send(query);
        
        if (result == null)
        {
            return NotFound();
        }
        
        return Ok(result);
    }
}

Bước 7: Cấu Hình Services

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        // Thêm MediatR
        services.AddMediatR(cfg => 
            cfg.RegisterServicesFromAssembly(typeof(Startup).Assembly));
        
        // Thêm repositories
        services.AddScoped<IOrderRepository, OrderRepository>();
        
        // Thêm database contexts
        services.AddDbContext<WriteDbContext>(options =>
            options.UseSqlServer(Configuration.GetConnectionString("WriteDb")));
        
        services.AddDbContext<ReadDbContext>(options =>
            options.UseSqlServer(Configuration.GetConnectionString("ReadDb")));
        
        // Thêm các services khác
        services.AddScoped<IInventoryService, InventoryService>();
        services.AddScoped<IEventPublisher, EventPublisher>();
    }
}

Đồng Bộ Read và Write Models

Sử Dụng Event-Driven Communication

// Định nghĩa event
public class OrderCreatedEvent
{
    public int OrderId { get; set; }
    public string CustomerId { get; set; }
    public decimal TotalAmount { get; set; }
    public DateTime CreatedAt { get; set; }
}

// Event handler để cập nhật read model
public class OrderCreatedEventHandler : INotificationHandler<OrderCreatedEvent>
{
    private readonly IReadModelRepository _readModelRepository;

    public OrderCreatedEventHandler(IReadModelRepository readModelRepository)
    {
        _readModelRepository = readModelRepository;
    }

    public async Task Handle(
        OrderCreatedEvent notification, 
        CancellationToken cancellationToken)
    {
        // Cập nhật read model (denormalized view)
        var orderView = new OrderReadModel
        {
            OrderId = notification.OrderId,
            CustomerId = notification.CustomerId,
            TotalAmount = notification.TotalAmount,
            CreatedAt = notification.CreatedAt,
            LastUpdated = DateTime.UtcNow
        };

        await _readModelRepository.UpsertAsync(orderView);
    }
}

Các Use Case Thực Tế

1. Nền Tảng E-Commerce

Command Side:

  • Xử lý đơn hàng
  • Cập nhật kho
  • Xử lý thanh toán

Query Side:

  • Hiển thị catalog sản phẩm
  • Hiển thị lịch sử đơn hàng
  • Tạo báo cáo

2. Hệ Thống Ngân Hàng

Command Side:

  • Chuyển tiền
  • Tạo tài khoản
  • Xử lý giao dịch

Query Side:

  • Xem số dư tài khoản
  • Hiển thị lịch sử giao dịch
  • Tạo sao kê

3. Nền Tảng Mạng Xã Hội

Command Side:

  • Tạo bài viết
  • Thêm comment
  • Like/share nội dung

Query Side:

  • Hiển thị news feed
  • Tìm kiếm bài viết
  • Hiển thị profile người dùng

Best Practices

✅ Nên Làm

1. Giữ Commands Đơn Giản

// Tốt - Ý định rõ ràng
public class ActivateUserCommand : IRequest<bool>
{
    public int UserId { get; set; }
}

// Tránh - Quá chung chung
public class UpdateUserCommand : IRequest<bool>
{
    public int UserId { get; set; }
    public Dictionary<string, object> Changes { get; set; }
}

2. Tối Ưu Query Models

// Tốt - Denormalized để đọc nhanh
public class ProductListDto
{
    public int Id { get; set; }
    public string Name { get; set; }
    public decimal Price { get; set; }
    public string CategoryName { get; set; } // Denormalized
    public int ReviewCount { get; set; } // Pre-calculated
    public double AverageRating { get; set; } // Pre-calculated
}

3. Dùng Async Communication

// Tốt - Non-blocking
public async Task<Result> Handle(
    UpdateInventoryCommand request, 
    CancellationToken cancellationToken)
{
    await _repository.UpdateAsync(request);
    
    // Publish event bất đồng bộ
    await _eventBus.PublishAsync(new InventoryUpdatedEvent
    {
        ProductId = request.ProductId,
        NewQuantity = request.Quantity
    });
    
    return Result.Success();
}

❌ Không Nên Làm

1. Không Trộn Lẫn Responsibilities

// Không tốt - Query không nên sửa dữ liệu
public class GetUserQuery : IRequest<UserDto>
{
    public int UserId { get; set; }
    public bool UpdateLastAccess { get; set; } // Sai!
}

2. Không Over-Normalize Read Models

// Không tốt - Cần quá nhiều joins
public class OrderDto
{
    public int OrderId { get; set; }
    public int CustomerId { get; set; } // Cần join
    public int ProductId { get; set; } // Cần join
    // Nên include denormalized data thay vì
}

3. Không Dùng CQRS Cho CRUD Đơn Giản

// Quá phức tạp cho thao tác đơn giản
public class GetUserByIdQuery : IRequest<User>
{
    public int Id { get; set; }
}

// Chỉ cần dùng:
var user = await _dbContext.Users.FindAsync(id);

Khi Nào Nên Dùng CQRS

✅ Phù Hợp

1. Domain Logic Phức Tạp

  • Nhiều business rules
  • Validation khác nhau cho đọc và ghi

2. Tỷ Lệ Đọc/Ghi Cao

  • Đọc nhiều hơn ghi rất nhiều
  • Cần scale độc lập

3. Performance Quan Trọng

  • Cần queries tối ưu
  • Yêu cầu scaling khác nhau

4. Hệ Thống Cộng Tác

  • Nhiều users làm việc trên cùng dữ liệu
  • Cần ngăn conflicts

❌ Không Phù Hợp

1. Ứng Dụng CRUD Đơn Giản

  • Chỉ cơ bản create/read/update/delete
  • Không có business logic phức tạp

2. Ứng Dụng Traffic Thấp

  • User base nhỏ
  • Không cần scaling

3. Yêu Cầu Consistency Nghiêm Ngặt

  • Giao dịch ngân hàng (một số trường hợp)
  • Hệ thống real-time quan trọng

Các Lỗi Thường Gặp Và Giải Pháp

Lỗi 1: Eventual Consistency Issues

Vấn Đề:

// User tạo đơn hàng
await _mediator.Send(new CreateOrderCommand { ... });

// Ngay lập tức đọc nó - có thể chưa có!
var order = await _mediator.Send(new GetOrderByIdQuery { OrderId = newId });
// order có thể null do replication lag

Giải Pháp:

// Trả về data đã tạo trực tiếp từ command
public class CreateOrderResult
{
    public bool Success { get; set; }
    public int OrderId { get; set; }
    public OrderDetailsDto Order { get; set; } // Include chi tiết đầy đủ
}

// Hoặc dùng correlation IDs để track sync status
public class CreateOrderCommand : IRequest<CreateOrderResult>
{
    public Guid CorrelationId { get; set; } = Guid.NewGuid();
}

Lỗi 2: Over-Engineering

Vấn Đề: Dùng CQRS cho mọi thứ, kể cả thao tác đơn giản.

Giải Pháp: Chỉ dùng CQRS khi nó mang lại giá trị:

// Thao tác đơn giản - bỏ qua CQRS
public class UserService
{
    public async Task<User> GetByIdAsync(int id)
    {
        return await _context.Users.FindAsync(id);
    }
}

// Thao tác phức tạp - dùng CQRS
public class GetUserDashboardQuery : IRequest<DashboardDto>
{
    public int UserId { get; set; }
}

Lỗi 3: Bỏ Qua Error Handling

Vấn Đề: Không xử lý lỗi đồng bộ.

Giải Pháp:

public class OrderCreatedEventHandler : INotificationHandler<OrderCreatedEvent>
{
    private readonly ILogger<OrderCreatedEventHandler> _logger;
    private readonly IReadModelRepository _repository;

    public async Task Handle(OrderCreatedEvent notification, CancellationToken ct)
    {
        try
        {
            await _repository.UpsertAsync(notification);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Không cập nhật được read model cho đơn {OrderId}", 
                notification.OrderId);
            
            // Implement retry logic hoặc dead letter queue
            throw; // Re-throw để trigger retry mechanism
        }
    }
}

Lợi Ích Và Thách Thức

Lợi Ích

Scale Độc Lập - Scale đọc và ghi riêng biệt

Hiệu Suất Tối Ưu - Models khác nhau cho nhu cầu khác nhau

Tách Biệt Rõ Ràng - Dễ hiểu và maintain hơn

Data Models Linh Hoạt - Dùng database tốt nhất cho mỗi side

Security Tốt Hơn - Kiểm soát access vào write operations

Thách Thức

Tăng Độ Phức Tạp - Nhiều code và infrastructure hơn

Eventual Consistency - Dữ liệu có thể tạm thời out of sync

Learning Curve - Team cần hiểu pattern

Nhiều Infrastructure Hơn - Có thể cần message queues, multiple databases

Tổng Kết

CQRS là pattern mạnh mẽ để xây dựng ứng dụng scalable, high-performance. Các điểm chính:

1. Tách đọc khỏi ghi để tối ưu tốt hơn

2. Dùng commands cho thay đổi state với business logic

3. Dùng queries cho lấy dữ liệu không có side effects

4. Áp dụng chọn lọc - không phải app nào cũng cần CQRS

5. Xử lý eventual consistency với UI/UX design phù hợp

6. Bắt đầu đơn giản với logical separation trước khi physical separation

Nhớ rằng: CQRS là công cụ, không phải giải pháp vạn năng. Chỉ dùng khi lợi ích vượt trội độ phức tạp thêm vào.

Tài Nguyên Thêm

  • Microsoft Docs: CQRS Pattern
  • Greg Young: CQRS Documents
  • Martin Fowler: CQRS Pattern
  • MediatR Documentation

Bài Viết Liên Quan

No image
Test Post