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

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

- 公開日: 2026-06-28
- 著者: 友田 陽大
- タグ: Go, Echo, 型安全, セキュリティ, バリデーション, アーキテクチャ設計
- URL: https://tomodahinata.com/blog/go-echo-request-binding-validation-error-handling-guide
- カテゴリ: Go・Echo 本番運用
- 総合ガイド: https://tomodahinata.com/blog/go-echo-framework-production-guide

## 要点

- c.Bind はパス・クエリ・ボディを構造体タグで流し込む。ヘッダは対象外で BindHeaders を明示的に呼ぶ
- ビジネス構造体に直接 Bind しない。受け口専用 DTO を定義し、IsAdmin 等の意図しないフィールド上書き（mass assignment）を防ぐ
- 数値・bool・スライスは型安全に取りたい。v5 のジェネリック PathParam[T]/QueryParam[T] と Fluent バインダで変換失敗を 400 に落とす
- 検証は go-playground/validator を echo.Validator に被せ、フィールド単位の 422 に整形して『どこがなぜ不正か』を返す
- エラーは握りつぶさず return。集中 HTTPErrorHandler で内部詳細を隠し、4xx は構造化、5xx は相関 ID 付きでログへ

---

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

この記事は、[Go Echo 本番運用ガイド](/blog/go-echo-framework-production-guide)の入力処理・エラー設計編です。Echo v5 の **バインディング → バリデーション → エラー整形** を、「外部入力は信用しない」という[セキュリティ原則](/blog/nextjs-supabase-application-security-guide)に沿って実装します。

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

---

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

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

```go
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 単一ソースだけをバインドする

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

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

---

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

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

```go
// ❌ 危険：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 脆弱性）
	// ...
}
```

```go
// ✅ 安全：受け口専用の 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 として受ける事故**を構造的に防げます。

```go
// パスパラメータ /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（流れるような）バインダが便利です。

```go
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` を被せるのが定番です。

```go
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` | 列挙のいずれか |
| `uuid4` | UUID v4 形式 |
| `dive` | スライス／マップの各要素に適用 |

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

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

```go
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 のエラー処理は**中央集権**です。ハンドラ／ミドルウェアは `error` を `return` するだけで、変換は**1つの `HTTPErrorHandler`** が担います。

```go
// ステータス付きエラー（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 から引数順が反転）です。

```go
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 ステータスに対応づけ**ます。

```go
// 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](/blog/go-echo-framework-production-guide#9-アーキテクチャハンドラを薄く保ちdiでテスト可能にする)）。クライアント⇄サーバー間でエラー形式まで型で合わせたい場合は、OpenAPI + Problem Details（RFC 9457）でスキーマ化する設計が有効です（[Next.js × Go × OpenAPI](/blog/nextjs-go-openapi-end-to-end-type-safety)）。

---

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

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

```go
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/validator` を `echo.Validator` に被せ**、422 にフィールド単位で整形。
6. **エラーは握りつぶさず `return`**。集中ハンドラで整形・ログ・機微遮断を一箇所に集約。
7. **ドメインエラーは HTTP から独立**させ、変換層でステータスに対応づける。

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