iT邦幫忙

2026 iThome 鐵人賽

DAY 15
0
Software Development

AI時代下的軟體工程系列 第 15 篇

Day15: Contract:連接 boundary 兩側的橋樑

  • 分享至 

  • xImage
  •  

Day14 介紹了 boundary:把一段程式分成裡與外,在外面使用它的是 client,在裡面負責實作的是 implementer。好的 boundary 讓 client 不必知道裡面怎麼做,也讓 implementer 可以修改裡面,而不影響外面。

但這兩件事要同時成立,有一個前提:雙方對「提供了什麼」,必須有相同的認知。

一個反例:
client 以為 list() 會依寫入順序回傳,implementer 卻沒保證此行為。

於是約束雙方共同認知的東西,就是今天的主題:contract。

What is contract

Contract,中文是「契約」。它說明一個 boundary 該怎麼使用,以及使用之後會得到什麼結果。

回到 Day14 的例子:

get_today("Asia/Taipei")  # -> "2026/09/27"

只看名稱與參數,還有幾件事說不清楚:傳入不存在的時區會怎樣?月份與日期不足兩位時,會補零嗎?這些問題沒有答案,client 最後還是得打開實作來看。

Contract 回答的就是這些問題。它由兩個部分組成:

  • Precondition:呼叫前必須成立的條件,是 client 的義務。
  • Postcondition:precondition 成立時,回傳正確的結果,是 implementer 的義務。

以 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 是一座橋

有了 contract,client 與 implementer 之間的關係就不一樣了。

雙方不再直接對彼此負責,而是各自對 contract 負責。
client 只要滿足 precondition,就能得到承諾的結果,
implementer 只要兌現 postcondition,就能靈活的設計細節,不必知道 client 怎麼用。

我們可以把 contract 想像成一座橋。只要橋還在,往來就不受影響。
但如果 contract 出現變動,則雙方都需要調整。

這也解釋了 Day14 的觀察:為什麼 boundary 能減少 clinet 負擔?
差別在於改動有沒有碰到 contract。SQLite 換成 PostgreSQL,save() 與 list() 的承諾沒有改變,change 就只留在 implementer 這一側而已。

把 contract 寫下來

Contract 如果只存在於人的腦中,就只是一種默契。對每個 session 都從零開始的 coding agent 來說,沒有寫下來的默契,就等於不存在。
Contract 實際代表什麼呢?

Spec:告訴 client 承諾是什麼

把 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 前需要讀的全部內容。

Test:確認 implementer 做到了

而對 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,而不是實作。

What makes a good contract

Spec 與 test 能保護 contract,卻無法保證 contract 本身設計得好。一份不好的約定,寫得再清楚、測得再完整,client 用起來依然辛苦,implementer 改起來依然綁手綁腳。

回頭看前面的 storage,它寫得清楚,也有 test 守著,卻仍然留下幾個問題:同一個 id 存兩次會怎樣?儲存失敗時,又會怎樣?

不論是哪一層 boundary,好的 contract 都要同時照顧邊界的兩側。

對 client 而言,contract 要好用:

  • 工具的獨立性:言下之意就是減少綑綁,舉個例子:相比於 save() 裡面包含 notify()功能,分別提供 save(), notify() 兩個的工具。能讓 clinet 更自由的選擇與使用。
  • 回傳結果要能區分情況:如果 save() 遇到相同 id 會直接覆蓋,卻沒有回傳值,client 就分不出這次是新增還是覆蓋。
  • 失敗也要寫進約定:如果 contract 一開始就寫明「儲存失敗時,丟出 StorageError」,client 從一開始就會處理失敗。之後改成 remote API,網路錯誤只是 StorageError 的一種,Day14 那個穿過 boundary 的 change,就能留在裡面。

對 implementer 而言,contract 要留空間:

  • 不承諾做不到,或 client 不需要的事:網路與硬碟都可能出錯,save() 無法保證永遠成功,因此約定只能承諾「成功時」的結果。list() 寫明不保證順序,也是同樣的道理:client 不在意順序,就不該承諾,implementer 日後換 database 時才不會被綁住。

這兩個方向會互相拉扯。承諾得越多,client 用起來越安心,implementer 能動的空間就越小;承諾得越少,implementer 越自由,client 卻可能無從依賴。

因此:

好的 contract,是在 client 需要的保證與 implementer 需要的空間之間,找到剛好的位置。

Reference


上一篇
Day14: Boundary:距離產生美感
下一篇
Day16: Abstraction:把 contract 從實作中抽出來
系列文
AI時代下的軟體工程 共 18 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言