Con bug bắt đầu bằng một đơn hàng đã nằm trong database nhưng không đi tới đâu nữa. Khách hàng đã bấm checkout. Row order vẫn ở đó. Payment service thì chờ một event không bao giờ tới. Support nhìn ra hình dạng của vấn đề trước cả khi engineering gọi được tên nó: một phần của system biết sự thật, còn phần còn lại vẫn đang sống ở ngày hôm qua.
Đây là dual-write problem rất quen thuộc. Một service thường cần làm hai việc cho cùng một business action: ghi vào database của chính nó và publish một message cho service khác. Nếu database commit thành công nhưng publish lên broker fail, system có một sự thật cục bộ mà không ai khác nghe thấy. Nếu publish trước rồi database write fail, service khác có thể phản ứng với một sự kiện chưa bao giờ thật sự xảy ra. Khoảng hở giữa hai write đó rất nhỏ trong code, nhưng rất lớn trong production.
Outbox Pattern là một cách bình tĩnh để đóng khoảng hở đó. Thay vì ghi business data rồi publish thẳng ra broker, service ghi business data và một message record vào bảng outbox trong cùng database transaction. Nếu transaction commit, cả state change và ý định publish cùng commit. Nếu rollback, cả hai cùng biến mất. Sau đó một relay riêng đọc bảng outbox và publish message ra broker.
Pattern này hiệu quả vì nó đẩy boundary kém tin cậy sang một nơi an toàn hơn. Database transaction vẫn là local transaction, nơi ACID còn dùng được. Việc publish ra broker trở thành asynchronous và có thể retry. Nếu relay crash sau khi đọc một row, nó có thể đọc lại. Nếu broker đang down, các row outbox chờ ở đó. System có thể trễ, nhưng không còn âm thầm bị tách giữa state đã lưu và event bị mất.
Có một trade-off quan trọng: outbox delivery thường là at-least-once, không phải exactly-once. Relay có thể publish cùng một message hai lần nếu nó crash sau khi publish nhưng trước khi đánh dấu row đã gửi. Vì vậy consumer phải idempotent. Chúng cần message id, event id hoặc business key để nói rằng: tôi đã xử lý việc này rồi. Outbox sửa phía producer của dual write, nhưng không thay consumer thiết kế retry cẩn thận.
Ordering cũng cần được chú ý. Nếu một aggregate phát ra nhiều event, relay nên publish chúng theo thứ tự dễ dự đoán cho aggregate đó, hoặc consumer phải được thiết kế để không phụ thuộc vào global order. Global ordering trên toàn system thường đắt và không cần thiết. Câu hỏi hữu ích nhỏ hơn là: event nào phải được nhìn thấy đúng thứ tự để business object này còn có nghĩa?
Bản thân bảng outbox cũng cần được vận hành tử tế. Nó cần status field, timestamp, retry count, error detail và cleanup policy. Row đã publish không nên lớn mãi. Row fail không nên biến mất không dấu vết. Dashboard về pending count, message cũ nhất chưa xử lý, publish latency và lỗi lặp lại biến outbox từ một cơ chế ẩn thành thứ team có thể vận hành.
Có vài cách implement. Một số team dùng polling relay, query các row chưa gửi mỗi vài giây. Team khác dùng change data capture để stream các row outbox đã commit ra ngoài database. Polling đơn giản hơn và thường đủ tốt. CDC có thể giảm latency và giảm tải database ở scale cao hơn, nhưng thêm platform complexity. Lựa chọn tốt nhất phụ thuộc vào volume, độ trưởng thành của team và service khác cần phản ứng nhanh tới đâu.
Outbox Pattern không phải lý do để publish mọi chi tiết nội bộ thành event. Một event tốt vẫn cần meaning rõ, schema ổn định, versioning plan và owner. Nếu domain event mơ hồ, outbox chỉ giao những message mơ hồ đó đáng tin hơn. Architecture vẫn phải quyết định chuyện gì đã xảy ra trong business, ai cần biết, và field nào đủ an toàn để hứa lâu dài.
Tôi thích outbox vì nó không kịch tính. Nó chấp nhận rằng database và broker không có một transaction chung dễ dàng, rồi dựng một cây cầu nhỏ bằng những công cụ mỗi bên có thể tin. Bài học rất thực tế: khi một action vừa phải được lưu vừa phải được thông báo, đừng hy vọng hai write rời nhau sẽ luôn đi cùng nhờ may mắn. Hãy đặt lời thông báo vào cùng commit, relay nó kiên nhẫn, và làm mỗi receiver an toàn khi nghe nó hơn một lần.
Nếu team của bạn từng thấy event bị mất, webhook tới hai lần, hoặc một workflow đứng im vì một service biết điều mà service khác không biết, outbox rất đáng được mang vào cuộc thảo luận. Nó không nhất thiết làm system đơn giản hơn, nhưng có thể làm failure mode trung thực hơn, dễ nhìn hơn và dễ sửa hơn.