
在神話中,宙斯把火藏了起來,在沒有火之前,人類只能生吃食物、在黑暗中生活。
普羅米修斯是泰坦族,也是人類的守護者。
他有次上奧林帕斯山,偷了一點火種,藏在空心的茴香莖裡帶回人間,人類從此有了火,也有了技藝,學會烹飪、建造與鍛造。
而懂得越多,也開始意識到自己還有多少不懂、多少事情可能出錯。
宙斯發現之後,把普羅米修斯鎖在高加索山的岩石上,每天派一隻老鷹啄食他的肝臟,肝臟入夜後重新長回來,隔天老鷹又再次飛來。他在那裡受苦多年,直到赫拉克勒斯射殺老鷹,才終於獲救。
據說普羅米修斯早就知道自己會受到懲罰,但他還是把火帶給了人類。
接手 Legacy Code 也有點像拿到火。它讓我們有能力改變系統,卻也可能一不小心燒掉原本還能運作的東西...既然不能因為害怕就永遠不改,那至少在下手之前,我們得先知道要注意些什麼。
昨天我們寫過測試,也練習過拆依賴,但真的收到需求時,還是很容易站在一大坨 Legacy Code 前面發呆:
現在到底該先做什麼?
這時候別急著憑感覺挑一段 code 開始改,我們可以先把工作拆成五個步驟:
這五步驟不是只能套用在某一種架構上的標準答案,而是一種在「不知道從哪裡開始」時很實用的路線,熟練之後,我們未必會刻意在腦中照著做,但思考順序大概不會離得太遠。
今日範例 就用一個 CheckoutService 的結帳流程,從需求進來一路走到功能完成。
先看看目前的結帳流程:
public class CheckoutService
{
public decimal Checkout(int customerId, List<OrderItem> items)
{
var conn = new SqlConnection(
ConfigurationManager.ConnectionStrings["DB"].ToString());
var record = new InventoryRecord(items);
var subtotal = record.CalculateTotalPrice();
var memberLevel = conn.QueryFirst<int>(
"SELECT Level FROM Members WHERE Id = @id",
new { id = customerId });
var discountedSubtotal =
memberLevel >= 3 ? subtotal * 0.9m : subtotal;
var tax = record.CalculateTax(discountedSubtotal);
var customer = conn.QueryFirst<Customer>(
"SELECT Id, Address, IsVip FROM Customer WHERE Id = @id",
new { id = customerId });
var fee =
new PriceCalculator().CalculateBaseFee(discountedSubtotal);
if (customer.IsVip)
fee = 0m;
else if (IsRemoteArea(customer.Address))
fee += 50m;
return discountedSubtotal + tax + fee;
}
private bool IsRemoteArea(string address)
{
return address.Contains("金門")
|| address.Contains("澎湖")
|| address.Contains("馬祖");
}
}
public class InventoryRecord
{
private readonly IReadOnlyCollection<OrderItem> _items;
public InventoryRecord(IEnumerable<OrderItem> items)
{
_items = items.ToList();
}
public decimal CalculateTotalPrice()
=> _items.Sum(x => x.UnitPrice * x.Quantity);
public decimal CalculateTax(decimal amount)
=> Math.Round(amount * 0.05m, 0);
}
這時 PM 帶來一個新需求:
結帳滿千免運,明天上線。
看到「滿千」,第一眼很容易先停在 InventoryRecord.CalculateTotalPrice,而進一步查看 InventoryRecord 後,會發現它似乎負責計算商品總額,好像只要在這裡回傳一個「是否滿千」就能解決。
但我們這時候要先忍住,再繼續把 Checkout 讀完。
我們會發現後面有一段在算 fee 的邏輯,變數名只告訴我們這裡在處理某種費用,因此還得藉由幾個線索,試著拼湊出它真正代表的意思:
撈資料庫取得客戶的
Address、IsVip
如果客戶是 VIP,則費用為 0
如果客戶地址是偏遠地區,則費用 + 50
如果把 費用 換成 運費,這幾條線索似乎都說得通,接著可以查看相關文件、詢問 PM,甚至自行操作系統,確認這裡確實在計算運費,這樣一來,改動點就不在 InventoryRecord,而是 Checkout 後段這個區塊。
另外,「滿千」到底是原價滿千,還是折扣後滿千?偏遠地區滿千之後還要不要加價?這些不是看 code 就能猜出來的事情,跟主事人確認後,我們就可以把運費規則稍微整理一下:
這一來一回就能體現出「觀察」的重要性,在還沒熟到能感覺出改動位置之前,我們就先讀完,也問清楚,不要急著動。
知道要改哪裡之後,下一步我們要知道:
測試要從哪裡進去,才能知道這次沒有改壞?
Checkout 這個 method 的流程邊界還算乾淨,也許我們可以直接測它!
各位可能覺得很奇怪,改哪裡,不就是測哪裡嗎?方向沒錯,但如果要改的 method 有幾千、幾萬行,難道要從頭測到尾嗎?這次只是剛好遇到相對可愛、好處理的 Checkout,真正的戰場通常不會這麼簡單。後面的章節會再示範其他做法,今天先記住一件事:
處理 Legacy Code 時,仍然要刻意思考測試該從哪個入口切入
所以我們決定了!這次的改動點與測試點都是 Checkout,不過光用肉眼就能看見幾個直接依賴,這個 method 會讀設定檔、連資料庫,既然我們只想確認金額,就需要用到昨天學會的拆依賴技術了。
昨天已經學過如何透過覆寫技巧解開直接依賴,現在就先照同樣的方式試一次:
public class CheckoutService
{
public decimal Checkout(int customerId, List<OrderItem> items)
{
var conn = GetDbConnection();
var record = new InventoryRecord(items);
var subtotal = record.CalculateTotalPrice();
var memberLevel = GetMemberLevel(customerId, conn);
var discountedSubtotal =
memberLevel >= 3 ? subtotal * 0.9m : subtotal;
var tax = record.CalculateTax(discountedSubtotal);
var customer = GetCustomerById(customerId, conn);
var fee = new PriceCalculator().CalculateBaseFee(discountedSubtotal);
if (customer.IsVip)
fee = 0m;
else if (IsRemoteArea(customer.Address))
fee += 50m;
return discountedSubtotal + tax + fee;
}
protected virtual IDbConnection GetDbConnection()
{
return new SqlConnection(ConfigurationManager.ConnectionStrings["DB"].ToString());
}
protected virtual int GetMemberLevel(int customerId, IDbConnection conn)
{
return conn.QueryFirst<int>(
"SELECT Level FROM Members WHERE Id = @id",
new { id = customerId });
}
protected virtual Customer GetCustomerById(int customerId, IDbConnection conn)
{
return conn.QueryFirst<Customer>(
"SELECT Id, Address, IsVip FROM Customer WHERE Id = @id",
new { id = customerId });
}
}
做到這裡其實已經能開始測試了,只是得覆寫三個資料庫相關的 method,設定成本並不低。
趁還沒動手寫測試,我們先停下來想一件事:
Checkout到底從資料庫拿了什麼?
這兩個:
Checkout 根本不在乎這兩筆資料是用什麼 DB 查出來的、用 Dapper 還是其他套件,它只在乎「拿不拿得到」。
順著這個念頭,我們先借用 Repository 的想法,替資料存取建立一道邊界。
「我只需要知道我要什麼,不需要知道怎麼找到它。」這個念頭,和 Repository 想處理的問題很接近。
Repository 被收錄在 Martin Fowler 的《Patterns of Enterprise Application Architecture》中,隔年的 Eric Evans《Domain-Driven Design》也將 Repository 納入 DDD 的戰術設計,兩者的實作細節雖有不同,核心方向都在於:
讓使用端以領域需要表達資料存取,不必直接依賴資料庫結構與查詢細節。
如果嚴格按照定義,Repository 通常會用接近「集合」的介面取得與保存領域物件,這次範例的介面除了取得 Customer,也直接查詢 memberLevel 這種單一資料,所以借用 Repository 的核心思想,先把業務流程和 SQL 查詢隔開。
說來抽象,看看下面這張餐巾紙,是一位母語人士隨手寫下的英文縮寫:

圖片擷取自網路
before 寫成 B4、please 寫成 pls、you 寫成 u,可以看到單字的形式全都變了,不過只要對方讀得懂,真正重要的「意思」從沒改變。
系統也一樣,我們公司以前的開發模式大致是這樣,從拿到需求開始:
這蠻接近一種叫做 Transaction Script 的開發方式,系統按照一個個業務操作,把查資料、判斷、計算與寫回資料庫集中成一段流程。
實務上它也常搭配資料庫導向的設計,資料表長怎樣,Entity 就長怎樣,再一路當成 DTO(Data Transfer Object) 傳到前端。這樣做最大的優點就是快,需求一進來,開好表、用工具產生 Class,再把 CRUD 流程像寫流水帳一樣,很快就能交付功能。
Transaction Script 本身並不是錯誤,對規則單純的 CRUD 系統來說,它其實相當務實。問題通常發生在規則逐漸變複雜之後,如果團隊建立了看似領域模型的物件,卻仍把計算、判斷與規則全放在外層 Service,模型只剩資料,就可能逐漸形成 貧血模型(Anemic Domain Model),如果設計又長期由資料表主導,團隊就容易把「資料表欄位」直接當成「業務語言」。
久了之後,真正聽得懂規則的人越來越少,程式裡只有少數人知道來龍去脈的區域,也就慢慢變成團隊裡誰都不敢動的 Legacy,我接手過不少專案都有這樣的跡象。
而 Repository 所提供的核心價值,就是在業務物件與資料來源之間劃出界線。
業務這一側透過符合自身語言的介面取得或保存物件;另一側再負責怎麼查、怎麼存,以及如何對應資料欄位:

圖片擷取自網路
這道邊界也讓部分儲存決策可以延後,只要介面所表達的語意維持不變,業務邏輯就有機會先寫、先測,再決定具體資料結構,中途更換儲存方式時,改動也比較可能集中在資料存取實作,不過交易一致性、查詢能力與效能特性仍可能影響上層,並不是換掉實作就保證主流程永遠不用調整。
釐清我們要建立的資料存取邊界後,接下來就把 Checkout 裡那幾段直接查 SQL 的程式,收斂成只描述「我需要什麼資料」的介面。
剛才用 Extract Method 抽出的幾個方法並沒有白費,現在可以繼續透過重構工具,把它們收進 Repository,操作如下:
先選取剛才抽出的 method,開啟重構工具,再選擇 Extract Class:

接著替 Repository 命名,勾選要移入新類別的 method。Reference to extracted 欄位則用來設定 IDE 在 CheckoutService 中自動建立的 Repository 欄位名稱:

截圖中先命名為 CustomerRepository;由於這個實作會直接查 SQL,接著再用 Rename 把具體類別改成 SqlCustomerRepository,讓名稱說出它的資料來源。按下確認後,程式碼如下:
public class CheckoutService
{
private readonly SqlCustomerRepository _sqlCustomerRepository = new SqlCustomerRepository();
public decimal Checkout(int customerId, List<OrderItem> items)
{
var conn = GetDbConnection();
var record = new InventoryRecord(items);
var subtotal = record.CalculateTotalPrice();
var memberLevel = _sqlCustomerRepository.GetMemberLevel(customerId, conn);
var discountedSubtotal = memberLevel >= 3 ? subtotal * 0.9m : subtotal;
var tax = record.CalculateTax(discountedSubtotal);
var customer = _sqlCustomerRepository.GetCustomerById(customerId, conn);
var fee = new PriceCalculator().CalculateBaseFee(discountedSubtotal);
if (customer.IsVip)
fee = 0m;
else if (IsRemoteArea(customer.Address))
fee += 50m;
return discountedSubtotal + tax + fee;
}
// ... 略
}
接著替 Repository 提取介面。選取 SqlCustomerRepository,再次打開重構工具,再選擇 Extract Interface:

一樣填入想要的名稱,並選取要放進介面的 method:

如此一來,CheckoutService 就可以改用介面描述它需要的資料存取能力。此時物件內部仍然會直接建立 SqlCustomerRepository,依賴還沒有完全拆開;不過不用著急,現在的重點是先不要改變對外介面。
不過現在仍然直接依賴具體類別,所以先稍微調整程式碼,把 Repository 欄位 Inline 回 Checkout:

接著把 conn 注入到 Repository,這時候 IDE 會先出現紅色蚯蚓:
var conn = GetDbConnection();
CustomerRepositoryGateway customerRepository = new SqlCustomerRepository(conn);
點擊這一行,用快捷鍵或左側圖示打開 Context Action,再選擇 Create Primary Constructor:

因為這時候連線物件已經注入 Repository,所以不必再傳連線給每個 method,我們就可以對目標 method 使用 Change Signature,直接移除連線參數:

調整完成後,介面就只描述業務邏輯真正需要的兩個查詢,DB 連線則在建構時注入:
public interface CustomerRepositoryGateway
{
int GetMemberLevel(int customerId);
Customer GetCustomerById(int customerId);
}
public class SqlCustomerRepository(IDbConnection conn)
: CustomerRepositoryGateway
{
public int GetMemberLevel(int customerId)
=> conn.QueryFirst<int>(
"SELECT Level FROM Members WHERE Id = @id",
new { id = customerId });
public Customer GetCustomerById(int customerId)
=> conn.QueryFirst<Customer>(
"SELECT Id, Address, IsVip FROM Customer WHERE Id = @id",
new { id = customerId });
}
快結束了!最後回到 CheckoutService,再做一次 Extract Method:
protected virtual CustomerRepositoryGateway GetCustomerRepository()
{
var conn = GetDbConnection();
CustomerRepositoryGateway customerRepository =
new SqlCustomerRepository(conn);
return customerRepository;
}
最後,主程式會變成這樣:
public decimal Checkout(int customerId, List<OrderItem> items)
{
var customerRepository = GetCustomerRepository();
var record = new InventoryRecord(items);
var subtotal = record.CalculateTotalPrice();
var memberLevel = customerRepository.GetMemberLevel(customerId);
var discountedSubtotal = memberLevel >= 3 ? subtotal * 0.9m : subtotal;
var tax = record.CalculateTax(discountedSubtotal);
var customer = customerRepository.GetCustomerById(customerId);
var fee = new PriceCalculator().CalculateBaseFee(discountedSubtotal);
if (customer.IsVip)
fee = 0m;
else if (IsRemoteArea(customer.Address))
fee += 50m;
return discountedSubtotal + tax + fee;
}
到這裡,我們已經有條件開始測試了。這個過程看似落落長,但熟悉工具之後其實很快;我們藉由 IDE 的自動化重構降低了在沒有測試時搬動程式碼的風險,更重要的是:
CheckoutService 的對外介面完全沒變
人類有了火之後,總算不用把時間全耗在生食和取暖上,才有餘力蓋房子、鍛工具、學習其他技藝,重構工具給我的感覺差不多:
省下手動搬動程式碼的時間,我們才有餘力處理真正想做的事,下一步,就開始寫測試啦!
依賴拆開之後,我們終於可以在測試裡放入自己準備的客戶資料,不必真的連上資料庫。
還記得前面認識過的**測試替身(Test Double)**家族嗎?
這次我們要用的是家族裡的 Stub,它會提供測試預先安排好的答案,像這裡直接實作 CustomerRepositoryGateway 就可以了,因此不管收到哪個 customerId,它都會回傳我們指定的會員等級與客戶資料。
public class StubCustomerRepository : CustomerRepositoryGateway
{
private readonly int _memberLevel;
private readonly Customer _customer;
public StubCustomerRepository(
int memberLevel,
Customer customer)
{
_memberLevel = memberLevel;
_customer = customer;
}
public Customer GetCustomerById(int customerId) => _customer;
public int GetMemberLevel(int customerId) => _memberLevel;
}
接著用測試專用的 FakeCheckoutService 子類別覆寫,讓正式流程取得這個 Stub:
public class FakeCheckoutService(CustomerRepositoryGateway repo) : CheckoutService
{
protected override CustomerRepositoryGateway GetCustomerRepository() => repo;
}
目前系統的既有規則是基本運費 60 元。我們先從最單純的一般客戶開始,寫下第一支測試:
[Fact]
public void Checkout_一般客戶購買800元_應付900元()
{
var repository = new StubCustomerRepository(
memberLevel: 1,
customer: new Customer { Address = "台北市", IsVip = false });
var sut = new FakeCheckoutService(repository);
var total = sut.Checkout(
customerId: 1,
items: [new OrderItem { UnitPrice = 800m, Quantity = 1 }]);
Assert.Equal(900m, total);
}
跑起來是綠燈,很好,接著把原本沒被保護到的組合一起補進來:
[Theory]
[InlineData(1, "台北市", false, 900)] // 一般客戶:商品 800 + 稅 40 + 運費 60
[InlineData(1, "台北市", true, 840)] // VIP:免運
[InlineData(1, "金門縣", false, 950)] // 偏遠地區:運費 60 + 加價 50
[InlineData(1, "金門縣", true, 840)] // VIP 又住偏遠:VIP 優先,仍免運
[InlineData(3, "台北市", false, 816)] // 三級會員:折扣後 720 + 稅 36 + 運費 60
public void Checkout_既有運費規則(int memberLevel, string address, bool isVip, decimal expected)
{
var repository = new StubCustomerRepository(
memberLevel: memberLevel,
customer: new Customer { Address = address, IsVip = isVip });
var sut = new FakeCheckoutService(repository);
var total = sut.Checkout(
customerId: 1,
items: [new OrderItem { UnitPrice = 800, Quantity = 1 }]);
Assert.Equal(expected, total);
}
測試長這樣確實有點醜,而且只用註解標示每筆測資的意圖也不夠可靠。
xUnit v3 對 Theory 的顯示名稱有更多支援,不過並不是每個專案都適合立刻升級,所以這裡先用另一種老派但是簡單的方式做整理。
抽出方法後,用五個具名的 Fact 來做取代:
public class CheckoutTests
{
[Fact]
public void 一般客戶_商品800_稅40_運費60() => CheckoutTesting(1, "台北市", false, 800, 900);
[Fact]
public void VIP_免運() => CheckoutTesting(1, "台北市", true, 800, 840);
[Fact]
public void 偏遠地區_運費60_加價50() => CheckoutTesting(1, "金門縣", false, 800, 950);
[Fact]
public void VIP又住偏遠_VIP優先_仍免運() => CheckoutTesting(1, "金門縣", true, 800, 840);
[Fact]
public void 三級會員_折扣後720_稅36_運費60() => CheckoutTesting(3, "台北市", false, 800, 816);
private void CheckoutTesting(int memberLevel, string address, bool isVip, decimal amount, decimal expected)
{
var repository = new StubCustomerRepository(
memberLevel: memberLevel,
customer: new Customer { Address = address, IsVip = isVip });
var sut = new FakeCheckoutService(repository);
var total = sut.Checkout(
customerId: 1,
items: [new OrderItem { UnitPrice = amount, Quantity = 1 }]);
Assert.Equal(expected, total);
}
}
測試總管中還可以直接看見每條測試到底在驗證什麼:

這樣在測試總管中,就能直接辨識每條測試想保護的情境。
但這不是一套完整的測試矩陣,實務上仍應依改動風險與業務規則補上相應的邊界案例,這裡省略它們,是為了讓篇幅集中在 Legacy Code 的改動流程,不代表這些案例不重要。
照理說,現在可以直接加入「滿千免運」,功能確實可以馬上做得出來,但新的 else if 仍然會落在 CheckoutService,下一次再來一條離島活動、超商免運或特殊會員規則,我們還是只能繼續往同一個 Checkout 裡面塞邏輯。
所以這時候先不急著改行為。接下來幾個動作都只做結構上的重構:每完成一小步就跑一次測試。
ShippingPolicy先回頭看 CheckoutService 裡在計算運費的這一段:
var fee = new PriceCalculator().CalculateBaseFee(discountedSubtotal);
if (customer.IsVip)
fee = 0m;
else if (IsRemoteArea(customer.Address))
fee += 50m;
運費計算已經是一項能獨立描述的責任,適合收斂到自己的位置。
我們就不改裡面的判斷式了,只用 Extract Class 把這段程式原封不動搬進 ShippingPolicy:
public class ShippingPolicy(
Customer customer,
decimal discountedSubtotal)
{
public decimal CalculateFee()
{
var fee = new PriceCalculator()
.CalculateBaseFee(discountedSubtotal);
if (customer.IsVip)
fee = 0m;
else if (IsRemoteArea(customer.Address))
fee += 50m;
return fee;
}
private bool IsRemoteArea(string address)
{
return address.Contains("金門")
|| address.Contains("澎湖")
|| address.Contains("馬祖");
}
}
Checkout 中則變成這樣:
var fee = new ShippingPolicy(
customer,
discountedSubtotal
).CalculateFee();
重新執行五個既有測試,確保全部綠燈。
搬完之後再看 PriceCalculator,會發現 CalculateBaseFee 無論收到多少金額,都只會回傳固定的 60m,而且這個輸入根本沒有用到:
public decimal CalculateBaseFee(decimal amount)
=> 60m;
這個方法沒有封裝任何會變動的計算,卻讓讀的人多繞了一層,因此可以把 60 元基本運費直接放進 ShippingPolicy,同時移除 ShippingPolicy 不再需要的 discountedSubtotal 建構子參數:
public class ShippingPolicy(Customer customer)
{
public decimal CalculateFee()
{
var fee = 60m;
// ... 略
return fee;
}
}
InventoryRecord運費搬走後,CheckoutService 還是親自處理很多價格細節:
var record = new InventoryRecord(items);
var subtotal = record.CalculateTotalPrice();
var memberLevel = customerRepository.GetMemberLevel(customerId);
var discountedSubtotal =
memberLevel >= 3 ? subtotal * 0.9m : subtotal;
var tax = record.CalculateTax(discountedSubtotal);
var fee = new ShippingPolicy(customer).CalculateFee();
return discountedSubtotal + tax + fee;
我們知道 InventoryRecord 目前負責商品小計與稅金,但會員折扣與最後的總額組合卻留在 CheckoutService,先把這些計算集中到 InventoryRecord,讓商品金額相關的職責回到同一個地方:
public class InventoryRecord(
IEnumerable<OrderItem> items,
int memberLevel)
{
private readonly IReadOnlyCollection<OrderItem> _items =
items.ToList();
private decimal TotalPrice
=> _items.Sum(x => x.UnitPrice * x.Quantity);
private decimal TierDiscount
=> memberLevel >= 3
? TotalPrice * 0.9m
: TotalPrice;
public decimal Tax
=> Math.Round(TierDiscount * 0.05m, 0);
public decimal CalculateTotalFee(decimal shippingFee)
=> TierDiscount + Tax + shippingFee;
}
CheckoutService 不再自己算小計、折扣與稅金,只需要查出計算所需的資料,再把運費交給價格物件:
var memberLevel = customerRepository.GetMemberLevel(customerId);
var customer = customerRepository.GetCustomerById(customerId);
var record = new InventoryRecord(items, memberLevel);
var shippingFee = new ShippingPolicy(customer).CalculateFee();
return record.CalculateTotalFee(shippingFee);
為什麼不是順便改成更漂亮的公式?因為這一步的目的只有集中原本已存在的計算,折扣仍然是九折、稅率仍然是 5%、運費仍然照舊,確保測試再次維持綠燈後,我們再繼續往下整理。
現在的 ShippingPolicy 接收整個 Customer,實際上卻只讀取 IsVip 與 Address,這種「要求的比實際用到的多」,其實是一種隱性的過度耦合。
光看建構子的簽名,讀的人沒辦法一眼判斷運費規則到底和 Customer 的哪些欄位有關,即使大多欄位都跟運費一點關係都沒有,還是只能把整個類別讀過一遍才敢確定,讓閱讀成本增加。
那既然運費規則只需要「是不是 VIP」與「地址」,輸入就只需要這兩個欄位:
public class ShippingPolicy(bool isVip, string address)
{
public decimal CalculateFee()
{
var fee = 60m;
if (isVip)
fee = 0m;
else if (IsRemoteArea(address))
fee += 50m;
return fee;
}
// ... 略
}
這麼做雖然稍微麻煩一點,卻能讓邊界更清楚,外部呼叫也一目瞭然。
如果這兩項資料經常一起出現,也可以再收斂成一個描述運費判斷條件的 值物件(Value Object)。本例先保留原始型別,避免為單一規則增加太多結構。
接著再看 CheckoutService 中:
var shippingFee = new ShippingPolicy(
customer.IsVip,
customer.Address
).CalculateFee();
return record.CalculateTotalFee(shippingFee);
既然最後總額本來就由價格物件組合,價格物件可以直接拿到 ShippingPolicy,需要運費時再請它計算,就不必讓主流程當傳話筒了。
程式碼如下:
public class InventoryRecord(
IEnumerable<OrderItem> items,
int memberLevel,
ShippingPolicy shippingPolicy)
{
private readonly IReadOnlyCollection<OrderItem> _items =
items.ToList();
public decimal CalculateTotalFee()
=> TierDiscount + Tax + shippingPolicy.CalculateFee();
public decimal Tax
=> Math.Round(TierDiscount * 0.05m, 0);
private decimal TotalPrice
=> _items.Sum(x => x.UnitPrice * x.Quantity);
private decimal TierDiscount
=> memberLevel >= 3
? TotalPrice * 0.9m
: TotalPrice;
}
仔細一看目前這裡還藏著另一個不容易從表面看出的問題。
CalculateTotalFee 先讀取一次 TierDiscount,接著 Tax 又讀取一次 TierDiscount,而每次讀取 TierDiscount,最後都會走到 TotalPrice。也就是說,結帳的計算與命名在幾個成員之間跳來跳去,好亂啊!
既然這些動作是在「計算」,就整理一下用命名好好的表達出來:
public class InventoryRecord(
IEnumerable<OrderItem> items,
int memberLevel,
ShippingPolicy shippingPolicy)
{
private const decimal TaxRate = 0.05m;
private const decimal MemberDiscountMultiplier = 0.9m;
private const int MinimumDiscountMemberLevel = 3;
private readonly IReadOnlyCollection<OrderItem> _items =
items.ToList();
public decimal CalculateTotalFee()
{
var discountedSubtotal = CalculateDiscountedSubtotal();
return discountedSubtotal
+ CalculateTax(discountedSubtotal)
+ shippingPolicy.CalculateFee();
}
private decimal CalculateDiscountedSubtotal()
{
var subtotal = _items.Sum(
item => item.UnitPrice * item.Quantity);
var discountMultiplier =
memberLevel >= MinimumDiscountMemberLevel
? MemberDiscountMultiplier
: 1m;
return subtotal * discountMultiplier;
}
private static decimal CalculateTax(
decimal discountedSubtotal)
=> Math.Round(discountedSubtotal * TaxRate, 0);
}
現在 CalculateTotalFee 好懂多了。
看到 ShippingPolicy 之後,也許會讓人想繼續建立 TaxPolicy 與 DiscountPolicy,把每一條規則都抽成物件,但抽象本身也有成本,規則還沒有多種變化時,先建立一整套 Policy 只會增加不必要的複雜度,先這樣就好了。
此時 CheckoutService 後半段如下:
var record = new InventoryRecord(
items,
memberLevel,
new ShippingPolicy(customer.IsVip, customer.Address)
);
return record.CalculateTotalFee();
價格細節都回到對應的物件後,CheckoutService 終於只剩下協調流程的工作。
一開始 InventoryRecord 就承擔了金額計算,經過重構後,它還知道會員折扣、稅金與運費策略,並負責算出訂單的最後金額。
如果名字仍然叫做 InventoryRecord,不過下一個讀到這段程式的人很可能以為它是庫存紀錄,根本看不出它負責結帳金額,因此使用 Rename 重構,把它改成更符合責任的 OrderPriceCalculator:
public class OrderPriceCalculator(
IEnumerable<OrderItem> items,
int memberLevel,
ShippingPolicy shippingPolicy)
{
// ... 略
}
步驟三為了在不大改對外介面的前提下先讓測試覆蓋,所以我們在 CheckoutService 留下了 GetCustomerRepository 作為 Seam,測試程式再用子類別覆寫它。
而經過一連串重構後,它在 CheckoutService 裡顯得非常突兀,因為 CheckoutService 本來就不該知道如何建立 SQL 連線。
現在既有行為已經有測試保護,就可以把它改成建構子注入,之後由外部提供給 CheckoutService,不再自行決定要怎麼建立。
public class CheckoutService(CustomerRepositoryGateway customerRepo)
{
public decimal Checkout(
int customerId,
List<OrderItem> items)
{
var memberLevel = customerRepo.GetMemberLevel(customerId);
var customer = customerRepo.GetCustomerById(customerId);
var record = new OrderPriceCalculator(
items,
memberLevel,
new ShippingPolicy(
customer.IsVip,
customer.Address)
);
return record.CalculateTotalFee();
}
}
測試也不需要 FakeCheckoutService 了,直接把 Stub 傳進正式物件即可:
var repository = new StubCustomerRepository(
memberLevel: memberLevel,
customer: new Customer
{
Address = address,
IsVip = isVip
});
var sut = new CheckoutService(repository);
最後再由 .NET 的 DI 容器負責註冊這些服務。
做到這裡,CheckoutService 不會再讀設定檔、不建立資料庫連線,也不再親自計算價格,它就只負責以下流程:
測試仍然全部通過,這一連串動作看起來很多,但熟練之後其實很快,重點是透過反覆練習,讓每次重構都維持在可驗證的步驟。
前面已經和主事人確認過:
規則說清楚之後,不要急著先寫判斷,我們先用幾個情境,把「滿千」代表什麼用測試寫下來。
經過前面的重構,運費規則已經集中到 ShippingPolicy,這次要改的正是免運門檻,所以我們就針對它做測試:
public class ShippingPolicyTests
{
[Fact]
public void 剛好滿千_一般地區免運()
=> ShippingTesting(
isVip: false,
address: "台北市",
discountedSubtotal: 1000,
expectedFee: 0);
[Fact]
public void 滿千_偏遠地區也免運()
=> ShippingTesting(
isVip: false,
address: "金門縣",
discountedSubtotal: 1000,
expectedFee: 0);
private void ShippingTesting(
bool isVip,
string address,
decimal discountedSubtotal,
decimal expectedFee)
{
var policy = new ShippingPolicy(isVip, address);
var fee = policy.CalculateFee(discountedSubtotal);
Assert.Equal(expectedFee, fee);
}
}
此時 CalculateFee 還沒有接收折扣後小計,測試會先因為編譯不過而失敗,先替 method 與既有呼叫端補上 discountedSubtotal 參數後,兩個滿千免運情境仍會因實際運費不是 0 元而失敗,因為接下來我們才要開始實作新邏輯。
CalculateFee 加上參數時,呼叫端也必須一起更新:
public decimal CalculateTotalFee()
{
var discountedSubtotal = CalculateDiscountedSubtotal();
return discountedSubtotal
+ CalculateTax(discountedSubtotal)
+ shippingPolicy.CalculateFee(discountedSubtotal);
}
接著讓 ShippingPolicy.CalculateFee 收下折扣後小計,並加入足以讓測試通過的判斷:
public decimal CalculateFee(decimal discountedSubtotal)
{
var fee = 60m;
if (isVip)
fee = 0m;
else if (discountedSubtotal >= 1000)
fee = 0m;
else if (IsRemoteArea(address))
fee += 50m;
return fee;
}
本章定義的兩個新增情境都通過後,實作已符合目前明訂的需求,最後再進行一次小小的整理。
計算流程就是 免運判斷 與 偏遠地區 判斷,那就分成兩個 method:
public class ShippingPolicy(bool isVip, string address)
{
private const decimal BaseFee = 60m;
private const decimal RemoteAreaSurcharge = 50m;
private const decimal FreeShippingThreshold = 1000m;
public decimal CalculateFee(decimal discountedSubtotal)
{
if (IsFreeShipping(discountedSubtotal))
return 0m;
if (IsRemoteArea())
return BaseFee + RemoteAreaSurcharge;
return BaseFee;
}
private bool IsFreeShipping(
decimal discountedSubtotal)
=> isVip || discountedSubtotal >= FreeShippingThreshold;
private bool IsRemoteArea()
{
return address.Contains("金門")
|| address.Contains("澎湖")
|| address.Contains("馬祖");
}
}
最後再跑一次測試,確保全部維持綠燈。
在上述範圍內,我們完成了這次需求,給螢幕前的自己掌聲鼓勵鼓勵!
今天的內容會有點多,請各位看官見諒!
今天其實只圍繞著一個簡單的新需求,動作卻這麼多,不免讓人感到厭煩,甚至覺得「有必要嗎?」
在本例中,這些步驟換來的是更短的驗證迴圈,實際需要投入多少時間,就要看系統規模、既有測試與環境準備成本,若沒有先確認需求並建立回饋,時間往往仍會花在猜改動點、等待環境、重複手動驗證、追查回歸問題與返工上。
以前剛出社會的時候,光是確認一次改動,可能就得先把後端環境準備好,從前端進入訂單頁面下單,再把 payload 複製到 Postman,每次修改程式碼後都要重新呼叫 API,碰上資料庫裡沒有合適的資料,還得先確認資料、補上假資料,甚至另外建立測試設定檔,一旦漏掉既有規則,後面還要再追查是哪次改動造成回歸,省下的步驟不一定真的省下了時間。
反之我們照著五步驟,會發現下一次改動時會更有安全感,維護性更佳,不然的話 Legacy Code 是怎麼造成的呢,您說是吧?
這兩天的篇幅特別長,但我覺得這會是很重要的部分,當我們熟悉這套處理 Legacy Code 的常用起手式後,面對許多不熟悉的程式碼,就比較知道該從哪裡開始,後面的章節則會繼續介紹特殊狀況下可以採取的策略,讓我們在這個基礎上更加游刃有餘。
至於重構,今天的範例其實還可以繼續整理,但重構本身也有成本,「過頭」了未必更好,什麼時候該停,取決團隊共同的維護經驗,重要的是讓下一次改動更容易理解與驗證,而不是追求某一種真理。
就像我現在其實對 CalculateTotalFee 這個命名又有點不滿意,聽起來像只計算「費用」,實際回傳的是整筆訂單應付總額,所以重構工具按一下就把它再改成 CalculateTotalAmount,就是這麼快速又任性 XD
不過,今天剛好有時間,能從公開入口把這次可能受影響的既有計算路徑先保護起來,但是如果今天客戶已經在發火了,PM 要我們一小時內交出東西,還能堅持住嗎?
明天我們繼續看:客戶已經火大了,還能寫測試嗎?