Menu
ESC

Nhập từ khóa để tìm kiếm

↑↓ Di chuyển
Enter Mở
ESC Đóng

Đang tải...

Bài 59 — Technical Debt — Tracking ADR-level

Architecture Decision-Making Bài 59/60

Mở đầu — vì sao bài này quan trọng

Có một sự thật đắng lòng mà hầu như kiến trúc sư nào cũng gặp: technical debt (nợ kỹ thuật) tích lũy trong im lặng. Không ai ký giấy nợ. Không có hóa đơn nào gửi về hàng tháng. Nó chỉ đơn giản là những câu nói vô hại trong lúc code review hay họp sprint: "Thôi tạm thời hardcode cái này, sau sửa", "Chưa cần index đâu, dữ liệu còn ít", "Để service này gọi thẳng database của service kia cho nhanh, refactor sau". Từng câu nhỏ xíu, nhưng cộng dồn lại sau hai năm, bạn có một hệ thống mà không ai dám đụng vào.

Vấn đề cốt lõi không phải là bạn tạo ra nợ — tạo nợ kỹ thuật đôi khi là quyết định hoàn toàn đúng đắn để kịp deadline, để test thị trường, để không over-engineer khi chưa biết sản phẩm có sống được không. Vấn đề là nợ được tạo ra mà không được ghi nhận (untracked). Mà thứ gì không được ghi nhận thì không có tín hiệu ưu tiên (priority signal). Không có tín hiệu ưu tiên thì không bao giờ được đưa vào backlog một cách có chủ đích. Và thế là "sửa sau" trở thành "không bao giờ sửa".

Trong toàn bộ khóa học này, chúng ta đã học cách ra quyết định kiến trúc và ghi lại chúng bằng ADR (Architecture Decision Record). Bài học hôm nay ghép hai mảnh lại: dùng chính cơ chế ADR để theo dõi nợ kỹ thuật ở tầm kiến trúc. Đây là kỹ năng phân biệt một Senior "code giỏi" với một Principal Architect "quản lý được hệ quả dài hạn của quyết định". Nếu bạn đang trên con đường đó, bài này là mảnh ghép mà nhiều người bỏ qua — và trả giá đắt.

Khái niệm cốt lõi

Technical debt là gì và không phải là gì

Ẩn dụ "nợ" của Ward Cunningham rất chuẩn: bạn vay một khoản (ship nhanh bằng giải pháp chưa hoàn hảo) để đổi lấy giá trị ngay lúc này (kịp ra thị trường). Nhưng bạn phải trả lãi — mỗi lần chạm vào phần code đó, bạn tốn thêm thời gian, dễ gây bug hơn, khó mở rộng hơn. Nếu không trả gốc, lãi cứ chồng lên mãi.

Cần phân biệt rõ:

  • Nợ có chủ đích, cẩn trọng (deliberate & prudent): "Chúng ta biết cách làm đúng, nhưng chọn làm nhanh để kịp ra mắt, và chấp nhận trả sau." — Đây là nợ tốt, cần được ghi lại.
  • Nợ vô tình, liều lĩnh (inadvertent & reckless): "Chúng ta không biết là mình đang làm sai." — Đây là nợ do thiếu kiến thức, nguy hiểm nhất.
Bài học này tập trung vào nợ kiến trúc (architectural debt): những quyết định ở tầm hệ thống — chọn sai database tạm thời, coupling giữa các service, thiếu bounded context rõ ràng, dùng sync call ở chỗ đáng lẽ phải async. Đây là loại nợ lãi suất cao nhất vì sửa nó thường phải đụng nhiều thành phần, migration dữ liệu, và có rủi ro downtime.

Tại sao ADR là công cụ đúng để track nợ kiến trúc

Bạn có thể hỏi: "Sao không dùng Jira ticket, hay một cái file TODO.md, hay tag // TODO trong code?". Câu trả lời nằm ở tầm (altitude) của thông tin.

Một // TODO: fix this trong code chỉ nói cái gì cần sửa, ở đúng một dòng. Nó không giải thích tại sao ta chấp nhận nó, điều kiện nào thì phải trả nợ, hệ quả nếu không trả. Một Jira ticket thì dễ bị đóng lại, dễ trôi vào backlog vô tận, và không gắn với ngữ cảnh quyết định kiến trúc.

ADR thì khác. Nó vốn được thiết kế để ghi lại quyết định + ngữ cảnh + hệ quả. Nợ kỹ thuật ở tầm kiến trúc, về bản chất, chính là hệ quả (consequences) của một quyết định. Vì vậy nguyên tắc vàng của bài này là:

> Mỗi quyết định "sẽ sửa sau" ở tầm kiến trúc → phải sinh ra một ADR (hoặc một mục Consequences trong ADR hiện có) ghi rõ khoản nợ đó.

Cấu trúc một "Debt ADR"

Bạn không cần một định dạng hoàn toàn mới. Hãy mở rộng ADR chuẩn với vài trường dành riêng cho nợ:

  • Title: Ngắn gọn, ví dụ "ADR-042: Chấp nhận shared database giữa Order và Inventory service".
  • Status: Accepted (Debt) — đánh dấu rõ đây là quyết định tạo nợ, không phải giải pháp cuối.
  • Context: Vì sao ta phải làm nhanh? Deadline nào, áp lực gì, ràng buộc gì.
  • Decision: Giải pháp tạm thời cụ thể là gì.
  • Debt incurred (Nợ phát sinh): Mô tả chính xác khoản nợ. Ví dụ "Hai service coupling ở tầng dữ liệu, không thể deploy độc lập, không scale riêng được."
  • Interest / Cost of carry (Lãi phải trả): Cái giá của việc giữ nợ này. Ví dụ "Mỗi thay đổi schema phải điều phối 2 team, tăng ~30% thời gian dev cho các feature liên quan."
  • Repayment trigger (Điều kiện phải trả): Đây là trường quan trọng nhất, thứ biến nợ mờ mịt thành nợ có kỳ hạn. Ví dụ "Khi Order service đạt >500 TPS, hoặc khi cần deploy độc lập, hoặc chậm nhất là Q3/2026."
  • Estimated principal (Ước tính gốc): Ước lượng công sức để trả — "khoảng 3 tuần cho 2 dev, có migration dữ liệu."
  • Owner: Ai chịu trách nhiệm theo dõi khoản nợ này.
Điểm mấu chốt: repayment triggerowner là hai thứ mà TODO comment và Jira ticket thông thường luôn thiếu. Có chúng, khoản nợ mới có "tín hiệu ưu tiên" thực sự.

Debt register — sổ cái nợ kiến trúc

Khi bạn có nhiều Debt ADR, hãy tổng hợp chúng vào một debt register (sổ cái nợ) — một bảng duy nhất liệt kê: ID ADR, mô tả ngắn, mức lãi (High/Medium/Low), trigger, owner, trạng thái. Đây là thứ bạn mang ra bàn trong các buổi Architecture Review (bài 57) và khi lập kế hoạch quý. Nó biến nợ từ thứ vô hình thành một bảng cân đối kế toán mà lãnh đạo kỹ thuật có thể nhìn và quyết định.

Tình huống thực tế

Tình huống 1 — Sàn TMĐT tăng trưởng nóng và khoản nợ "shared database"

Một sàn thương mại điện tử ở TP.HCM (gọi là ShopViet) trong giai đoạn tăng trưởng đã tách hệ thống thành microservices. Nhưng để kịp mùa sale 12/12, team quyết định để service Order và service Inventory dùng chung một database thay vì tách riêng và gọi qua API. Lý do rất hợp lý lúc đó: viết API đồng bộ tồn kho tốn 3 tuần, mà chỉ còn 2 tuần đến sale.

Sai lầm không nằm ở quyết định. Sai lầm là họ chỉ nói miệng trong standup "sau sale mình tách ra nhé". Không ai ghi lại. Kết quả: sau sale, mọi người lao vào feature mới, khoản nợ chìm nghỉm. Mười tháng sau, khi Order đạt đỉnh và team muốn scale riêng phần đặt hàng, họ mới phát hiện hai service dính chặt ở tầng database — mọi thay đổi schema đều phải điều phối chéo, và một lần deadlock trong DB đã kéo sập cả hai chức năng trong 40 phút giữa giờ cao điểm.

Bài học rút ra: Nếu ngay từ đầu họ viết một Debt ADR với repayment trigger là "khi Order vượt 300 TPS hoặc trước Q2", khoản nợ đã có tín hiệu. Khi metric chạm ngưỡng, hệ thống theo dõi (hoặc buổi review hàng quý) sẽ tự nhắc. Chi phí trả nợ đúng lúc ước tính 3 tuần; chi phí trả nợ trong hoảng loạn sau sự cố là 7 tuần cộng với uy tín bị tổn hại.

Tình huống 2 — Fintech và khoản nợ "bỏ qua idempotency"

Một startup fintech ở Singapore xây dựng API xử lý giao dịch nạp tiền. Để ra MVP nhanh, họ bỏ qua idempotency (bài 39) ở endpoint nạp tiền — chấp nhận rủi ro nạp trùng nếu client retry. Điều đáng khen là kiến trúc sư trưởng đã viết một Debt ADR rõ ràng:

  • Debt incurred: Endpoint /topup không idempotent, retry có thể tạo giao dịch trùng.
  • Interest: Mỗi tháng phát sinh ~5-10 case nạp trùng phải hoàn tiền thủ công, tốn công vận hành và rủi ro pháp lý.
  • Repayment trigger: "Khi khối lượng giao dịch vượt 10.000/ngày, HOẶC ngay khi có khách hàng doanh nghiệp đầu tiên onboard."
  • Owner: Trưởng nhóm Payments.
Sáu tháng sau, một khách hàng doanh nghiệp lớn ký hợp đồng. Vì trigger đã được viết rõ và nằm trong debt register được review hàng tháng, khoản nợ tự động nhảy lên đầu backlog trước cả khi khách kịp lên production. Họ trả nợ trong 2 tuần, không sự cố nào xảy ra.

Bài học rút ra: Nợ được ghi nhận với trigger rõ ràng không đáng sợ — nó là công cụ tăng tốc hợp pháp. Chính việc biết mình nợ gì và khi nào phải trả cho phép team ship nhanh một cách tự tin thay vì liều lĩnh.

Tình huống 3 — Ngân hàng số và khoản nợ vô hình không ai ghi

Một ngân hàng số tại Việt Nam kế thừa một module tính lãi được viết vội bằng batch job chạy đêm, với logic tính toán nhét thẳng vào stored procedure dài 2.000 dòng. Không có ADR, không có comment giải thích, người viết đã nghỉ việc. Đây là nợ vô tình và liều lĩnh — không ai còn biết vì sao nó được làm như vậy.

Khi cần đổi cách tính lãi theo quy định mới của Ngân hàng Nhà nước, không ai dám sửa stored procedure đó vì sợ tính sai lãi cho hàng trăm nghìn tài khoản. Họ mất 4 tháng chỉ để hiểu lại logic trước khi dám chạm vào — phần lớn chi phí này là lãi tích lũy của một khoản nợ chưa từng được ghi nhận.

Bài học rút ra: Không phải mọi khoản nợ đều có thể tránh, nhưng bạn có thể truy vết ngược (retroactive ADR). Khi phát hiện một vùng nợ vô hình, hãy dừng lại viết một ADR mô tả hiện trạng, mức lãi, và trigger — biến nợ vô hình thành nợ hữu hình để nó bước vào tầm ngắm ưu tiên, thay vì tiếp tục âm thầm sinh lãi.

Hướng dẫn từng bước

Đây là quy trình thực tế để thiết lập việc track nợ ở tầm ADR trong team của bạn:

  • Đặt "định nghĩa nợ kiến trúc" cho team. Thống nhất: khoản nợ nào đủ tầm để cần ADR? Quy tắc đơn giản: nếu quyết định "sửa sau" ảnh hưởng đến ranh giới service, mô hình dữ liệu, giao thức giao tiếp, hoặc một NFR quan trọng (bảo mật, độ tin cậy) — thì cần Debt ADR. Nếu chỉ là một hàm cần refactor gọn hơn — dùng TODO comment là đủ.
  • Thêm trạng thái Accepted (Debt) vào quy trình ADR. Khi review một ADR mà đội nhận ra đây là giải pháp tạm, đánh dấu status này ngay. Nó là cờ hiệu để tổng hợp về sau.
  • Bắt buộc điền hai trường: Repayment trigger và Owner. Không có trigger thì ADR đó chưa được duyệt. Trigger nên gắn với một tín hiệu đo được: một metric (TPS, latency p99, dung lượng dữ liệu), một sự kiện kinh doanh (khách enterprise đầu tiên, mở thị trường mới), hoặc tệ nhất là một mốc thời gian cứng.
  • Lập debt register và để nó ở nơi ai cũng thấy. Một trang wiki, một bảng trong repo docs/adr/, hoặc một bảng trong công cụ quản lý. Mỗi dòng link tới ADR gốc.
  • Đưa debt register vào nhịp review định kỳ. Mỗi buổi Architecture Review (bài 57) hoặc đầu mỗi quý, duyệt lại register: trigger nào đã chạm? khoản nào lãi đang tăng? cần đưa khoản nào vào sprint tới?
  • Gắn trigger với cảnh báo tự động khi có thể. Nếu trigger là "Order > 300 TPS", hãy tạo một alert trong hệ thống observability (bài 43) trỏ tới ADR. Khi metric chạm ngưỡng, con người được nhắc — nợ không còn phụ thuộc vào trí nhớ.
  • Khi trả xong, cập nhật status thành Superseded và ghi ADR mới. Vòng đời khép kín: nợ được ghi nhận → theo dõi → trả → đóng sổ. Đừng xóa ADR cũ; nó là lịch sử quyết định quý giá.

Lỗi thường gặp & mẹo

Lỗi 1 — Trigger mơ hồ kiểu "khi có thời gian". Đây là trigger vô dụng nhất, vì "có thời gian" không bao giờ đến. Mẹo: mọi trigger phải trả lời được câu "ai/cái gì sẽ báo cho tôi biết đã đến lúc?". Nếu không trả lời được, trigger chưa đủ tốt.

Lỗi 2 — Ghi nợ nhưng không định lượng lãi. Nếu bạn không ước lượng được chi phí giữ khoản nợ, lãnh đạo không có cơ sở ưu tiên nó so với feature mới. Mẹo: dù chỉ là ước lượng thô ("làm chậm ~20% mỗi feature liên quan"), con số vẫn thắng cảm tính.

Lỗi 3 — Coi mọi thứ đều là nợ cần ADR. Ngược lại của việc bỏ quên là việc bureaucracy hóa: viết ADR cho từng biến đặt tên xấu. Mẹo: giữ ngưỡng ở tầm kiến trúc. ADR cho nợ nên tính bằng đơn vị chục, không phải hàng trăm.

Lỗi 4 — Nợ có owner là "cả team". Cái gì thuộc về mọi người thì không thuộc về ai. Mẹo: mỗi Debt ADR phải có đúng một con người chịu trách nhiệm theo dõi.

Lỗi 5 — Không phân biệt nợ với bug hay thiếu sót. Nợ là quyết định có ý thức đánh đổi. Nếu nó là lỗi thuần túy, nó thuộc bug tracker, không phải debt register. Mẹo: câu hỏi phân loại — "Lúc đó ta có biết cách làm đúng không, và cố tình chọn khác?" Nếu có → nợ. Nếu không → thiếu sót/bug.

Mẹo nâng cao — "debt budget". Một số team đặt ngân sách nợ mỗi quý: dành sẵn ví dụ 15-20% năng lực sprint để trả nợ ưu tiên cao trong register. Điều này biến việc trả nợ thành hoạt động thường xuyên, có kỷ luật, thay vì chờ đến khi khủng hoảng.

Bài tập thực hành

  • Viết một Debt ADR retroactive. Chọn một khoản nợ kiến trúc có thật trong dự án hiện tại của bạn (hoặc một dự án cũ). Viết đầy đủ một ADR theo cấu trúc trong bài: Context, Decision, Debt incurred, Interest, Repayment trigger, Estimated principal, Owner. Đặc biệt tập trung viết một repayment trigger đo được — không được dùng cụm "khi rảnh".
  • Xây debt register mini. Liệt kê 3-5 khoản nợ kiến trúc mà bạn biết trong hệ thống của mình vào một bảng: ID | Mô tả | Lãi (H/M/L) | Trigger | Owner | Status. Sắp xếp theo mức lãi giảm dần. Nhìn vào bảng, khoản nào bạn thấy giật mình vì "lẽ ra phải trả từ lâu"?
  • Thiết kế một trigger tự động. Với khoản nợ có lãi cao nhất trong bài tập 2, hãy mô tả (bằng lời) cách bạn sẽ biến repayment trigger của nó thành một alert tự động trong hệ thống observability: metric nào, ngưỡng bao nhiêu, cảnh báo gửi cho ai.
  • Phân loại 5 phát biểu. Với mỗi câu sau, xác định: đây là nợ có chủ đích/cẩn trọng, nợ vô tình/liều lĩnh, hay không phải nợ kiến trúc: (a) "Hardcode config vì chưa có config service, sẽ tách khi có service thứ 3"; (b) "Không biết là query này thiếu index"; (c) "Đặt tên biến hơi xấu"; (d) "Dùng sync call giữa 2 service vì kịp deadline, biết là nên async"; (e) "Copy nguyên logic tính thuế sang service mới vì không hiểu code gốc".

Tóm tắt

Nợ kỹ thuật ở tầm kiến trúc nguy hiểm không phải vì nó tồn tại, mà vì nó tích lũy trong im lặng, không được ghi nhận, nên không có tín hiệu ưu tiên. Giải pháp không phải là ngừng vay nợ — vay nợ thông minh là một công cụ tăng tốc hợp pháp. Giải pháp là biến mọi quyết định "sửa sau" ở tầm kiến trúc thành một Debt ADR có cấu trúc rõ ràng.

Điều làm cho Debt ADR khác biệt với một TODO comment hay Jira ticket thông thường là hai trường sống còn: repayment trigger đo được (biến nợ vô kỳ hạn thành nợ có kỳ hạn) và owner cụ thể (biến trách nhiệm chung thành trách nhiệm rõ). Tổng hợp các Debt ADR vào một debt register, mang nó ra bàn trong các buổi Architecture Review, gắn trigger với alert tự động khi có thể, và khép vòng đời khi trả xong.

Ba tình huống — sàn TMĐT với shared database, fintech với idempotency, và ngân hàng số với nợ vô hình — cho thấy cùng một chân lý: khoản nợ được ghi nhận với tín hiệu rõ ràng thì được trả đúng lúc với chi phí thấp; khoản nợ im lặng thì bùng phát trong khủng hoảng với chi phí gấp nhiều lần. Với tư cách một kiến trúc sư đang tiến tới tầm Principal, khả năng quản lý bảng cân đối nợ kỹ thuật này chính là thứ phân biệt bạn với người chỉ biết viết code sạch cho hôm nay.