Bỏ qua để đến nội dung
Knowledge Hub

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

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ũ.

ADR outbox gặp rất nhiều trong review

ADR tồi

# ADR 7: Outbox
Status: accepted
## Decision
Dùng outbox pattern cho event.
## Consequences
- Đảm bảo không mất event
- Kiến trúc chuẩn microservices

ADR 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
## Context
Commit DB rồi producer.send() là dual write. Tháng 2 có 2 lần deploy kill process
giữ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.
## Decision
Chúng ta sẽ ghi order và dòng outbox trong cùng một transaction; relay riêng publish.
## Consequences
Xấ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

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 khi
khách hỏi. Đổi thứ tự (send trước, commit sau) tạo lỗi ngược: Shipping xử lý đơn
mà 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ột
PostgreSQL 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 key
là `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 polling
hoặ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:

  1. 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ị.
  2. Đổi ý = ADR mới. ADR-0031 “Chuyển relay outbox sang Debezium CDC” ghi Supersedes: ADR-0012; ADR-0012 đổi status thành Superseded by ADR-0031. Hai file cùng tồn tại.
  3. 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ố.
  4. 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…

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–2
README.md

ADR-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.