iT邦幫忙

2026 iThome 鐵人賽

DAY 13
0
Software Development

當 AI 寫得比你讀得快:Code Review 該審什麼系列 第 13 篇

Day 13:案例拆解——DTO/Mapper 轉換鏈為什麼讓一次修改要動 8 個檔案

  • 分享至 

  • xImage
  •  

前言:「Repository 回傳的物件不該直接暴露給外層,這不是常識嗎?」

「Domain 物件不該直接穿過 Application 層,曝露給外層使用」——這是一句在分層架構討論裡很常聽到的教條。聽起來很有道理,於是「幫每個 Domain 物件配一個對應的 DTO,再寫一個 Mapper 負責轉換」就變成一個「反正加了不會錯」的預設動作。

今天的案例會告訴你,這句教條如果沒有搭配「這一層轉換有沒有真的被誰依賴」的檢驗,加上去的 DTO/Mapper 只會變成一段從頭到尾沒人呼叫、卻讓修改成本上升的死碼。

今日目標

  • 看懂版本 B 裡 DTO/Mapper 轉換鏈的真實使用情況
  • 理解「這一層有沒有被呼叫」跟「這一層理論上該不該存在」是兩個獨立的問題
  • 用實際數字理解「改動範圍」怎麼被不必要的轉換鏈放大
  • 回顧新增「分類」欄位這個真實案例:乾淨版本改 3 個檔案,過度設計版本要改 10-11 個檔案
  • 把這個案例跟這個系列的量化指標(改動範圍 vs 需求範圍)連起來

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 呼叫
  • 「有沒有被呼叫」跟「理論上該不該存在」是兩個獨立的問題,只看後者容易產生死碼
  • 新增「分類」欄位這個真實案例:乾淨版本改 3 個檔案,過度設計版本(僅計被呼叫的部分)要改 10-11 個檔案
  • 改動範圍跟需求範圍是否成比例,是比行數更具體的過度設計量化指標

明日預告

Day 14 會回到一個更根本的問題:兩個版本都通過同一組驗收測試,但只有一個「設計對」——為什麼測試通過不能證明設計正確,這裡會用兩個版本的實際測試結果直接對照。

老派工程師的心得

「Domain 物件不該直接暴露給外層」這句話我自己過去也常常拿出來當理由,說服自己「多加一層 DTO 總是比較保險」。這次看到 ArticleAggregateToDTOMapper 這個從沒被呼叫過的類別,才真正意識到:我當時說的「保險」,其實只是一種心理安慰,沒有對應到任何具體會發生的風險。 教條之所以是教條,是因為它在某些情境下真的成立,但把教條當成不用思考的預設動作,就是過度設計最常見的入口之一。


上一篇
Day 12:案例拆解——Event/Listener 機制解決了不存在的問題
系列文
當 AI 寫得比你讀得快:Code Review 該審什麼 共 13 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言