Back to blog

CQRS & Event Sourcing: Tách Biệt Read Khỏi Write

software-architecturecqrsevent-sourcingbackendsystem-design
CQRS & Event Sourcing: Tách Biệt Read Khỏi Write

Phần lớn ứng dụng dùng cùng data model cho cả đọc và ghi. Cùng entity Order được dùng để tạo order, cập nhật order, và query order. Cùng bảng database phục vụ cả trang checkout và analytics dashboard.

Điều này hoạt động tốt cho ứng dụng CRUD đơn giản. Nhưng khi hệ thống phát triển, read và write phát triển yêu cầu khác nhau cơ bản. Write cần validation nghiêm ngặt và transactional consistency. Read cần query nhanh tối ưu cho view cụ thể. Write ít nhưng phức tạp. Read nhiều nhưng đơn giản. Scale chúng cùng cách là lãng phí.

CQRS (Command Query Responsibility Segregation) tách biệt các mối quan tâm này thành model riêng biệt. Event Sourcing đi xa hơn bằng cách lưu mọi thay đổi state dưới dạng event bất biến, biến event log thành source of truth thay vì current state.

Trong bài viết này, chúng ta sẽ đề cập:

✅ Vấn đề với single read/write model
✅ CQRS giải thích: tách command và query model
✅ Tại sao CQRS: lợi ích scaling, optimization, và security
✅ Event Sourcing cơ bản: event là source of truth
✅ Event store và replay event
✅ Projection và rebuild read model
✅ Snapshot cho performance
✅ CQRS không có Event Sourcing (và ngược lại)
✅ Thách thức eventual consistency
✅ Triển khai thực tế với Spring Boot
✅ Khi nào CQRS thêm complexity không cần thiết


Vấn Đề: Một Model Cai Trị Tất Cả

Trong ứng dụng CRUD truyền thống, cùng model xử lý mọi thứ:

// Cùng entity cho read và write
@Entity
public class Order {
    @Id private Long id;
    private String customerId;
    private OrderStatus status;
    private BigDecimal totalAmount;
 
    @OneToMany(cascade = CascadeType.ALL)
    private List<OrderItem> items;
 
    @OneToMany(cascade = CascadeType.ALL)
    private List<StatusChange> statusHistory;
 
    @OneToMany(cascade = CascadeType.ALL)
    private List<Payment> payments;
}

Điều này tạo ra nhiều vấn đề khi hệ thống phát triển:

1. Nhu cầu optimization xung đột

Trang checkout cần write nhanh với validation overhead tối thiểu. Admin dashboard cần aggregation phức tạp trên hàng triệu order. Tối ưu database cho cái này làm hại cái kia.

2. Model phình to

Write model cần validation rule, business logic, và state transition. Read model cần computed field, joined data, và denormalized view. Nhét cả hai vào một entity tạo ra God object phình to.

3. Scaling lệch

Read traffic thường gấp 10-100 lần write traffic. Nhưng với single model, bạn scale cả hai cùng nhau — thêm read replica đòi hỏi cùng schema như write database.

4. Lo ngại security

Write model có field nhạy cảm (chi tiết thanh toán, status nội bộ). Read model cho public API chỉ nên expose field an toàn. Với một model, bạn dựa vào lọc field cẩn thận thay vì tách biệt cấu trúc.


CQRS: Command Query Responsibility Segregation

CQRS giải quyết các vấn đề này bằng cách chia ứng dụng thành hai phía: command side (write) và query side (read).

Command Side (Write)

Command side xử lý mọi thay đổi state. Nó enforce business rule, validate input, và persist kết quả.

// Command — yêu cầu thay đổi state
public record PlaceOrderCommand(
    String customerId,
    List<OrderItemRequest> items,
    String shippingAddress
) {}
 
// Command handler — enforce business rule
@Service
public class OrderCommandHandler {
 
    private final OrderRepository orderRepository;
    private final InventoryClient inventoryClient;
    private final EventPublisher eventPublisher;
 
    public String handle(PlaceOrderCommand command) {
        // 1. Validate business rule
        if (command.items().isEmpty()) {
            throw new ValidationException("Order phải có ít nhất một item");
        }
 
        // 2. Kiểm tra tồn kho
        for (OrderItemRequest item : command.items()) {
            if (!inventoryClient.isAvailable(item.productId(), item.quantity())) {
                throw new InsufficientInventoryException(item.productId());
            }
        }
 
        // 3. Tạo và persist order (write model)
        Order order = Order.create(command);
        orderRepository.save(order);
 
        // 4. Publish event để read side cập nhật
        eventPublisher.publish(new OrderPlacedEvent(
            order.getId(),
            order.getCustomerId(),
            order.getItems(),
            order.getTotalAmount()
        ));
 
        return order.getId();
    }
}

Đặc điểm chính của command side:

  • Domain model phong phú với business rule
  • Validate input và enforce invariant
  • Cấu trúc data chuẩn hóa (3NF hoặc domain-driven)
  • Tối ưu cho consistency, không phải query performance
  • Trả về data tối thiểu (thường chỉ ID hoặc success/failure)

Query Side (Read)

Query side xử lý mọi truy vấn data. Nó dùng model tối ưu cho query cụ thể mà UI cần.

// Query — yêu cầu thông tin
public record GetOrderDetailsQuery(String orderId) {}
public record ListCustomerOrdersQuery(String customerId, int page, int size) {}
 
// Read model — tối ưu cho hiển thị, denormalized
public record OrderDetailsView(
    String orderId,
    String customerName,    // Denormalized từ bảng Customer
    String customerEmail,
    List<OrderItemView> items,
    BigDecimal totalAmount,
    String status,
    String statusLabel,     // Status dạng đọc được
    LocalDateTime placedAt,
    LocalDateTime updatedAt,
    String shippingAddress,
    String trackingNumber   // Join từ bảng Shipping
) {}
 
// Query handler — đơn giản và nhanh
@Service
public class OrderQueryHandler {
 
    private final OrderReadRepository readRepository;
 
    public OrderDetailsView handle(GetOrderDetailsQuery query) {
        return readRepository.findOrderDetails(query.orderId())
            .orElseThrow(() -> new NotFoundException("Không tìm thấy order"));
    }
 
    public Page<OrderSummaryView> handle(ListCustomerOrdersQuery query) {
        return readRepository.findByCustomerId(
            query.customerId(),
            PageRequest.of(query.page(), query.size())
        );
    }
}

Đặc điểm chính của query side:

  • Denormalized, pre-computed view
  • Không có business logic — chỉ truy xuất data
  • Tối ưu cho nhu cầu UI cụ thể (dashboard, list, detail view)
  • Có thể dùng storage khác nhau (Elasticsearch cho search, Redis cho caching)
  • Trả về data phong phú, sẵn sàng hiển thị

Đồng Bộ Hai Phía

Write và read model phải giữ đồng bộ. Có ba cách tiếp cận:

1. Cập nhật đồng bộ (cùng transaction)

@Transactional
public void handle(PlaceOrderCommand command) {
    Order order = Order.create(command);
    orderRepository.save(order);  // Write model
 
    // Cập nhật read model trong cùng transaction
    orderReadRepository.save(OrderDetailsView.from(order));
}

Đơn giản nhưng coupling hai phía và hạn chế scaling.

2. Cập nhật bất đồng bộ (event)

// Projection — cập nhật read model từ event
@Component
public class OrderProjection {
 
    @KafkaListener(topics = "order-events", groupId = "order-projection")
    public void onOrderPlaced(OrderPlacedEvent event) {
        // Build denormalized view
        Customer customer = customerClient.getCustomer(event.getCustomerId());
 
        OrderDetailsView view = new OrderDetailsView(
            event.getOrderId(),
            customer.getName(),
            customer.getEmail(),
            event.getItems().stream().map(OrderItemView::from).toList(),
            event.getTotalAmount(),
            "PLACED",
            "Đã Đặt Hàng",
            event.getTimestamp(),
            event.getTimestamp(),
            event.getShippingAddress(),
            null  // Chưa có tracking
        );
 
        orderReadRepository.save(view);
    }
 
    @KafkaListener(topics = "order-events", groupId = "order-projection")
    public void onOrderShipped(OrderShippedEvent event) {
        orderReadRepository.updateStatus(event.getOrderId(), "SHIPPED");
    }
}

Tách biệt và scalable, nhưng tạo ra eventual consistency.

3. Change Data Capture (CDC)

Dùng công cụ như Debezium để capture thay đổi từ transaction log của write database và stream đến read side — không cần code ứng dụng.


Event Sourcing: Event Là Source of Truth

Event Sourcing là cách tiếp cận hoàn toàn khác biệt về data persistence. Thay vì lưu current state của entity, bạn lưu mọi event từng xảy ra với nó.

State Truyền Thống vs Event Sourcing

Cách truyền thống (lưu current state):

orders table:
| id  | status    | total  | updated_at          |
|-----|-----------|--------|---------------------|
| 123 | SHIPPED   | 109.97 | 2026-03-02 14:30:00 |

Bạn biết order đã ship và giá $109.97. Nhưng bạn không biết:

  • Khi nào nó được đặt? Bởi ai?
  • Tổng ban đầu trước giảm giá là bao nhiêu?
  • Nó có bao giờ bị hủy rồi đặt lại không?
  • Ai đã thay đổi địa chỉ giao hàng?

Cách Event Sourcing (lưu event):

order_events table:
| event_id | order_id | type              | data                           | timestamp           |
|----------|----------|-------------------|--------------------------------|---------------------|
| evt-1    | 123      | OrderPlaced       | {customerId: "c1", items: [...], total: 129.97} | 2026-03-01 10:00:00 |
| evt-2    | 123      | DiscountApplied   | {code: "SAVE20", discount: 20.00}              | 2026-03-01 10:01:00 |
| evt-3    | 123      | PaymentProcessed  | {paymentId: "p1", amount: 109.97}              | 2026-03-01 10:05:00 |
| evt-4    | 123      | OrderConfirmed    | {}                                              | 2026-03-01 10:05:01 |
| evt-5    | 123      | AddressChanged    | {old: "123 Main", new: "456 Oak"}              | 2026-03-01 15:00:00 |
| evt-6    | 123      | OrderShipped      | {carrier: "FedEx", tracking: "FX123"}          | 2026-03-02 14:30:00 |

Giờ bạn có audit trail đầy đủ về mọi thứ xảy ra với order này. Bạn có thể reconstruct state tại bất kỳ thời điểm nào bằng cách replay event đến thời điểm đó.

Event Sourcing Hoạt Động Thế Nào

Luồng xử lý:

  1. Command đến (ví dụ: PlaceOrder)
  2. Load aggregate bằng cách replay event quá khứ từ event store
  3. Aggregate validate command với current state
  4. Nếu hợp lệ, aggregate tạo event mới
  5. Append event mới vào event store (append-only — không bao giờ update hay delete)
  6. Projection consume event để build read model
// Order aggregate — rebuild từ event
public class OrderAggregate {
 
    private String orderId;
    private String customerId;
    private OrderStatus status;
    private BigDecimal totalAmount;
    private List<OrderItem> items;
    private final List<DomainEvent> uncommittedEvents = new ArrayList<>();
 
    // Rebuild state từ event quá khứ
    public static OrderAggregate fromEvents(List<DomainEvent> events) {
        OrderAggregate aggregate = new OrderAggregate();
        for (DomainEvent event : events) {
            aggregate.apply(event);
        }
        return aggregate;
    }
 
    // Xử lý command — validate và tạo event mới
    public void placeOrder(PlaceOrderCommand command) {
        if (status != null) {
            throw new IllegalStateException("Order đã tồn tại");
        }
        if (command.items().isEmpty()) {
            throw new ValidationException("Order phải có item");
        }
 
        // Không mutate state trực tiếp — tạo event
        raiseEvent(new OrderPlacedEvent(
            command.orderId(),
            command.customerId(),
            command.items(),
            calculateTotal(command.items())
        ));
    }
 
    public void cancelOrder(CancelOrderCommand command) {
        if (status != OrderStatus.PLACED && status != OrderStatus.CONFIRMED) {
            throw new IllegalStateException(
                "Không thể hủy order ở status: " + status
            );
        }
 
        raiseEvent(new OrderCancelledEvent(orderId, command.reason()));
    }
 
    // Apply event — cập nhật internal state (không side effect!)
    private void apply(DomainEvent event) {
        switch (event) {
            case OrderPlacedEvent e -> {
                this.orderId = e.getOrderId();
                this.customerId = e.getCustomerId();
                this.items = e.getItems();
                this.totalAmount = e.getTotalAmount();
                this.status = OrderStatus.PLACED;
            }
            case OrderConfirmedEvent e -> {
                this.status = OrderStatus.CONFIRMED;
            }
            case OrderCancelledEvent e -> {
                this.status = OrderStatus.CANCELLED;
            }
            case OrderShippedEvent e -> {
                this.status = OrderStatus.SHIPPED;
            }
            default -> {}  // Bỏ qua event không biết
        }
    }
 
    private void raiseEvent(DomainEvent event) {
        apply(event);  // Cập nhật state local
        uncommittedEvents.add(event);  // Theo dõi để persist
    }
}

Event Store

Event store là log append-only chứa tất cả event. Event không bao giờ được update hay delete — chúng là sự thật bất biến.

// Event store interface
public interface EventStore {
    // Append event mới cho aggregate
    void appendEvents(String aggregateId, List<DomainEvent> events,
                      long expectedVersion);
 
    // Load tất cả event cho aggregate
    List<DomainEvent> getEvents(String aggregateId);
 
    // Load event từ version cụ thể
    List<DomainEvent> getEvents(String aggregateId, long fromVersion);
}
 
// Sử dụng trong command handler
@Service
public class OrderCommandService {
 
    private final EventStore eventStore;
 
    public void placeOrder(PlaceOrderCommand command) {
        // 1. Load aggregate từ event history
        List<DomainEvent> history = eventStore.getEvents(command.orderId());
        OrderAggregate order = OrderAggregate.fromEvents(history);
 
        // 2. Execute command (validate và tạo event)
        order.placeOrder(command);
 
        // 3. Persist event mới (append-only)
        eventStore.appendEvents(
            command.orderId(),
            order.getUncommittedEvents(),
            history.size()  // Optimistic concurrency check
        );
    }
}

Event store implementation:

  • EventStoreDB — event store database chuyên dụng
  • Apache Kafka — với log compaction và infinite retention
  • PostgreSQL — với bảng events (đủ tốt cho nhiều use case)
  • DynamoDB — với partition key = aggregateId, sort key = version
-- Event store đơn giản trong PostgreSQL
CREATE TABLE events (
    event_id      UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    aggregate_id  VARCHAR(255) NOT NULL,
    aggregate_type VARCHAR(100) NOT NULL,
    event_type    VARCHAR(100) NOT NULL,
    event_data    JSONB NOT NULL,
    version       BIGINT NOT NULL,
    timestamp     TIMESTAMPTZ DEFAULT NOW(),
 
    UNIQUE (aggregate_id, version)  -- Optimistic concurrency
);
 
CREATE INDEX idx_events_aggregate ON events (aggregate_id, version);

Projection: Build Read Model Từ Event

Event trong event store tối ưu cho write — chúng là chuỗi sự thật về từng aggregate. Nhưng read cần view denormalized, queryable. Projection biến đổi event stream thành read model.

Loại Projection

1. Inline projection — build đồng bộ khi xử lý event

// Inline projection đơn giản
@Component
public class OrderListProjection {
 
    private final OrderListViewRepository viewRepository;
 
    public void handle(OrderPlacedEvent event) {
        viewRepository.save(new OrderListView(
            event.getOrderId(),
            event.getCustomerId(),
            event.getTotalAmount(),
            "PLACED",
            event.getTimestamp()
        ));
    }
 
    public void handle(OrderShippedEvent event) {
        viewRepository.updateStatus(event.getOrderId(), "SHIPPED");
    }
 
    public void handle(OrderCancelledEvent event) {
        viewRepository.updateStatus(event.getOrderId(), "CANCELLED");
    }
}

2. Async projection — build từ event stream (Kafka, EventStoreDB subscription)

// Async projection — consume từ event stream
@Component
public class CustomerDashboardProjection {
 
    private final CustomerDashboardRepository dashboardRepo;
 
    @KafkaListener(topics = "order-events", groupId = "customer-dashboard")
    public void handle(DomainEvent event) {
        switch (event) {
            case OrderPlacedEvent e -> {
                dashboardRepo.incrementOrderCount(e.getCustomerId());
                dashboardRepo.addToTotalSpent(
                    e.getCustomerId(), e.getTotalAmount()
                );
                dashboardRepo.setLastOrderDate(
                    e.getCustomerId(), e.getTimestamp()
                );
            }
            case OrderCancelledEvent e -> {
                dashboardRepo.decrementOrderCount(e.getCustomerId());
            }
            default -> {}  // Bỏ qua event không liên quan
        }
    }
}

Rebuild Read Model

Một trong những benefit mạnh nhất của Event Sourcing: bạn có thể rebuild bất kỳ read model nào từ đầu bằng cách replay tất cả event.

Điều này nghĩa là:

  • Fix bug trong projection — fix code, replay event, có data đúng
  • Thêm projection mới — cần dashboard mới? Tạo projection mới và replay
  • Thay đổi read model schema — không cần data migration phức tạp, chỉ rebuild từ event
// Rebuild projection từ đầu
@Service
public class ProjectionRebuilder {
 
    private final EventStore eventStore;
    private final OrderListProjection projection;
 
    public void rebuild() {
        // 1. Xóa read model hiện tại
        projection.clear();
 
        // 2. Replay tất cả event qua projection
        eventStore.getAllEvents()
            .forEach(event -> projection.handle(event));
 
        log.info("Projection đã rebuild từ {} event",
            eventStore.getEventCount());
    }
}

Snapshot: Tối Ưu Performance

Khi aggregate tích lũy hàng nghìn event, load chúng bằng cách replay tất cả event trở nên chậm. Snapshot giải quyết bằng cách định kỳ lưu current state của aggregate.

// Load aggregate có hỗ trợ snapshot
@Service
public class OrderCommandService {
 
    private final EventStore eventStore;
    private final SnapshotStore snapshotStore;
 
    public OrderAggregate loadAggregate(String orderId) {
        // 1. Thử load snapshot mới nhất
        Optional<Snapshot> snapshot = snapshotStore.getLatest(orderId);
 
        OrderAggregate aggregate;
        long fromVersion;
 
        if (snapshot.isPresent()) {
            // Bắt đầu từ snapshot
            aggregate = snapshot.get().deserialize(OrderAggregate.class);
            fromVersion = snapshot.get().getVersion() + 1;
        } else {
            // Bắt đầu từ đầu
            aggregate = new OrderAggregate();
            fromVersion = 0;
        }
 
        // 2. Replay event kể từ snapshot
        List<DomainEvent> newEvents = eventStore.getEvents(orderId, fromVersion);
        for (DomainEvent event : newEvents) {
            aggregate.apply(event);
        }
 
        return aggregate;
    }
 
    // Lưu snapshot mỗi N event
    public void saveEventsWithSnapshot(String orderId,
                                       List<DomainEvent> events,
                                       OrderAggregate aggregate) {
        eventStore.appendEvents(orderId, events, aggregate.getVersion());
 
        if (aggregate.getVersion() % 100 == 0) {  // Snapshot mỗi 100 event
            snapshotStore.save(new Snapshot(
                orderId,
                aggregate.getVersion(),
                serialize(aggregate)
            ));
        }
    }
}

Khi nào dùng snapshot:

  • Aggregate có hàng trăm hoặc hàng nghìn event
  • Aggregate traffic cao được load thường xuyên
  • Khi replay time vượt quá latency chấp nhận được

Khi nào snapshot không cần thiết:

  • Aggregate có dưới 50-100 event
  • Event được load không thường xuyên
  • Event replay đã đủ nhanh

CQRS Không Có Event Sourcing

CQRS và Event Sourcing là pattern độc lập mà tình cờ hoạt động tốt với nhau. Bạn có thể dùng một mà không cần cái kia.

CQRS Với Database Truyền Thống

Write side dùng relational database chuẩn hóa. Read side dùng Elasticsearch cho full-text search nhanh. Event hoặc CDC đồng bộ chúng. Không liên quan gì đến event sourcing.

Đây thực ra là CQRS pattern phổ biến nhất trong thực tế. Đa số team hưởng lợi từ việc tách read và write model mà không cần complexity của event sourcing.

Event Sourcing Không Có CQRS

Bạn có thể lưu event là source of truth mà không tách biệt read và write model. Cùng service xử lý cả command và query — nhưng persistence là event-based.

// Event Sourcing không có CQRS — cùng service xử lý read
@Service
public class OrderService {
 
    // Write: event-sourced
    public void placeOrder(PlaceOrderCommand command) {
        OrderAggregate order = loadAggregate(command.orderId());
        order.placeOrder(command);
        eventStore.appendEvents(command.orderId(), order.getUncommittedEvents());
    }
 
    // Read: replay event để build current state
    public OrderDetails getOrder(String orderId) {
        OrderAggregate order = loadAggregate(orderId);
        return OrderDetails.from(order);  // Map aggregate thành view
    }
}

Cách này hoạt động cho hệ thống quy mô nhỏ nhưng phản tác dụng khi read tăng — bạn đang replay event cho mỗi query.


Thách Thức Eventual Consistency

Khi read model được cập nhật bất đồng bộ từ event, có độ trễ giữa write và read. Đây là eventual consistency — read model sẽ cuối cùng phản ánh write, nhưng không ngay lập tức.

Vấn Đề

User đặt order, nhận xác nhận, rồi xem danh sách order ngay lập tức — nhưng order mới chưa có ở đó.

Chiến Lược Xử Lý Eventual Consistency

1. Trả kết quả trực tiếp từ command

// Command trả đủ data cho hiển thị ngay lập tức
public OrderConfirmation placeOrder(PlaceOrderCommand command) {
    // ... xử lý command ...
    return new OrderConfirmation(
        order.getId(),
        order.getStatus(),
        order.getTotalAmount(),
        "Đơn hàng của bạn đã được đặt!"
    );
    // Redirect đến trang xác nhận với data này
    // Đừng query read model ngay lập tức
}

2. Optimistic UI

Frontend cập nhật UI lạc quan trước khi server xác nhận read model đã sẵn sàng:

// Frontend — optimistic update
async function placeOrder(orderData: OrderRequest) {
  const result = await api.post('/orders', orderData);
 
  // Thêm vào local state ngay (không chờ read model)
  addToLocalOrders({
    id: result.orderId,
    status: 'PROCESSING',
    ...orderData,
  });
 
  // Read model sẽ bắt kịp sau
}

3. Polling với timeout

// Poll đến khi read model nhất quán
public OrderDetailsView getOrderWithConsistency(String orderId, Duration timeout) {
    Instant deadline = Instant.now().plus(timeout);
 
    while (Instant.now().isBefore(deadline)) {
        Optional<OrderDetailsView> result = readRepository.findById(orderId);
        if (result.isPresent()) {
            return result.get();
        }
        Thread.sleep(100);  // Dừng ngắn trước khi retry
    }
 
    throw new EventualConsistencyTimeoutException(
        "Read model chưa cập nhật cho order: " + orderId
    );
}

4. Notification qua subscription

Dùng WebSocket hoặc Server-Sent Event để thông báo client khi read model sẵn sàng:

// Push notification khi projection hoàn tất
@Component
public class OrderProjection {
 
    private final SimpMessagingTemplate websocket;
 
    public void handle(OrderPlacedEvent event) {
        // Cập nhật read model...
        orderReadRepository.save(view);
 
        // Thông báo client qua WebSocket
        websocket.convertAndSendToUser(
            event.getCustomerId(),
            "/queue/order-updates",
            new OrderReadyNotification(event.getOrderId())
        );
    }
}

Khi Nào CQRS Thêm Complexity Không Cần Thiết

CQRS và Event Sourcing là pattern mạnh mẽ — nhưng không phải lựa chọn mặc định. Chúng thêm complexity đáng kể phải được justify bằng yêu cầu thực tế.

Đừng Dùng CQRS Khi

1. Ứng dụng CRUD đơn giản

Nếu app chỉ là create-read-update-delete cơ bản không có query phức tạp hay nhu cầu scaling, CQRS là over-engineering.

// Blog với post, comment, và tag
// KHÔNG cần CQRS — single model hoạt động hoàn hảo

2. Read và write model giống hệt nhau

Nếu bạn đọc đúng cái bạn viết mà không có transformation, projection, hay aggregation, không có gì để tách biệt.

3. Team nhỏ không có event-driven infrastructure

CQRS với async projection đòi hỏi message broker, monitoring, và operational expertise. Nếu team không có những thứ này, gánh nặng infrastructure lớn hơn benefit.

4. Strong consistency không thể thương lượng

Ngân hàng, giao dịch tài chính, và quản lý tồn kho đôi khi đòi hỏi read-after-write consistency ngay lập tức. Eventual consistency trong read model có thể không chấp nhận được.

Đừng Dùng Event Sourcing Khi

1. Bạn không cần audit trail

Benefit lớn nhất của Event Sourcing là lịch sử đầy đủ. Nếu bạn không cần, bạn đang trả giá complexity mà không nhận được giá trị.

2. Event sẽ chứa data nhạy cảm

PII, chi tiết thanh toán, và hồ sơ y tế trong event tạo ra ác mộng compliance (GDPR quyền xóa data là bất khả thi với event bất biến nếu không có crypto-shredding).

3. Domain đơn giản

CRUD entity với ít state transition không hưởng lợi từ event modeling. Event-sourced UserProfile với event như NameChanged, EmailChanged là over-engineering.

4. Schema evolution thay đổi thường xuyên

Nếu domain model thay đổi liên tục (startup giai đoạn đầu), quản lý event schema evolution trên hàng trăm loại event trở thành gánh nặng maintenance.

Chi Phí Complexity

Quy tắc ngón tay cái: Bắt đầu với kiến trúc đơn giản. Áp dụng CQRS khi read/write scaling hay optimization phân kỳ. Thêm Event Sourcing khi bạn cần audit trail đầy đủ hoặc temporal query.


Tham Khảo Thực Tế: Hệ Thống Order Hoàn Chỉnh

Đây là cách CQRS + Event Sourcing kết hợp trong hệ thống thực:

Framework và Library

Công cụNgôn ngữCung cấp
Axon FrameworkJava/KotlinCQRS + ES framework đầy đủ với saga support
EventStoreDBAny (gRPC)Event store database chuyên dụng
Marten.NETDocument DB + Event Store trên PostgreSQL
Eventuous.NETLightweight ES library
SequentRubyCQRS + ES framework
Apache KafkaAnyEvent streaming làm event store

Tóm Tắt

CQRS tách ứng dụng thành command side (write) và query side (read), cho phép mỗi phía được tối ưu, scale, và bảo mật độc lập. Event Sourcing lưu mọi thay đổi state dưới dạng event bất biến, cung cấp audit trail đầy đủ và khả năng reconstruct state tại bất kỳ thời điểm nào.

Khái niệm CQRS cốt lõi:

  • Command side — domain model phong phú, validate business rule, persist state change
  • Query side — read model denormalized tối ưu cho view cụ thể
  • Projection — biến đổi event thành read model
  • Read và write có thể dùng database và chiến lược scaling khác nhau

Khái niệm Event Sourcing cốt lõi:

  • Event là source of truth — lưu chuyện gì đã xảy ra, không phải current state
  • Event store — log append-only chứa event bất biến
  • Aggregate reconstruction — replay event để rebuild current state
  • Snapshot — lưu state định kỳ để tránh replay toàn bộ event

Kết hợp chính:

  • CQRS không có Event Sourcing — phổ biến nhất; tách read/write model với persistence truyền thống
  • Event Sourcing không có CQRS — có thể nhưng hạn chế; event cho write, cùng model cho read
  • CQRS + Event Sourcing — sức mạnh đầy đủ; event drive cả write persistence và read model projection

Khi nào dùng:

  • Read và write workload cần optimization khác nhau
  • Bạn cần audit trail đầy đủ về mọi thay đổi state
  • Cần nhiều read model cho các consumer khác nhau
  • Domain phức tạp với business rule phong phú

Khi nào KHÔNG dùng:

  • Ứng dụng CRUD đơn giản
  • Team nhỏ không có event infrastructure
  • Strong consistency không thể thương lượng
  • Domain model thay đổi thường xuyên

CQRS và Event Sourcing là optimization pattern, không phải kiến trúc mặc định. Bắt đầu đơn giản, thêm complexity chỉ khi nỗi đau justify nó.


Tiếp Theo Trong Chuỗi Kiến Trúc Phần Mềm

Đây là bài viết thứ 7 trong 12 bài của chuỗi Software Architecture Patterns:

Bài viết liên quan:

📬 Đăng ký nhận bản tin

Nhận những bài viết mới nhất qua email mỗi tuần. Không spam, hủy đăng ký bất cứ lúc nào.

Chúng tôi tôn trọng quyền riêng tư của bạn. Hủy đăng ký bất cứ lúc nào.

💬 Comments

Đăng nhập để bình luận

Chúng tôi sẽ không đăng bất cứ điều gì mà không có sự cho phép của bạn.