階段二我們談了 boundary,以及它帶來的好處:分出 implementer 與 client,定義各自的責任,讓雙方在維護與使用上都更輕鬆。
但昨天也提到,這八天裡的 boundary,都是一開始就給定的。所以今天要問的是:什麼樣的東西,該放在同一個 boundary 裡面?
直覺的答案是「相關的東西」。但「相關」又是什麼?
以一個小型的購物系統為例,我們可以用很多理由把 code 放在一起:
validators,所有的格式轉換放進 utils。order。這些都算某種「相關」。一個 boundary 裡的東西彼此相關的程度,稱為 cohesion:Day17 的 coupling 看的是 boundary 之間綁得多緊,cohesion 看的則是 boundary 裡面的東西,是不是真的該在一起。
Cohesion 沒有唯一的公式,早期的 structured design 甚至依聚在一起的理由,把它分成好幾種。但這個系列衡量 structure 的標準一直很明確:完成一次 change,需要理解多少、又會影響多少。所以今天從 change 的角度來看它。
Cohesion 問的是:boundary 裡的東西,有沒有足夠強的理由待在一起。而我們用 change 發生的方式,來檢驗這個理由。
假設這個購物系統目前分成三個 module:
shop
├── cart 購物車:顯示目前的金額
├── order 下單:計價、扣庫存、寄通知
└── invoice 發票:算出應付金額並開立
下單的邏輯集中在 OrderService:
# shop/order/service.py
class OrderService:
def place(self, cart: Cart, member: Member) -> Order:
total = sum(item.price * item.quantity for item in cart.items)
if total >= 1000: # 滿 1000 折 100
total -= 100
for item in cart.items:
self.db.decrease_stock(item.sku, item.quantity)
self.mailer.send(member.email, f"訂單成立,共 {total} 元")
return Order(cart.items, total)
購物車要顯示折扣後的金額,發票要算出應付金額,所以同一條折扣規則,在 cart 與 invoice 裡又各寫了一次。
用階段二的標準檢查,這個設計沒有問題:三個 module 各自只留一扇門,contract 寫得清楚,dependency graph 上也沒有環。它其實就是 Day13 留下的那兩個例子。
現在換一個角度看它。Day21 用一張圖表示 module 與 module 之間的 dependency;今天用一張表,表示 change 與 module 之間的關係。每一列是一種 change,每一欄是一個 module;完成這個 change 需要修改哪些 module,就在格子裡打勾:
| Change | cart |
order |
invoice |
|---|---|---|---|
| 調整折扣規則 | ✓ | ✓ | ✓ |
| 改變扣庫存方式 | ✓ | ||
| 通知從 email 換成 LINE | ✓ | ||
| 調整下單流程 | ✓ | ||
| 調整購物車畫面 | ✓ | ||
| 調整發票格式 | ✓ |
這張表可以往兩個方向讀。
先看第一列。行銷決定把「滿 1000 折 100」改成「滿 2000 折 300」。這是一個決定,一句話就能講完,卻要改三個 module。
更麻煩的是,dependency graph 上看不到這層關係。cart 沒有 import order,invoice 也沒有;它們之所以必須一起改,只因為各自保存了一份相同的規則。假設只改了 order,另外兩個 module 的 test 依然會通過,因為它們驗證的是自己手上那一份舊規則。
這種情況常被稱為 shotgun surgery:一個小小的 change,要在好幾個地方各開一刀。
一種 change 經常散到好幾個 boundary,代表該一起維護的東西可能被拆散了;它的影響範圍,已經穿過了 boundary。
再看 order 這一欄。它被折扣、庫存、通知、下單流程四種 change 勾到,而這些 change 來自完全不同的人:
折扣規則 → 行銷
扣庫存方式 → 倉儲
通知管道 → 客服
下單流程 → 產品
它們會在不同的時間、因為不同的理由發生,卻全部落在同一個 OrderService。於是,只想把 email 換成 LINE 的人打開 place(),仍然得讀過計價與扣庫存,才能確定哪幾行可以碰;改完之後,還得確認沒有碰壞另外兩件事。
這種情況稱為 divergent change:同一個 module,因為許多彼此獨立的理由,不斷被修改。
一個 boundary 經常承受許多彼此獨立的 change,代表裡面可能關了不需要待在一起的東西;想改其中一件,就得先理解全部。
這也正是 Single Responsibility Principle(SRP,SOLID 的第一條)想處理的問題。它常被簡化成「一個 class 只做一件事」,但「一件事」有多大,並沒有答案。它原本的說法是「一個 module 應該只有一個改變的理由」,後來又把「理由」說得更具體:一個 module 應該只對一個 actor 負責。重點不是 method 的數量,而是有多少彼此獨立的需求來源,可以要求這個 boundary 改變。
把兩個方向放在一起,我們希望的是:一種 change 落在盡可能少的 boundary,一個 boundary 也只承受盡可能少種彼此獨立的 change。照著這個方向重劃:把三處的折扣規則收進 pricing,再把庫存與通知從 order 裡分出去。
| Change | cart |
order |
invoice |
pricing |
inventory |
notification |
|---|---|---|---|---|---|---|
| 調整折扣規則 | ✓ | |||||
| 改變扣庫存方式 | ✓ | |||||
| 通知從 email 換成 LINE | ✓ | |||||
| 調整下單流程 | ✓ | |||||
| 調整購物車畫面 | ✓ | |||||
| 調整發票格式 | ✓ |
OrderService 則變成這樣:
# shop/order/service.py
class OrderService:
def place(self, cart: Cart, member: Member) -> Order:
total = pricing.total_of(cart)
self.inventory.reserve(cart.items)
self.notifier.order_placed(member, total)
return Order(cart.items, total)
place() 留下的,是下單的流程本身:先計價,再保留庫存,最後通知。折扣規則則只存在 pricing 一個地方,cart、order、invoice 都依賴它;原本藏在三份重複的 code 裡、誰也看不見的關係,現在成了 dependency graph 上看得見的箭頭。
這張表剛好每一列只勾一格,是因為例子很小。真實的需求本來就可能跨 module,一個合理的 module 也可能承受不只一種 change;目標是「盡可能少」,不是一張完美的一對一表。
從 change 的角度看,cohesion 高代表 boundary 的切法大致對上了 change 發生的方式:會一起改的聚在一起,彼此獨立的 change 不會被綁在一起。
看到這裡,很容易得出一個錯誤的結論:切得越細越好。看兩個極端就知道不對:
假設我們繼續把 pricing 拆成 discount、shipping_fee、tax,今天看起來很整齊。但哪天規則改成「折扣後的金額滿 500 才免運」,同一個決定就同時牽動折扣與運費,還多了一份跨 boundary 的 contract 要維護。拆開之前,這只是同一個 module 裡的幾行改動。
而 Day22 已經算過另一面的成本:每多一個 boundary,就多一個名稱、一份 contract、幾條 dependency,以及一扇 client 必須讀懂的門。
Cohesion 追求的不是小,而是對齊。切得太粗,獨立的 change 被綁在一起;切得太細,同一個 change 又被迫跨過太多 boundary。
這張表還有一個更根本的問題。欄可以從今天的 code 讀出來,列卻不行:列是未來會發生的 change,而未來沒有人知道。 上面的六列是為了例子挑出來的;真實的系統裡,不可能在第一天就把所有的 change 列完。我們只能找證據,可靠的程度大致依序遞減:
所以這張表不必在第一天就畫對,也不可能畫對。比較務實的做法,是優先依據前兩種證據來劃 boundary;如果唯一的理由只是「未來可能」,通常還不足以先付出 abstraction 的成本。
回到開頭的問題:什麼東西該放在一起?從 change 的角度,可以問兩件事:哪些東西會因為同一個理由一起改?哪些東西今天放在一起,卻會因為不同的理由各自改?前者散開了,change 就會穿過很多 boundary;後者綁住了,每次 change 都得理解不相關的東西。
會因為同一個理由改變的,放在一起;因為不同理由改變的,分開。
對 coding agent 而言,這兩種問題都特別容易發生,因為它們正好是最省事的路:
兩種結果都能通過 type check、test 與 dependency 的檢查。所以檢查 agent 的產出時,可以把 diff 當成表上的一列來讀:一個小需求動了幾個 module?加進去的 code,和這個 module 原本改變的理由是同一個嗎?工具答不出來、需要人判斷的是:這次 change,應該住在哪裡?
階段二教我們讓 change 停在 boundary 裡。而一條 boundary 能不能把 change 停住,在決定裡面放什麼的時候,就已經決定了大半。