0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【設計記録 #1】Goで図書管理アプリを作りながらエラー設計を見直した話

0
Posted at

背景

現在SIerで働いていますが、日々の業務では直接コードを書く機会が少ない環境にいます。
そのため、自己研鑽としてバックエンドの理解とGo言語の習得を目指して個人開発を始めました。
そして、ただ作るだけでなく、「なぜその設計にするのか」を自分の言葉で語れるように練習していきたい

今回のテーマ

開発中の「図書管理アプリ」において、最初の一歩として取り組んだエラー設計のリファクタリングについて記録します。

まだCRUDすら動いていない初期段階ですが、土台となるエラーの扱いがガタガタだと後で必ず詰むと思ったので、あえてこのタイミングで向き合ってみました。

現在のアプリ構成

  • 言語:Go
  • 機能:
    • 書籍CRUD(現在は登録・検索のみ実装)
    • ユーザ認識(未実装)

エラーはEntity層で定義し、各ハンドラで個別に判定している。

発生した問題:

現在の実装では、各メソッドで if err != nil を個別に判定し、
レスポンスを生成している。

登録
output, err := h.useCase.CreateBook(r.Context(), input)
	if err != nil {
        // エラーが発生するたびに、ここで判定する必要があった
		if errors.Is(err, domain.ErrInvalidBookTitle) || errors.Is(err, domain.ErrInvalidBookPrice) {
			http.Error(w, err.Error(), http.StatusBadRequest)
			return
		}
        // 新しいエラーが増えるたびに、ここに else if が増えていく...
		http.Error(w, "internal server error", http.StatusInternalServerError)
		return
	}
取得
output, err := h.useCase.GetBookByID(r.Context(), id)
	if err != nil {
		if errors.Is(err, domain.ErrBookNotFound) {
			http.Error(w, "Book not found", http.StatusNotFound)
			return
		}
		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
		return
	}

この実装に対して以下の課題を感じた。

  • 同じような if errors.Is(...) が各ハンドラーにコピペされる
  • ステータスコードの判定ロジックが重複しまくり
  • ハンドラーがドメイン層の詳細(具体的なエラー型)を知りすぎている
  • レスポンス形式がバラバラで、APIとしての品質が安定しない

そこで、エラー内容を抽象化し、一括で処理できる仕組みを実装しました。
まず、ドメイン層に独自のAppError型定義します

apperror.go
type AppError struct {
	Err     error      // 元のエラー
  	Code    string     // フロントエンドに返すための独自コード
	ErrType ErrorType  // NOT_FOUND, INVALID 等の分類
}

この「型」を持たせることで、ハンドラ側のコードは劇的にシンプルになりました。

登録・取得
output, err := h.useCase.CreateBook(r.Context(), input)
	if err != nil {
		RespondWithError(w, err)
		return
	}

output, err := h.useCase.GetBookByID(r.Context(), id)
	if err != nil {
		RespondWithError(w, err)
		return
	}

RespondWithError 内部で AppError を解析し、自動的に HTTP ステータスコード(404 や 400)へマッピングして JSON を返します。これにより、「エラーの判定ロジック」を一箇所に集約することができました。

func RespondWithError(w http.ResponseWriter, err error) {
	var appErr *apperror.AppError
	var status int
	var code string
	var message string

	// 発生したエラーが AppError 型かどうかをチェック
	if errors.As(err, &appErr) {
		code = appErr.Code
		message = appErr.Error()

		// ErrorType を HTTP ステータスコードにマッピング
		switch appErr.ErrType {
		case apperror.TypeNotFound:
			status = http.StatusNotFound
		case apperror.TypeConflict:
			status = http.StatusConflict
		case apperror.TypeInvalid:
			status = http.StatusBadRequest
		default:
			status = http.StatusInternalServerError
		}
	} else {
		// AppError 以外(予期せぬエラー)の場合
		status = http.StatusInternalServerError
		code = "INTERNAL_SERVER_ERROR"
		message = "予期せぬエラーが発生しました"
	}

	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(errorResponse{
		Error: errorDetail{
			Code:    code,
			Message: message,
		},
	})
}

最後に

まだCRUDすら完成していない段階ですが、この「エラーの整理」をしたことで、これからの開発が楽になりそうです。
「急がば回れ」じゃないですが、土台を固める楽しさを実感しています。
次はユーザー認証周りの実装していく予定です。そこでもまた新しい「気付き」を拾い集めて、形にしていきたいと思います!
開発は、まだまだ続きます!:v:

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?