# Go Echo Framework Production-Operations Guide: Building APIs That Don't Fall Over with v5's New API, Routing, Context, and Graceful Shutdown

> An implementation guide to operating Go's Echo framework at production quality. Faithful to the official documentation (v5), it explains the v4→v5 breaking changes (*echo.Context, slog, StartConfig), routing, Context, binding and validation, centralized error handling, graceful shutdown, testing, Docker deployment, and the Echo adoption decision—all in real code.

- Published: 2026-06-28
- Author: 友田 陽大
- Tags: Go, Echo, アーキテクチャ設計, 型安全, 可観測性, セキュリティ
- URL: https://tomodahinata.com/en/blog/go-echo-framework-production-guide
- Category: Go & Echo in production

## Key points

- Echo v5 changed the handler signature to func(c *echo.Context) error (Context went from an interface to a struct pointer). v4 code won't run as-is
- The logger is unified to log/slog, and middleware.Logger and Timeout are removed from the core. v4 is supported until 2026-12-31; new projects use v5, existing ones assuming a migration plan
- The key to production is graceful shutdown. v5 abolishes e.Shutdown() and unifies it into passing signal.NotifyContext to StartConfig{GracefulTimeout}
- Kill external input at the boundary by Bind→Validate into a DTO. Don't swallow errors—return them, and convert them to consistent JSON in the centralized HTTPErrorHandler
- Echo is a thin layer compatible with net/http. Split into Controller/UseCase/Repository and keep the handler pure with DI (google/wire etc.), and both testing and swapping become easy

---

"I want to stand up an API in Go. `net/http` is too thin, and a full stack is heavy"—what's in between is **Echo.** It has the lightness of running with one line of `@app.get`, plus the parts production needs (router, middleware, binding, centralized error handling) all from the start. But the moment you put it in **production**, the things to decide multiply at once. **Which version do you use? How do you validate requests? Won't a panic bring down the whole thing? Won't in-flight requests be dropped at deploy time? When it falls over, can you trace it from the logs?**

This article is an implementation guide to operating Echo at **production quality.** Whereas the official tutorial teaches you up to "making it work," here I focus on the decision criteria and code for building "**doesn't fall over, can be traced, easy to change.**" As source material, I'll weave in design decisions from a [restaurant-matching site for foreign tourists](/case-studies/restaurant-matching) whose backend I actually built in Go/Echo (clean architecture with Echo + `google/wire`, Controller/UseCase/Repository separation, `go test` / `golangci-lint` enforced in CI).

> **The rules of this article**: API specs and recommended patterns are based on the **Echo official documentation (v5, as of June 2026).** Echo `v5` is the current stable line, and `v4` gets **security fixes and bug fixes until 2026-12-31.** Specs are revised, so always confirm the latest behavior in the [official documentation](https://echo.labstack.com/) before going to production. The code is arranged in a form usable in real operation, but **secrets (signing keys, DB URLs, API keys) are assumed to be in environment variables** (never hardcode).

---

## 0. Why Echo—grasp its position in 3 lines

Before design decisions, grasp what Echo stands on. The official docs cite these three as its foundation.

- **A thin layer over `net/http`**: Echo is a "high-performance, minimalist" framework built on the standard library's `net/http`. It doesn't deviate greatly from Go's standard practice.
- **An optimized HTTP router**: a radix-tree-based router that cleverly prioritizes static, parameter, and wildcard. Near-zero-allocation route lookup is its selling point.
- **Batteries-included**: middleware, JSON/XML/form data binding, template rendering, centralized error handling, automatic TLS via Let's Encrypt, and HTTP/2 from the start.

In other words, Echo's value is **exactly in between "`net/http`'s plainness" and "the parts production needs."** Compared to Gin, its middleware design is more consistent, and unlike Fiber (`fasthttp`-based), it's `net/http`-compatible, so you can use the standard ecosystem (`context`, `http.Handler`, various middleware) as-is. The details of the adoption decision are covered in the [final chapter](#12-echo-vs-gin-vs-fiber-vs-nethttp-adoption-decision).

---

## 1. Most important: grasp "what changed" in v5 first

Skip this and you fall into a state where you copy-paste v4 samples from the web and **they don't compile**, or **behave subtly differently.** Echo v5 is a milestone release with **breaking changes** from v4. Get the diff into your head before you start writing.

### 1.1 The minimal server (v5's official Hello World)

```go
package main

import (
	"log/slog"
	"net/http"

	"github.com/labstack/echo/v5"
	"github.com/labstack/echo/v5/middleware"
)

// ハンドラ：v5 では *echo.Context（構造体ポインタ）を受け取る
func hello(c *echo.Context) error {
	return c.String(http.StatusOK, "Hello, World!")
}

func main() {
	e := echo.New()

	// ミドルウェア
	e.Use(middleware.RequestLogger()) // v4 の middleware.Logger() は廃止
	e.Use(middleware.Recover())       // panic を回復し集中エラーハンドラへ

	// ルート
	e.GET("/", hello)

	// 起動
	if err := e.Start(":8080"); err != nil {
		slog.Error("failed to start server", "error", err)
	}
}
```

Installation is as follows. Echo v5 requires **Go 1.25 or later** (the most recent four Go major releases).

```bash
go get github.com/labstack/echo/v5
```

### 1.2 The decisive diff of v4 → v5

| Item | v4 | v5 | Impact |
| --- | --- | --- | --- |
| Handler signature | `func(c echo.Context) error` (**interface**) | `func(c *echo.Context) error` (**struct pointer**) | **All handlers and all middleware need fixing** |
| Logging | A custom `Logger` interface | The standard `log/slog` (`e.Logger` is `*slog.Logger`) | Structured logging becomes standard |
| Request log | `middleware.Logger()` | `middleware.RequestLogger()` | The old `Logger` is removed from the core |
| Timeout | `middleware.Timeout()` | **Removed from the core** | Substitute with `http.Server`'s timeout / `context` |
| Graceful stop | `e.Shutdown(ctx)` / `e.Close()` | Consolidated into `StartConfig{GracefulTimeout}` | Startup code needs rewriting |
| Route return | `*echo.Route` | `RouteInfo` (filter with the `Routes` collection) | The way to write reverse routing changes |
| `HTTPError.Message` | `interface{}` | `string` (`NewHTTPError(code, msg)` non-variadic) | The message-formatting policy becomes clear |
| Error-handler signature | `func(err error, c echo.Context)` | `func(c *echo.Context, err error)` | The argument order is reversed |
| `c.Response()` | `*echo.Response` | `http.ResponseWriter` (get it with `echo.UnwrapResponse`) | The way to write low-level operations changes |
| Binder | `FormParam*` | `FormValue*`, `Bind(c, target)` with reversed signature | Form-system API names change |
| Type-safe extraction | `ValueBinder` | Generics (`PathParam[T]`/`QueryParam[T]`) | Compile-time type safety advances |
| `echo.Map` | Present | **Removed** | Use `map[string]any` directly |
| Constants | `echo.CONNECT` etc. | Removed → `http.MethodConnect` etc. | Lean on standard constants |
| JWT | `echo-jwt/v4` | `echo-jwt/v5` (a separate module outside the core) | import change |
| Testing | `e.NewContext` | `e.NewContext` + the new `echotest` package | Reduced boilerplate |

> **A realistic migration guideline**: new projects choose **v5.** Existing v4 projects should make a migration plan with an eye on the **end of support on 2026-12-31**, but there's no need to break things in a hurry. `API_CHANGES_V5.md` (official) is the primary source for the diff. All code in this article is written in **v5.**

---

## 2. Routing: the priority of static → parameter → wildcard

The key to Echo's router is that **the priority is decided by the kind of match.** Understand this and you won't agonize over "why this route was picked."

```go
e := echo.New()

e.GET("/users/:id", getUser)        // パラメータ
e.POST("/users", createUser)
e.PUT("/users/:id", updateUser)
e.DELETE("/users/:id", deleteUser)
e.GET("/users/profile", profile)    // 静的（:id より優先される）
e.GET("/files/*", serveFiles)       // ワイルドカード
```

The signature of each method (`GET`/`POST`/`PUT`/`DELETE`/`PATCH`…) is as follows, with the return value `RouteInfo`.

```go
func (e *Echo) GET(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.RouteInfo
```

| Pattern | Kind | Example | Priority |
| --- | --- | --- | --- |
| `/users/profile` | Static | `/users/profile` | 1 (highest) |
| `/users/:id` | Parameter | `/users/42` | 2 |
| `/files/*` | Wildcard | `/files/css/app.css` | 3 |

Because it's evaluated in the order **"static → parameter → wildcard,"** even if you register both `/users/profile` and `/users/:id`, `/users/profile` is absorbed into the static route as intended. It's deterministic behavior, independent of order.

### 2.1 Reading path parameters and wildcards

```go
func getUser(c *echo.Context) error {
	id := c.Param("id") // "/users/42" → "42"
	return c.String(http.StatusOK, id)
}

// ワイルドカードは "*" で受ける
e.GET("/files/*", func(c *echo.Context) error {
	return c.String(http.StatusOK, c.Param("*")) // "/files/a/b.css" → "a/b.css"
})
```

Left as a string, it's not type-safe. A numeric ID like `"42"` should, per the production practice, be converted to `int64` with the [type-safe binder](#41-do-not-bind-clean-input-directly-to-business-structs) described later, dropping conversion failures to 400 (details in the [binding & validation article](/blog/go-echo-request-binding-validation-error-handling-guide)).

### 2.2 Route groups: bundle cross-cutting concerns in one place

Auth, versioning, and tenant boundaries are bundled with **groups** in the Echo style. You can apply a prefix and middleware together.

```go
// /api/v1 配下に共通ミドルウェアを適用
api := e.Group("/api/v1")
api.Use(middleware.CORS("https://app.example.com"))

// 認証が必要な管理 API は、さらにネストしたグループに切る
admin := api.Group("/admin")
admin.Use(requireAdmin) // 自作の認可ミドルウェア
admin.GET("/metrics", metrics) // GET /api/v1/admin/metrics
admin.GET("/users", listUsers) // GET /api/v1/admin/users
```

Groups can be nested, expressing permission boundaries like directories: **"public API," "auth-required API," "admin-only API."** The design of middleware application levels (whole route / group / individual route) and order is dug into in the [complete middleware guide](/blog/go-echo-middleware-cors-csrf-jwt-rate-limit-security-guide).

---

## 3. Context: read and write complete with one `*echo.Context`

An Echo handler receives one `*echo.Context`, and from it does both **reading the request** and **writing the response.** Becoming a struct pointer in v5 brought pointer handling closer to standard Go feel.

### 3.1 How to write responses

```go
func handler(c *echo.Context) error {
	// JSON（API で最頻出。構造体をそのまま渡す）
	return c.JSON(http.StatusOK, map[string]any{"ok": true})
}

// 用途別のレスポンスヘルパー
c.String(http.StatusOK, "plain text")
c.HTML(http.StatusOK, "<b>hi</b>")
c.XML(http.StatusOK, payload)
c.Blob(http.StatusOK, "application/pdf", pdfBytes)
c.Stream(http.StatusOK, "application/octet-stream", reader) // 大きな本文をストリーム
c.NoContent(http.StatusNoContent)                            // 204
c.Redirect(http.StatusFound, "/elsewhere")                   // 302
c.File("public/index.html")                                  // ファイルを直接配信
c.Attachment("report.csv", "report.csv")                     // ダウンロードを促す
```

### 3.2 Reading the request and "default-equipped" helpers

```go
id := c.Param("id")                            // パスパラメータ
q := c.QueryParam("q")                         // クエリ文字列
name := c.FormValue("name")                    // フォーム値
ua := c.Request().Header.Get(echo.HeaderUserAgent) // ヘッダ

// 値がないときの既定値を一行で（v5 の利便メソッド）
page := c.QueryParamOr("page", "1")
sort := c.FormValueOr("sort", "created_at")
```

### 3.3 `Set`/`Get`: pass values from middleware to the handler

Used when **passing to the handler** a user resolved in the auth middleware or a request ID. This is the key to keeping middleware and the handler loosely coupled.

```go
// ミドルウェア側：認証済みユーザーを詰める
func authMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
	return func(c *echo.Context) error {
		user := resolveUser(c) // トークン検証など
		c.Set("user", user)
		return next(c)
	}
}

// ハンドラ側：型アサーションで取り出す（境界で必ず ok を確認）
func me(c *echo.Context) error {
	user, ok := c.Get("user").(*User)
	if !ok {
		return echo.ErrUnauthorized
	}
	return c.JSON(http.StatusOK, user)
}
```

> **A note on low-level operations**: in v5, `c.Response()` returns an `http.ResponseWriter`. When you need Echo's own `*Response` (the `Committed` flag, etc.), extract it with `echo.UnwrapResponse(c.Response())`. It's used in the scene of judging "already sent?" in the centralized error handler ([below](#5-error-handling-return-errors-without-swallowing-them)).

---

## 4. Data binding and validation: kill invalid input at the boundary

> Security principle: **don't trust input coming from outside until it can be converted to a trustworthy type.** Echo thinly supports this boundary processing with `c.Bind` and `c.Validate`, but **the design responsibility is the implementer's.**

### 4.1 Do not `Bind` clean input directly to business structs

`c.Bind(&dto)` flows path parameters, query, and the request body (JSON/XML/form) into a struct per struct tags.

```go
// ❌ DB エンティティに直接 Bind すると、IsAdmin など意図しないフィールドまで上書きされ得る
// ✅ 受け口専用の DTO を定義し、検証してからドメインに移す
type CreateUserDTO struct {
	Name  string `json:"name"  validate:"required,min=2,max=50"`
	Email string `json:"email" validate:"required,email"`
}

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 // 集中エラーハンドラが 400/422 に整形
	}

	user, err := h.users.Create(c.Request().Context(), dto.Name, dto.Email)
	if err != nil {
		return err
	}
	return c.JSON(http.StatusCreated, user)
}
```

Tags are used per source (`json`/`xml`=body, `query`=query, `param`=path, `form`=form, `header`=header). **Headers are outside the target of `c.Bind`**—call `echo.BindHeaders(c, &dto)` explicitly.

### 4.2 Registering the validator

Echo **doesn't force a specific validation library.** Wrap the de-facto `github.com/go-playground/validator/v10` in the `echo.Validator` interface and register it.

```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 {
		// 422 にして「どのフィールドがなぜ落ちたか」を返すのが本番の作法
		return echo.NewHTTPError(http.StatusUnprocessableEntity, err.Error())
	}
	return nil
}

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

Structuring validation errors (a per-field `422` response), the type-safe fluent binder (`echo.QueryParamsBinder(c).Int64s(...)`), and implementing a custom `Binder` are fully covered in the **[binding & validation & error-design article](/blog/go-echo-request-binding-validation-error-handling-guide).**

---

## 5. Error handling: return errors without swallowing them

Echo's error handling is **centralized.** Handlers and middleware just `return` an `error`. **One `HTTPErrorHandler`** converts it to an HTTP response. You don't need to write `if err != nil { c.JSON(...) ; return }` all over.

```go
// ステータス付きエラーを返す
return echo.NewHTTPError(http.StatusUnauthorized, "Please provide valid credentials")

// メッセージ省略時はステータステキストが使われる
return echo.NewHTTPError(http.StatusNotFound)

// センチネルエラー（定義済み）も使える
return echo.ErrBadRequest // 400
return echo.ErrForbidden  // 403
```

Returning a plain `error` becomes `500 Internal Server Error`, and an `*echo.HTTPError` becomes that status and message. The default handler returns `{"message": "..."}` JSON.

### 5.1 A production-oriented centralized error handler

In production, swap to a handler that **doesn't leak internal error details to the client**, **leaves full information in the logs**, and **returns consistent JSON.** The v5 signature is `func(c *echo.Context, err error)` (**the argument order is reversed from v4**).

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

	// ステータスコードの解決（HTTPError 等は HTTPStatusCoder を実装している）
	code := http.StatusInternalServerError
	var sc echo.HTTPStatusCoder
	if errors.As(err, &sc) {
		if s := sc.StatusCode(); s != 0 {
			code = s
		}
	}

	// 5xx は詳細を隠し、相関 ID 付きでログへ。4xx はメッセージを返す
	if code >= 500 {
		c.Logger().Error("unhandled error",
			"error", err.Error(),
			"path", c.Request().URL.Path,
			"request_id", c.Response().Header().Get(echo.HeaderXRequestID),
		)
		_ = c.JSON(code, map[string]string{"error": "internal server error"})
		return
	}
	_ = c.JSON(code, map[string]string{"error": http.StatusText(code)})
}

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

> The official docs state "forwarding errors to Sentry / Elasticsearch / Splunk etc. is also good to do from this centralized handler." Being able to **consolidate log emission, metrics counting, and notification in one place** is the biggest advantage of centralized error handling. Returning in Problem Details (RFC 9457) format and formatting validation errors are covered in the [error-design article](/blog/go-echo-request-binding-validation-error-handling-guide).

---

## 6. Middleware: the application level and order are everything

Middleware is `func(echo.HandlerFunc) echo.HandlerFunc`, inserting processing **before and after** the request. There are three application levels.

```go
e.Use(middleware.Recover())                 // ① 全ルートに適用（最外周）
api := e.Group("/api", middleware.CORS(...)) // ② グループ単位
e.GET("/admin", handler, requireAdmin)       // ③ 個別ルート
```

The standards to put in first in production are as follows (**note that the order has meaning**).

```go
e := echo.New()
e.Use(middleware.Recover())        // panic を最外周で回収（最初に置く）
e.Use(middleware.RequestID())      // 相関 ID を採番
e.Use(middleware.RequestLogger())  // 構造化アクセスログ（slog）
e.Use(middleware.Secure())         // セキュリティヘッダ
e.Use(middleware.BodyLimit(2_097_152)) // 本文サイズ上限 2MB（DoS 緩和）
e.Use(middleware.Gzip())           // レスポンス圧縮
```

`middleware.Recover()` **recovers from a panic wherever it occurs in the chain**, outputs a stack trace, and passes control to the centralized error handler. So a panic doesn't kill the whole process. The settings of CORS, CSRF, Secure, RateLimiter, JWT/KeyAuth, and custom middleware, and **"in what order to line them up,"** are covered in the standalone article **[the complete Echo middleware guide](/blog/go-echo-middleware-cors-csrf-jwt-rate-limit-security-guide).**

> **A v5 note**: `middleware.Logger()` and `middleware.Timeout()` were **removed** from the core. Access logging is `RequestLogger()`, and request timeouts are achieved with `http.Server`'s timeouts or `context.WithTimeout` (inside the handler).

---

## 7. Graceful shutdown: the move that makes the biggest difference in production

The process is swapped every deploy. If you **cut off in-flight requests** at this time, users get a 502, and for payments, inconsistencies arise. Kubernetes / ECS / Cloud Run all first send **`SIGTERM`** at stop time, telling you "clean up within the grace period." Receiving this correctly is a production must.

v5 unified this practice into **`StartConfig`** (v4's `e.Shutdown()` / `e.Close()` are abolished). Make a context that waits for `SIGTERM`/`SIGINT` with `signal.NotifyContext`, and just pass it to `StartConfig.Start(ctx, e)`.

```go
package main

import (
	"context"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"

	"github.com/labstack/echo/v5"
	"github.com/labstack/echo/v5/middleware"
)

func main() {
	e := echo.New()
	e.Use(middleware.Recover())
	e.Use(middleware.RequestLogger())

	e.GET("/slow", func(c *echo.Context) error {
		time.Sleep(5 * time.Second) // 処理中のリクエストを想定
		return c.JSON(http.StatusOK, map[string]string{"status": "done"})
	})

	// SIGINT / SIGTERM を受けると ctx がキャンセルされる
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	sc := echo.StartConfig{
		Address:         ":8080",
		GracefulTimeout: 10 * time.Second, // 既存リクエストの完了を最大10秒待つ
	}
	if err := sc.Start(ctx, e); err != nil {
		e.Logger.Error("server stopped", "error", err)
	}
}
```

When `SIGTERM` comes, Echo **stops accepting new connections**, waits within `GracefulTimeout` for in-flight requests to complete, and then exits. `StartConfig` also has fields for TLS settings and lifecycle callbacks, consolidating here the v4 startup code that called `Start`/`StartTLS`/`StartAutoTLS` individually.

> **Operational alignment**: set `GracefulTimeout` **shorter than the orchestrator's termination grace (`terminationGracePeriodSeconds` etc.).** The reverse means you're force-killed with `SIGKILL` before cleanup finishes. The design of `SIGTERM`, idempotency, and zero-downtime in container environments is contiguous with the [ECS/Fargate production guide](/blog/aws-ecs-fargate-production-guide) and the [Azure Container Apps production guide](/blog/azure-container-apps-production-guide).

---

## 8. Observability: slog structured logs + correlation ID

"Can you trace it when it falls over" is decided by whether you can **structure** the logs and **run one request through with a correlation ID.** v5 unified the logger to the standard `log/slog`, so you get JSON logs as standard.

```go
import (
	"context"
	"log/slog"
	"os"
)

logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))

e.Use(middleware.RequestLoggerWithConfig(middleware.RequestLoggerConfig{
	LogStatus:   true,
	LogURI:      true,
	LogError:    true,
	HandleError: true, // エラーを集中ハンドラへ正しく伝播させる
	LogValuesFunc: func(c *echo.Context, v middleware.RequestLoggerValues) error {
		if v.Error == nil {
			logger.LogAttrs(context.Background(), slog.LevelInfo, "REQUEST",
				slog.String("uri", v.URI),
				slog.Int("status", v.Status),
			)
		} else {
			logger.LogAttrs(context.Background(), slog.LevelError, "REQUEST_ERROR",
				slog.String("uri", v.URI),
				slog.Int("status", v.Status),
				slog.String("err", v.Error.Error()),
			)
		}
		return nil
	},
}))
```

`RequestLoggerValues` contains `Status`/`URI`/`Error`/`Latency`/`RemoteIP` and more. Combined with `middleware.RequestID()`, putting `request_id` on each log line lets you trace from one frontend error to the backend logs and traces with a single thread. Extension to traces and metrics via OpenTelemetry is covered Echo-specifically in the [Echo observability (OpenTelemetry) article](/blog/go-echo-opentelemetry-distributed-tracing-metrics-observability-guide), and cross-platform design in the [observability practical guide](/blog/opentelemetry-observability-production-tracing-metrics-logs). File uploads ([S3, presigned URLs](/blog/go-echo-file-upload-multipart-s3-streaming-presigned-url-guide)), realtime ([WebSocket/SSE](/blog/go-echo-websocket-sse-realtime-streaming-guide)), and API docs ([OpenAPI/Swagger](/blog/go-echo-openapi-swagger-swag-oapi-codegen-documentation-guide)) are each dug into separately.

---

## 9. Architecture: keep handlers thin and testable with DI

Echo is, after all, the **HTTP input/output layer.** Writing business logic into the handler makes tests un-writable and changes scary. The structure I adopted for the [restaurant-matching site](/case-studies/restaurant-matching)'s backend is Go's standard **clean architecture + DI.**

- **Controller (Echo handler)**: only HTTP input/output. Bind/Validate, call the UseCase, and turn the result into JSON.
- **UseCase**: the application's use case. Pure logic that knows neither HTTP nor the DB.
- **Repository**: the persistence abstraction. Define it as an interface and make the implementation (PostgreSQL etc.) swappable.
- **DI (`google/wire`)**: resolve dependency wiring at compile time. Don't hold concretes directly in the handler (dependency inversion).

```go
// Repository はインターフェースで定義（依存性逆転）
type UserRepository interface {
	Create(ctx context.Context, name, email string) (*User, error)
}

// UseCase は Repository に依存（具象 DB を知らない）
type UserUseCase struct {
	repo UserRepository
}

func (u *UserUseCase) Register(ctx context.Context, name, email string) (*User, error) {
	// バリデーション済み前提のドメインロジックだけ
	return u.repo.Create(ctx, name, email)
}

// Controller は UseCase に依存（HTTP の作法だけを知る）
type UserHandler struct {
	uc *UserUseCase
}

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
	}
	user, err := h.uc.Register(c.Request().Context(), dto.Name, dto.Email)
	if err != nil {
		return err
	}
	return c.JSON(http.StatusCreated, user)
}
```

The return of this separation is **testability.** Swap `UserRepository` for a mock, and the UseCase can be unit-tested exhaustively without a DB. Code-generate the wiring with `google/wire`, and hand-written dependency-injection mistakes disappear too. How to cut layers, dependency inversion, and how to use wire are dug into in the [clean architecture + DI article](/blog/go-echo-clean-architecture-dependency-injection-google-wire-guide); the Repository implementation (pgx/sqlc/GORM, transactions, connection pools) in the [database-layer article](/blog/go-echo-database-postgresql-pgx-sqlc-gorm-transaction-guide); and auth/authorization (JWT issuance/validation, RBAC, refresh tokens) in the [auth/authorization article](/blog/go-echo-jwt-authentication-authorization-rbac-refresh-token-guide).

---

## 10. Testing: a handler can be verified by "just passing a Context"

An Echo handler is a bare function receiving `*echo.Context`, so you can test it as-is with `net/http/httptest`. This is the source of "testability."

```go
func TestUserHandler_Create(t *testing.T) {
	e := echo.New()
	e.Validator = &CustomValidator{validator: validator.New()}

	body := `{"name":"Hinata","email":"hinata@example.com"}`
	req := httptest.NewRequest(http.MethodPost, "/users", strings.NewReader(body))
	req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
	rec := httptest.NewRecorder()
	c := e.NewContext(req, rec)

	h := &UserHandler{uc: newUseCaseWithMockRepo()} // Repository はモック

	if assert.NoError(t, h.Create(c)) {
		assert.Equal(t, http.StatusCreated, rec.Code)
	}
}
```

v5 has the new **`echotest`** package, reducing boilerplate further.

```go
import "github.com/labstack/echo/v5/echotest"

func TestUserHandler_Create_WithHelper(t *testing.T) {
	h := &UserHandler{uc: newUseCaseWithMockRepo()}

	rec := echotest.ContextConfig{
		Headers: map[string][]string{
			echo.HeaderContentType: {echo.MIMEApplicationJSON},
		},
		JSONBody: []byte(`{"name":"Hinata","email":"hinata@example.com"}`),
	}.ServeWithHandler(t, h.Create)

	assert.Equal(t, http.StatusCreated, rec.Code)
}
```

Path parameters can be specified with `ContextConfig`'s `PathValues`, and forms and multipart with dedicated fields. The standard is to make `go test ./... -race -cover` mandatory in CI (GitHub Actions) and combine it with `golangci-lint` as a quality gate. The strategy through table-driven tests, mocks, and real-DB integration tests with `testcontainers` is systematized in the [Echo testing-strategy article](/blog/go-echo-testing-strategy-httptest-echotest-testcontainers-guide).

---

## 11. Deployment: ship minimal and safe with a multi-stage Docker

Go's strength is a **single binary.** With a multi-stage build, separate the "build environment" and the "runtime environment," and make the runtime image tiny and least-privileged.

```dockerfile
# --- build stage ---
FROM golang:1.25 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# 静的リンク・デバッグ情報除去で軽量化
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /app ./cmd/server

# --- runtime stage ---
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /app /app
USER nonroot:nonroot         # 非 root で実行（最小権限）
EXPOSE 8080
ENTRYPOINT ["/app"]
```

There are three points. **(1)** Make it an image with neither a shell nor a package manager with `distroless` to cut the attack surface, **(2)** run as `nonroot`, **(3)** read the port, DB URL, and signing keys from **environment variables** (don't bake them into the image). Only combined with code that receives `SIGTERM` and [stops gracefully](#7-graceful-shutdown-the-move-that-makes-the-biggest-difference-in-production) do you get zero-downtime with rolling deploys. Production deployment including server timeouts (v5's `BeforeServeFunc`), health checks, and ECS/Cloud Run practice is detailed in the [complete deployment guide](/blog/go-echo-deployment-docker-distroless-ecs-cloud-run-graceful-shutdown-guide).

---

## 12. Echo vs Gin vs Fiber vs net/http: Adoption Decision

"Should you use Echo" is decided by the project's constraints. By axes, not feel.

| Aspect | net/http (standard) | Echo | Gin | Fiber |
| --- | --- | --- | --- | --- |
| Foundation | Standard | `net/http` | `net/http` | `fasthttp` (incompatible) |
| Learning cost | Medium (much written by hand) | Low–medium | Low | Low |
| Middleware | Self-made | Consistent built-in set | Built-in + community | Built-in (Express-like) |
| Standard-ecosystem compatibility | ◎ | ◎ | ◎ | △ (`http.Handler`-incompatible) |
| Error handling | Self-made | Centralized handler (strength) | Self-made-ish | Centralized handler |
| Suited scene | Tiny, don't want to add dependencies | **REST/JSON API quickly at production quality** | Existing assets, the largest number of examples | Raw throughput top priority |

**The rule of thumb**:

- To build **a REST/JSON business API at production quality, quickly**, **Echo** is a good middle choice. Centralized error handling and middleware consistency pay off.
- If you want to **minimize dependencies** or have **a very small service**, Go 1.22+'s enhanced `net/http` (method-pattern routing) alone may suffice.
- If **`fasthttp`'s raw throughput is a requirement** and you can tolerate standard-ecosystem incompatibility, Fiber. But the cost of not being able to use `context.Context` or `http.Handler` assets is large.

A fair comparison of Echo, Gin, net/http, and Fiber, and a per-use selection framework and migration policy, are dug into in the standalone article **[Echo vs Gin vs net/http thorough comparison](/blog/go-echo-vs-gin-framework-comparison-selection-migration-guide).**

> I myself have adopted a configuration of Next.js for the front and Go/Echo for the backend. The design of placing OpenAPI as the contract and guaranteeing client⇄server type consistency at build time is detailed in the [Next.js × Go × OpenAPI article](/blog/nextjs-go-openapi-end-to-end-type-safety).

---

## Summary: 7 points to raise Echo to "production quality"

1. **Grasp the v5 diff first**—`*echo.Context`, `slog`, `StartConfig`. v4 samples won't run unmodified.
2. **Understand routing's priority (static→param→*)** and bundle permission boundaries with groups.
3. **`Bind`→`Validate` input into a DTO** and kill it at the boundary. Don't bind directly to business structs.
4. **Don't swallow errors—`return` them.** Consolidate into consistent JSON and log emission with the centralized `HTTPErrorHandler`.
5. **Put `Recover` at the outermost** and don't kill the process on a panic.
6. **`StartConfig{GracefulTimeout}` + `SIGTERM`** to make rolling deploys zero-downtime.
7. **Keep handlers thin and testable with DI.** Echo is the HTTP layer, logic goes to the UseCase.

Echo, precisely because it sits between "lightness" and "the parts production needs," is a framework where **the designer's judgment directly shows in quality.** With this article's pattern as a foundation, and the [middleware](/blog/go-echo-middleware-cors-csrf-jwt-rate-limit-security-guide) and [binding & validation & error design](/blog/go-echo-request-binding-validation-error-handling-guide) grasped, you reach an API that doesn't fall over, can be traced, and is easy to change.
