前言:同一個功能,截然不同的 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?

面向VueFlutter
問題位置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 站在「信任邊界」上,只做三件事:

  1. 驗證:不符合內部期待的資料,直接拒絕
  2. 轉換:把外部模型轉成內部 Domain Model
  3. 隔離:外部 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穿透到 ViewModelBFF 驗證失敗,回傳明確錯誤
#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、舊邏輯)
            ║
════════════║════════════  ← 信任邊界
            ║                 「不要假設它是對的」
            ↓
你維護的代碼

這次學到的教訓

「之前正常」不代表「現在正確」——尤其是交接來的代碼。

當你接手別人的代碼時:

  1. 不要假設它是對的,即使「之前正常」
  2. 建立你自己的驗證機制(Domain Model 的 required 欄位)
  3. 當 bug 找不到時,擴大懷疑範圍——包括你以為可信的部分

總結:六個層次的反思

這次的 debug 經驗,可以從六個層次來看:

層次一:Debug 技巧

  • 當症狀是「有資料但不對」,問題往往在資料取得層
  • 多個 bug 可能互相掩護,修一個沒效果不代表方向錯
  • 逐層加 log,找出資料在哪一層開始「不對勁」

層次二:架構防禦

  • Domain Model 的 required 欄位可以攔下資料完整性問題
  • 但無法攔下權限問題和業務邏輯問題
  • 不同類型的問題需要不同層級的防禦機制

層次三:責任邊界

  • Repository 只該負責「資料存取」
  • 業務邏輯(過濾、分組)應該在 Use Case 層
  • 「讓 Repository 多做一點」短期方便,長期會模糊邊界

層次四:架構取捨

  • 多層架構:問題被隔離,但可能藏在縫隙
  • 扁平架構:問題無處躲藏,但缺乏緩衝
  • 沒有絕對的好壞,只有適不適合

層次五:可靠性工程

  • 信任邊界 = 「在這裡之後,我就信任資料是對的」的那條線
  • BFF 可以把「扁平的便利」和「多層的防禦」拆成兩個世界
  • 目標不是「減少 bug」,而是「確保 bug 不會影響用戶」

層次六:交接代碼

  • 交接代碼是另一種「不可信」的來源
  • 「之前正常」不代表「現在正確」
  • 當 bug 找不到時,擴大懷疑範圍——包括你以為可信的部分

結語

同樣的業務邏輯,Vue 和 Flutter 的 debug 難度差了一個數量級。

表面原因是 bug 的數量和位置不同。 深層原因是架構的複雜度和問題的本質不同。 真正原因是我信任了不該信任的代碼

這次的三個 bug——Client 選錯、欄位遺漏、過濾沒寫——分別發生在不同層級。它們能夠互相掩護、難以追蹤,是因為:

  1. 沒有一個「信任邊界」在統一把關
  2. 我假設外包商的 Query 是對的
  3. 我在自己的邏輯裡繞圈子,沒有擴大懷疑範圍

架構的價值不在於它有多少層,而在於它能不能讓問題變簡單。

但更重要的是:你要知道哪些代碼可以信任,哪些不行。

畫對那條線,問題會被攔在線外。 畫錯那條線,問題會藏在你以為安全的地方。

有時候,你需要修好所有的 bug,問題才會消失。 有時候,你需要畫對那條線,bug 才不會出現。 有時候,你需要承認:「之前正常」的代碼,現在可能是問題的根源。