メインコンテンツへスキップ
友田 陽大
Go・Echo 本番運用
Go
Echo
型安全
セキュリティ
バリデーション
アーキテクチャ設計

Echo のリクエストバインディング&バリデーション&エラー設計:型安全な境界で不正入力を殺す

Go Echo(v5)の入力処理を本番品質にする実装ガイド。公式ドキュメントに忠実に、c.Bind と構造体タグ、単一ソースバインダ、ジェネリック型安全バインダ、go-playground/validator 連携、DTO 分離、カスタム Binder、HTTPError と集中エラーハンドラ、Problem Details 風の 422 整形までを実コードで解説します。

公開日
読了時間
10分
著者
友田 陽大
シェア

API の堅牢性は、境界で不正入力をどれだけ殺せるかでほぼ決まります。"abc" を ID として受けて DB に投げる、必須項目が空のまま登録する、IsAdmin: true を勝手に渡される——これらは「あとでチェックする」のでは遅く、型に変換できた時点で正しい状態を作るのが本番の作法です。

この記事は、Go Echo 本番運用ガイドの入力処理・エラー設計編です。Echo v5 の バインディング → バリデーション → エラー整形 を、「外部入力は信用しない」というセキュリティ原則に沿って実装します。

この記事のルール:API は Echo 公式ドキュメント(v5・2026年6月時点) に基づきます。v5 はバインダの署名(Bind(c, target))が反転し、ジェネリックな型安全抽出(PathParam[T])が入り、HTTPError.Messagestring 化、エラーハンドラ署名が func(c *echo.Context, err error) に反転するなど、v4 から差分が大きい領域です。本番投入前に必ず公式で最新を確認してください。


1. c.Bind:4つのソースを構造体タグで束ねる

c.Bind(&dto) は、1回の呼び出しで**パスパラメータ・クエリ・リクエストボディ(JSON/XML/フォーム)**を、構造体タグに従って流し込みます。

type SearchRequest struct {
	Keyword  string   `query:"q"`
	Tags     []string `query:"tag"`  // 繰り返し ?tag=go&tag=web → ["go","web"]
	Page     int      `query:"page"`
	Category string   `param:"category"` // パスパラメータ /search/:category
}

func search(c *echo.Context) error {
	var req SearchRequest
	if err := c.Bind(&req); err != nil {
		return echo.NewHTTPError(http.StatusBadRequest, "invalid request")
	}
	// req は型変換済み(page は int、tags は []string)
	return c.JSON(http.StatusOK, req)
}
タグソース
jsonリクエストボディ(JSON)
xmlリクエストボディ(XML)
formフォームデータ
queryクエリパラメータ
paramパスパラメータ
headerヘッダ(c.Bind の対象外。後述)

重要な落とし穴ヘッダは c.Bind では束ねられませんAuthorization や独自ヘッダを構造体に取り込みたいときは、echo.BindHeaders(c, &dto) を明示的に呼びます。「Bind したのにヘッダが空」はこれが原因の筆頭です。

1.1 単一ソースだけをバインドする

「ボディだけ」「クエリだけ」を厳密にバインドしたいときは、ソース別の関数を使います。意図しないソースからの流入を防げます。

echo.BindBody(c, &payload)        // ボディのみ
echo.BindQueryParams(c, &payload) // クエリのみ
echo.BindPathValues(c, &payload)  // パスパラメータのみ
echo.BindHeaders(c, &payload)     // ヘッダのみ

2. mass assignment を防ぐ:DTO をビジネス構造体と分ける

ここがセキュリティの肝です。DB エンティティやドメインモデルに直接 Bind してはいけません

// ❌ 危険:DB エンティティに直接 Bind
type User struct {
	ID       int64  `json:"id"`
	Email    string `json:"email"`
	IsAdmin  bool   `json:"is_admin"` // 攻撃者が {"is_admin": true} を送れば昇格できる
}

func badCreate(c *echo.Context) error {
	var u User
	_ = c.Bind(&u) // IsAdmin まで外部から上書きされる(mass assignment 脆弱性)
	// ...
}
// ✅ 安全:受け口専用の DTO を定義し、許可したフィールドだけを受ける
type CreateUserDTO struct {
	Name     string `json:"name"     validate:"required,min=2,max=50"`
	Email    string `json:"email"    validate:"required,email"`
	Password string `json:"password" validate:"required,min=12"`
	// IsAdmin は DTO に存在しない=外部から絶対にセットできない
}

func (h *UserHandler) Create(c *echo.Context) error {
	var dto CreateUserDTO
	if err := c.Bind(&dto); err != nil {
		return echo.NewHTTPError(http.StatusBadRequest, "invalid request body")
	}
	if err := c.Validate(&dto); err != nil {
		return err
	}
	// DTO → ドメインへ「明示的に」詰め替える。権限は常にサーバーが決める
	user, err := h.uc.Register(c.Request().Context(), dto.Name, dto.Email, dto.Password)
	if err != nil {
		return err
	}
	return c.JSON(http.StatusCreated, toUserResponse(user)) // 出力も専用 DTO
}

公式も「ビジネス構造体に直接バインドせず、専用 DTO を使って明示的にマッピングし、意図しないフィールド露出を防げ」と明記しています。入力 DTO と出力 DTO を分けPassword のような機微フィールドを出力に絶対載せないのも同じ原則です。


3. ジェネリック型安全バインダ:単一の値を型付きで取る

ID やページ番号のような単一の値は、構造体を切るまでもなく型安全に取りたいものです。v5 はジェネリクスを使った型安全抽出を提供します。変換できなければエラーになるので、"abc"int64 ID として受ける事故を構造的に防げます。

// パスパラメータ /users/:id を int64 として取得
id, err := echo.PathParam[int64](c, "id")
if err != nil {
	return echo.NewHTTPError(http.StatusBadRequest, "id must be an integer")
}

// クエリ ?active=true を bool として取得
active, err := echo.QueryParam[bool](c, "active")

3.1 Fluent バインダ:複数の値をまとめて変換しエラーを集約

複数のクエリ/パラメータを一括で型変換し、エラーをまとめて拾うときは Fluent(流れるような)バインダが便利です。

var opts struct {
	IDs    []int64
	Active bool
	Limit  int64
}

err := echo.QueryParamsBinder(c).
	Int64s("id", &opts.IDs).            // ?id=1&id=2 → []int64{1,2}
	Bool("active", &opts.Active).
	Int64("limit", &opts.Limit).
	BindError()                          // 最初のエラー(または集約)を返す
if err != nil {
	return echo.NewHTTPError(http.StatusBadRequest, "invalid query parameters")
}

PathValuesBinder(c) / FormFieldBinder(c) も同系統です。各メソッドには Int64(任意)/MustInt64(必須・無ければエラー)/Int64s(複数)といった派生があり、必須・任意・複数値を型で表現できます。


4. バリデーション:go-playground/validator を被せる

Echo はバリデーションライブラリを同梱しません。代わりに echo.Validator インターフェースという差し込み口を用意しています。デファクトの github.com/go-playground/validator/v10 を被せるのが定番です。

import "github.com/go-playground/validator/v10"

type CustomValidator struct {
	validator *validator.Validate
}

func (cv *CustomValidator) Validate(i any) error {
	if err := cv.validator.Struct(i); err != nil {
		return err // ここでは生エラーを返し、整形は集中エラーハンドラに任せる
	}
	return nil
}

func main() {
	e := echo.New()
	e.Validator = &CustomValidator{validator: validator.New()}
	// ...
}

validate:"..." タグでルールを宣言します。よく使うものを挙げます。

タグ意味
required必須(ゼロ値を拒否)
emailメール形式
min=2,max=50文字列長/数値範囲
gte=0,lte=100以上/以下
oneof=draft published列挙のいずれか
uuid4UUID v4 形式
diveスライス/マップの各要素に適用

4.1 バリデーションエラーを「フィールド単位の 422」に整形する

{"message":"Key: 'CreateUserDTO.Email' Error:Field validation..."} という生のエラーをそのまま返すのは、API として不親切で情報も漏れます。本番ではどのフィールドがなぜ落ちたかを構造化して 422 Unprocessable Entity で返します。

type FieldError struct {
	Field   string `json:"field"`
	Rule    string `json:"rule"`
	Message string `json:"message"`
}

// validator のエラーをフィールド単位に変換する
func toFieldErrors(err error) []FieldError {
	var ve validator.ValidationErrors
	if !errors.As(err, &ve) {
		return nil
	}
	out := make([]FieldError, 0, len(ve))
	for _, fe := range ve {
		out = append(out, FieldError{
			Field:   fe.Field(),
			Rule:    fe.Tag(),
			Message: humanize(fe), // "email は正しい形式で入力してください" 等
		})
	}
	return out
}

この変換は、次章の集中エラーハンドラで一度だけ行います。各ハンドラに整形ロジックを散らさないのが DRY の要点です。


5. エラーハンドリング:握りつぶさず return し、一箇所で整形する

Echo のエラー処理は中央集権です。ハンドラ/ミドルウェアは errorreturn するだけで、変換は1つの HTTPErrorHandler が担います。

// ステータス付きエラー(v5 はメッセージが string・非可変長)
return echo.NewHTTPError(http.StatusNotFound, "user not found")

// メッセージ省略時はステータステキスト
return echo.NewHTTPError(http.StatusBadGateway)

// 定義済みセンチネルエラー
return echo.ErrUnauthorized // 401
return echo.ErrForbidden    // 403

5.1 本番向け集中エラーハンドラ

要件は4つ。(1) バリデーションエラーは 422 にフィールド単位で整形、(2) *echo.HTTPError はそのステータス、(3) それ以外(想定外)は 500 で詳細を隠す(4) 5xx は相関 ID 付きでログへ。v5 の署名は func(c *echo.Context, err error)(v4 から引数順が反転)です。

type ErrorResponse struct {
	Error  string       `json:"error"`
	Fields []FieldError `json:"fields,omitempty"`
}

func customHTTPErrorHandler(c *echo.Context, err error) {
	// すでに送信済みなら二重送信しない
	if resp, uErr := echo.UnwrapResponse(c.Response()); uErr == nil && resp.Committed {
		return
	}

	// ① バリデーションエラー → 422(フィールド単位)
	var ve validator.ValidationErrors
	if errors.As(err, &ve) {
		_ = c.JSON(http.StatusUnprocessableEntity, ErrorResponse{
			Error:  "validation failed",
			Fields: toFieldErrors(ve),
		})
		return
	}

	// ② ステータス解決(HTTPError は HTTPStatusCoder を実装)
	code := http.StatusInternalServerError
	msg := "internal server error"
	var sc echo.HTTPStatusCoder
	if errors.As(err, &sc) {
		if s := sc.StatusCode(); s != 0 {
			code = s
		}
	}
	var he *echo.HTTPError
	if errors.As(err, &he) {
		msg = he.Message // v5 は string
	}

	// ③ 5xx は詳細を隠し、相関 ID 付きでログへ
	if code >= 500 {
		c.Logger().Error("unhandled error",
			"error", err.Error(),
			"path", c.Request().URL.Path,
			"request_id", c.Response().Header().Get(echo.HeaderXRequestID),
		)
		msg = "internal server error" // クライアントには内部詳細を出さない
	}

	_ = c.JSON(code, ErrorResponse{Error: msg})
}

func main() {
	e := echo.New()
	e.HTTPErrorHandler = customHTTPErrorHandler
	// ...
}

これで、各ハンドラはドメインのエラーを return するだけになり、HTTP の体裁・ログ・機微情報の遮断が一箇所に集約されます。公式も「エラーを Sentry / Elasticsearch / Splunk 等へ転送するなら、この集中ハンドラから」と推奨しています。

5.2 ドメインエラーを HTTP に対応づける

UseCase 層は HTTP を知るべきではありません。ドメイン固有のセンチネルエラーを定義し、集中ハンドラ(または薄い変換層)で HTTP ステータスに対応づけます。

// domain 層:HTTP を知らない
var (
	ErrUserNotFound = errors.New("user not found")
	ErrEmailTaken   = errors.New("email already taken")
)

// エラーハンドラ側でマッピング(抜粋)
switch {
case errors.Is(err, ErrUserNotFound):
	code, msg = http.StatusNotFound, "user not found"
case errors.Is(err, ErrEmailTaken):
	code, msg = http.StatusConflict, "email already taken"
}

この分離により、ビジネスロジックは HTTP から独立してテスト可能なまま保てます(クリーンアーキテクチャ + DI)。クライアント⇄サーバー間でエラー形式まで型で合わせたい場合は、OpenAPI + Problem Details(RFC 9457)でスキーマ化する設計が有効です(Next.js × Go × OpenAPI)。


6. カスタム Binder:独自フォーマットを受ける

Protocol Buffers や独自の Content-Type、あるいは「JSON の数値を必ず厳格に解釈する」といった要件では、echo.Binder を自作して差し替えます。

type StrictBinder struct {
	fallback echo.DefaultBinder
}

func (b *StrictBinder) Bind(c *echo.Context, i any) error { // v5 は (c, target) の順
	if c.Request().Header.Get(echo.HeaderContentType) == "application/vnd.myapp+json" {
		// 独自フォーマットの処理
		return decodeStrict(c.Request().Body, i)
	}
	return b.fallback.Bind(c, i) // それ以外は標準バインダに委譲
}

func main() {
	e := echo.New()
	e.Binder = &StrictBinder{}
}

Bind の署名が Bind(c *echo.Context, i any)(v5 で引数順が反転)である点に注意してください。標準挙動を活かしたいケースが大半なので、DefaultBinder委譲する形が安全です。


まとめ:境界設計の7原則

  1. c.Bind はパス・クエリ・ボディを束ねるヘッダは対象外——BindHeaders を明示。
  2. ビジネス構造体に直接 Bind しない。受け口専用 DTO で mass assignment を防ぐ。
  3. 入力 DTO と出力 DTO を分ける。機微フィールドを出力に載せない。
  4. 単一値は型安全に——PathParam[T] / Fluent バインダで変換失敗を 400 に。
  5. 検証は go-playground/validatorecho.Validator に被せ、422 にフィールド単位で整形。
  6. エラーは握りつぶさず return。集中ハンドラで整形・ログ・機微遮断を一箇所に集約。
  7. ドメインエラーは HTTP から独立させ、変換層でステータスに対応づける。

「動く API」と「本番で信用できる API」を分けるのは、まさにこの境界処理です。入力を型で殺し、エラーを一箇所で整形する——この型を持てば、Echo の API は格段に堅くなります。手前のミドルウェア設計と合わせ、全体像はGo Echo 本番運用ガイドに戻って俯瞰してください。

友田

友田 陽大

経済産業大臣賞 受賞プロダクト開発者。TypeScript + Python + AWS で、SaaS・業界DX・ 実用レベルの生成AI(RAG)を、要件定義からインフラ・運用まで一人で完遂します。

この記事の実装を、案件として承ります

Go / Echo のバックエンドを、設計から本番運用まで承ります

Echo v5 へのAPI設計・移行、クリーンアーキテクチャ(Controller/UseCase/Repository + DI)、ミドルウェアとセキュリティ、集中エラー処理、グレースフルシャットダウン、テストとCIまで。Go/Echo + google/wire で実際にクリーンアーキのバックエンドを構築した知見で、落ちない・追える・変更しやすいAPIを実装します。

プロジェクト単位(請負)・技術顧問のどちらにも対応可能です。まずは30分の無料技術相談から。

あわせて読みたい