7.1 把 handler 邏輯拆成可測單元
handler 重構的核心目標是把 transport concern 和 application concern 分開。handler 應處理 request/response,usecase 應處理行為規則,domain 應保存狀態語意。
本章目標
學完本章後,你將能夠:
- 辨識 handler 過重的訊號
- 把 request DTO 與 command 分開
- 把業務規則搬到 usecase
- 讓 handler 只做 request/response 轉換
- 分開撰寫 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 重構時,可以按這個順序:
- 先補 handler 現有行為測試,鎖住 status code 與 response body。
- 抽出 request DTO,但暫時不改行為。
- 抽出 command 與 usecase,讓 handler 呼叫 usecase。
- 把 repository 或 map 寫入移到 usecase 後方。
- 抽出 response struct 與 error mapping helper。
- 補 usecase 單元測試。
- 縮減 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 分層;如果你要先回看語言教材,可以讀: