Technical debt không phải “code xấu”, nó là chi phí cơ hội và rủi ro tích luỹ vì quyết định trong quá khứ. Một pattern thường gặp: mỗi lần thêm feature billing mất gấp ba lần feature khác. Không phải team chậm, mà vì billing module là nợ systemic: coupling chặt với 4 service khác, schema không version, test coverage thấp. Mỗi PR đụng billing là một cuộc phiêu lưu, không biết sẽ break gì ở đâu.

Nhưng than phiền trong retro “code billing bẩn lắm” không dẫn đến hành động. PM nghe như cảm xúc. Middle+ cần khung để định giá nợ bằng ngôn ngữ mà PM hiểu, risk, cost, timeline, và trả nợ có kế hoạch thay vì rewrite ảo tưởng.


Phân loại nợ

Không phải mọi nợ đều giống nhau. Phân loại giúp chọn đúng chiến lược trả.

Deliberate vs accidental. Deliberate là team biết đang tạo nợ: “ship MVP trước, chấp nhận schema tạm, sẽ refactor sau”. Nợ này có ghi nhận, có owner, có timeline dự kiến. Accidental là nợ team không biết đang tạo, thiếu kiến thức về domain, thiếu thời gian test, copy-paste giải pháp tạm mà quên quay lại. Nợ accidental nguy hiểm hơn vì không ai theo dõi.

Local vs systemic. Local debt nằm trong một module có boundary rõ ràng, function khó đọc, class quá lớn, test thiếu. Refactor incremental an toàn vì blast radius nhỏ. Systemic debt lan qua nhiều module/service, “god service” mà mọi team phụ thuộc, schema không version mà 5 consumer đọc trực tiếp, shared library mà không ai dám upgrade. Systemic debt đắt vì cần coordinate nhiều team.

Ma trận 2×2 (deliberate/accidental × local/systemic) giúp phân loại nhanh mỗi khoản nợ và chọn chiến lược:

  • Local + deliberate: refactor incremental khi có thời gian, risk thấp.
  • Local + accidental: boy scout rule, mỗi PR clean up một chút.
  • Systemic + deliberate: cần initiative có owner, milestone, budget.
  • Systemic + accidental: nguy hiểm nhất, cần freeze surface trước, rồi strangler.

“Lãi suất” nợ: làm sao biết nợ đang ăn lãi

Nợ không phải static, nó tích lũy “lãi suất” theo thời gian. Module billing không ai refactor → mỗi feature mới cần workaround → workaround chồng workaround → engineer mới onboard mất 3 tuần chỉ để hiểu flow billing thay vì 3 ngày.

Ba tín hiệu lãi suất cao cần theo dõi:

Lead time tăng. Feature cùng loại (thêm payment method) Q1 mất 5 ngày, Q3 mất 15 ngày. Nếu không phải do scope lớn hơn, đó là lãi suất nợ.

Incident lặp lại. P1/P2 liên quan cùng vùng code hoặc schema, cùng failure mode. Incident lặp là nợ đang trả lãi bằng downtime.

Onboarding chậm. Engineer mới cần bao lâu để “đụng production an toàn” ở module đó. Nếu câu trả lời là “hỏi anh A, chỉ anh ấy hiểu”, đó là nợ knowledge debt kèm bus factor = 1.

Quan trọng: nếu không đo được gì cụ thể, PM nghe “code xấu” như cảm xúc và không prioritize. Cần số, dù ước lượng.


Chiến lược trả nợ

Strangler fig

Mô hình cây strangler: cây mới bám quanh cây cũ, lớn dần, cuối cùng thay thế hoàn toàn. Trong software: route traffic hoặc migrate API theo slice, giữ hệ cũ chạy song song cho đến khi slice cuối cùng cắt xong.

Strangler hiệu quả nhất khi hệ cũ có boundary rõ (API, message queue, database view) để intercept. Adapter pattern là cách phổ biến: viết adapter mới implement cùng interface, route traffic dần qua adapter mới bằng feature flag, monitor metric, cắt adapter cũ khi 100% traffic đã chuyển.

Strangler thường đi cùng feature flag để route traffic, flag cho phép rollback nhanh nếu implementation mới có bug. Nhớ retire flag sau khi migration xong, không để flag tồn tại vĩnh viễn.

Freeze surface

Khi chưa đủ resource cho strangler, freeze surface: ngừng mở rộng API/schema legacy. Mọi feature mới đi đường mới, service mới, schema mới, API mới. Hệ cũ chỉ maintain, không thêm.

Freeze surface không trả nợ gốc nhưng ngừng tăng lãi, nợ không phình thêm. Đây là chiến lược “rẻ nhất” khi chưa có budget cho strangler.

Incremental refactor có cờ

Feature flag + test regression + metric, tránh “tắt đèn refactor 3 tháng không ai biết progress”. Mỗi increment có metric đo được: test coverage tăng từ X% lên Y%, lead time PR giảm, incident rate giảm.


Nói chuyện với PM bằng risk/cost

Đây là skill quan trọng nhất khi deal với technical debt. Thay vì “code bẩn quá phải refactor”, nói:

“Vùng billing chiếm 30% incident Q2. Mỗi feature billing thêm khoảng 10 ngày so với feature tương đương ở vùng khác (5 ngày). Nếu không tách module và thêm test, ước lượng Q3 mỗi billing feature thêm 12-15 ngày vì coupling tăng. Đề xuất: 10 ngày freeze surface + tách adapter, giảm lead time billing PR xuống mục tiêu 7 ngày trong Q3.”

Kèm một lựa chọn rẻ hơn nếu không đủ budget: “Option B: chỉ freeze surface + test tối thiểu cho 3 path hay break nhất, 5 ngày. Không giảm lead time nhưng giảm incident rate.”

Con số có thể sai, nhưng con số có thể tranh luận, cảm xúc thì không. PM cần ngôn ngữ risk/cost để prioritize debt vs feature trong roadmap.


Khi rewrite là trap

Thực tế đã có nhiều rewrite mất 8 tháng rồi cancel. Dấu hiệu rewrite sẽ fail:

Không ai viết được invariant hệ thống cũ đang đảm bảo. Nếu không biết hệ cũ làm gì đúng, hệ mới sẽ thiếu behavior mà user depend on, và phát hiện sau khi launch.

Không có harness so sánh output. Shadow traffic hoặc parallel run, gửi request đến cả hệ cũ và mới, compare response. Không có harness → không biết hệ mới đã đúng chưa cho đến khi production user phàn nàn.

Timeline “6 tháng” không có milestone đo được mỗi 2 tuần. Rewrite không milestone = waterfall ngụy trang. Mỗi 2 tuần phải có deliverable: “slice X đã migrate, traffic đã route, metric so sánh OK”.

Rewrite đôi khi đúng, khi codebase nhỏ, team hiểu rõ domain, có harness, có milestone. Nhưng middle+ phải chứng minh vì sao strangler không đủ trước khi đề xuất rewrite.


Nợ kiến trúc vs nợ implementation

Hai loại này cần chiến lược khác nhau, nhưng hay bị nhầm.

Implementation debt, code lộn xộn nhưng boundary rõ. Function quá dài, class god object, test thiếu. Refactor an toàn trong module: extract function, split class, thêm test. Risk thấp, timeline ngắn.

Architecture debt, sai coupling giữa bounded context. Service A gọi trực tiếp database service B. Schema shared giữa 5 consumer không có contract. Event schema không version. Cần thay đổi contract, migration dữ liệu, coordinate nhiều team. Risk cao, timeline dài.

Nhầm hai loại dẫn đến “refactor 2 tuần” cho architecture debt, 2 tuần refactor code nhưng coupling vẫn còn vì sai lever. Hoặc initiative 3 tháng cho implementation debt, overkill.


Boy Scout rule có giới hạn

“Để lại sạch hơn một chút mỗi lần đụng” là triết lý tốt cho local debt. Nhưng với systemic debt, thay đổi nhỏ rải rác không có chủ đề dễ tạo regression, engineer A clean up module X theo hướng này, engineer B clean up theo hướng khác, kết quả là code “sạch hơn” nhưng inconsistent hơn.

Systemic debt cần initiative có owner, vision rõ ràng (hệ thống sẽ trông như thế nào sau khi trả xong), và milestone. Boy scout rule bổ sung, không thay thế, initiative.


Deprecation là trả nợ

Ngừng hỗ trợ endpoint cũ, xóa field legacy, giảm số version SDK active, tất cả là giảm entropy. Mỗi endpoint, field, version tồn tại là maintenance cost: test, documentation, backward compatibility.

Deprecation cần lịch trình công khai: changelog, email API consumers, sunset date. Không communicate → consumer không migrate → deprecation bị hoãn vô hạn → nợ tái sinh.


“Not invented here” cũng là nợ

Framework nội bộ khi ecosystem đã có giải pháp mature có thể là deliberate debt (team cần control đặc biệt) hoặc accidental debt (tự cao, NIH syndrome). Đánh giá định kỳ: chi phí maintain framework nội bộ vs chi phí adopt framework ecosystem.

Nếu team dành 30% effort maintain framework mà 5 người dùng, trong khi ecosystem có giải pháp 500 người maintain, đó là lãi suất đang ăn budget feature.


Nợ trong API contract vs nội bộ

Public API / SDK, nợ đắt vì breaking change ảnh hưởng khách hàng. Cần versioning strategy (URL versioning, header versioning), deprecation window (6-12 tháng), migration guide. Mỗi version cũ tồn tại là maintenance cost.

Nội bộ monolith, có thể refactor mạnh hơn nếu test coverage tốt. Breaking change ảnh hưởng team nội bộ, coordinate dễ hơn. Đừng áp cùng deprecation window 12 tháng cho internal API mà chỉ 2 team dùng.


“Debt week” có hiệu quả không

Một tuần chỉ trả nợ có thể hiệu quả nếu trước đó đã có danh sách initiative đã chốt owner + metric. Chuẩn bị tối thiểu: 3 initiative nhỏ đo được, mỗi initiative có acceptance criteria (“test coverage module X từ 40% lên 70%”, “tách adapter billing, route 10% traffic qua adapter mới”).

Debt week không chuẩn bị = tuần “refactor lung tung”, mỗi người refactor theo gu cá nhân, không có metric, không biết improvement hay regression.


Anti-pattern khi trả nợ

Refactor “big bang” không metric. Dừng feature 3 tháng để rewrite, không có milestone 2 tuần, không có shadow traffic compare. Kết quả thường là: 3 tháng sau, hệ mới chưa xong, feature backlog phình, management cancel initiative.

Viết lại vì ghét code cũ. “Code này ugly quá” không phải business justification. Hỏi: code này có chặn feature không? Có gây incident không? Có slow down onboarding không? Nếu không, có thể nó ugly nhưng không phải priority.

Trả nợ không có test. Refactor mà không thêm test = di chuyển bug. Code mới “sạch” hơn nhưng behavior có thể khác, và không ai biết cho đến production bug.


Đo năng suất sau khi trả nợ

Sau initiative trả nợ, so sánh metric trước/sau:

Median thời gian merge PR tại vùng đó, giảm nghĩa là developer velocity cải thiện. Số incident liên quan, giảm nghĩa là reliability cải thiện. Thời gian onboarding (survey hoặc observation), giảm nghĩa là knowledge debt giảm.

Nếu metric không đổi sau initiative, cần hỏi lại: đã trả đúng nợ chưa hay chỉ “đẹp code”? Có thể đã refactor implementation nhưng vấn đề thực sự là architecture debt.


Tóm tắt

Technical debt không phải “code xấu”, nó là chi phí tích lũy đo được bằng lead time, incident rate, và onboarding time. Phân loại nợ (deliberate/accidental × local/systemic) giúp chọn đúng chiến lược: boy scout cho local, freeze surface cho systemic chưa có budget, strangler cho systemic có budget.

PM cần ngôn ngữ risk/cost, không cần drama. “30% incident Q2 ở module X, mỗi feature thêm 10 ngày”, con số có thể tranh luận, cảm xúc thì không.

Rewrite là trap khi không có invariant hệ cũ, không có harness compare, không có milestone 2 tuần. Strangler + feature flag an toàn hơn trong hầu hết trường hợp.

Trả nợ cần metric trước/sau, nếu metric không đổi, đã trả sai nợ hoặc sai lever. Deprecation, retire flag, xóa version cũ, tất cả là giảm entropy, giảm lãi suất nợ.


Tham khảo