Day14 介紹了 boundary:把一段程式分成裡與外,在外面使用它的是 client,在裡面負責實作的是 implementer。好的 boundary 讓 client 不必知道裡面怎麼做,也讓 implementer 可以修改裡面,而不影響外面。
但這兩件事要同時成立,有一個前提:雙方對「提供了什麼」,必須有相同的認知。
一個反例:
client 以為 list() 會依寫入順序回傳,implementer 卻沒保證此行為。
於是約束雙方共同認知的東西,就是今天的主題:contract。
Contract,中文是「契約」。它說明一個 boundary 該怎麼使用,以及使用之後會得到什麼結果。
回到 Day14 的例子:
get_today("Asia/Taipei") # -> "2026/09/27"
只看名稱與參數,還有幾件事說不清楚:傳入不存在的時區會怎樣?月份與日期不足兩位時,會補零嗎?這些問題沒有答案,client 最後還是得打開實作來看。
Contract 回答的就是這些問題。它由兩個部分組成:
以 get_today() 來說:
| 內容 | 負責的一方 | |
|---|---|---|
| Precondition | tz 是合法的時區名稱,例如 "Asia/Taipei" |
client |
| Postcondition | 回傳 tz 時區今天的日期,格式為 YYYY/MM/DD,不足兩位補零 |
implementer |
client 負責傳入合法的時區,implementer 負責回傳格式正確的日期。雙方各自該做什麼,都寫得清清楚楚。
因此:
Contract 同時規定了兩件事:client 該做到什麼,以及 implementer 承諾什麼。
p.s. 但我自己是覺得 implementater 該 handle clinet 沒遵守 contract 時的情況
有了 contract,client 與 implementer 之間的關係就不一樣了。
雙方不再直接對彼此負責,而是各自對 contract 負責。
client 只要滿足 precondition,就能得到承諾的結果,
implementer 只要兌現 postcondition,就能靈活的設計細節,不必知道 client 怎麼用。
我們可以把 contract 想像成一座橋。只要橋還在,往來就不受影響。
但如果 contract 出現變動,則雙方都需要調整。
這也解釋了 Day14 的觀察:為什麼 boundary 能減少 clinet 負擔?
差別在於改動有沒有碰到 contract。SQLite 換成 PostgreSQL,save() 與 list() 的承諾沒有改變,change 就只留在 implementer 這一側而已。
Contract 如果只存在於人的腦中,就只是一種默契。對每個 session 都從零開始的 coding agent 來說,沒有寫下來的默契,就等於不存在。
Contract 實際代表什麼呢?
把 contract 寫下來,就是 spec。
Day14 提過,boundary 是一層套著一層的:application 底下有 module,module 裡有 object 與 function。每一層 boundary 都有自己的 client 與 implementer,也就都有自己的 contract。不同的只是 contract 寫在哪裡:
| Boundary | 誰是 client | spec 常見的形式 |
|---|---|---|
| Function/Class | 呼叫它的程式 | type signature、docstring |
| Module | 其他 module | 公開的 interface、模組的 README |
| Application | 使用者、其他系統 | API 文件、系統的 spec |
以 storage 為例,它的 spec 可以直接寫在 docstring 裡:
class Storage:
def save(self, entry: Entry) -> None:
"""儲存一筆帳目。
Precondition: entry.id 不可為空字串。
Postcondition: 儲存後,list() 的結果會包含這筆帳目。
"""
def list(self) -> list[Entry]:
"""回傳所有已儲存的帳目,不保證順序。"""
對 client 來說,這就是使用 storage 前需要讀的全部內容。
而對 implementer 而言,spec 算是一個需要遵守的目標,還需要透過 test 來驗證是否正確
Day10 介紹過 spec-based testing:依據規格,而不是程式碼來設計 test case。當時用 V-model 區分的 unit、integration、system 三個層級的 test,對照的其實正是不同層級的 contract。
以 Storage 來說,test 要檢查的是 postcondition:存進去的帳目,查得到。這樣的 test 只依賴 contract,不論底下是 SQLite 還是 PostgreSQL 都應該通過;換掉實作後依然全部通過,就代表兩個實作對 client 而言可以互換。
不過,spec 寫明 list() 不保證順序,test 也就不該檢查順序。否則換成 PostgreSQL 後,順序一變,test 就會失敗,實際上卻沒有任何承諾被打破。這樣的 test,本身就成了 Day14 說的 unwanted dependency。
Day10 也提過,事後請 AI 補上的 test,預期輸出往往是從實作抄來的,最容易犯這種錯。因此請 agent 寫 test 時,依據的應該是 spec,而不是實作。
Spec 與 test 能保護 contract,卻無法保證 contract 本身設計得好。一份不好的約定,寫得再清楚、測得再完整,client 用起來依然辛苦,implementer 改起來依然綁手綁腳。
回頭看前面的 storage,它寫得清楚,也有 test 守著,卻仍然留下幾個問題:同一個 id 存兩次會怎樣?儲存失敗時,又會怎樣?
不論是哪一層 boundary,好的 contract 都要同時照顧邊界的兩側。
對 client 而言,contract 要好用:
save() 裡面包含 notify()功能,分別提供 save(), notify() 兩個的工具。能讓 clinet 更自由的選擇與使用。save() 遇到相同 id 會直接覆蓋,卻沒有回傳值,client 就分不出這次是新增還是覆蓋。StorageError」,client 從一開始就會處理失敗。之後改成 remote API,網路錯誤只是 StorageError 的一種,Day14 那個穿過 boundary 的 change,就能留在裡面。對 implementer 而言,contract 要留空間:
save() 無法保證永遠成功,因此約定只能承諾「成功時」的結果。list() 寫明不保證順序,也是同樣的道理:client 不在意順序,就不該承諾,implementer 日後換 database 時才不會被綁住。這兩個方向會互相拉扯。承諾得越多,client 用起來越安心,implementer 能動的空間就越小;承諾得越少,implementer 越自由,client 卻可能無從依賴。
因此:
好的 contract,是在 client 需要的保證與 implementer 需要的空間之間,找到剛好的位置。