🎯 什麼情境該想到我

當你「想把輸入處理、畫面顯示、資料與邏輯三者分離,讓它們各自獨立變動」的時候。

⚙️ 怎麼用(步驟 / 公式)

意圖:把使用者介面拆成三個角色,降低耦合、便於各自替換與測試。

做法要點:

  • 模型(Model):領域資料與邏輯,不知道 UI 的存在。
  • 視圖(View):把模型呈現到畫面,只負責顯示。
  • 控制器(Controller):處理使用者輸入,操作模型並選擇要顯示的視圖。
  • 關鍵是把「輸入的處理」(控制器)和「輸出的呈現」(視圖)分開。

★★ 2002 年的 MVC 和今天的「MVC」不是同一件事

★ PoEAA 的 MVC(伺服器端):
  Controller 收請求 → 操作 Model → 選一個 View → View 渲染 HTML → 回傳整頁
  → ★ 一次請求產生一整頁

★★ 今天的主流(前後端分離):
  Go 後端:handler → service → 回傳 JSON        ← ★ 這裡沒有 View
  前端:   React/Vue 元件樹 + 狀態管理          ← ★ 這裡沒有 Controller
→ ★★ 「MVC」這三個角色被切到了網路的兩側,而中間是 [[資料傳輸物件]]

★★ 所以在 Go 後端談 MVC 容易誤導。 更準確的說法是:
handler(頁面控制器)→ 服務層 → DTO,
而「View」若存在,它是 樣板視圖(SSR)或根本在瀏覽器裡(轉換視圖)。

★★ MVC 真正不變的核心:一條依賴方向

★ 唯一真正重要的規則:Model 不可以認識 View 或 Controller。
→ ① 領域邏輯可以在沒有 HTTP 的情況下測試
   ② ★ 同一個 Model 可以被 HTTP、gRPC、CLI、訊息、排程器使用(見 [[服務啟動器]])
   ③ 換 UI 不必改業務規則
// ❌★★ Model 認識了 HTTP —— 這是最常見的違反
func (o *Order) Render(w http.ResponseWriter) { … }     // ★ 領域型別依賴 net/http
func (s *Service) Place(w http.ResponseWriter, r *http.Request) error   // ★ 同上
 
// ✅ 依賴方向正確
func (s *Service) Place(ctx context.Context, cmd PlaceCommand) (OrderView, error)
func (h *Handler) Place(w http.ResponseWriter, r *http.Request) {       // ★ Controller
    cmd, err := decode(r)                                              // ★ HTTP → command
    if err != nil { writeErr(w, err); return }
    view, err := h.svc.Place(r.Context(), cmd)                          // ★ 呼叫 Model 側
    writeJSON(w, view, err)                                             // ★ view → HTTP
}

★★ 判準:grep -rn "net/http" internal/domain/ 有結果嗎?
有 → 依賴方向錯了。這是一個可以放進 CI 的檢查(depguard linter)。

★★ 三個角色的責任清單(照這個分,就不會有「這段該放哪」的爭論)

責任★ 不可以做
Controller(handler)解析請求、驗證格式、呼叫服務、選擇回應形式與狀態碼★★ 業務規則、交易邊界、授權判斷
Model(領域 + 服務層)業務規則、不變式、交易邊界、授權★★ 認識 HTTP/樣板/JSON 格式
View(樣板/JSON 編碼)呈現★★ 查資料庫、做業務判斷

★★ 最常被放錯的三件事:

① ★★ 授權判斷放 Controller → 訊息/CLI 入口直接繞過(見 [[服務層]])
② ★★ 交易邊界放 Controller → 訊息入口會忘記開交易
③ ★★ View 裡查資料庫(樣板呼叫一個 lazy 的 getter)→ ★ N+1,而且在樣板裡完全看不出來

★★ 「胖 handler」是 Go 最常見的 MVC 退化

// ★★ 一個 300 行的 handler:解析 + 驗證 + 交易 + 業務規則 + 組 JSON
func (h *Handler) PlaceOrder(w http.ResponseWriter, r *http.Request) {
    // 50 行解析與驗證
    // 30 行權限檢查
    // 150 行業務邏輯與 SQL
    // 40 行組 response
}
// ★ 後果:① 測一條業務規則要起 HTTP server
//         ② 同樣的規則從訊息進來時只能複製一份
//         ③ ★★ 這個函式永遠不會被重構,因為它太大了

★ 拆的順序:

第 1 步 ★ 把「解析請求 → command struct」抽出去(純函式,好測)
第 2 步 ★★ 把業務邏輯移到 svc.PlaceOrder(ctx, cmd) —— ★ 這一步收益最大
第 3 步 ★ 把「結果 → JSON」抽成 view 組裝函式
→ ★ 三步做完,handler 剩 10 行,而且業務邏輯可以被訊息入口共用

★ 兄弟辨析

差別
頁面控制器★ 一個 URL 一個 controller(★ Go 的預設)
前端控制器★ 一個入口處理全部請求(= router + middleware)
應用控制器★ 處理「多步驟流程的下一步是什麼」
樣板視圖、轉換視圖兩種 View 的實作方式
服務層★ Model 側對外的邊界
觀察者模式★ 原始 MVC 裡「Model 通知 View」的機制(★ 伺服器端 MVC 用不到)

🧪 我實際套用的紀錄

  • (待填)

⚠️ 注意 / 什麼時候不適用

  • 對很簡單的畫面而言,三分拆會顯得過度。

  • 原始的視圖觀察模型機制在 Web 無狀態環境下未必適用(多改為請求驅動)。

  • 團隊需理解職責邊界,否則邏輯容易又漏回視圖或控制器。

  • ★★ 唯一真正重要的規則:Model 不可以認識 View 或 Controller。 可以用 depguard 把「domain 不得 import net/http」放進 CI。

  • ★★ 授權判斷與交易邊界屬於服務層,不屬於 handler。 放 handler 會讓訊息/CLI 入口繞過。

  • ★★ View 裡不可以查資料庫。 樣板呼叫 lazy getter 造成的 N+1 在樣板裡完全看不出來。

  • ★★ 「胖 handler」是 Go 最常見的退化。拆解順序:解析 → 業務邏輯移到服務 → 組 view,第二步收益最大。

  • ★ 在 Go 後端談「MVC」容易誤導。前後端分離後三個角色被切到網路兩側,更準確的說法是 handler → 服務層 → DTO。

  • ★ 原始 MVC 的「Model 通知 View」(觀察者)在伺服器端請求/回應模型裡用不到。 不要為此引入事件機制。

  • Controller 只能決定「回應的形式與狀態碼」,不能決定「這件事該不該做」。

🔗 相關工具