Event-driven Architecture — Bỏ gọi API, nói chuyện qua sự kiện
Mở đầu
Chắc anh em từng gặp cảnh: một cái action nhỏ như "đặt hàng" mà phải gọi orderService.PlaceOrder() rồi paymentService.Charge(), rồi notificationService.SendEmail(), inventoryService.Reserve()... Mỗi service phải biết mặt hết tụi kia, cứ thêm dịch vụ mới là phải sửa code cũ. Đó là lúc chúng ta cần event-driven architecture (EDA) — thay vì gọi nhau trực tiếp, các service chỉ cần phát sự kiện và lắng nghe những gì mình quan tâm.
Sự kiện (event) là gì?
Sự kiện là một fact đã xảy ra trong quá khứ, không thể đổi — ví dụ OrderPlaced, PaymentSucceeded, InventoryReserved. Điểm mấu chốt: tên sự kiện phải ở thì quá khứ, vì nó mô tả điều đã xảy ra, không phải là một mệnh lệnh.
Một event thường có:
{
"id": "evt_9f3a...",
"type": "OrderPlaced",
"occurredAt": "2026-08-18T09:00:00Z",
"data": { "orderId": "ord_123", "customerId": "cus_7", "total": 250000 }
}
Event id và occurredAt cực kỳ quan trọng — tụi nó giúp consumer deduplicate và biết thứ tự thật của sự kiện, chứ không phụ thuộc thứ tự nhận.
Nguyên lý decouple
Với kiến trúc gọi hàm truyền thống, producer (người phát) phải biết consumer (người xử lý) là ai. Còn với EDA, producer chỉ cần phát event lên event bus (Kafka, RabbitMQ, NATS...) và không cần biết ai sẽ nghe:
// Producer — chỉ phát, không cần biết ai xử lý
type OrderService struct {
bus EventPublisher
}
func (s *OrderService) PlaceOrder(ctx context.Context, o Order) error {
if err := s.repo.Save(ctx, o); err != nil {
return err
}
return s.bus.Publish(ctx, Event{
Type: "OrderPlaced",
ID: uuid.NewString(),
Data: o,
})
}
// Consumer — subscribe cái mình quan tâm
func main() {
bus.Subscribe("OrderPlaced", func(ctx context.Context, e Event) error {
return notification.SendEmail(ctx, e.Data)
})
}
Thêm một service mới (ví dụ loyaltyService muốn cộng điểm khi có đơn hàng) chỉ là thêm một subscriber — không đụng tới OrderService. Hệ thống mở rộng không ngừng, đây là lý do EDA hợp với microservices.
Outbox pattern — tránh mất event
Cái bẫy kinh điển của EDA là double-write problem: vừa ghi database vừa publish event, nếu publish fail thì mất event hoặc dữ liệu lệch. Giải pháp là outbox pattern: ghi event vào bảng outbox cùng transaction với nghiệp vụ:
BEGIN;
INSERT INTO orders (id, customer_id, total) VALUES ('ord_123', 'cus_7', 250000);
INSERT INTO outbox (id, event_type, payload, created_at)
VALUES ('evt_9f3a...', 'OrderPlaced', '{"orderId":"ord_123"}', now());
COMMIT;
Sau đó một worker (ví dụ chạy mỗi giây) quét outbox và publish lên bus. Nhờ nằm trong cùng transaction, event không bao giờ mất — nếu publish fail thì lần quét sau sẽ gửi lại. Đây là pattern tui áp dụng hầu như trong mọi hệ thống cần xử lý sự kiện quan trọng.
Kinh nghiệm thực chiến
- Đặt tên theo domain, thì quá khứ:
OrderPlaced, không phảiPlaceOrder. Điều này ép mình nghĩ đúng bản chất sự kiện. - Idempotent consumer: event có thể gửi lại (retry), nên consumer phải deduplicate bằng
event.id— đừng trừ tiền 2 lần. - Gửi kèm dữ liệu cần thiết (envelope), đừng bắt consumer đi query lại producer, kẻo tái decouple.
- Event schema versioning: sự kiện đã phát rồi là bất biến, thêm field thì version
OrderPlacedV2. - Bắt đầu đơn giản: đừng nhảy vào Kafka ngay khi mới có 2 service. Có thể bắt đầu bằng RabbitMQ, hoặc thậm chí outbox + worker trước.
Kết luận
Event-driven architecture không phải silver bullet — nó thêm độ trễ, khó debug hơn (không còn trace tuần tự), và cần hạ tầng messaging ổn định. Nhưng khi hệ thống đủ lớn, lợi ích decouple và scale vượt xa cái giá phải trả. Hãy bắt đầu từ một flow nghiệp vụ thật, áp dụng outbox từ đầu, và để EDA chứng minh giá trị dần dần.