Back to blog

Event-Driven Architecture: Message, Event & Giao Tiếp Bất Đồng Bộ

software-architectureevent-drivenkafkabackendsystem-design
Event-Driven Architecture: Message, Event & Giao Tiếp Bất Đồng Bộ

Trong kiến trúc request/response truyền thống, các component gọi nhau trực tiếp và chờ phản hồi. Service A gọi Service B, rồi gọi Service C, và toàn bộ chuỗi phải available ngay lúc đó. Điều này tạo ra tight coupling, cascading failure, và hệ thống sập dưới tải.

Event-driven architecture đảo ngược mô hình này. Thay vì bảo component khác phải làm gì, một component thông báo chuyện gì đã xảy ra. Các component khác phản ứng với thông báo đó một cách độc lập, theo tốc độ riêng, mà producer không cần biết chúng tồn tại.

Sự tách biệt này là thứ làm cho event-driven system có khả năng phục hồi, scalable, và mở rộng được — nhưng nó cũng tạo ra thách thức về eventual consistency, xử lý lỗi, và debug đòi hỏi thiết kế cẩn thận.

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

✅ Event vs command vs query — các building block cơ bản
✅ Event notification vs event-carried state transfer
✅ Message broker: Kafka, RabbitMQ, Redis Streams
✅ Pub/sub vs point-to-point messaging
✅ Event choreography vs orchestration
✅ Eventual consistency và compensation
✅ Idempotency và exactly-once processing
✅ Event schema evolution và versioning
✅ Dead letter queue và xử lý lỗi
✅ Event storming như kỹ thuật thiết kế
✅ Triển khai thực tế với Kafka và Spring Boot


Event vs Command vs Query

Trước khi đi sâu vào kiến trúc, hãy làm rõ ba loại message cơ bản. Phân biệt đúng là cực kỳ quan trọng — trộn lẫn chúng dẫn đến hệ thống rối và khó hiểu.

Event: "Chuyện Gì Đó Đã Xảy Ra"

Một event là thông báo rằng điều gì đó đã xảy ra trong quá khứ. Nó bất biến, thực tế, và được đặt tên ở thì quá khứ.

OrderPlaced        ← Đơn hàng đã được đặt
PaymentProcessed   ← Thanh toán đã được xử lý
UserRegistered     ← Người dùng đã đăng ký
InventoryDepleted  ← Tồn kho đã hết

Thuộc tính chính của event:

  • Bất biến — bạn không thể thay đổi quá khứ; event là sự thật
  • Thì quá khứ — đặt tên theo chuyện đã xảy ra
  • Producer không quan tâm ai đang lắng nghe — không, một, hay năm mươi consumer
  • Không mong đợi response — fire and forget

Command: "Làm Cái Này"

Một command là yêu cầu ai đó làm điều gì. Nó hướng đến receiver cụ thể, đặt tên ở dạng mệnh lệnh, và mong đợi kết quả.

PlaceOrder         ← "Ê Order Service, đặt đơn hàng này"
ProcessPayment     ← "Ê Payment Service, thu tiền thẻ này"
SendEmail          ← "Ê Email Service, gửi email này"
ReserveInventory   ← "Ê Inventory Service, giữ hàng này"

Thuộc tính chính của command:

  • Có hướng — gửi đến handler cụ thể
  • Mệnh lệnh — đặt tên như chỉ thị
  • Có thể bị từ chối — handler có thể không chấp nhận
  • Đúng một handler — không broadcast đến nhiều consumer

Query: "Cho Biết"

Một query là yêu cầu thông tin. Nó không thay đổi state — hoàn toàn là read.

GetOrderDetails    ← "Chi tiết đơn hàng 123 là gì?"
ListUserOrders     ← "User này có những đơn hàng nào?"
CheckInventory     ← "Còn bao nhiêu widget trong kho?"

Tại Sao Điều Này Quan Trọng

Sự nhầm lẫn xảy ra khi developer dùng event như command hoặc ngược lại:

Khi bạn publish PleaseProcessPayment, bạn đã tạo command ngụy trang thành event. Order Service vẫn đang bảo Payment Service phải làm gì — chỉ là gián tiếp. Khi bạn publish OrderPlaced, Order Service đơn giản nêu một sự thật. Payment Service tự quyết định phản ứng bằng cách xử lý thanh toán.

Sự phân biệt này thúc đẩy loose coupling: producer không biết và không quan tâm chuyện gì xảy ra tiếp theo.


Event Pattern: Notification vs State Transfer

Không phải tất cả event đều mang cùng lượng thông tin. Có hai cách tiếp cận khác nhau cơ bản về nội dung bên trong event.

Event Notification

Event notification là mỏng — nó cho bạn biết chuyện gì đã xảy ra và cung cấp vừa đủ thông tin để xác định chủ thể. Nếu bạn cần chi tiết, bạn gọi lại.

{
  "type": "OrderPlaced",
  "orderId": "order-789",
  "timestamp": "2026-03-02T10:30:00Z"
}

Consumer nhận event này và nghĩ: "Một đơn hàng đã được đặt. Để tôi gọi API Order Service để lấy chi tiết cần thiết."

Ưu điểm:

  • Event nhỏ và nhanh khi publish
  • Source of truth nằm ở service gốc
  • Không có rủi ro data cũ trong event

Nhược điểm:

  • Tạo runtime coupling — consumer phải gọi lại producer
  • Producer phải available khi consumer xử lý event
  • Nhiều network call hơn

Event-Carried State Transfer

Event có state transfer là béo — nó mang tất cả data mà consumer cần, để họ không phải gọi lại.

{
  "type": "OrderPlaced",
  "orderId": "order-789",
  "userId": "user-123",
  "items": [
    {"productId": "prod-1", "name": "Widget", "quantity": 2, "price": 29.99},
    {"productId": "prod-2", "name": "Gadget", "quantity": 1, "price": 49.99}
  ],
  "totalAmount": 109.97,
  "shippingAddress": {
    "street": "123 Main St",
    "city": "Springfield",
    "zipCode": "62704"
  },
  "timestamp": "2026-03-02T10:30:00Z"
}

Ưu điểm:

  • Không runtime coupling — consumer hoàn toàn tự chủ
  • Producer có thể offline khi consumer xử lý event
  • Consumer có thể build local read model (CQRS pattern)
  • Ít network call hơn

Nhược điểm:

  • Event lớn hơn tiêu tốn nhiều bandwidth và storage hơn
  • Data có thể cũ nếu event được publish cách đây nhiều giờ
  • Schema evolution phức tạp hơn với payload lớn

Chọn Cái Nào?

Kịch bảnNotificationState Transfer
Consumer cần 1-2 field✅Over-engineered
Consumer cần full entityThêm call✅
Producer uptime đảm bảo✅✅
Producer có thể offlineRủi ro✅
Build local read model❌✅
Hạn chế bandwidth✅Nặng

Nguyên tắc thực tế: Bắt đầu với event-carried state transfer cho command xuyên service boundary. Dùng thin notification cho internal domain event trong cùng service.


Message Broker

Event cần infrastructure để di chuyển từ producer đến consumer. Message broker là middleware lưu trữ, định tuyến, và giao message giữa các service.

Apache Kafka

Kafka là distributed event streaming platform được thiết kế cho xử lý event throughput cao, fault-tolerant, và bền vững.

Khái niệm chính:

  • Topic — danh mục event được đặt tên (như order-events, user-events)
  • Partition — topic được chia thành partition cho parallelism; event cùng key đi vào cùng partition (đảm bảo thứ tự)
  • Consumer group — mỗi group nhận mỗi message đúng một lần; nhiều group xử lý cùng topic độc lập
  • Retention — event được lưu trữ trong khoảng thời gian có thể cấu hình (ngày, tuần, hoặc mãi mãi với compaction)
  • Offset — mỗi consumer theo dõi vị trí của mình; có thể replay từ bất kỳ điểm nào

Kafka phù hợp nhất cho:

  • Event stream throughput cao (hàng triệu event/giây)
  • Event sourcing và replay
  • Build pipeline data real-time
  • Trường hợp thứ tự event quan trọng
// Kafka producer — Spring Boot
@Service
public class OrderEventPublisher {
 
    private final KafkaTemplate<String, OrderEvent> kafkaTemplate;
 
    public void publishOrderPlaced(Order order) {
        OrderPlacedEvent event = new OrderPlacedEvent(
            UUID.randomUUID().toString(),  // eventId
            order.getId(),
            order.getUserId(),
            order.getItems(),
            order.getTotalAmount(),
            Instant.now()
        );
 
        // Key = orderId → tất cả event cho cùng order vào cùng partition
        kafkaTemplate.send("order-events", order.getId(), event);
    }
}
 
// Kafka consumer — Spring Boot
@Component
public class PaymentEventConsumer {
 
    @KafkaListener(topics = "order-events", groupId = "payment-service")
    public void handleOrderPlaced(OrderPlacedEvent event) {
        log.info("Xử lý thanh toán cho order: {}", event.getOrderId());
        paymentService.processPayment(
            event.getUserId(),
            event.getTotalAmount(),
            event.getOrderId()
        );
    }
}

RabbitMQ

RabbitMQ là message broker truyền thống xây trên giao thức AMQP. Nó tập trung vào message routing, acknowledgment, và delivery guarantee.

Khái niệm chính:

  • Exchange — nhận message và route chúng đến queue dựa trên rule
  • Queue — lưu trữ message cho đến khi được consume
  • Binding — rule kết nối exchange với queue (routing key, pattern)
  • Acknowledgment — consumer xác nhận đã xử lý; message chưa acknowledge sẽ được gửi lại
  • Exchange type — direct (routing key chính xác), fanout (broadcast), topic (pattern matching), headers

RabbitMQ phù hợp nhất cho:

  • Task queue với yêu cầu routing phức tạp
  • Pattern request/reply
  • Priority queue
  • Khi bạn cần message-level acknowledgment
  • Throughput thấp hơn nhưng routing phong phú hơn

Kafka vs RabbitMQ

Tính năngKafkaRabbitMQ
Mô hìnhDistributed logMessage queue
ThroughputRất cao (hàng triệu/giây)Cao (hàng chục nghìn/giây)
Lưu trữ messageCấu hình được (ngày/tuần/mãi mãi)Cho đến khi consume
ReplayCó (đọc lại từ bất kỳ offset)Không (consume = mất)
Thứ tựPer partitionPer queue
RoutingTopic + partition keyExchange + routing key + binding
Consumer modelPull (consumer poll)Push (broker giao)
Phù hợp choEvent streaming, event sourcingTask queue, routing phức tạp

Redis Streams

Redis Streams là giải pháp nhẹ được tích hợp sẵn trong Redis. Lý tưởng cho pattern event-driven đơn giản hơn mà không cần operational overhead của Kafka hay RabbitMQ.

# Producer — thêm event vào stream
XADD order-events * type OrderPlaced orderId order-789 amount 109.97
 
# Consumer — đọc từ stream (consumer group)
XREADGROUP GROUP payment-service consumer-1 COUNT 10 BLOCK 5000 STREAMS order-events >

Redis Streams phù hợp nhất cho:

  • Pattern event đơn giản với throughput thấp đến trung bình
  • Khi bạn đã dùng Redis cho caching
  • Prototype hệ thống event-driven trước khi commit vào Kafka

Pub/Sub vs Point-to-Point

Hai pattern messaging cơ bản quyết định cách message đi từ producer đến consumer.

Point-to-Point (Queue)

Mỗi message được giao đến đúng một consumer. Nhiều consumer cạnh tranh lấy message từ cùng queue (competing consumer pattern).

Use case:

  • Phân phối task — chia việc cho các worker
  • Gửi email — mỗi email được gửi một lần bởi một worker
  • Xử lý ảnh — mỗi ảnh được xử lý bởi một worker
  • Bất kỳ job nào mà xử lý trùng lặp là lãng phí

Publish/Subscribe (Pub/Sub)

Mỗi message được giao đến tất cả subscriber. Mỗi consumer group nhận bản sao của mọi message.

Use case:

  • Event broadcast — nhiều hệ thống cần cùng event
  • OrderPlaced → payment, inventory, notification, analytics đều phản ứng
  • Real-time update — nhiều dashboard cần cùng data
  • Audit logging — mọi event được ghi lại cho compliance

Mô Hình Kết Hợp Của Kafka

Kafka kết hợp cả hai pattern một cách thanh lịch bằng consumer group:

  • Giữa các group → pub/sub — cả Payment và Notification group đều nhận mọi event
  • Trong một group → point-to-point — mỗi partition được consume bởi đúng một thành viên

Nghĩa là bạn có pub/sub (nhiều service phản ứng) với load balancing sẵn có (nhiều instance của mỗi service chia sẻ công việc).


Choreography vs Orchestration

Khi một business process trải rộng nhiều service, bạn cần phối hợp các bước. Có hai cách: choreography (phi tập trung, event-driven) và orchestration (tập trung, command-driven).

Choreography: Nhảy Mà Không Cần Đạo Diễn

Trong choreography, mỗi service lắng nghe event và tự quyết định làm gì tiếp theo. Không có coordinator trung tâm — service phản ứng với nhau như vũ công theo nhạc.

// Choreography — mỗi service phản ứng độc lập
 
// Order Service publish event
@Service
public class OrderService {
    public Order createOrder(CreateOrderCommand cmd) {
        Order order = orderRepository.save(Order.create(cmd));
        eventPublisher.publish(new OrderPlacedEvent(order));
        return order;
    }
 
    @EventListener
    public void onPaymentProcessed(PaymentProcessedEvent event) {
        Order order = orderRepository.findById(event.getOrderId());
        order.confirm();
        orderRepository.save(order);
        eventPublisher.publish(new OrderConfirmedEvent(order));
    }
}
 
// Inventory Service phản ứng với OrderPlaced
@Component
public class InventoryEventHandler {
    @KafkaListener(topics = "order-events")
    public void onOrderPlaced(OrderPlacedEvent event) {
        inventoryService.reserve(event.getItems());
        eventPublisher.publish(new InventoryReservedEvent(event.getOrderId()));
    }
}
 
// Payment Service phản ứng với InventoryReserved
@Component
public class PaymentEventHandler {
    @KafkaListener(topics = "inventory-events")
    public void onInventoryReserved(InventoryReservedEvent event) {
        paymentService.charge(event.getOrderId());
        eventPublisher.publish(new PaymentProcessedEvent(event.getOrderId()));
    }
}

Ưu điểm:

  • Loose coupling — service không biết về nhau
  • Dễ thêm consumer mới mà không thay đổi service hiện tại
  • Không có single point of failure (không có coordinator trung tâm)

Nhược điểm:

  • Khó thấy toàn bộ flow — logic phân tán khắp các service
  • Khó theo dõi process đang kẹt ở đâu
  • Chuỗi event vòng tròn có thể tạo vòng lặp vô tận
  • Compensating transaction phức tạp

Orchestration: Theo Nhạc Trưởng

Trong orchestration, một orchestrator trung tâm (saga coordinator) gọi từng service theo trình tự, xử lý flow logic tại một nơi.

// Orchestration — saga định nghĩa toàn bộ flow
@Component
public class CreateOrderSaga {
 
    public void execute(CreateOrderCommand command) {
        try {
            // Bước 1: Tạo order
            Order order = orderService.createOrder(command);
 
            // Bước 2: Giữ chỗ tồn kho
            inventoryService.reserve(order.getItems());
 
            // Bước 3: Xử lý thanh toán
            Payment payment = paymentService.charge(
                order.getUserId(), order.getTotalAmount()
            );
 
            // Bước 4: Xác nhận order
            orderService.confirm(order.getId(), payment.getId());
 
            // Bước 5: Gửi thông báo
            notificationService.sendOrderConfirmation(order);
 
        } catch (InventoryException e) {
            // Bù đắp: hủy order
            orderService.cancel(command.getOrderId());
            throw e;
 
        } catch (PaymentException e) {
            // Bù đắp: giải phóng inventory, hủy order
            inventoryService.release(command.getItems());
            orderService.cancel(command.getOrderId());
            throw e;
        }
    }
}

Ưu điểm:

  • Visibility đầy đủ — toàn bộ flow ở một chỗ
  • Dễ thêm xử lý lỗi và compensation
  • Dễ theo dõi trạng thái hiện tại của process
  • Test đơn giản

Nhược điểm:

  • Coordinator trung tâm là potential single point of failure
  • Coupling chặt hơn — orchestrator biết về tất cả service
  • Có thể trở thành "God object" khi flow phình to

Khi Nào Dùng Cái Nào

Yếu tốChoreographyOrchestration
Số bước2-3 bước4+ bước
Complexity xử lý lỗiĐơn giảnPhức tạp
Cần visibility processThấpCao
Team autonomyService phát triển độc lậpTeam orchestrator phối hợp
Thêm bước mớiDễ (thêm consumer mới)Cần thay đổi orchestrator
DebugKhó (trace event xuyên service)Dễ (check state orchestrator)

Nguyên tắc thực tế: Dùng choreography cho flow event đơn giản, loose-coupled (notification, analytics). Dùng orchestration cho process đa bước business-critical nơi visibility và xử lý lỗi quan trọng (order fulfillment, payment workflow).


Eventual Consistency và Compensation

Trong event-driven system, data là eventually consistent — các service khác nhau có thể có view khác nhau về sự thật tại bất kỳ thời điểm nào. Đây là trade-off cơ bản bạn phải chấp nhận, không phải chống lại.

Tại Sao Không Strong Consistency?

Trong monolith với single database, bạn có ACID transaction:

BEGIN TRANSACTION;
  UPDATE accounts SET balance = balance - 100 WHERE id = 1;
  UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
-- Cả hai update thành công hoặc cả hai fail. Luôn nhất quán.

Trong distributed system với database riêng per service, bạn không thể làm điều này xuyên service boundary. Bạn có hai lựa chọn:

  1. Distributed transaction (2PC) — phối hợp commit xuyên database. Không scale, block khi failure, tránh trong thực tế.
  2. Eventual consistency với compensation — mỗi service commit locally, và nếu có gì sai ở downstream, bạn compensate (hoàn tác) các bước trước đó.

Compensation: Hoàn Tác Những Gì Đã Làm

Khi một bước trong saga fail, bạn cần hoàn tác các bước trước. Những operation hoàn tác này gọi là compensating transaction.

Quan trọng: Compensating transaction không phải lúc nào cũng là hoàn tác thực sự. Bạn không thể "un-send" email hay "un-charge" thẻ tín dụng ngay lập tức. Compensation là semantic inverse — gửi email hủy, hoàn tiền, v.v.

// Ví dụ compensation
public class CompensatingActions {
 
    // Action gốc                        // Compensation
    // createOrder()                  →   cancelOrder()
    // reserveInventory()             →   releaseInventory()
    // chargePayment()                →   refundPayment()
    // sendConfirmationEmail()        →   sendCancellationEmail()
    // awardLoyaltyPoints()           →   revokeLoyaltyPoints()
}

Thiết Kế Cho Eventual Consistency

Pattern UI:

  • Hiển thị trạng thái "Đang xử lý..." thay vì xác nhận tức thì
  • Dùng optimistic UI update với background sync
  • Hiển thị order status dạng timeline: Đã đặt → Đã xác nhận → Đã giao

Pattern data:

  • Chấp nhận rằng các service tạm thời có view khác nhau
  • Dùng timestamp và versioning để phát hiện data cũ
  • Thiết kế operation idempotent (phần tiếp theo)

Idempotency: Xử Lý Message An Toàn

Trong distributed system, message có thể được giao nhiều hơn một lần. Network timeout có thể gây retry dù lần đầu đã thành công. Consumer crash sau khi xử lý nhưng trước khi acknowledge nghĩa là message được giao lại.

Idempotency nghĩa là xử lý cùng message hai lần cho cùng kết quả như xử lý một lần.

Tại Sao Message Được Giao Nhiều Lần

Chiến Lược Cho Idempotency

Chiến lược 1: Idempotency Key (Phổ Biến Nhất)

Bao gồm event ID duy nhất. Theo dõi event nào đã được xử lý.

@KafkaListener(topics = "order-events")
public void handleOrderPlaced(OrderPlacedEvent event) {
    // Kiểm tra đã xử lý chưa
    if (processedEventRepository.existsById(event.getEventId())) {
        log.info("Event {} đã xử lý, bỏ qua", event.getEventId());
        return;
    }
 
    // Xử lý event
    paymentService.charge(event.getUserId(), event.getTotalAmount());
 
    // Đánh dấu đã xử lý (phải cùng transaction với side effect)
    processedEventRepository.save(new ProcessedEvent(event.getEventId()));
}

Chiến lược 2: Natural Idempotency

Thiết kế operation tự nhiên idempotent — chạy hai lần cho cùng kết quả như chạy một lần.

// ❌ KHÔNG idempotent — chạy hai lần cộng 100 hai lần
account.setBalance(account.getBalance() + 100);
 
// ✅ Idempotent — chạy hai lần cho cùng kết quả
account.setBalance(500); // Set giá trị tuyệt đối
 
// ✅ Idempotent — conditional update
UPDATE orders SET status = 'CONFIRMED'
WHERE id = ? AND status = 'PENDING';  -- Chỉ update nếu còn PENDING

Chiến lược 3: Database Constraint

Dùng unique constraint để ngăn xử lý trùng lặp ở tầng database.

-- Unique constraint ngăn xử lý trùng
CREATE TABLE processed_payments (
    order_id VARCHAR(255) PRIMARY KEY,  -- Không thể insert hai lần
    payment_id VARCHAR(255) NOT NULL,
    processed_at TIMESTAMP DEFAULT NOW()
);
 
-- Insert sẽ fail nếu trùng
INSERT INTO processed_payments (order_id, payment_id)
VALUES ('order-789', 'payment-456');

At-Least-Once vs At-Most-Once vs Exactly-Once

GuaranteeÝ nghĩaRủi roKhi nào dùng
At-most-onceGiao một lần, không retryMất messageMetric, analytics không critical
At-least-onceRetry đến khi acknowledgeTrùng lặpPhần lớn business event (với idempotency)
Exactly-onceGiao đúng một lầnTốn kémTransaction tài chính (Kafka transaction)

Thực tế: Đa số hệ thống dùng at-least-once delivery với idempotent consumer. Exactly-once thực sự rất tốn kém và phức tạp — Kafka hỗ trợ trong Kafka transaction, nhưng exactly-once xuyên hệ thống đòi hỏi thiết kế cẩn thận.


Event Schema Evolution

Event là contract giữa producer và consumer. Khi hệ thống phát triển, event schema thay đổi. Xử lý những thay đổi này mà không làm hỏng consumer là cực kỳ quan trọng.

Vấn Đề

// Version 1: OrderPlaced event
{
  "orderId": "order-789",
  "amount": 109.97
}
 
// Version 2: Thêm field currency
{
  "orderId": "order-789",
  "amount": 109.97,
  "currency": "USD"          ← Field mới
}
 
// Version 3: Đổi tên amount → totalAmount
{
  "orderId": "order-789",
  "totalAmount": 109.97,     ← Đổi tên!
  "currency": "USD"
}

Nếu consumer cũ mong đợi amount nhưng event mới có totalAmount, chúng sập.

Quy Tắc Schema Compatibility

Backward compatible (an toàn deploy consumer trước):

  • ✅ Thêm field optional mới
  • ✅ Thêm field mới với giá trị mặc định
  • ❌ Xóa field
  • ❌ Đổi tên field
  • ❌ Thay đổi kiểu field

Forward compatible (an toàn deploy producer trước):

  • ✅ Xóa field optional
  • ✅ Thêm field mới (consumer bỏ qua field không biết)
  • ❌ Thêm field bắt buộc
  • ❌ Thay đổi kiểu field

Full compatible (an toàn deploy theo bất kỳ thứ tự):

  • ✅ Thêm field optional với giá trị mặc định
  • Cơ bản là vậy thôi — full compatibility rất hạn chế

Schema Registry

Dùng schema registry (như Confluent Schema Registry cho Kafka) để enforce compatibility rule tự động.

// Avro schema evolution — backward compatible
// Version 1
{
  "type": "record",
  "name": "OrderPlacedEvent",
  "fields": [
    {"name": "orderId", "type": "string"},
    {"name": "amount", "type": "double"}
  ]
}
 
// Version 2 — backward compatible (field mới có default)
{
  "type": "record",
  "name": "OrderPlacedEvent",
  "fields": [
    {"name": "orderId", "type": "string"},
    {"name": "amount", "type": "double"},
    {"name": "currency", "type": "string", "default": "USD"}
  ]
}

Mẹo Schema Evolution Thực Tế

  1. Luôn thêm, không bao giờ xóa — thêm field mới với default; deprecate field cũ nhưng giữ lại
  2. Không bao giờ đổi tên — thêm field mới và điền cả field cũ lẫn mới trong giai đoạn chuyển tiếp
  3. Dùng schema registry — kiểm tra compatibility tự động bắt breaking change trong CI
  4. Version event của bạn — bao gồm field schemaVersion để consumer xử lý nhiều version
// Xử lý nhiều version event
public void handleOrderPlaced(JsonNode event) {
    int version = event.has("schemaVersion")
        ? event.get("schemaVersion").asInt()
        : 1;  // Mặc định v1 cho event cũ
 
    switch (version) {
        case 1:
            processV1(event);  // Dùng "amount"
            break;
        case 2:
            processV2(event);  // Dùng "totalAmount" + "currency"
            break;
        default:
            log.warn("Schema version không biết: {}", version);
            processLatest(event);  // Cố gắng tốt nhất
    }
}

Dead Letter Queue và Xử Lý Lỗi

Chuyện gì xảy ra khi consumer không thể xử lý message? Có thể data bị malformed, dependency bị sập, hoặc có bug trong logic xử lý. Bạn không thể bỏ message — và cũng không thể retry mãi mãi.

Dead Letter Queue (DLQ)

Dead letter queue là queue riêng biệt nơi message đến sau khi đã fail xử lý một số lần nhất định.

// Spring Kafka — Dead Letter Queue configuration
@Configuration
public class KafkaConfig {
 
    @Bean
    public ConcurrentKafkaListenerContainerFactory<String, String>
            kafkaListenerContainerFactory() {
 
        ConcurrentKafkaListenerContainerFactory<String, String> factory =
            new ConcurrentKafkaListenerContainerFactory<>();
 
        // Retry 3 lần với backoff
        factory.setCommonErrorHandler(new DefaultErrorHandler(
            new DeadLetterPublishingRecoverer(kafkaTemplate),
            new FixedBackOff(1000L, 3)  // 1s delay, 3 retry
        ));
 
        return factory;
    }
}
 
// Consumer — failure tự động retry rồi gửi vào DLQ
@KafkaListener(topics = "order-events")
public void handleOrderEvent(OrderEvent event) {
    // Nếu throw 3 lần, message đi vào "order-events.DLT"
    processOrder(event);
}
 
// DLQ consumer — để điều tra và retry thủ công
@KafkaListener(topics = "order-events.DLT")
public void handleDeadLetter(ConsumerRecord<String, String> record) {
    log.error("Dead letter nhận được. Key: {}, Value: {}, Error: {}",
        record.key(), record.value(),
        new String(record.headers().lastHeader("kafka_dlt-exception-message").value())
    );
    alertService.notifyOncall("Dead letter trong order-events", record);
}

Phân Loại Lỗi và Chiến Lược

Không phải tất cả lỗi đều giống nhau. Chiến lược xử lý lỗi nên phân biệt giữa chúng:

Loại lỗiVí dụChiến lược
Tạm thờiDatabase timeout, network blipRetry với exponential backoff
Poison messageJSON malformed, data không hợp lệGửi vào DLQ ngay lập tức
Business errorKhông đủ tiền, sản phẩm hết hàngPublish failure event, trigger compensation
BugNullPointerException, logic errorGửi vào DLQ, fix code, reprocess
@KafkaListener(topics = "order-events")
public void handleOrderEvent(OrderEvent event) {
    try {
        processOrder(event);
    } catch (TransientException e) {
        // Throw lại — để retry mechanism xử lý
        throw e;
    } catch (ValidationException e) {
        // Poison message — log và skip (không retry)
        log.error("Event không hợp lệ, bỏ qua: {}", event, e);
        deadLetterService.send(event, e);
    } catch (InsufficientFundsException e) {
        // Business error — publish compensation event
        eventPublisher.publish(new PaymentFailedEvent(event.getOrderId(), e.getMessage()));
    }
}

Event Storming: Khám Phá Event

Làm sao bạn tìm ra hệ thống cần những event nào? Event storming là kỹ thuật workshop cộng tác được Alberto Brandolini tạo ra, giúp team khám phá domain event, command, và aggregate.

Quy Trình

  1. Tập hợp đúng người — domain expert, developer, product manager
  2. Sticky note cam — viết domain event (thì quá khứ) lên sticky cam và đặt trên timeline
  3. Sticky note xanh — xác định command (hành động) trigger mỗi event
  4. Sticky note vàng — xác định actor (user, hệ thống) phát hành command
  5. Sticky note vàng nhạt — xác định aggregate (entity) xử lý command và tạo event
  6. Sticky note hồng — đánh dấu hotspot (nhầm lẫn, bất đồng, phức tạp)
  7. Xác định bounded context — nhóm event và aggregate liên quan thành bounded context

Output Event Storming → Kiến Trúc

Bounded context bạn khám phá ánh xạ trực tiếp đến ranh giới microservice. Event trở thành message chạy giữa các service. Command trở thành API endpoint trong mỗi service.

Artifact Event StormingÁnh Xạ Đến
Bounded contextMicroservice
Domain eventMessage/event trong broker
CommandAPI endpoint
AggregateDomain entity
ActorUser role hoặc external system
Policy ("Khi X, thì Y")Event handler / consumer

Triển Khai Thực Tế: Hệ Thống Xử Lý Order

Hãy xây một hệ thống xử lý order event-driven hoàn chỉnh với Spring Boot và Kafka.

Định Nghĩa Event

// Base event class
public abstract class DomainEvent {
    private final String eventId;
    private final String eventType;
    private final Instant timestamp;
    private final int schemaVersion;
 
    protected DomainEvent(String eventType) {
        this.eventId = UUID.randomUUID().toString();
        this.eventType = eventType;
        this.timestamp = Instant.now();
        this.schemaVersion = 1;
    }
}
 
// Event cụ thể
public class OrderPlacedEvent extends DomainEvent {
    private final String orderId;
    private final String userId;
    private final List<OrderItem> items;
    private final BigDecimal totalAmount;
 
    public OrderPlacedEvent(String orderId, String userId,
                            List<OrderItem> items, BigDecimal totalAmount) {
        super("OrderPlaced");
        this.orderId = orderId;
        this.userId = userId;
        this.items = items;
        this.totalAmount = totalAmount;
    }
}
 
public class PaymentProcessedEvent extends DomainEvent {
    private final String orderId;
    private final String paymentId;
    private final BigDecimal amount;
    private final PaymentStatus status;  // SUCCESS, FAILED
 
    // constructor...
}
 
public class InventoryReservedEvent extends DomainEvent {
    private final String orderId;
    private final List<ReservedItem> reservedItems;
 
    // constructor...
}

Producer: Order Service

@Service
public class OrderService {
 
    private final OrderRepository orderRepository;
    private final KafkaTemplate<String, DomainEvent> kafkaTemplate;
 
    public Order placeOrder(PlaceOrderRequest request) {
        // 1. Validate và tạo order locally
        Order order = Order.create(
            request.getUserId(),
            request.getItems()
        );
        order = orderRepository.save(order);
 
        // 2. Publish event — consumer phản ứng độc lập
        kafkaTemplate.send(
            "order-events",
            order.getId(),  // Partition key = orderId
            new OrderPlacedEvent(
                order.getId(),
                order.getUserId(),
                order.getItems(),
                order.getTotalAmount()
            )
        );
 
        log.info("Order đã đặt và event đã publish: {}", order.getId());
        return order;
    }
 
    @KafkaListener(topics = "payment-events", groupId = "order-service")
    public void onPaymentResult(PaymentProcessedEvent event) {
        Order order = orderRepository.findById(event.getOrderId())
            .orElseThrow();
 
        if (event.getStatus() == PaymentStatus.SUCCESS) {
            order.confirm(event.getPaymentId());
            log.info("Order đã xác nhận: {}", order.getId());
        } else {
            order.cancel("Thanh toán thất bại: " + event.getReason());
            log.warn("Order bị hủy do thanh toán thất bại: {}", order.getId());
        }
 
        orderRepository.save(order);
    }
}

Consumer: Payment Service

@Service
public class PaymentEventHandler {
 
    private final PaymentService paymentService;
    private final ProcessedEventRepository processedEvents;
    private final KafkaTemplate<String, DomainEvent> kafkaTemplate;
 
    @KafkaListener(topics = "order-events", groupId = "payment-service")
    @Transactional
    public void onOrderPlaced(OrderPlacedEvent event) {
        // Kiểm tra idempotency
        if (processedEvents.existsById(event.getEventId())) {
            log.info("Event đã xử lý: {}", event.getEventId());
            return;
        }
 
        try {
            // Xử lý thanh toán
            Payment payment = paymentService.charge(
                event.getUserId(),
                event.getTotalAmount()
            );
 
            // Publish success event
            kafkaTemplate.send("payment-events", event.getOrderId(),
                new PaymentProcessedEvent(
                    event.getOrderId(),
                    payment.getId(),
                    event.getTotalAmount(),
                    PaymentStatus.SUCCESS
                )
            );
        } catch (PaymentException e) {
            // Publish failure event
            kafkaTemplate.send("payment-events", event.getOrderId(),
                new PaymentProcessedEvent(
                    event.getOrderId(),
                    null,
                    event.getTotalAmount(),
                    PaymentStatus.FAILED
                )
            );
        }
 
        // Đánh dấu event đã xử lý
        processedEvents.save(new ProcessedEvent(event.getEventId()));
    }
}

Consumer: Notification Service

@Component
public class NotificationEventHandler {
 
    private final EmailService emailService;
    private final UserClient userClient;
 
    @KafkaListener(
        topics = {"order-events", "payment-events"},
        groupId = "notification-service"
    )
    public void handleEvent(DomainEvent event) {
        switch (event.getEventType()) {
            case "OrderPlaced" -> handleOrderPlaced((OrderPlacedEvent) event);
            case "PaymentProcessed" -> handlePayment((PaymentProcessedEvent) event);
        }
    }
 
    private void handleOrderPlaced(OrderPlacedEvent event) {
        User user = userClient.getUser(event.getUserId());
        emailService.send(
            user.getEmail(),
            "Đã Nhận Đơn Hàng",
            "Đơn hàng " + event.getOrderId() + " của bạn đã được nhận!"
        );
    }
 
    private void handlePayment(PaymentProcessedEvent event) {
        // Chỉ gửi email khi thành công — failure xử lý riêng
        if (event.getStatus() == PaymentStatus.SUCCESS) {
            // Lấy user info và gửi xác nhận...
        }
    }
}

Infrastructure: Kafka Configuration

# application.yml — Spring Boot Kafka config
spring:
  kafka:
    bootstrap-servers: localhost:9092
    producer:
      key-serializer: org.apache.kafka.common.serialization.StringSerializer
      value-serializer: org.springframework.kafka.support.serializer.JsonSerializer
      acks: all  # Chờ tất cả replica acknowledge
      retries: 3
      properties:
        enable.idempotence: true  # Kafka producer idempotency
    consumer:
      key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
      value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer
      auto-offset-reset: earliest
      enable-auto-commit: false  # Manual commit cho reliability
      properties:
        spring.json.trusted.packages: "com.example.events"

Anti-Pattern Phổ Biến

1. Event Soup

Publish event cho mọi thứ — kể cả chi tiết implementation nội bộ — tạo ra nhiễu khiến không thể hiểu hệ thống.

// ❌ Event soup — quá chi tiết
UserTableRowInserted
OrderValidationStarted
InventoryDatabaseQueryExecuted
CacheInvalidated
LogFileRotated
// ✅ Domain event có ý nghĩa
UserRegistered
OrderPlaced
PaymentProcessed
InventoryDepleted

Quy tắc: Chỉ publish event đại diện cho sự kiện business có ý nghĩa mà service khác thực sự quan tâm.

2. Async Everything

Không phải mọi thứ đều nên async. Nếu user đang chờ response và operation mất 50ms, async hóa chỉ thêm complexity mà không có benefit.

// ❌ Over-engineering: async cho lookup đơn giản
User click "View Profile" → publish ViewProfileRequested event
→ Profile Service consume event → publish ProfileLoaded event
→ API Gateway consume event → trả về cho user
// Kết quả: 500ms latency thay vì 50ms, cộng thêm eventual consistency headache
 
// ✅ Thực dụng: sync cho read, async cho write
GET /users/123 → gọi sync trực tiếp → 50ms response
POST /orders → publish OrderPlaced event → "Đang xử lý..." response

3. Temporal Coupling Qua Event

Dùng event để tạo chuỗi synchronous ngụy trang thành async:

// ❌ Chuỗi sync ngụy trang thành event
OrderService publish "PleaseValidateUser" event
→ chờ "UserValidated" event (blocking!)
→ rồi publish "PleaseCheckInventory" event
→ chờ "InventoryChecked" event (blocking!)

Nếu bạn đang chờ response, cứ gọi synchronous. Event dành cho fire-and-forget và phản ứng độc lập.

4. Không Version Event

Thay đổi event schema mà không versioning làm hỏng consumer một cách im lặng:

// Tuần 1: {orderId: "123", amount: 100}
// Tuần 2: {orderId: "123", totalAmount: 100}  ← Breaking change!
// Consumer cũ crash: field "amount" là null

Luôn version schema của bạn. Luôn dùng schema registry trong production.


Tóm Tắt

Event-driven architecture tách biệt các service bằng cách giao tiếp qua event thay vì gọi trực tiếp. Điều này tạo ra hệ thống có khả năng phục hồi, scalable, và mở rộng được — với cái giá là complexity tăng lên về consistency và debug.

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

  • Event thông báo chuyện gì đã xảy ra (thì quá khứ, bất biến)
  • Command yêu cầu hành động (mệnh lệnh, có hướng)
  • Query yêu cầu thông tin (read-only)

Event pattern:

  • Event notification — event mỏng, consumer gọi lại để lấy chi tiết
  • Event-carried state transfer — event béo, consumer tự chủ

Message broker:

  • Kafka — event streaming throughput cao với replay
  • RabbitMQ — routing linh hoạt với message-level acknowledgment
  • Redis Streams — nhẹ, tốt cho pattern đơn giản

Coordination pattern:

  • Choreography — phi tập trung, service phản ứng độc lập (2-3 bước)
  • Orchestration — saga coordinator tập trung quản lý flow (4+ bước)

Reliability pattern:

  • Idempotency — xử lý mỗi event đúng một lần bằng idempotency key
  • Dead letter queue — bắt message fail để điều tra
  • Schema evolution — thêm field với default, không bao giờ xóa hay đổi tên

Khi nào dùng event-driven architecture:

  • Nhiều service cần phản ứng với cùng business event
  • Bạn cần temporal decoupling (producer/consumer không cần available đồng thời)
  • Bạn đang xây hệ thống cần scale read và write độc lập
  • Bạn cần audit trail mọi thứ đã xảy ra

Khi nào KHÔNG dùng event-driven architecture:

  • CRUD operation đơn giản với user mong đợi synchronous
  • Hệ thống nhỏ nơi complexity không đáng
  • Khi bạn không có infrastructure cho message broker và monitoring

Event đại diện cho ngôn ngữ của business. Xác định đúng event, kiến trúc sẽ theo sau.


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

Đây là bài viết thứ 6 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.