如果你 review 到一段程式碼,類別名稱叫 RetryingArticleRepositoryDecorator、TransactionalArticleRepositoryDecorator,建構子還宣告了 MAX_ATTEMPTS = 3 這種常數,大概率會覺得「這個系統的容錯跟一致性有人認真處理過」,直接放行。
今天要拆給你看:版本 B 裡這兩個類別,名字承諾的能力,跟它們實際的行為,是兩件事。這正是這個系列反覆強調的核心風險——AI 產出的程式碼,很容易讓「架構意圖」讀起來很完整,但沒有人真的去驗證「意圖」跟「行為」是不是同一回事。
beginTransaction/commit/rollBack)沒有被呼叫,代表什麼SqliteUnitOfWork 完全沒有交易保護Unit of Work Pattern 的原始用意,是把一次操作涉及的多筆資料庫寫入包在同一個交易邊界裡,確保「要嘛全部成功、要嘛全部回滾」。版本 B 裡確實有一個實作了 UnitOfWorkInterface 的 SqliteUnitOfWork:
class SqliteUnitOfWork implements UnitOfWorkInterface
{
public function begin(): void { $this->logger->log('SqliteUnitOfWork: begin (no-op)'); }
public function commit(): void { $this->logger->log('SqliteUnitOfWork: commit (no-op)'); }
public function rollback(): void{ $this->logger->log('SqliteUnitOfWork: rollback (no-op)'); }
}
begin()、commit()、rollback() 三個方法都只寫了一行 log,從未呼叫 PDO 的 beginTransaction()/commit()/rollBack()。但 PublishArticleService 的每一個操作都包了一層 runInTransaction(),實際呼叫的就是這個類別:
private function runInTransaction(\Closure $operation): mixed
{
$this->unitOfWork->begin();
try {
$result = $operation();
$this->unitOfWork->commit();
return $result;
} catch (\Throwable $exception) {
$this->unitOfWork->rollback();
throw $exception;
}
}
版本 B 原始碼裡這個類別自己的註解寫得很直白:
名為「SQLite Unit of Work」,實際上不對 PDO 連線呼叫任何 beginTransaction/commit/rollBack……它只留下「看起來嚴謹」的介面與 log,卻沒有提供任何實際的交易保護。
❌ 只看類別名稱與介面的判斷:
implements UnitOfWorkInterface
+ begin()/commit()/rollback() 三個方法都在
= 這裡有交易保護
✅ 該追問的問題:
這三個方法裡面,
真的呼叫了資料庫的交易 API 嗎?
還是只是 log 了一句話?
如果這個系統將來真的需要跨多筆寫入的一致性保證,這裡完全沒有提供——這比「乾脆沒有 UnitOfWork」更危險,因為它讓人誤以為保護已經存在。 讀程式碼的人如果只看到介面實作齊全,很容易在後續開發時假設「反正有 UnitOfWork 罩著」,而不會另外加防護。
RetryingArticleRepositoryDecorator 永遠只試一次Decorator Pattern(裝飾器模式)的用途,是在不改動原始物件介面的前提下,動態疊加額外行為——這裡本來的用意應該是「資料庫暫時性錯誤時自動重試」。版本 B 裡的實作:
private const MAX_ATTEMPTS = 3;
private function withRetry(\Closure $operation): mixed
{
$attempt = 1;
$this->logger->log("RetryingArticleRepositoryDecorator: attempt {$attempt}/".self::MAX_ATTEMPTS);
return $operation();
}
宣告了 MAX_ATTEMPTS = 3,方法名字叫 withRetry,但邏輯裡沒有迴圈、沒有 catch、沒有真的重試第二次——$attempt 永遠是 1,$operation() 只被呼叫一次。這是很典型的「架構意圖」跟「實際行為」脫鉤的樣貌:讀 class 名稱、讀常數,會以為系統有容錯能力,但它沒有。
這兩個案例合起來看,反映的是同一個問題:當程式碼的產出速度快到來不及逐行驗證時,「看起來像」什麼,很容易被誤認成「就是」什麼。 這正是這個系列的主題句:AI 沒有發明過度設計,它只是讓過度設計的速度追上了你按下 Enter 的速度;Review 要跟得上,審的就不能再是程式碼本身,而是產生程式碼的規則。 審的重點不能停留在「這個類別是不是照著模式的介面寫的」,而要往前一步問「產生這段程式碼的規則,有沒有要求它的行為跟名字一致」。
「標題不可為空」這條規則,在版本 B 裡同時存在於 TitleNotEmptySpecification::isSatisfiedBy()、呼叫它的 CreateArticleValidator::validate(),以及又呼叫了一次同一個 Specification 的 ArticleAggregate 建構子。三個地方都會拋出同一種例外,行為上不衝突,但如果將來要改這條規則(例如加上標題長度上限),需要同時記得改三個地方——這正是 Validator/Specification/Aggregate 三層各自負責什麼職責劃分不清楚時,最容易發生的重複。
回想你 review 過的程式碼裡,有沒有類似「類別名字承諾了某個能力,但你沒有真的去讀實作內容確認它做到了」的情況?你覺得要怎麼樣的 review 習慣,才能系統性地抓出這種「名不符實」?
SqliteUnitOfWork 的 begin()/commit()/rollback() 都只是 log,從未呼叫 PDO 的交易 APIRetryingArticleRepositoryDecorator 宣告了 MAX_ATTEMPTS = 3,但邏輯上永遠只執行一次Day 12 會拆解版本 B 裡的 Event/Listener 機制——這套聽起來很「事件驅動」的架構,實際上解決的是一個根本不存在的問題。
我自己在職涯早期,也寫過類似 withRetry 這樣「先把介面掛上去,邏輯之後再補」的程式碼,通常是因為當下沒時間、想著「先讓 code review 過,之後再補完整」。這次拆解版本 B 才意識到,AI 生成程式碼時完全沒有「之後再補」的自覺——它一次寫完一整個類別,名字、常數、方法簽名全部到位,讀起來像是完工的樣子,但邏輯是空的。這種「完工感」比人手寫的半成品更容易騙過 review,因為它連該有的破綻(TODO 註解、明顯的簡化)都沒有留下。