Skip to main content
友田 陽大
Go & Echo in production
Go
Echo
型安全
セキュリティ
バリデーション
アーキテクチャ設計

Echo request binding, validation, and error design: kill invalid input at a type-safe boundary

An implementation guide to bring Go Echo's (v5) input processing to production quality. Faithful to the official documentation, it explains in real code c.Bind and struct tags, single-source binders, generic type-safe binders, go-playground/validator integration, DTO separation, a custom Binder, HTTPError and the centralized error handler, and Problem-Details-style 422 formatting.

Published
Reading time
10 min read
Author
友田 陽大
Share

An API's robustness is almost decided by how much invalid input you can kill at the boundary. Receiving "abc" as an ID and throwing it at the DB, registering with a required field empty, having IsAdmin: true passed in arbitrarily — for these, "checking later" is too late; the production practice is to create a state that is correct at the point it could be converted to a type.

This article is the input-processing / error-design edition of the Go Echo production-operation guide. It implements Echo v5's binding → validation → error formatting along the security principle of "don't trust external input."

The rules of this article: the API is based on the Echo official documentation (v5, as of June 2026). v5 is an area with large diffs from v4 — the binder's signature is inverted (Bind(c, target)), generic type-safe extraction (PathParam[T]) is added, HTTPError.Message becomes a string, and the error-handler signature is inverted to func(c *echo.Context, err error). Always confirm the latest at the official before production.


1. c.Bind: bundle four sources via struct tags

c.Bind(&dto) pours, in one call, path parameters, query, and the request body (JSON/XML/form) according to the struct tags.

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)
}
TagSource
jsonrequest body (JSON)
xmlrequest body (XML)
formform data
queryquery parameters
parampath parameters
headerheaders (out of scope of c.Bind. Described later)

An important pitfall: headers aren't bundled by c.Bind. When you want to take Authorization or custom headers into a struct, call echo.BindHeaders(c, &dto) explicitly. "I bound but the header is empty" is the top cause of this.

1.1 Bind only a single source

When you want to strictly bind "only the body" or "only the query," use the per-source function. You can prevent inflow from an unintended source.

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

2. Prevent mass assignment: separate the DTO from the business struct

This is the crux of security. You must not Bind directly to a DB entity or domain model.

// ❌ 危険: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
}

The official also clearly states "don't bind directly to a business struct; use a dedicated DTO and map explicitly to prevent unintended field exposure." Separating the input DTO and the output DTO, and never putting a sensitive field like Password in the output, is the same principle.


3. The generic type-safe binder: take a single value typed

A single value like an ID or page number is something you want to take type-safely without even carving a struct. v5 provides type-safe extraction using generics. Since it errors if it can't convert, you can structurally prevent the accident of receiving "abc" as an 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 The Fluent binder: convert multiple values together and aggregate errors

When you batch-convert the type of multiple queries/parameters and pick up the errors together, the Fluent (flowing) binder is convenient.

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) are the same lineage. Each method has derivatives like Int64 (optional) / MustInt64 (required, errors if absent) / Int64s (multiple), so you can express required / optional / multiple-value by type.


4. Validation: overlay go-playground/validator

Echo doesn't bundle a validation library. Instead, it provides an insertion point, the echo.Validator interface. Overlaying the de facto github.com/go-playground/validator/v10 is the standard.

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()}
	// ...
}

Declare rules with the validate:"..." tag. Let me list commonly-used ones.

TagMeaning
requiredrequired (rejects the zero value)
emailemail format
min=2,max=50string length / numeric range
gte=0,lte=100greater-or-equal / less-or-equal
oneof=draft publishedone of the enumeration
uuid4UUID v4 format
diveapplies to each element of a slice/map

4.1 Format the validation error into a "per-field 422"

Returning a raw error like {"message":"Key: 'CreateUserDTO.Email' Error:Field validation..."} as-is is unkind as an API and leaks information too. In production, return which field failed and why structured with 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
}

Do this conversion only once, in the next chapter's centralized error handler. Not scattering the formatting logic across each handler is the point of DRY.


5. Error handling: return without swallowing, and format in one place

Echo's error handling is centralized. A handler/middleware just returns an error, and the conversion is handled by one 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 A production-oriented centralized error handler

There are four requirements. (1) format validation errors into a 422 per field, (2) for a *echo.HTTPError, its status, (3) for anything else (unexpected), a 500 that hides the details, (4) log 5xx with a correlation ID. v5's signature is func(c *echo.Context, err error) (the argument order is inverted from 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
	// ...
}

With this, each handler becomes just returning the domain's error, and the HTTP appearance, logging, and the blocking of sensitive information are consolidated in one place. The official also recommends "if you forward errors to Sentry / Elasticsearch / Splunk, etc., do it from this centralized handler."

5.2 Map domain errors to HTTP

The UseCase layer shouldn't know HTTP. Define domain-specific sentinel errors and map them to HTTP statuses in the centralized handler (or a thin conversion layer).

// 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"
}

With this separation, the business logic can be kept testable independently of HTTP (clean architecture + DI). When you want to align even the error format by type between client and server, a design that schematizes it with OpenAPI + Problem Details (RFC 9457) is effective (Next.js × Go × OpenAPI).


6. A custom Binder: receive a proprietary format

For requirements like Protocol Buffers, a proprietary Content-Type, or "always strictly interpret JSON numbers," make and swap in your own 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{}
}

Note that Bind's signature is Bind(c *echo.Context, i any) (the argument order is inverted in v5). Since the case of wanting to leverage the standard behavior is the majority, the form of delegating to DefaultBinder is safe.


Summary: the seven principles of boundary design

  1. c.Bind bundles path, query, and body. Headers are out of scope — make BindHeaders explicit.
  2. Don't Bind directly to a business struct. Prevent mass assignment with a dedicated receiving DTO.
  3. Separate the input DTO and the output DTO. Don't put sensitive fields in the output.
  4. Take single values type-safely — drop conversion failures to a 400 with PathParam[T] / the Fluent binder.
  5. For validation, overlay go-playground/validator on echo.Validator and format it into a 422 per field.
  6. Don't swallow errors; return them. Consolidate formatting, logging, and sensitive-info blocking in one place with the centralized handler.
  7. Make domain errors independent of HTTP and map them to statuses in the conversion layer.

What separates a "working API" from an "API trustworthy in production" is exactly this boundary processing. Kill the input by type, and format the errors in one place — if you have this form, Echo's API becomes markedly more robust. Together with the preceding middleware design, return to the Go Echo production-operation guide to view the whole picture.

友田

友田 陽大

Developer of a METI Minister's Award–winning product. With TypeScript + Python + AWS, I deliver SaaS, industry DX, and production-grade generative AI (RAG) end to end — from requirements to infrastructure and operations — single-handedly.

I can take on the implementation from this article as an engagement

I build Go / Echo backends, from design to production

API design and migration to Echo v5, clean architecture (Controller/UseCase/Repository + DI), middleware and security, centralized error handling, graceful shutdown, and testing/CI. With experience building a clean-architecture backend in Go/Echo + google/wire, I implement APIs that don't fall over, are traceable, and are easy to change.

Available for both project-based (contract) and advisory engagements. Start with a free 30-minute consult.

Also worth reading