echo-patterns
Echo (Go) production patterns — typed context, route groups, middleware chain, binding/validation, custom HTTPErrorHandler, graceful shutdown, and testing. Use when building or reviewing Echo services. Compare with gin-patterns for framework choice.
Echo Patterns
Production patterns for Echo — minimalist Go HTTP framework with typed context.
When to activate
- Creating Echo services from scratch
- Configuring middleware chain (logger, recover, CORS, rate limit)
- Binding and validating request bodies
- Mapping errors to HTTP status via custom HTTPErrorHandler
- Choosing between Echo and Gin for a new project
Echo vs Gin — quick
| Aspecto | Echo | Gin |
|---|---|---|
| Context | echo.Context (interface) | *gin.Context (struct pointer) |
| Middleware chain | Explicit Use() ordering | Explicit Use() ordering |
| Binding tags | validate:"..." (custom validator) | binding:"..." (go-playground/validator) |
| Error handler | Customizable via HTTPErrorHandler | Middleware pattern (c.Error + mid) |
| Performance | ~equivalent | ~equivalent |
Choice: Echo if you prefer context as an interface and a centralized HTTPErrorHandler; Gin if you're already used to it or want a larger community.
Recommended structure
cmd/server/main.go
internal/
├── handler/
│ ├── users.go
│ └── health.go
├── middleware/
├── model/
├── repository/
└── service/
Router + Groups
func NewServer(svc *service.Service) *echo.Echo {
e := echo.New()
e.HideBanner = true
e.HTTPErrorHandler = customHTTPErrorHandler
e.Validator = &CustomValidator{v: validator.New()}
e.Use(middleware.Recover())
e.Use(middleware.RequestID())
e.Use(middleware.Logger())
e.Use(middleware.CORS())
e.GET("/health", handler.Health)
v1 := e.Group("/api/v1")
v1.POST("/auth/login", handler.Login(svc))
authed := v1.Group("", AuthMiddleware(svc))
authed.GET("/me", handler.Me(svc))
authed.GET("/users/:id", handler.GetUser(svc))
authed.POST("/users", handler.CreateUser(svc))
return e
}
Typed Context + Binding
type CreateUserRequest struct {
Email string `json:"email" validate:"required,email"`
Password string `json:"password" validate:"required,min=12"`
FullName string `json:"full_name" validate:"omitempty,max=120"`
}
func CreateUser(svc *service.Service) echo.HandlerFunc {
return func(c echo.Context) error {
var req CreateUserRequest
if err := c.Bind(&req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid json")
}
if err := c.Validate(&req); err != nil {
return echo.NewHTTPError(http.StatusUnprocessableEntity, err.Error())
}
user, err := svc.CreateUser(c.Request().Context(), req.Email, req.Password, req.FullName)
if err != nil {
return err // HTTPErrorHandler handles the mapping
}
return c.JSON(http.StatusCreated, toUserResponse(user))
}
}
// Custom validator
type CustomValidator struct{ v *validator.Validate }
func (cv *CustomValidator) Validate(i interface{}) error {
return cv.v.Struct(i)
}
Custom HTTPErrorHandler
Instead of mapping errors in each handler (as in Gin), centralize them:
func customHTTPErrorHandler(err error, c echo.Context) {
if c.Response().Committed {
return
}
code := http.StatusInternalServerError
errCode := "internal"
message := "internal error"
switch {
case errors.Is(err, domain.ErrNotFound):
code, errCode, message = http.StatusNotFound, "not_found", err.Error()
case errors.Is(err, domain.ErrUnauthorized):
code, errCode, message = http.StatusUnauthorized, "unauthorized", "unauthorized"
case errors.Is(err, domain.ErrConflict):
code, errCode, message = http.StatusConflict, "conflict", err.Error()
default:
var he *echo.HTTPError
if errors.As(err, &he) {
code = he.Code
message = fmt.Sprint(he.Message)
errCode = strings.ToLower(http.StatusText(code))
} else {
c.Logger().Error(err)
}
}
_ = c.JSON(code, echo.Map{"error": echo.Map{"code": errCode, "message": message}})
}
Advantage: handlers stay clean — they just return err, and the mapping is a single responsibility.
Custom middleware
func AuthMiddleware(svc *service.Service) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
token := extractBearer(c.Request().Header.Get("Authorization"))
if token == "" {
return echo.NewHTTPError(http.StatusUnauthorized)
}
user, err := svc.ValidateToken(c.Request().Context(), token)
if err != nil {
return echo.NewHTTPError(http.StatusUnauthorized)
}
c.Set("user", user)
return next(c)
}
}
}
Usage: user := c.Get("user").(*domain.User) — or a type-safe helper function.
Graceful Shutdown
func main() {
e := NewServer(svc)
go func() {
if err := e.Start(":8080"); err != nil && !errors.Is(err, http.ErrServerClosed) {
e.Logger.Fatal("shutting down unexpectedly: ", err)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := e.Shutdown(ctx); err != nil {
e.Logger.Fatal(err)
}
}
Testing
func TestCreateUser(t *testing.T) {
e := echo.New()
e.Validator = &CustomValidator{v: validator.New()}
body := `{"email":"[email protected]","password":"secret1234567","full_name":"A B"}`
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
svc := service.NewMock()
if assert.NoError(t, handler.CreateUser(svc)(c)) {
assert.Equal(t, http.StatusCreated, rec.Code)
}
}
Related to
- skill
golang-patterns— Go idioms - skill
golang-testing— table-driven tests - skill
gin-patterns— alternative framework - skill
api-design— REST conventions