功能標誌讓程式可以先整合與部署,再依產品安排開放功能。如果要替換既有功能的實作,改版期間仍須維持原本功能運作,也要讓其他開發者能繼續開發依賴它的功能。
抽象分支(Branch by Abstraction)是一種逐步替換既有程式的開發技巧,透過共同抽象,讓新舊實作可以並存。開發者先調整既有程式的結構,保留原本行為,再逐步加入新實作,完成驗證後切換,最後清理不再需要的程式。改版可以持續一段時間,每個完成的步驟仍能整合到主幹。
Nathan 團隊接著想做的事是寄信改版。
目前所有通知信都在網站處理請求時寄送,網站必須等寄信操作結束,才能回應使用者。團隊希望把寄信移到背景程序,縮短使用者等待網站回應的時間。研究完發現,可以使用佇列。它可以先保存寄信任務,再由背景程序取出執行,但排入佇列不表示信件已經寄出。
Mandy 負責修改寄信程式,Brent 準備佇列與執行背景任務的程序。由於團隊還沒有使用佇列寄信的經驗,兩人需要一起確認任務是否能正常處理,以及寄送失敗時怎麼辦。準備與驗證期間,正式功能仍須使用原本的寄信方式,其他成員也會繼續修改通知信的內容與觸發通知的功能。
為了控制切換後的影響範圍,團隊先選定一個允許延後收到通知的客戶,以其收信網域 example.net 作為這次改版的範圍。確認這批通知的背景寄送流程能正常運作後,再評估是否擴大範圍;其他網域先維持原本方式。
目前通知功能會先準備好收件地址與信件內容,再交給 ImmediateSender 寄送。寄信操作集中在這個類別裡,因此可以從這裡著手調整寄送方式。
以下範例使用 PHP 8.5 與 Laravel 13,原本的 app/Mail/ImmediateSender.php 如下:
namespace App\Mail;
use Illuminate\Mail\Mailable;
use Illuminate\Support\Facades\Mail;
final class ImmediateSender
{
public function send(string $email, Mailable $message): void
{
Mail::to($email)->send($message);
}
}
Mailable 是 Laravel 用來描述信件主旨、內容與附件的類別。本例每次寄信都建立新的信件物件,沒有實作會讓信件自動排入佇列的 ShouldQueue 介面,因此這裡的 send() 會在當下執行寄送操作。
這些呼叫 ImmediateSender::send() 的通知程式,以下稱為呼叫端。改版前,呼叫端直接建立並使用 ImmediateSender,關係如下:

Mandy 可以在自己的分支裡修改 ImmediateSender,讓指定網域的信件改為排入佇列,等背景寄送流程準備好、驗證通過後,再合併回主幹。在改版合併回主幹前,主幹上的程式仍使用原本的寄信方式。
不過,其他人會繼續修改通知程式。如果其他人修改了通知信的內容,並合併到主幹,Mandy 就要把這些變更合併到自己的分支,確認新的寄信方式仍能正常寄送這些通知信。
這又回到開發者之間的距離問題。即使 Mandy 持續取得主幹更新,新寄信程式與通知功能的配合仍只在她的分支上驗證。等到整份改版完成才合併,團隊就無法提早在共同主幹上整合與驗證這些程式。
另一種做法是保留 ImmediateSender,另外建立一個類別實作新的寄信方式。新類別可以先供改版與測試使用,不必等背景寄送全部準備好才整合。新舊類別都在主幹後,其他人修改通知信的內容時,團隊就能在同一個版本裡驗證兩種寄信方式,正式功能則繼續使用原本的類別。
只是,如果兩個類別各自定義方法,後續有人調整其中一個方法的名稱或參數,另一個類別沒有跟著調整,呼叫端換用實作時就可能無法照原本方式呼叫。團隊因此需要先約定兩個類別共同提供的操作,再用介面約束方法宣告。
方法名稱與參數相同,還不足以代表兩種實作可以替換。原本要等寄送操作結束,呼叫端才會繼續執行;指定網域改走佇列後,只要成功排入任務,呼叫端就會繼續執行,不會等待背景程序完成寄送。
這些指定網域的通知雖然允許延後寄送,仍要確認呼叫端是否依賴原本的寄送時機。Mandy 和負責通知功能的成員確認,呼叫端不需要立即知道寄送結果。如果呼叫端需要在當次請求中得知寄送失敗並顯示錯誤,就不能直接改成背景寄送,必須先調整提示與錯誤處理方式。
立即寄送或排入佇列時發生的錯誤,會由目前的呼叫流程處理;成功排入之後才發生的寄送錯誤,則交由背景任務的失敗處理接手。
這些確認也呼應了 Liskov 與 Wing 對子型別的要求:
“…any property proved about supertype objects also holds for its subtype objects.”
—— Barbara H. Liskov、Jeannette M. Wing,1994(節錄)
也就是說,對父型別物件已證明成立的性質,對子型別物件也必須成立。套用到這個案例,兩種寄信實作都需要遵守共同介面的行為約定,讓呼叫端依照約定所做的判斷仍然成立。
確認用途後,可以把共同的操作寫成 Sender 介面,放在 app/Mail/Sender.php:
namespace App\Mail;
use Illuminate\Mail\Mailable;
interface Sender
{
public function send(string $email, Mailable $message): void;
}
介面要求實作提供 send(),接收收件地址與信件物件。PHP 會檢查實作的方法宣告是否相容,但不會替團隊確認信件內容、寄送時機與錯誤處理是否符合約定,這些仍要透過測試與實際流程驗證。
接著讓原本的 ImmediateSender 實作介面,只調整類別宣告:
final class ImmediateSender implements Sender
send() 裡的程式保持不變。原本直接建立 ImmediateSender 的通知功能仍可照常使用,此時還沒有開始背景寄送。
下圖呈現這一步的關係:呼叫端仍直接建立並使用 ImmediateSender,而 ImmediateSender 實作了 Sender 介面。

Mandy 和通知功能負責人先確認原本的流程仍使用相同收件人、主旨與內容寄信,失敗時也按照原本方式處理。必要驗證未通過時,就先修正這一步,不能等新實作完成後再一起處理。完成審查與驗證後,就可以把介面與原本的實作整合到主幹。
這樣就完成了改版的第一個可整合步驟:團隊已經約定共同的寄信操作,並用介面約束方法宣告,原本的通知功能也能繼續運作。後續調整呼叫端與加入佇列實作時,就有共同的介面可以依循。
抽象分支讓改版能分步整合。這裡的「分支」是在程式中保留可替換的實作,每個步驟仍可透過短期分支提出、審查與合併,不必把整份改版留在長期分支。
目前呼叫端仍直接建立 ImmediateSender。接下來要把建立物件的決定移到共同位置,才能在切換實作時,不必逐一修改每個通知功能。