iT邦幫忙

2026 iThome 鐵人賽

DAY 23
0
Software Development

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

Day23: Cohesion:會一起變的,放在一起

  • 分享至 

  • xImage
  •  

階段二我們談了 boundary,以及它帶來的好處:分出 implementer 與 client,定義各自的責任,讓雙方在維護與使用上都更輕鬆。

但昨天也提到,這八天裡的 boundary,都是一開始就給定的。所以今天要問的是:什麼樣的東西,該放在同一個 boundary 裡面?

直覺的答案是「相關的東西」。但「相關」又是什麼?

「相關」可以有很多意思

以一個小型的購物系統為例,我們可以用很多理由把 code 放在一起:

  • 種類相同:所有的 validation 放進 validators,所有的格式轉換放進 utils。
  • 主題相近:計價、扣庫存、寄通知都發生在下單的時候,所以全部放進 order。
  • 一起改變:會因為同一個理由被修改的東西,放在一起。

這些都算某種「相關」。一個 boundary 裡的東西彼此相關的程度,稱為 cohesion:Day17 的 coupling 看的是 boundary 之間綁得多緊,cohesion 看的則是 boundary 裡面的東西,是不是真的該在一起。

Cohesion 沒有唯一的公式,早期的 structured design 甚至依聚在一起的理由,把它分成好幾種。但這個系列衡量 structure 的標準一直很明確:完成一次 change,需要理解多少、又會影響多少。所以今天從 change 的角度來看它。

Cohesion 問的是:boundary 裡的東西,有沒有足夠強的理由待在一起。而我們用 change 發生的方式,來檢驗這個理由。

一張表:Change × Module

假設這個購物系統目前分成三個 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 ✓
調整下單流程 ✓
調整購物車畫面 ✓
調整發票格式 ✓

這張表可以往兩個方向讀。

橫著看:一種 change,要改幾個 module

先看第一列。行銷決定把「滿 1000 折 100」改成「滿 2000 折 300」。這是一個決定,一句話就能講完,卻要改三個 module。

更麻煩的是,dependency graph 上看不到這層關係。cart 沒有 import order,invoice 也沒有;它們之所以必須一起改,只因為各自保存了一份相同的規則。假設只改了 order,另外兩個 module 的 test 依然會通過,因為它們驗證的是自己手上那一份舊規則。

這種情況常被稱為 shotgun surgery:一個小小的 change,要在好幾個地方各開一刀。

一種 change 經常散到好幾個 boundary,代表該一起維護的東西可能被拆散了;它的影響範圍,已經穿過了 boundary。

直著看:一個 module,會被幾種 change 改到

再看 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 不會被綁在一起。

兩個方向會互相拉扯

看到這裡,很容易得出一個錯誤的結論:切得越細越好。看兩個極端就知道不對:

  1. 所有的 code 放進同一個 module:任何 change 都只改一個 module,但這個 module 也承受了系統裡所有的 change。
  2. 每個 function 各自成為一個 module:每個 module 的責任都很單純,但一個真實的需求,可能得穿過十幾個 boundary 才能完成。

假設我們繼續把 pricing 拆成 discount、shipping_fee、tax,今天看起來很整齊。但哪天規則改成「折扣後的金額滿 500 才免運」,同一個決定就同時牽動折扣與運費,還多了一份跨 boundary 的 contract 要維護。拆開之前,這只是同一個 module 裡的幾行改動。

而 Day22 已經算過另一面的成本:每多一個 boundary,就多一個名稱、一份 contract、幾條 dependency,以及一扇 client 必須讀懂的門。

Cohesion 追求的不是小,而是對齊。切得太粗,獨立的 change 被綁在一起;切得太細,同一個 change 又被迫跨過太多 boundary。

表上的列,從哪裡來

這張表還有一個更根本的問題。欄可以從今天的 code 讀出來,列卻不行:列是未來會發生的 change,而未來沒有人知道。 上面的六列是為了例子挑出來的;真實的系統裡,不可能在第一天就把所有的 change 列完。我們只能找證據,可靠的程度大致依序遞減:

  • 已經發生的 change:每一個 commit,都是表上真的出現過的一列。幾個檔案總是一起被修改,或是某個 module 不斷因為不相干的需求被修改,都是看得見的訊號。歷史紀錄不一定是正確答案,但至少是發生過的事。
  • 已知的需求來源:有些 change 還沒發生,來源卻已經很清楚:折扣由行銷決定,庫存由倉儲決定,稅務受各地法規影響。這些 actor 本來就會各自提出需求,不必等系統痛過一次才分開。
  • 想像中的 change:「以後說不定會需要。」任何 abstraction 都能用這句話合理化,所以它是最弱、也最危險的一種。

所以這張表不必在第一天就畫對,也不可能畫對。比較務實的做法,是優先依據前兩種證據來劃 boundary;如果唯一的理由只是「未來可能」,通常還不足以先付出 abstraction 的成本。

Cohesion 真正想表達的事

回到開頭的問題:什麼東西該放在一起?從 change 的角度,可以問兩件事:哪些東西會因為同一個理由一起改?哪些東西今天放在一起,卻會因為不同的理由各自改?前者散開了,change 就會穿過很多 boundary;後者綁住了,每次 change 都得理解不相關的東西。

會因為同一個理由改變的,放在一起;因為不同理由改變的,分開。

對 coding agent 而言,這兩種問題都特別容易發生,因為它們正好是最省事的路:

  • 就近放:新的 code 加進正在讀的那個 class。久而久之,一個 boundary 承受越來越多種 change。
  • 再寫一份:agent 沒有讀到既有的規則,就在手邊重新實作一次。同一種 change 於是散到更多地方。

兩種結果都能通過 type check、test 與 dependency 的檢查。所以檢查 agent 的產出時,可以把 diff 當成表上的一列來讀:一個小需求動了幾個 module?加進去的 code,和這個 module 原本改變的理由是同一個嗎?工具答不出來、需要人判斷的是:這次 change,應該住在哪裡?

階段二教我們讓 change 停在 boundary 裡。而一條 boundary 能不能把 change 停住,在決定裡面放什麼的時候,就已經決定了大半。

Reference


上一篇
Day22: 階段二回顧,與階段三的起點
下一篇
Day24: Information Hiding:藏的不是 code,而是 decision
系列文
AI時代下的軟體工程 共 24 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言