「Domain 物件不該直接穿過 Application 層,曝露給外層使用」——這是一句在分層架構討論裡很常聽到的教條。聽起來很有道理,於是「幫每個 Domain 物件配一個對應的 DTO,再寫一個 Mapper 負責轉換」就變成一個「反正加了不會錯」的預設動作。
今天的案例會告訴你,這句教條如果沒有搭配「這一層轉換有沒有真的被誰依賴」的檢驗,加上去的 DTO/Mapper 只會變成一段從頭到尾沒人呼叫、卻讓修改成本上升的死碼。
ArticleDTO/ArticleAggregateToDTOMapper:一段從未被呼叫的轉換鏈版本 B 的 Application/DTOs/ArticleDTO.php 定義了一個看起來很標準的資料傳輸物件:
final class ArticleDTO
{
public function __construct(
public readonly int $id,
public readonly string $title,
public readonly string $content,
public readonly string $status,
public readonly string $createdAt,
public readonly ?string $publishedAt,
) {
}
}
搭配的 ArticleAggregateToDTOMapper:
class ArticleAggregateToDTOMapper
{
public function map(ArticleAggregate $article): ArticleDTO
{
return new ArticleDTO(
$article->id() ?? 0,
$article->title(),
$article->content(),
$article->status(),
$article->createdAt()->format(DATE_ATOM),
$article->publishedAt()?->format(DATE_ATOM),
);
}
}
這兩個類別本身的程式碼註解已經寫得非常直白:
Application 層跟 Domain 層之間直接傳遞
ArticleAggregate本身,從來沒有真的轉換成這個 DTO 過——這是「Repository 回傳的物件不應該直接暴露給外層」這種教條式規則的產物,但這個系統目前沒有任何「外層」會因為拿到 Aggregate 而受害。
檢查 PublishArticleService::listAll() 跟 listPublished() 的實際回傳型別可以直接驗證這件事——兩個方法都直接回傳 ArticleAggregate[],沒有任何一個 QueryHandler 呼叫過 ArticleAggregateToDTOMapper::map()。這個轉換層,從程式碼寫出來的那一刻起,就沒有任何呼叫路徑會經過它。
❌ 只看類別是否符合分層教條的判斷:
有 DTO ✓
有 Mapper ✓
Domain 物件不外露 ✓(理論上)
= 分層做得很確實
✅ 該追問的問題:
grep 一下 ArticleAggregateToDTOMapper::map() 這個方法,
有沒有任何地方真的呼叫它?
素材裡有一個具體的量化實驗:假設要新增一個真實的小需求——文章加上「分類」欄位。
| 版本 | 需要碰的檔案 |
|---|---|
| 乾淨版本 | Article.php(建構子+getter)、ArticleRepository.php(SQL 與 mapping)、PublishArticleService.php(方法簽名)——3 個檔案 |
| 過度設計版本(僅計「有被呼叫」的 62 個檔案內) | ArticleAggregate、ArticleTableGateway、ArticleSqlBuilder、ArticleRowToAggregateMapper、CreateArticleCommand/Handler/Validator、EditArticleCommand/Handler/Validator、PublishArticleService——約 10-11 個檔案 |
注意這個對照表裡的過度設計版本欄位,是「僅計有被呼叫的 62 個檔案內」——也就是說,連從未被呼叫的 ArticleDTO/ArticleAggregateToDTOMapper 都還沒算進去。如果這個轉換鏈真的被某處呼叫,新增欄位還要多改這兩個檔案,實際改動數字會更高。這正是這個案例最值得注意的地方:即使是死碼,只要它「看起來」是資料流動路徑的一部分,review 的人在評估改動範圍時仍然容易誤把它算進「應該要改」的清單裡,徒增認知成本。
需求複雜度沒有變(只是多一個欄位),乾淨版本改動 3 個檔案,過度設計版本要改 10-11 個檔案,是 3-4 倍。這組數字比「行數多寡」更能具體回答「這段程式碼的架構有沒有如預期」——預期應該是「改動幅度跟需求幅度成比例」,而不是「看起來架構完整」。
DTO/Mapper 這類轉換層,如果真的有外部系統依賴 Domain 物件的內部結構(例如要對外曝露 API 回應格式、或者 Domain 物件包含敏感欄位不該外流),它們是合理甚至必要的設計。版本 B 的問題不是「用了 DTO」,而是沒有任何一條規則要求:加一層轉換之前,先確認有沒有人真的依賴這層轉換的邊界。**AI 沒有發明過度設計,它只是讓過度設計的速度追上了你按下 Enter 的速度;Review 要跟得上,審的就不能再是程式碼本身,而是產生程式碼的規則。**這個案例具體示範了「規則」該長在哪裡——不是禁止 DTO,而是要求「新增轉換層前,先証明有呼叫端需要它」。
你的專案裡有沒有類似「Domain 物件到 DTO」的轉換層?如果現在做一次 grep,確認這些 DTO/Mapper 有沒有真的被使用,你預期結果會是什麼?
ArticleDTO/ArticleAggregateToDTOMapper 是版本 B 裡典型的「教條式分層」產物,從未被任何 QueryHandler 呼叫Day 14 會回到一個更根本的問題:兩個版本都通過同一組驗收測試,但只有一個「設計對」——為什麼測試通過不能證明設計正確,這裡會用兩個版本的實際測試結果直接對照。
「Domain 物件不該直接暴露給外層」這句話我自己過去也常常拿出來當理由,說服自己「多加一層 DTO 總是比較保險」。這次看到 ArticleAggregateToDTOMapper 這個從沒被呼叫過的類別,才真正意識到:我當時說的「保險」,其實只是一種心理安慰,沒有對應到任何具體會發生的風險。 教條之所以是教條,是因為它在某些情境下真的成立,但把教條當成不用思考的預設動作,就是過度設計最常見的入口之一。