gin-patterns
Gin (Go) production patterns — routing groups, middleware, binding/validation, structured error responses, graceful shutdown, context propagation, and testing with httptest. Use when building or reviewing Gin services.
Gin Patterns
Production patterns for Gin — performant and minimalist Go HTTP framework.
When to activate
- Creating Gin services from scratch
- Adding route groups, middleware, auth
- Validating request bodies with binding tags
- Handling graceful shutdown and context propagation
- Writing handler tests with httptest
Recommended structure
cmd/server/main.go # entry point + graceful shutdown
internal/
├── config/ # Config struct + env parsing
├── handlers/
│ ├── users.go
│ └── health.go
├── middleware/
│ ├── auth.go
│ └── logger.go
├── models/ # structs + binding tags
├── repository/ # DB access
├── service/ # business logic
└── errors/ # domain errors + HTTP mapping
Router + Groups
func NewRouter(svc *service.Service) *gin.Engine {
r := gin.New()
r.Use(gin.Recovery(), middleware.RequestID(), middleware.Logger())
r.GET("/health", handlers.Health)
v1 := r.Group("/api/v1")
{
v1.POST("/auth/login", handlers.Login(svc))
authed := v1.Group("")
authed.Use(middleware.RequireAuth(svc))
{
authed.GET("/me", handlers.Me(svc))
authed.GET("/users/:id", handlers.GetUser(svc))
authed.POST("/users", handlers.CreateUser(svc))
}
}
return r
}
Pattern: handlers return a gin.HandlerFunc produced by a closure that captures dependencies. Avoids globals.
Binding + Validation
type CreateUserRequest struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=12"`
FullName string `json:"full_name" binding:"omitempty,max=120"`
}
func CreateUser(svc *service.Service) gin.HandlerFunc {
return func(c *gin.Context) {
var req CreateUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusUnprocessableEntity, "validation", err.Error())
return
}
user, err := svc.CreateUser(c.Request.Context(), req.Email, req.Password, req.FullName)
if err != nil {
mapError(c, err)
return
}
c.JSON(http.StatusCreated, toUserResponse(user))
}
}
Rules:
ShouldBindJSON>BindJSON—Bind*sets the status and aborts automatically, hiding the error- Binding tags:
required,email,min=N,max=N,oneof=a b c,omitempty - Custom validation: register via
binding.Validator.(*validator.Validate).RegisterValidation
Error Handling
Pattern: domain errors → types; handler maps to HTTP status.
// internal/errors/errors.go
package errors
import "errors"
var (
ErrNotFound = errors.New("not found")
ErrUnauthorized = errors.New("unauthorized")
ErrConflict = errors.New("conflict")
ErrValidation = errors.New("validation")
)
// handlers/errors.go
func mapError(c *gin.Context, err error) {
switch {
case errors.Is(err, domain.ErrNotFound):
respondError(c, http.StatusNotFound, "not_found", err.Error())
case errors.Is(err, domain.ErrUnauthorized):
respondError(c, http.StatusUnauthorized, "unauthorized", "")
case errors.Is(err, domain.ErrConflict):
respondError(c, http.StatusConflict, "conflict", err.Error())
default:
log.Error().Err(err).Msg("internal error")
respondError(c, http.StatusInternalServerError, "internal", "")
}
}
func respondError(c *gin.Context, status int, code, msg string) {
c.JSON(status, gin.H{"error": gin.H{"code": code, "message": msg}})
}
Middleware
func RequireAuth(svc *service.Service) gin.HandlerFunc {
return func(c *gin.Context) {
token := extractBearer(c.GetHeader("Authorization"))
if token == "" {
respondError(c, http.StatusUnauthorized, "unauthorized", "")
c.Abort()
return
}
user, err := svc.ValidateToken(c.Request.Context(), token)
if err != nil {
respondError(c, http.StatusUnauthorized, "unauthorized", "")
c.Abort()
return
}
c.Set("user", user)
c.Next()
}
}
// Downstream usage:
func Me(svc *service.Service) gin.HandlerFunc {
return func(c *gin.Context) {
user := c.MustGet("user").(*domain.User)
c.JSON(http.StatusOK, toUserResponse(user))
}
}
Always call c.Abort() after writing an error response — otherwise the middleware continues and may write again.
Context Propagation
func (s *Service) CreateUser(ctx context.Context, email, password, name string) (*User, error) {
// ctx comes from c.Request.Context() — propagates cancellation and deadline
return s.repo.Insert(ctx, &User{Email: email, ...})
}
Always pass c.Request.Context() down to lower layers — never context.Background() inside a handler.
Graceful Shutdown
func main() {
r := NewRouter(svc)
srv := &http.Server{Addr: ":8080", Handler: r}
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatal().Err(err).Msg("listen failed")
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Info().Msg("shutting down")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Fatal().Err(err).Msg("forced shutdown")
}
}
Testing with httptest
func TestCreateUser(t *testing.T) {
gin.SetMode(gin.TestMode)
svc := service.NewMock()
r := NewRouter(svc)
body := `{"email":"[email protected]","password":"secret1234567","full_name":"A B"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/users", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusCreated, w.Code)
var resp UserResponse
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.Equal(t, "[email protected]", resp.Email)
}
Related to
- skill
golang-patterns— Go idioms, concurrency, errors - skill
golang-testing— table-driven tests, subtests, fuzzing - skill
echo-patterns— Gin alternative (similar framework) - skill
api-design— REST conventions