iT邦幫忙

2026 iThome 鐵人賽

DAY 16
0
Software Development

文藝復興:這段程式碼,好像有點味道系列 第 16

Day 16|換一種顏料,整間畫室都要重新調色:霰彈式修改 (Shotgun Surgery)

  • 分享至 

  • xImage
  •  

有些顏料配方,不是由一位畫家獨自掌握,而是畫室裡每一個工作站,各自記著一份配方筆記

準備底漆的學徒記一份、調肉色的師傅記一份、修飾金箔邊框的工匠也記一份

如果贊助人要求換一種底料,這件事聽起來很單純

但畫室裡三、四個工作站,每一個人手上的筆記,都要跟著改,改完還要祈禱,沒有人漏改

一條驗證規則,寫在了三個地方

Day 15 把 OrderReport 拆乾淨了。同一時間,另一組人也在擴充訂單相關功能:

  • OrderProcessor:客戶下單時處理訂單
  • OrderImportService:客服用 CSV 批次匯入舊系統的歷史訂單
  • OrderEditService:客服人員手動修改一筆尚未出貨的訂單

三個服務,都各自寫了同一條規則「訂單商品數量必須大於零,且單筆金額不能超過 10 萬元

public class OrderProcessor
{
    public decimal Process(OrderRequest request)
    {
        if (request.Line.Qty <= 0 || request.Line.Qty * 100 > 100_000)
            throw new InvalidOperationException("訂單不符合規則");

        // ...原有處理邏輯...
        return 0;
    }
}

public class OrderImportService
{
    public void Import(OrderRequest request)
    {
        if (request.Line.Qty <= 0 || request.Line.Qty * 100 > 100_000)
            throw new InvalidOperationException("訂單不符合規則");

        // ...匯入邏輯...
    }
}

public class OrderEditService
{
    public void Edit(OrderRequest request)
    {
        if (request.Line.Qty <= 0 || request.Line.Qty * 100 > 100_000)
            throw new InvalidOperationException("訂單不符合規則");

        // ...編輯邏輯...
    }
}

一條新規則,變成三次手術

business 提出一條新規則 「VIP 客戶的單筆訂單,至少要有 2 件商品,才能享有折扣」

負責這個需求的工程師,得依序打開三個檔案,在每一個地方都補上幾乎一模一樣的判斷式

if (request.Tier == CustomerTier.Vip && request.Line.Qty < 2)
    throw new InvalidOperationException("VIP 訂單至少需要兩件商品");

三個檔案,三次手術

改完 OrderProcessorOrderImportService,改到一半被會議打斷

OrderEditService 忘了改

兩週後,客服透過編輯功能,把一筆 VIP 訂單改成只有一件商品,系統完全沒有攔下來

沒有人做錯任何一行程式碼,這條規則本身寫得完全正確
問題是它被抄寫了三次,其中一次,忘了跟上進度

讓訂單自己回答「我合不合規」

解法的核心思想:「這筆訂單是否有效」這個問題,不該由三個外部服務各自回答一次,該由訂單自己回答一次,其他人只管問它

換成程式碼,這是搬移方法 (Move Method)
把散落在三個服務裡的驗證邏輯,全部搬回 OrderRequest 自己身上

public record OrderRequest
{
    public int CustomerId { get; init; }
    public OrderLine Line { get; init; }
    public CustomerTier Tier { get; init; }
    public string CouponCode { get; init; }
    public bool SendEmail { get; init; }
    public bool SendSms { get; init; }

    public void Validate()
    {
        if (Line.Qty <= 0 || Line.Qty * 100 > 100_000)
            throw new InvalidOperationException("訂單不符合規則");

        if (Tier == CustomerTier.Vip && Line.Qty < 2)
            throw new InvalidOperationException("VIP 訂單至少需要兩件商品");
    }
}

三個服務,改成只呼叫這一個共同的入口:

public class OrderProcessor
{
    public decimal Process(OrderRequest request)
    {
        request.Validate();
        // ...原有處理邏輯...
        return 0;
    }
}

public class OrderImportService
{
    public void Import(OrderRequest request)
    {
        request.Validate();
        // ...匯入邏輯...
    }
}

public class OrderEditService
{
    public void Edit(OrderRequest request)
    {
        request.Validate();
        // ...編輯邏輯...
    }
}

現在只有一份配方筆記

下一次規則異動,只要改 OrderRequest.Validate() 一個地方
三個服務會自動套用最新規則,不需要任何人記得「還有哪裡也要改」

跟昨天的問題,剛好相反

Day 15 的發散式變更,是一個類別扛了太多互不相關的理由

今天的霰彈式修改,是一個單一的理由,卻被拆散到太多不相關的類別

兩者的成因常常互為因果,上一次為了解決發散式變更,把職責拆得太細,拆過頭了,就變成了今天的霰彈式修改

拆分本身不是目標,讓每一種變化的理由,只對應到一個修改的地方,才是目標

自我檢查清單

  1. 這次的需求變更,是不是要求我打開超過三個類別?
  2. 這幾個要修改的地方,邏輯是不是幾乎一模一樣?
  3. 有沒有可能漏改其中一個地方,而且不會有任何錯誤提示?
  4. 這條散落各處的邏輯,本來是不是該由某一個物件自己負責?
  5. 如果把它們集中回一個地方,呼叫端會不會變得更簡單?

明日預告

明天我們看兩套繼承體系,像鏡子一樣互相對應,每加一位新的畫室成員,都得同時在兩本花名冊上,各記一筆

模組三最終站:平行繼承體系(Parallel Inheritance Hierarchies)


上一篇
Day 15|改一個需求,卻要在同一幅畫裡到處補筆:發散式變更 (Divergent Change)
下一篇
Day 17|兩本手抄本,永遠要一起翻頁:平行繼承體系 (Parallel Inheritance Hierarchies)
系列文
文藝復興:這段程式碼,好像有點味道27
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言