Tất cả bài viết

Async rồi vẫn coupling — đừng biến consumer thành client ngược

Nhận event xong lại gọi ngược producer để lấy dữ liệu khiến consumer vẫn phụ thuộc đồng bộ. Event-carried state transfer cắt chuỗi lỗi đó.

event-drivenrabbitmqcouplingmicroservicescase-study

Notification-service nhận được event escrow.penalized. Event chỉ có escrowId và bên bị phạt, nên consumer gọi ngược escrow-service để lấy số tiền rồi mới gửi email.

Nhìn qua vẫn là kiến trúc async: producer publish RabbitMQ, consumer xử lý riêng. Nhưng giữa method consumer lại xuất hiện một lời gọi Feign đồng bộ.

Nếu escrow-service đang down, notification-service cũng không xử lý được message. Event bị retry, queue bắt đầu dồn, rồi message rơi vào DLQ. Một outage đã lan sang service vốn được tách ra để tránh chính chuyện đó.

Queue không tự động tạo ra decoupling

Flow ban đầu:

escrow-service
  → publish escrow.penalized { escrowId, party }
  → RabbitMQ
  → notification-service
      → GET /escrows/{id}/transactions
      → gửi email

Consumer không phụ thuộc producer ở thời điểm message được gửi, nhưng lại phụ thuộc producer ở thời điểm message được xử lý. Đây là temporal coupling được dời sang chỗ khác, không phải được loại bỏ.

Queue vẫn có ích cho buffering và retry. Nhưng mục tiêu fault isolation chưa đạt được.

Event mang đủ dữ liệu cần thiết

Nếu producer đã biết số tiền tại thời điểm phát event, hãy đưa nó vào payload:

public record EscrowPenalizedEvent(
    UUID eventId,
    UUID escrowId,
    Party penalizedParty,
    BigDecimal penaltyAmount,
    String currency,
    Instant occurredAt
) {}

Flow mới:

escrow-service
  → publish { escrowId, party, penaltyAmount, currency }
  → RabbitMQ
  → notification-service
      → render email
      → gửi email

Escrow-service có thể down ngay sau khi publish. Consumer vẫn có đủ dữ liệu để hoàn thành công việc.

Pattern này thường được gọi là event-carried state transfer: event không chỉ báo “có chuyện xảy ra”, mà mang phần state mà consumer hợp lệ cần dùng.

Event không cần mang toàn bộ aggregate

Giải pháp không phải serialize nguyên EscrowAccount rồi phát cho mọi nơi. Payload chỉ cần đủ cho business fact đó:

Không đưa field nội bộ không consumer nào cần. Event quá lớn làm tăng coupling theo schema và có thể vô tình lộ dữ liệu nhạy cảm.

Business event khác ledger event

Một lần release escrow có thể tạo nhiều dòng ledger: trả tiền cho seller, hoàn deposit, ghi phí. Không nhất thiết mỗi dòng ledger phải trở thành một event public.

Consumer thường quan tâm business milestone:

escrow.released
escrow.penalized
escrow.refunded

Mỗi event có thể mang các amount đã được tính tại thời điểm transition. Ledger vẫn là audit detail thuộc escrow-service; notification-service không cần biết cấu trúc bảng giao dịch bên trong.

Nhờ vậy producer có thể refactor ledger mà không bắt consumer sửa theo.

Cái giá của event giàu dữ liệu

Event-carried state transfer cũng có trade-off:

Nhưng với notification, audit email hoặc analytics, snapshot tại thời điểm event thường chính là dữ liệu đúng. Email “bạn được hoàn 10 triệu” không nên đổi theo số dư hiện tại một tuần sau.

Khi nào consumer có thể gọi API?

Không phải mọi callback đều sai. Có thể chấp nhận khi:

Nhưng nếu dữ liệu đã tồn tại ngay lúc producer tạo event, gọi ngược chỉ để lấy lại dữ liệu đó thường là dấu hiệu payload đang quá mỏng.

Takeaway

Async transport không bảo đảm kiến trúc đã decoupled. Nếu consumer nhận event rồi phải gọi ngược producer mới làm được việc, failure chain vẫn còn nguyên.

Hãy để event mang đủ state của business fact. Consumer nên có thể xử lý message ngay cả khi producer đang tắt — đó mới là fault isolation mà queue được kỳ vọng mang lại.


Bài liên quan: Message đã vào queue nhưng handler chưa chạy.