</> 技術筆記Tech Notes

只有一個實作,還要不要寫介面?

直接依賴實作,跟依賴抽象的差別

「只有一個實作,幹嘛寫介面?」這句話我聽過很多次,自己也講過。

平心而論它不是沒道理。YAGNI 講的就是這件事:不要為了想像中的需求先做設計。如果你寫的是一個一次性的小腳本,跑完就丟,那多開一個檔案放介面確實只是增加負擔。

但工作上碰到的多半不是那種東西。維護個幾年的系統,我後來的答案是:還是會寫。而且理由跟「以後可能會有第二個實作」關係不大。

一、寫介面不是為了多型,是為了不要黏在一起

先看一段沒有介面的程式碼。假設 OrderService 下完單要發通知:

// 直接依賴具體的 EmailService
public class OrderService {
    private EmailService emailService;

    public OrderService() {
        this.emailService = new EmailService(); // OrderService 自己 new 出來
    }

    public void placeOrder() {
        // ... 處理訂單邏輯 ...
        emailService.send("[email protected]", "訂單已成立");
    }
}

這樣寫,OrderServiceEmailService 就綁死了。

問題不會馬上出現,通常是半年後才來。老闆說「也要發簡訊」,你得改 OrderService;或者 EmailService 的建構子多了一個 API Key 參數,所有 new 它的地方都要跟著改。改的地方明明跟訂單邏輯無關,你卻得動訂單的程式碼。

換成介面:

// 1. 先定義合約
public interface INotificationService {
    void send(String recipient, String message);
}

// 2. 實作
public class EmailService implements INotificationService {
    @Override
    public void send(String recipient, String message) {
        // ... 寄 Email ...
    }
}

// 3. 依賴合約,不依賴實作
public class OrderService {
    private final INotificationService notificationService;

    // 從外面傳進來,這就是依賴注入
    public OrderService(INotificationService notificationService) {
        this.notificationService = notificationService;
    }

    public void placeOrder() {
        // OrderService 只知道要 send,不管是怎麼 send 的
        notificationService.send("[email protected]", "訂單已成立");
    }
}

// 在程式進入點決定要用哪個實作
INotificationService emailSender = new EmailService();
OrderService orderService = new OrderService(emailSender);
orderService.placeOrder();

差別在於 OrderService 現在不知道 EmailService 存在,它只認得 INotificationService 這份合約。之後要加簡訊,就多寫一個 SmsService,然後在組裝的地方換掉傳進去的東西,OrderService 一行都不用改。

介面本身沒做什麼事,它的價值在於把「誰決定用哪個實作」這件事,從 OrderService 裡面搬到外面

二、真正會逼你寫介面的,其實是測試

這才是我最有感的部分。

回到剛才的例子。你想測 placeOrder 有沒有正確跑完,但你絕對不希望測試真的寄出一封信。沒有介面的話,new EmailService() 寫死在建構子裡,你很難攔得住它,測試就會變成要連網路、跑很慢,而且別人的信箱會被你的測試塞爆。

有介面就簡單了,隨手做一個假的塞進去:

正式跑跟測試跑,換掉注入的物件就好

@Test
public void testPlaceOrder() {
    // 做一個假的通知服務
    INotificationService mockNotification = Mockito.mock(INotificationService.class);
    OrderService orderService = new OrderService(mockNotification);

    orderService.placeOrder();

    // 驗證 placeOrder 有沒有真的呼叫 send
    Mockito.verify(mockNotification).send("[email protected]", "訂單已成立");
}

這個測試在毫秒內就跑完,完全不碰外部系統。

所以我後來的判斷方式很簡單:如果這個東西我想測,但它會碰到外面的世界(資料庫、網路、檔案、時間),那就給它一個介面。 反過來說,純粹在算數的邏輯類別,我通常就不會多包一層。

三、介面順便把「這個模組能做什麼」寫清楚了

介面是一份公開的合約,它只講「做什麼」,不講「怎麼做」。

實務上這件事在分工的時候特別有用。兩個人要接同一塊功能,先把介面談好,就可以各自往下寫,最後只要實作符合合約就接得起來,不用等對方寫完才能動工。

還有一個好處是讀程式碼的時候。當你看到某個類別依賴的是 IUserRepository 而不是 MySqlUserRepository,你馬上就知道:作者是刻意不想跟資料庫綁在一起的。這個資訊光看類別名稱就傳達出來了。

四、所以我現在的做法

軟體最貴的是維護,不是第一次寫。你今天覺得「一定只會有一個實作」,半年後需求變了,會慶幸當初多寫了那個介面。

不過我也不會每個類別都包介面,那樣只是換一種方式把程式碼變複雜。我的判斷大概是這樣:

情況 我會不會寫介面
會碰到資料庫、網路、檔案、外部 API 的服務 會,主要是為了測試
核心商業邏輯的服務,別的模組會依賴它 會,當作模組邊界
純資料的 DTO、Value Object 不會
只在同一個檔案裡用的小工具類別 不會
一次性的腳本 不會

所以那句話我會改成這樣:

如果你寫的是生命週期很短、功能很單純、幾乎不會變的拋棄式腳本,不寫介面完全沒問題。但長期要維護、多人協作的專案,服務類別寫介面是為了讓它可以被測試、可以被替換、邊界講得清楚。這跟現在有幾個實作沒什麼關係。