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 > BindJSONBind* 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