handler 重構的核心目標是把 transport concern 和 application concern 分開。handler 應處理 request/response,usecase 應處理行為規則,domain 應保存狀態語意。

本章目標

學完本章後,你將能夠:

  1. 辨識 handler 過重的訊號
  2. 把 request DTO 與 command 分開
  3. 把業務規則搬到 usecase
  4. 讓 handler 只做 request/response 轉換
  5. 分開撰寫 usecase test、handler test 與少量 integration test

【觀察】過重 handler 會混合三種責任

handler 過重的核心問題是 transport、application 與 state concern 混在同一個函式。當一個 handler 同時解析 JSON、驗證欄位、檢查重複、修改 map、組 response,它就很難測,也很難重用。

常見壞味道:

  • handler 超過一兩個螢幕。
  • 測試核心規則必須透過 HTTP。
  • JSON tag 出現在 domain type 上。
  • handler 直接改 repository 的 map 或 slice。
  • 多個 handler 重複同樣的驗證與錯誤 mapping。
  • 想新增 CLI、worker 或 WebSocket action 時,只能複製 handler 內的邏輯。

以下是一個過重的建立通知 handler:

 1var notifications = map[string]Notification{}
 2
 3func handleCreateNotification(w http.ResponseWriter, r *http.Request) {
 4    if r.Method != http.MethodPost {
 5        http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
 6        return
 7    }
 8
 9    var req struct {
10        ID    string `json:"id"`
11        Topic string `json:"topic"`
12        Title string `json:"title"`
13    }
14    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
15        http.Error(w, "invalid json", http.StatusBadRequest)
16        return
17    }
18
19    if strings.TrimSpace(req.ID) == "" || strings.TrimSpace(req.Topic) == "" {
20        http.Error(w, "missing required field", http.StatusBadRequest)
21        return
22    }
23
24    if _, exists := notifications[req.ID]; exists {
25        http.Error(w, "notification already exists", http.StatusConflict)
26        return
27    }
28
29    notification := Notification{
30        ID:        req.ID,
31        Topic:     req.Topic,
32        Title:     req.Title,
33        CreatedAt: time.Now(),
34    }
35    notifications[notification.ID] = notification
36
37    w.Header().Set("Content-Type", "application/json")
38    w.WriteHeader(http.StatusCreated)
39    _ = json.NewEncoder(w).Encode(notification)
40}

這段程式可以跑,但它把太多責任放進 HTTP 邊界。只要要測「重複 ID 不可建立」,就必須走 HTTP;只要要改儲存方式,就必須改 handler。

【判讀】先拆 request DTO

request DTO 的核心責任是描述外部輸入格式。它可以有 JSON tag,但不應直接當成 domain model 或 repository model。

 1type createNotificationRequest struct {
 2    ID    string `json:"id"`
 3    Topic string `json:"topic"`
 4    Title string `json:"title"`
 5}
 6
 7func (r createNotificationRequest) validate() error {
 8    if strings.TrimSpace(r.ID) == "" {
 9        return ErrInvalidInput{Field: "id", Reason: "required"}
10    }
11    if strings.TrimSpace(r.Topic) == "" {
12        return ErrInvalidInput{Field: "topic", Reason: "required"}
13    }
14    return nil
15}

DTO 可以是 unexported,因為它只服務 HTTP handler。JSON tag 也停在 transport layer,不會污染 application command。

錯誤可以先用簡單型別表達:

1type ErrInvalidInput struct {
2    Field  string
3    Reason string
4}
5
6func (e ErrInvalidInput) Error() string {
7    return e.Field + ": " + e.Reason
8}

這個錯誤型別讓 handler 可以把輸入錯誤轉成 400 Bad Request,而不必靠字串比對。

【策略】command 表達 usecase 輸入

command 的核心責任是描述 application layer 要執行的行為。它不需要 JSON tag,也不需要知道 request body 來自 HTTP、WebSocket 或 CLI。

1type CreateNotificationCommand struct {
2    ID        string
3    Topic     string
4    Title     string
5    CreatedAt time.Time
6}

handler 負責 DTO -> command 的轉換:

1func (r createNotificationRequest) toCommand(now time.Time) CreateNotificationCommand {
2    return CreateNotificationCommand{
3        ID:        strings.TrimSpace(r.ID),
4        Topic:     strings.TrimSpace(r.Topic),
5        Title:     strings.TrimSpace(r.Title),
6        CreatedAt: now,
7    }
8}

CreatedAt 由 handler 或 usecase 決定都可以,但要一致。若時間是業務規則的一部分,通常由 usecase 注入 clock 會更穩;若只是 request 接收時間,handler 傳入也合理。重點是不要在測試中散落 time.Now()

【執行】usecase 保存行為規則

usecase 的核心責任是處理行為規則與資料能力。重複檢查、儲存、事件發布或狀態轉移應該在 usecase,而不是 handler。

先定義 usecase 需要的 repository:

1type NotificationRepository interface {
2    Save(ctx context.Context, notification Notification) error
3    FindByID(ctx context.Context, id string) (Notification, bool, error)
4}

再定義 service:

1type CreateNotificationUsecase struct {
2    repository NotificationRepository
3}
4
5func NewCreateNotificationUsecase(repository NotificationRepository) *CreateNotificationUsecase {
6    return &CreateNotificationUsecase{repository: repository}
7}

執行 command:

 1func (u *CreateNotificationUsecase) Execute(ctx context.Context, cmd CreateNotificationCommand) (Notification, error) {
 2    if strings.TrimSpace(cmd.ID) == "" {
 3        return Notification{}, ErrInvalidInput{Field: "id", Reason: "required"}
 4    }
 5    if strings.TrimSpace(cmd.Topic) == "" {
 6        return Notification{}, ErrInvalidInput{Field: "topic", Reason: "required"}
 7    }
 8
 9    if _, exists, err := u.repository.FindByID(ctx, cmd.ID); err != nil {
10        return Notification{}, fmt.Errorf("find notification: %w", err)
11    } else if exists {
12        return Notification{}, ErrAlreadyExists{ID: cmd.ID}
13    }
14
15    notification := Notification{
16        ID:        cmd.ID,
17        Topic:     cmd.Topic,
18        Title:     cmd.Title,
19        CreatedAt: cmd.CreatedAt,
20    }
21
22    if err := u.repository.Save(ctx, notification); err != nil {
23        return Notification{}, fmt.Errorf("save notification: %w", err)
24    }
25
26    return notification, nil
27}

ErrAlreadyExists 可以是明確錯誤型別:

1type ErrAlreadyExists struct {
2    ID string
3}
4
5func (e ErrAlreadyExists) Error() string {
6    return "notification already exists: " + e.ID
7}

這樣 handler 可以用 errors.As 把它對應到 409 Conflict

【執行】handler 只做轉換與 mapping

重構後 handler 的核心責任是 request -> command、result -> response、error -> HTTP status。它不直接碰 map,也不保存業務規則。

 1type NotificationCreator interface {
 2    Execute(ctx context.Context, cmd CreateNotificationCommand) (Notification, error)
 3}
 4
 5type NotificationHandler struct {
 6    creator NotificationCreator
 7    now     func() time.Time
 8}
 9
10func NewNotificationHandler(creator NotificationCreator, now func() time.Time) NotificationHandler {
11    return NotificationHandler{creator: creator, now: now}
12}

handler 實作:

 1func (h NotificationHandler) Create(w http.ResponseWriter, r *http.Request) {
 2    if r.Method != http.MethodPost {
 3        writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
 4        return
 5    }
 6
 7    var req createNotificationRequest
 8    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
 9        writeError(w, http.StatusBadRequest, "invalid_json", "request body must be valid JSON")
10        return
11    }
12
13    if err := req.validate(); err != nil {
14        writeError(w, http.StatusBadRequest, "invalid_input", err.Error())
15        return
16    }
17
18    notification, err := h.creator.Execute(r.Context(), req.toCommand(h.now()))
19    if err != nil {
20        writeUsecaseError(w, err)
21        return
22    }
23
24    writeJSON(w, http.StatusCreated, newNotificationResponse(notification))
25}

這個 handler 仍然有 HTTP 協定責任,但核心行為已經搬出去。未來 WebSocket action 或 worker 也可以建立 CreateNotificationCommand 呼叫同一個 usecase。

【策略】response struct 是對外 contract

response struct 的核心責任是描述 HTTP 回應格式。不要直接把 domain model 全部輸出,否則內部欄位會變成外部 API 承諾。

 1type notificationResponse struct {
 2    ID        string    `json:"id"`
 3    Topic     string    `json:"topic"`
 4    Title     string    `json:"title"`
 5    CreatedAt time.Time `json:"createdAt"`
 6}
 7
 8func newNotificationResponse(notification Notification) notificationResponse {
 9    return notificationResponse{
10        ID:        notification.ID,
11        Topic:     notification.Topic,
12        Title:     notification.Title,
13        CreatedAt: notification.CreatedAt,
14    }
15}

error response 也應該穩定:

 1type errorResponse struct {
 2    Code    string `json:"code"`
 3    Message string `json:"message"`
 4}
 5
 6func writeError(w http.ResponseWriter, status int, code string, message string) {
 7    writeJSON(w, status, errorResponse{
 8        Code:    code,
 9        Message: message,
10    })
11}

writeJSON 集中 JSON response 寫法:

1func writeJSON(w http.ResponseWriter, status int, value any) {
2    w.Header().Set("Content-Type", "application/json")
3    w.WriteHeader(status)
4    _ = json.NewEncoder(w).Encode(value)
5}

這個 helper 可以忽略 encode error,因為 response 已經開始寫出;正式服務通常會記錄 log

【判讀】error mapping 是 handler 邊界

error mapping 的核心責任是把 application error 轉成 HTTP status 與對外 code。usecase 不應知道 HTTP status;handler 不應靠錯誤字串猜狀態。

 1func writeUsecaseError(w http.ResponseWriter, err error) {
 2    var invalid ErrInvalidInput
 3    if errors.As(err, &invalid) {
 4        writeError(w, http.StatusBadRequest, "invalid_input", invalid.Error())
 5        return
 6    }
 7
 8    var alreadyExists ErrAlreadyExists
 9    if errors.As(err, &alreadyExists) {
10        writeError(w, http.StatusConflict, "already_exists", "notification already exists")
11        return
12    }
13
14    writeError(w, http.StatusInternalServerError, "internal_error", "internal server error")
15}

內部錯誤不要直接回給 client。對外 message 應該穩定且安全;詳細錯誤留給 log 與 error chain。

【執行】usecase 測試不需要 HTTP

usecase 測試的核心目標是驗證行為規則。它應該直接建立 command,使用 fake repository,不需要 httptest

 1type fakeNotificationRepository struct {
 2    existing map[string]Notification
 3    saved    []Notification
 4}
 5
 6func (f *fakeNotificationRepository) Save(ctx context.Context, notification Notification) error {
 7    f.saved = append(f.saved, notification)
 8    return nil
 9}
10
11func (f *fakeNotificationRepository) FindByID(ctx context.Context, id string) (Notification, bool, error) {
12    notification, ok := f.existing[id]
13    return notification, ok, nil
14}

測試建立成功:

 1func TestCreateNotificationUsecaseExecute(t *testing.T) {
 2    repo := &fakeNotificationRepository{existing: map[string]Notification{}}
 3    usecase := NewCreateNotificationUsecase(repo)
 4
 5    _, err := usecase.Execute(context.Background(), CreateNotificationCommand{
 6        ID:        "ntf_1",
 7        Topic:     "deployments",
 8        Title:     "Deploy finished",
 9        CreatedAt: time.Date(2026, 4, 22, 10, 0, 0, 0, time.UTC),
10    })
11    if err != nil {
12        t.Fatalf("execute usecase: %v", err)
13    }
14
15    if len(repo.saved) != 1 {
16        t.Fatalf("saved notifications = %d, want 1", len(repo.saved))
17    }
18}

這個測試速度快、錯誤定位明確。若失敗,問題在 usecase,不在 HTTP parsing。

【執行】handler test 專注 request/response

handler test 的核心目標是驗證 HTTP 協定行為。它應該使用 fake usecase,而不是真 repository。

 1type fakeNotificationCreator struct {
 2    got CreateNotificationCommand
 3    out Notification
 4    err error
 5}
 6
 7func (f *fakeNotificationCreator) Execute(ctx context.Context, cmd CreateNotificationCommand) (Notification, error) {
 8    f.got = cmd
 9    if f.err != nil {
10        return Notification{}, f.err
11    }
12    return f.out, nil
13}

測試成功 response:

 1func TestNotificationHandlerCreate(t *testing.T) {
 2    creator := &fakeNotificationCreator{
 3        out: Notification{
 4            ID:        "ntf_1",
 5            Topic:     "deployments",
 6            Title:     "Deploy finished",
 7            CreatedAt: time.Date(2026, 4, 22, 10, 0, 0, 0, time.UTC),
 8        },
 9    }
10    handler := NewNotificationHandler(creator, func() time.Time {
11        return time.Date(2026, 4, 22, 10, 0, 0, 0, time.UTC)
12    })
13
14    req := httptest.NewRequest(http.MethodPost, "/notifications", strings.NewReader(`{
15        "id": "ntf_1",
16        "topic": "deployments",
17        "title": "Deploy finished"
18    }`))
19    rec := httptest.NewRecorder()
20
21    handler.Create(rec, req)
22
23    if rec.Code != http.StatusCreated {
24        t.Fatalf("status = %d, want %d", rec.Code, http.StatusCreated)
25    }
26    if creator.got.Topic != "deployments" {
27        t.Fatalf("topic = %q, want deployments", creator.got.Topic)
28    }
29}

這個測試確認 handler 能解析 JSON、建立 command、呼叫 usecase、寫出狀態碼。它不測重複 ID 的儲存規則,那已經是 usecase 測試的責任。

【策略】integration test 只保留少數端到端路徑

integration test 的核心用途是確認組裝正確,不是覆蓋所有規則。當 usecase 與 handler 都已有單元測試,端到端測試只需要保留代表性成功與失敗路徑。

例如:

  • POST /notifications 成功建立。
  • invalid JSON 回 400
  • 重複 ID 回 409

不要把所有欄位驗證都只放在 integration test。那會讓測試慢、失敗定位模糊,也讓重構成本升高。

重構步驟

從過重 handler 重構時,可以按這個順序:

  1. 先補 handler 現有行為測試,鎖住 status code 與 response body。
  2. 抽出 request DTO,但暫時不改行為。
  3. 抽出 command 與 usecase,讓 handler 呼叫 usecase。
  4. 把 repository 或 map 寫入移到 usecase 後方。
  5. 抽出 response struct 與 error mapping helper。
  6. 補 usecase 單元測試。
  7. 縮減 handler 測試範圍,保留 request/response 行為。

每一步都應該讓程式可編譯、測試可跑。不要一次把 handler、repository、package 結構全部搬完。

設計檢查

檢查一:抽出真正的行為邊界

如果新函式仍然接收 http.ResponseWriter*http.Request,那只是移動程式碼,還沒有分離 transport concern。

檢查二:domain model 和 response model 分開

JSON tag 是 transport contract。domain model 若直接承擔對外格式,未來內部欄位調整就會牽動 API 相容性。

檢查三:錯誤類型對應 HTTP 回應

輸入錯誤、重複資料、權限問題與內部錯誤應該對應不同 status code。錯誤型別與 error mapping helper 可以避免字串判斷。

檢查四:分層測試保護不同責任

端到端測試重要,但不應是唯一測試。usecase 規則越多,越需要直接測 command 與 fake repository。

本章不處理

本章先處理 HTTP handler 的轉換邊界;router、middleware 與 transaction,會在下列章節再往外延伸:

和 Go 教材的關係

這一章承接的是 request DTO、command 與 usecase 分層;如果你要先回看語言教材,可以讀: