Viết ADR tốt: ví dụ đầy đủ, vòng đời và supersede Trung cấp
Thời gian đọc ước tính: 6 phút
Cần đọc trước
Trang này trả lời: một ADR tốt khác ADR tồi ở đâu, trông thế nào khi đầy đủ, và đổi ý thì làm gì với ADR cũ.
Trước / sau: một ADR tồi và bản sửa
Phần tiêu đề “Trước / sau: một ADR tồi và bản sửa”ADR outbox gặp rất nhiều trong review
ADR tồi
# ADR 7: Outbox
Status: accepted
## DecisionDùng outbox pattern cho event.
## Consequences- Đảm bảo không mất event- Kiến trúc chuẩn microservicesADR tốt (rút gọn)
# 0012. Phát event đơn hàng qua transactional outbox thay vì publish trực tiếp sau commit
## ContextCommit DB rồi producer.send() là dual write. Tháng 2 có 2 lần deploy kill processgiữa hai bước: đơn đã paid nhưng Shipping không nhận event.Phương án đã cân nhắc: retry trong process, Kafka transaction, outbox + polling, outbox + CDC.
## DecisionChúng ta sẽ ghi order và dòng outbox trong cùng một transaction; relay riêng publish.
## ConsequencesXấu: event trễ thêm một chu kỳ poll; vẫn at-least-once, consumer BẮT BUỘC idempotent.Vì sao
- Bản tồi không có bối cảnh, không có phương án bị loại, tiêu đề không nói quyết định gì.
- Hệ quả chỉ toàn tốt; “kiến trúc chuẩn” là lý do cargo-cult.
- Hai năm sau, nó không giúp ai quyết định giữ hay bỏ outbox.
Khác biệt chính của bản tốt:
| Phần | Quy tắc | Nguồn |
|---|---|---|
| Tiêu đề | Là câu quyết định, không phải tên công nghệ | MADR: “representative of solved problem and found solution” |
| Context | Sự kiện cụ thể và ràng buộc, viết trung tính | Nygard: “value-neutral” |
| Decision | Câu chủ động: “Chúng ta sẽ …” | Nygard: active voice, “We will …” |
| Consequences | Cả tốt, xấu và trung tính | Nygard: mọi hệ quả đều ảnh hưởng tương lai |
Ví dụ ADR đầy đủ
Phần tiêu đề “Ví dụ ADR đầy đủ”Kịch bản giả định cho order service NestJS + PostgreSQL + Kafka. Số liệu trong Context là của kịch bản, không phải số đo.
# 0012. Phát event đơn hàng qua transactional outbox thay vì publish trực tiếp sau commit
- Status: accepted- Ngày: 2026-03-04- Người quyết định: team Checkout (Lan, Minh), consulted: team Shipping, SRE- Supersedes: không. Liên quan: ADR-0004 (Kafka là event bus)
## Context
OrderService hiện commit `orders.status = 'paid'` rồi gọi `producer.send('OrderPaid')`trong cùng request handler. Đây là dual write: DB và Kafka có transaction độc lập.
Trong tháng 2 có 2 lần deploy làm process bị kill giữa commit và send. Kết quả làmột số đơn đã paid nhưng Shipping không nhận event; CS phát hiện sau 1–2 ngày khikhách hỏi. Đổi thứ tự (send trước, commit sau) tạo lỗi ngược: Shipping xử lý đơnmà DB đã rollback.
Ràng buộc: không được mất event OrderPaid; độ trễ tới Shipping vài giây là chấp nhậnđược; team chưa vận hành Kafka Connect; consumer của Shipping đã dedupe theo eventId.
Phương án đã cân nhắc:1. Retry `send` trong process: không đóng được khoảng hở khi chính process chết.2. Kafka transaction: không bao gồm transaction PostgreSQL, vẫn là dual write.3. Outbox + relay polling: dùng hạ tầng sẵn có (PostgreSQL), at-least-once.4. Outbox + CDC (Debezium): độ trễ thấp hơn nhưng cần Kafka Connect và replication slot.
## Decision
Chúng ta sẽ ghi thay đổi nghiệp vụ và một dòng vào bảng `outbox` trong cùng mộtPostgreSQL transaction. Một relay riêng (process worker, không chạy trong API pod)claim dòng pending theo lô bằng `FOR UPDATE SKIP LOCKED`, publish lên Kafka với keylà `orderId`, và chỉ đánh dấu `published_at` sau khi Kafka ack.
Chúng ta chưa dùng CDC. Xem lại khi throughput outbox vượt khả năng của pollinghoặc khi platform team cung cấp Kafka Connect dùng chung.
## Status
Accepted.
## Consequences
Tốt:- Không còn trạng thái "đơn paid nhưng không ai nhớ phải phát event".- Kafka ngừng tạm thời không làm fail request thanh toán; outbox tích backlog.
Xấu:- Event trễ thêm tối đa một chu kỳ poll cộng thời gian publish.- Vẫn at-least-once: relay có thể publish trùng. Mọi consumer mới BẮT BUỘC idempotent theo eventId.- Thêm một bảng chỉ tăng: cần job dọn dòng đã sent và alert cho dòng pending cũ.- Thêm một process phải deploy, giám sát, và có runbook (docs/runbooks/outbox-backlog.md).- Nhiều relay song song có thể phát đảo thứ tự hai event cùng aggregate; consumer kiểm tra version, hoặc chạy một relay cho mỗi shard.
Trung tính:- Payload outbox trở thành contract công khai; đổi schema phải theo quy trình tương thích ngược như API.Đọc lại phần Consequences: đó là phần người sau cần nhất. Nó nói trước chi phí, nên khi ai đó than “sao event chậm vài giây”, câu trả lời đã nằm sẵn, kèm điều kiện để xem lại.
Vòng đời: bất biến, supersede thay vì sửa
Phần tiêu đề “Vòng đời: bất biến, supersede thay vì sửa”Đang tải diagram…
Quy tắc vận hành:
- ADR accepted là bất biến. Chỉ sửa lỗi chính tả và trường status/link. Nygard: quyết định cũ được giữ lại, đánh dấu superseded, vì biết đã từng quyết định gì vẫn có giá trị.
- Đổi ý = ADR mới. ADR-0031 “Chuyển relay outbox sang Debezium CDC” ghi
Supersedes: ADR-0012; ADR-0012 đổi status thànhSuperseded by ADR-0031. Hai file cùng tồn tại. - Số tăng đơn điệu, không dùng lại. Nygard: “numbered sequentially and monotonically. Numbers will not be reused.” ADR bị reject vẫn giữ số.
- Proposed sống trong PR. Review ADR như review code; merge = accepted. Không cần công cụ riêng.
Đang tải diagram…
Thư mục và đánh số
Phần tiêu đề “Thư mục và đánh số”Nygard dùng doc/arch/, MADR dùng docs/decisions/. Chọn một và giữ nhất quán:
docs/ adr/ README.md # index: số, tiêu đề, status 0001-ghi-lai-quyet-dinh-bang-adr.md 0004-dung-kafka-lam-event-bus.md 0012-phat-event-qua-transactional-outbox.md # Superseded by 0031 0031-chuyen-relay-outbox-sang-cdc.md runbooks/ outbox-backlog.md architecture.md # sơ đồ C4 mức 1–2README.mdADR-0001 kinh điển là “Ghi lại quyết định kiến trúc bằng ADR”: nó ghi chính quy ước này.
Tiếp theo: Design doc, Diátaxis, runbook và C4.