前言:同一個功能,截然不同的 Debug 體驗
最近在維護一個同時有 Vue 前端和 Flutter App 的專案。兩邊都要實作「關於我們」頁面的選單過濾邏輯——根據不同情境顯示或隱藏特定項目。
Vue 那邊:兩天內改了十幾個 commit,每次都是小幅調整,順順地完成。
Flutter 這邊:卡了一整天,改了一個地方沒效果,懷疑方向錯誤,來回折騰。
同樣的業務邏輯,為什麼 debug 體驗差這麼多?
這篇文章從 debug 實戰出發,一路延伸到架構層面的反思。我們會探討 Domain Model 的防禦能力、Clean Architecture 的責任邊界、扁平架構的取捨、BFF 的可靠性價值,最後揭露這次 debug 困難的真正原因——交接代碼的信任陷阱。
Part 1:Debug 實戰
Vue:問題在 UI 顯示層
Vue 那邊的典型修正長這樣:
// Vue - 在 computed 裡加一個 filter
const filteredPageTabs = computed(() => {
return response.value.pageTabs
.filter(item => item.subtitle !== 'Service')
.map(item => {
if (item.subtitle === 'ABOUT_US') {
return {
...item,
subTabs: item.subTabs?.filter(
subTab => subTab.title !== '醫療團隊'
) || []
}
}
return item
})
})
問題本質:資料從 API 回來是正確且完整的,只需要決定「哪些要顯示在畫面上」。
Debug 過程:打開 Vue DevTools → 看 store 資料 → 加個 filter → 完成。整個過程不超過 10 分鐘。
Flutter:問題在資料取得層
Flutter 這邊的症狀是「有資料顯示,但內容不對」——所有 Tab 都顯示相同內容,而且未登入用戶有時看不到任何東西。
這種「有東西但不對」的症狀最難 debug,因為你會傾向認為資料流是正確的,只是某個小細節有問題。
追蹤下去,發現三個獨立的 bug:
Bug #1:GraphQL Client 選錯
// 修正前:公開內容卻用了需要授權的 client
final result = await _client.query(options);
// 修正後:改用公開的 client
final result = await _publicClient.query(options);
_client 帶有 Authorization header,當用戶未登入時,某些情況下 API 會靜默返回空資料,而非拋出 403。
Bug #2:GraphQL Query 缺少必要欄位
# 修正前:少了 id 欄位,無法過濾
query GetDoctors {
nested_cs {
groups { ... }
}
}
# 修正後
query GetDoctors {
nested_cs {
id
groups { ... }
}
}
GraphQL 的陰險之處:漏掉欄位不會報錯。API 照樣回傳,只是少了那個欄位。
Bug #3:過濾邏輯根本沒寫
// 修正前:直接取全部,忽略 pageId 參數
doctorSectionsByPageId[pageId] = layout.first.nested_cs;
// 修正後:根據 pageId 過濾
final matchingNestedC = allNestedCs
.where((nc) => nc.id == pageId)
.toList();
為什麼 Flutter 難 Debug?
| 面向 | Vue | Flutter |
|---|---|---|
| 問題位置 | UI 顯示層 | 資料取得層 |
| 資料正確性 | ✅ 資料正確 | ❌ 資料本身就錯 |
| Bug 數量 | 1 個 | 3 個互相掩護 |
| 失敗模式 | 明顯(多顯示東西) | 隱晦(有資料但不對) |
最痛苦的是「多個 bug 互相掩護」:
Bug #1 (Client 錯) 單獨存在 → 未登入時無資料
Bug #2 (過濾錯) 單獨存在 → 所有 Tab 顯示相同
兩個疊加 → 有時有資料、有時沒有、內容又不對
當你修好 Bug #1,問題看起來依然存在。這會讓你懷疑自己的判斷,甚至走回頭路。
Part 2:架構防禦——Domain Model 能攔住什麼?
一個自然的問題浮現:如果強制 Repository 回傳「完整、已驗證的 Domain Model」,這三個 bug 哪些會被提早攔下來?
分析結果
| Bug | 會被攔下來? | 原因 |
|---|---|---|
| #1 Client 選錯 | ❌ 不會 | Repository 內部實作細節 |
| #2 缺少 id 欄位 | ⚠️ 看定義 | required 會攔,nullable 會溜過 |
| #3 過濾邏輯沒寫 | ❌ 不會 | 業務邏輯問題,不是資料完整性問題 |
Bug #2 能被攔下的關鍵
// ✅ 這樣會攔下來
@freezed
class NestedC with _$NestedC {
factory NestedC({
required int id, // GraphQL 沒回傳就會 crash
required List<Group> groups,
}) = _NestedC;
}
// ❌ 這樣會溜過
@freezed
class NestedC with _$NestedC {
factory NestedC({
int? id, // null 也合法,bug 繼續潛伏
required List<Group> groups,
}) = _NestedC;
}
結論
「強制 Domain Model」只能解決資料完整性問題,無法解決:
- 權限/環境問題(用錯 client)
- 業務邏輯問題(該過濾沒過濾)
不同類型的問題需要不同層級的防禦機制。
Part 3:邊界思辨——Repository 該不該帶業務語意?
既然 Bug #3 是「過濾邏輯沒寫」,有個誘人的解法:
讓 Repository 直接回傳 Map<PageId, List<NestedC>>
這樣 ViewModel 就不用自己過濾了。Bug #3 確實會被消滅。但代價是什麼?
Repository 開始「理解」業務
原本清楚的分層:
┌─────────────┐
│ Repository │ → 取得 raw data,轉成 Domain Model
├─────────────┤
│ Use Case │ → 根據業務邏輯過濾、組合
├─────────────┤
│ ViewModel │ → 組裝成 View 需要的格式
└─────────────┘
讓 Repository 回傳 Map<PageId, ...> 後:
┌─────────────┐
│ Repository │ → 取得 + 根據 PageId 分組 ← 越界了
├─────────────┤
│ Use Case │ → ...沒事做了?
├─────────────┤
│ ViewModel │ → 直接用
└─────────────┘
Repository 本來只該知道「如何取得資料」。現在它開始知道「PageId 對應到哪些 NestedC」——這是業務規則,不是資料存取邏輯。
為什麼這條線難守?
因為「讓 Repository 多做一點」短期很方便:
- 少寫一個 Use Case
- ViewModel 程式碼更簡潔
- 「反正這個過濾邏輯不會變」
但一旦開這個口:
- 下次有人會說「讓 Repository 順便過濾 isActive 吧」
- 再下次「順便排序吧」
- 最後 Repository 變成什麼都做的 God Class
更乾淨的解法
// Use Case 層 — 業務邏輯應該在這裡
class GetDoctorsByPageIdUseCase {
final DoctorRepository _repository;
Future<List<NestedC>> execute(String pageId) async {
final allDoctors = await _repository.fetchDoctors();
return allDoctors.where((nc) => nc.id == pageId).toList();
}
}
這條線,正好卡在 Clean Architecture 最容易失守的地方。
Part 4:反面世界——如果採用扁平架構?
到目前為止,我們都在討論多層架構的問題。但如果反過來思考:
如果我們直接讓 ViewModel 呼叫 GraphQL,完全跳過 Repository / Use Case,用「畫面即邏輯」換取開發速度——這三個 bug 的命運會如何?
原本:View → ViewModel → Repository → Service → API
扁平:View → ViewModel → GraphQL Client → API
對照表
| Bug | 多層架構 | 扁平架構 |
|---|---|---|
| #1 Client 選錯 | 藏在 Service 層 | 可能更早爆(被迫選擇) |
| #2 缺少 id | 要追多層才發現 | ✅ 更早爆(用和取距離近) |
| #3 過濾沒寫 | 藏在層間縫隙 | 直接爆在 UI 上 |
扁平架構的諷刺
扁平架構的 bug 會「更早爆」,但爆的地方不一定是開發階段——因為沒有中間層的驗證,問題可能直接穿透到 View,讓用戶先看到。
| 架構 | 問題特性 | 發現時機 |
|---|---|---|
| 多層 | 問題被隔離,但可能藏在縫隙 | 開發後期或 production |
| 扁平 | 問題無處躲藏 | 開發時或直接爆在用戶面前 |
這就是「畫面即邏輯」的雙面刃:開發速度快,但缺乏緩衝層。
Part 5:可靠性工程——BFF、ACL 與信任邊界
如果目標不是「降低 debug 成本」,而是「確保錯誤永遠不會直接影響使用者」——該怎麼設計?
什麼是信任邊界?
信任邊界(Trust Boundary) 是系統架構中的一條線,用來區分「我信任的」和「我不信任的」。
┌─────────────────────────────────────────────┐
│ 我控制的、我信任的 │
│ ┌─────────┐ ┌─────────┐ │
│ │ 前端 │ │ BFF │ │
│ └─────────┘ └─────────┘ │
└─────────────────────────────────────────────┘
║ ← 信任邊界
║ (在這裡做驗證)
↓
┌─────────────────────────────────────────────┐
│ 我不控制的、我不信任的 │
│ ┌─────────────────────────────────────┐ │
│ │ 第三方 API / 外部服務 │ │
│ └─────────────────────────────────────┘ │
└─────────────────────────────────────────────┘
過了信任邊界的資料,後面的程式碼可以「放心用」。
BFF:為前端量身打造的後端
BFF(Backend for Frontend) 是一種架構模式:每一種前端(App / Web / Admin)都可以有一個「專屬後端」。
BFF 解決什麼問題?
後端 API 通常有以下特性:
- 為「多種客戶」設計(App、Web、第三方)
- 結構偏通用,未必符合某個畫面的需求
- 回傳資料可能很多,但前端只用其中一小部分
結果是:
- 前端要做大量轉換、過濾、補欄位
- 錯誤處理散落在各層
- Domain Model 防線被一層層磨薄
BFF 的核心理念:讓前端只拿到「剛好能用」的資料結構。
在這個案例中,BFF 會做什麼?
引入 BFF 後:
App → ViewModel → BFF → Backend
↑ 扁平便利 ↑ 集中防禦
| BFF 職責 | 說明 |
|---|---|
| 統一授權 | 不再讓前端選 client |
| 驗證必要欄位 | 缺 id 直接失敗 |
| 套用業務規則 | 依 PageId 分組 / 過濾 |
| 回傳可直接 render 的結構 | 前端不用再處理 |
前端不再需要:
- 判斷資料是否完整
- 猜測是否該過濾
- 擔心後端 schema 偷偷變動
ACL:防止外部世界污染內部模型
ACL(Anti-Corruption Layer) 是 DDD(Domain-Driven Design)中的概念。它關心的不是「效能」或「方便」,而是:
如何避免外部系統的混亂,滲透進我們的核心模型。
為什麼需要 ACL?
外部系統(包含後端)常見問題:
- 欄位可有可無(nullable everywhere)
- 語意模糊(status = 1 是什麼?)
- 結構不穩定(今天在 A,明天搬到 B)
如果我們直接在 App Domain 使用這些資料:
- Domain Model 被迫變得寬鬆
- required 變 optional
- 驗證邏輯散落各處
久了之後,整個 Domain 都被「外部世界的妥協」腐蝕。
ACL 的責任
ACL 站在「信任邊界」上,只做三件事:
- 驗證:不符合內部期待的資料,直接拒絕
- 轉換:把外部模型轉成內部 Domain Model
- 隔離:外部 schema 變動,不影響內部
在這個案例中:
- GraphQL Response = 外部模型
- App Domain Model = 內部秩序
- BFF = ACL 的最佳落點
BFF 與 ACL 的關係
| 概念 | 關注點 | 在本案例的角色 |
|---|---|---|
| BFF | 系統邊界 | 前端的專屬後端 |
| ACL | 模型邊界 | 驗證與轉換層 |
- BFF 是架構位置
- ACL 是設計原則
你可以:
- 在 BFF 裡實作 ACL(最常見、也最合理)
- 在 App 入口實作簡化版 ACL(當沒有 BFF 時)
這三個 Bug 在 BFF 架構下的命運
| Bug | 沒有 BFF | 有 BFF |
|---|---|---|
| #1 Client 選錯 | 藏在 Service 層 | BFF 統一處理授權 |
| #2 缺少 id | 穿透到 ViewModel | BFF 驗證失敗,回傳明確錯誤 |
| #3 過濾沒寫 | 藏在層間縫隙 | BFF 直接回傳過濾後的資料 |
Bug #2 和 #3 會被 BFF 完全消滅。
為什麼 BFF 比多層前端架構更可靠?
多層前端架構假設:
每一層都會好好守住自己的責任
但現實是:
- 層與層之間容易出現「責任縫隙」
- 驗證邏輯被稀釋
- Bug 剛好從縫隙穿過
BFF + ACL 的假設更務實:
只有在信任邊界集中防禦,錯誤才不會碰到使用者。
收斂一句話
- Clean Architecture 解決的是「系統內部怎麼乾淨」
- ACL 解決的是「外部世界多髒都沒關係」
- BFF 解決的是「前端不該當垃圾處理場」
當你把這三件事放對位置,錯誤就不再需要靠運氣才能被擋下來。
Part 6:真相——交接代碼的信任陷阱
到這裡,我們討論了很多架構層面的問題。但這次 debug 困難的真正原因,其實更實際:
GraphQL Query 是外包商寫的,之前是正常的。
重新審視信任邊界
| 來源 | 誰寫的 | 可信嗎? |
|---|---|---|
| Strapi API | 我 | ✅ 可信(Vue 用同樣的 API 沒問題) |
| Vue 前端 | 我 | ✅ 可信 |
| Flutter GraphQL Query | 外包商 | ⚠️ 交接代碼 |
| Flutter 業務邏輯 | 我在維護 | ✅ 可信 |
Debug 困難的真正原因
我的心理模型:
「Query 之前是正常的 → Query 應該沒問題 → 問題在我的邏輯」
實際情況:
「Query 之前正常 → 但需求變了 / 被改過了 → Query 已經不符合現在的需求」
我一直在自己的邏輯裡找問題,但問題其實在「我以為可信」的外包商代碼裡。
交接代碼的信任陷阱
| 陷阱 | 說明 |
|---|---|
| 「之前正常」的假設 | 你不知道它為什麼正常、在什麼條件下正常 |
| 沒有完整的測試覆蓋 | 外包商走了,測試也沒留下 |
| 需求變了但代碼沒跟著變 | 新功能需要 id,但沒人去改 Query |
| 文件不完整 | 不知道每個欄位的用途 |
真正該畫的信任邊界
傳統觀念認為信任邊界在「後端 vs 前端」。但這次的案例告訴我們:
信任邊界不只是「外部 vs 內部」,
也包括「交接代碼 vs 我的代碼」。
交接代碼 = 不可信,直到你親自驗證過。
外包商寫的代碼(GraphQL Query、舊邏輯)
║
════════════║════════════ ← 信任邊界
║ 「不要假設它是對的」
↓
你維護的代碼
這次學到的教訓
「之前正常」不代表「現在正確」——尤其是交接來的代碼。
當你接手別人的代碼時:
- 不要假設它是對的,即使「之前正常」
- 建立你自己的驗證機制(Domain Model 的 required 欄位)
- 當 bug 找不到時,擴大懷疑範圍——包括你以為可信的部分
總結:六個層次的反思
這次的 debug 經驗,可以從六個層次來看:
層次一:Debug 技巧
- 當症狀是「有資料但不對」,問題往往在資料取得層
- 多個 bug 可能互相掩護,修一個沒效果不代表方向錯
- 逐層加 log,找出資料在哪一層開始「不對勁」
層次二:架構防禦
- Domain Model 的
required欄位可以攔下資料完整性問題 - 但無法攔下權限問題和業務邏輯問題
- 不同類型的問題需要不同層級的防禦機制
層次三:責任邊界
- Repository 只該負責「資料存取」
- 業務邏輯(過濾、分組)應該在 Use Case 層
- 「讓 Repository 多做一點」短期方便,長期會模糊邊界
層次四:架構取捨
- 多層架構:問題被隔離,但可能藏在縫隙
- 扁平架構:問題無處躲藏,但缺乏緩衝
- 沒有絕對的好壞,只有適不適合
層次五:可靠性工程
- 信任邊界 = 「在這裡之後,我就信任資料是對的」的那條線
- BFF 可以把「扁平的便利」和「多層的防禦」拆成兩個世界
- 目標不是「減少 bug」,而是「確保 bug 不會影響用戶」
層次六:交接代碼
- 交接代碼是另一種「不可信」的來源
- 「之前正常」不代表「現在正確」
- 當 bug 找不到時,擴大懷疑範圍——包括你以為可信的部分
結語
同樣的業務邏輯,Vue 和 Flutter 的 debug 難度差了一個數量級。
表面原因是 bug 的數量和位置不同。 深層原因是架構的複雜度和問題的本質不同。 真正原因是我信任了不該信任的代碼。
這次的三個 bug——Client 選錯、欄位遺漏、過濾沒寫——分別發生在不同層級。它們能夠互相掩護、難以追蹤,是因為:
- 沒有一個「信任邊界」在統一把關
- 我假設外包商的 Query 是對的
- 我在自己的邏輯裡繞圈子,沒有擴大懷疑範圍
架構的價值不在於它有多少層,而在於它能不能讓問題變簡單。
但更重要的是:你要知道哪些代碼可以信任,哪些不行。
畫對那條線,問題會被攔在線外。 畫錯那條線,問題會藏在你以為安全的地方。
有時候,你需要修好所有的 bug,問題才會消失。 有時候,你需要畫對那條線,bug 才不會出現。 有時候,你需要承認:「之前正常」的代碼,現在可能是問題的根源。
