Topic 23. Design by Contract — Hợp đồng trong code (Pragmatic Programmer #23)

Phong

Chào mấy bạn, lại là mình đây! Hôm nay mình tiếp tục series đọc sách "The Pragmatic Programmer" (20th Anniversary Edition) với một chủ đề mà mình nghĩ cực kỳ quan trọng nhưng nhiều lập trình viên ít khi để ý tới: Design by Contract (DBC) — Thiết kế theo hợp đồng.

Trước giờ mình cũng từng nghe qua khái niệm này, nhưng đọc xong topic này mới thực sự thấm. Nó không chỉ là một kỹ thuật code, mà là một cách tư duy về trách nhiệm giữa các module trong hệ thống.

Ảnh: Ron Lach — Pexels

Topic 23. Design by Contract — Hợp đồng trong code

Pragmatic Paranoia — đó là tên của chương 4. Nghe hơi "lạ" nhưng thực ra ý tưởng rất đơn giản: bạn không thể viết phần mềm hoàn hảo. Tip 36 trong sách nói thẳng luôn: "You Can't Write Perfect Software". Biết vậy rồi, thay vì ngồi mơ mộng, chúng ta xây dựng hệ thống phòng thủ.

Và Design by Contract chính là một trong những lớp phòng thủ đó.

DBC là gì?

Khái niệm này được Bertrand Meyer phát triển cho ngôn ngữ Eiffel. Ý tưởng cốt lõi: mỗi module (hàm, method, class) trong phần mềm đều có một hợp đồng với phần còn lại của hệ thống. Hợp đồng đó xác định rõ: ai chịu trách nhiệm cái gì, và ai phải đảm bảo điều gì.

Hợp đồng này gồm ba phần:

  • Preconditions (Tiền điều kiện): Cái gì phải đúng trước khi hàm được gọi. Đây là trách nhiệm của caller (code gọi hàm).
  • Postconditions (Hậu điều kiện): Cái gì hàm đảm bảo sẽ đúng sau khi nó chạy xong. Đây là trách nhiệm của hàm.
  • Class Invariants: Những điều luôn đúng xuyên suốt vòng đời của object (từ góc nhìn của caller). Class phải đảm bảo invariant đúng mỗi khi trả quyền điều khiển về cho caller.

Điều khoản trung tâm của hợp đồng là:

Nếu caller đáp ứng ALL preconditions, thì hàm (routine) phải đảm bảo ALL postconditions và invariants đều đúng khi nó kết thúc.

Dễ hiểu mà dễ quên đúng không mấy bạn? :D

Ảnh: luis gomes — Pexels

Ví dụ trong thực tế

Cuốn sách đưa ra ví dụ bằng Clojure — một ngôn ngữ functional có hỗ trợ pre/post condition sẵn:

(defn accept-deposit [account-id amount]
  { :pre [ (> amount 0.00)
           (account-open? account-id) ]
    :post [ (contains? (account-transactions account-id) %) ] }
  ;; code xử lý...
  (create-transaction account-id :deposit amount))

Ở đây preconditions nói rõ: "số tiền phải lớn hơn 0""tài khoản phải còn hoạt động". Nếu caller gọi với amount <= 0 hoặc tài khoản đã đóng, hàm sẽ throw AssertionError ngay lập tức. Postcondition cam kết sau khi chạy, transaction mới sẽ xuất hiện trong danh sách giao dịch.

Còn Elixir thì sao? Họ dùng guard clauses để ràng buộc:

def accept_deposit(account_id, amount) when (amount > 100000) do
  # Gọi manager!
end
def accept_deposit(account_id, amount) when (amount > 0) do
  # Xử lý bình thường
end

Gọi với amount <= 0? Boom — FunctionClauseError. Đơn giản, rõ ràng.

Điều DBC không phải là

Cực kỳ quan trọng: DBC không phải là validation cho user input. Preconditions không phải để kiểm tra dữ liệu nhập từ UI hay API. Đó là để kiểm tra logic giữa các module trong code — những thứ lẽ ra không bao giờ sai nếu code đúng.

Nếu preconditions fail, đó là bug — không phải user nhập sai.

DBC vs Defensive Programming

Giữa DBC và Defensive Programming có một sự khác biệt thú vị:

  • Defensive: Mọi hàm đều kiểm tra mọi thứ. Code gọi kiểm tra, code được gọi cũng kiểm tra lại → trùng lặp, lộn xộn.
  • DBC: Chỉ một bên kiểm tra (caller check preconditions, routine check postconditions). Phân định rõ trách nhiệm → sạch sẽ, DRY hơn.

Sách có câu này hay lắm: "Be strict in what you will accept before you begin, and promise as little as possible in return." (Tip 37).

Ảnh: Rodrigo Santos — Pexels

DBC vs TDD

Điều mình thấy đặc biệt thú vị là sách so sánh DBC và TDD. Không phải cái nào thay thế cái nào, nhưng DBC có nhiều điểm mạnh:

  • Không cần setup / mock: DBC định nghĩa contract trong code luôn, không cần thư viện test.
  • Comprehensive: DBC bao phủ tất cả cases; TDD chỉ test những case bạn nghĩ tới.
  • Luôn chạy: TDD chỉ chạy khi build/test. DBC là runtime contract — luôn active.
  • Internal focus: TDD là black-box check public interface; DBC kiểm tra cả internal invariants.
  • Tránh "Happy Path" trap: Khi viết preconditions, bạn buộc phải nghĩ về tất cả trạng thái không hợp lệ.

Cảm nhận của mình

Mình làm việc với đủ loại codebase — từ Python backend đến JavaScript frontend. Một vấn đề mình thường gặp là ai chịu trách nhiệm cho cái gì thì không rõ ràng.

Ví dụ: function xử lý order nhận vào user_id, kiểm tra user có tồn tại không, kiểm tra số dư, kiểm tra inventory... xong một lúc. Sau đó function khác cũng kiểm tra user tồn tại ở chỗ khác. Code bị duplicate, ai cũng "phòng thủ" theo cách của riêng mình, dẫn đến hỗn loạn.

DBC mang đến một framework tư duy rõ ràng, có tổ chức. Nếu mọi module trong team đều hiểu và tuân theo contract, việc debug và maintain sẽ nhẹ nhàng hơn rất nhiều.

Tuy nhiên, mình nghĩ DBC cũng không dễ áp dụng 100% trong thực tế, nhất là với:

  • Python, JavaScript — không có built-in DBC (phải dùng assert mang tính thủ công)
  • Codebase cũ — việc thêm contract vào code đã viết từ năm trước cực kỳ khó
  • Dynamic typing — preconditions có thể rất dài nếu phải check kiểu dữ liệu thủ công

Nhưng ít nhất, tư duy DBC giúp mình viết code sạch hơn, gọi hàm có ý thức hơn, và quan trọng nhất: biết ai sai khi có lỗi.

Kết

Design by Contract có thể không phải là một khái niệm mới mẻ, nhưng với mình nó là một trong những "aha moment" của cuốn sách. Nó thay đổi cách nhìn về trách nhiệm trong code — từ "ai cũng phải kiểm tra mọi thứ" thành "mỗi module có trách nhiệm rõ ràng".

Bài sau mình sẽ nói về Topic 24: Dead Programs Tell No Lies — một chủ đề cũng thuộc chapter Pragmatic Paranoia, mà nghe tên đã thấy "máu" rồi! :D

Hẹn gặp lại mấy bạn ở bài sau nha!