Gin Framework Guide
Applies to: Gin 1.9+, REST APIs, Microservices, Web Applications Language Guide: @.claude/skills/go-guide/SKILL.md
Overview
Gin is a high-performance HTTP web framework written in Go featuring a martini-like API with performance up to 40x faster. It is the most popular Go web framework, ideal for building REST APIs and microservices.
Use Gin when:
- •Building high-performance REST APIs
- •You need a mature, well-documented framework
- •Middleware ecosystem is important
- •You want a balanced approach (not too minimal, not too heavy)
Consider alternatives when:
- •You need maximum minimalism (use standard library)
- •You want built-in WebSocket support (use Fiber)
- •You prefer a different API style (use Echo)
Guardrails
Gin-Specific Rules
- •Use application factory pattern for testability
- •Group routes with versioning (
/api/v1) - •Use middleware for cross-cutting concerns (auth, logging, CORS)
- •Use
gin.Contextfor request-scoped data only - •Use binding tags for input validation
- •Return consistent JSON response structure across all endpoints
- •Use proper HTTP status codes
- •Set
gin.ReleaseModein production - •Configure proper server timeouts (read, write, idle)
- •Implement graceful shutdown for all servers
- •Use connection pooling for database access
- •Use pagination for all list endpoints
Anti-Patterns
- •Do not use
gin.Default()in production without understanding its middleware - •Do not store business logic in handlers (use service layer)
- •Do not return raw error messages to clients
- •Do not skip input validation on any endpoint
- •Do not use global state; use dependency injection
Project Structure
myproject/ ├── cmd/ │ └── api/ │ └── main.go # Entry point, server setup, graceful shutdown ├── internal/ │ ├── config/ │ │ └── config.go # Configuration from env vars │ ├── handler/ │ │ ├── handler.go # Handler registry struct │ │ ├── user.go # User handlers │ │ └── auth.go # Auth handlers │ ├── middleware/ │ │ ├── auth.go # JWT/Bearer auth middleware │ │ ├── cors.go # CORS middleware │ │ └── logger.go # Request logging middleware │ ├── model/ │ │ ├── user.go # Domain model + request/response DTOs │ │ └── response.go # Standardized response wrappers │ ├── repository/ │ │ ├── repository.go # Repository registry (interfaces) │ │ └── user.go # User repository implementation │ ├── service/ │ │ ├── service.go # Service registry (interfaces) │ │ └── user.go # User business logic │ └── router/ │ └── router.go # Route definitions and grouping ├── pkg/ │ ├── validator/ │ │ └── validator.go # Custom validators │ └── response/ │ └── response.go # Shared response helpers ├── migrations/ ├── .env.example ├── go.mod ├── go.sum ├── Makefile └── README.md
Layer responsibilities:
- •
handler/— HTTP concerns only: parse request, call service, write response - •
service/— Business logic, validation, orchestration - •
repository/— Data access, database queries - •
model/— Domain types, request/response DTOs, validation tags - •
middleware/— Cross-cutting: auth, logging, CORS, rate limiting - •
router/— Route registration, grouping, middleware attachment
Routing
Route Groups and Versioning
func Setup(handlers *handler.Handlers, mw *middleware.Middleware) *gin.Engine {
r := gin.New()
// Global middleware
r.Use(gin.Recovery())
r.Use(middleware.Logger())
r.Use(middleware.CORS())
// Health check (always public)
r.GET("/health", func(c *gin.Context) {
c.JSON(200, gin.H{"status": "ok"})
})
// API v1 routes
v1 := r.Group("/api/v1")
{
// Public routes
auth := v1.Group("/auth")
{
auth.POST("/login", handlers.Auth.Login)
auth.POST("/refresh", handlers.Auth.Refresh)
}
// Protected routes
users := v1.Group("/users")
{
users.POST("", handlers.User.CreateUser) // Public
users.Use(mw.Auth()) // Auth from here down
users.GET("", handlers.User.GetUsers)
users.GET("/me", handlers.User.GetCurrentUser)
users.GET("/:id", handlers.User.GetUser)
users.PATCH("/:id", handlers.User.UpdateUser)
users.DELETE("/:id", mw.AdminOnly(), handlers.User.DeleteUser)
}
}
return r
}
Routing conventions:
- •Always use
gin.New()(notgin.Default()) and add middleware explicitly - •Group public and protected routes separately
- •Apply auth middleware at the group level, not per-route
- •Use per-route middleware for fine-grained access (e.g.,
mw.AdminOnly()) - •Always include a
/healthendpoint
Middleware
Auth Middleware (JWT Bearer)
func (m *Middleware) Auth() gin.HandlerFunc {
return func(c *gin.Context) {
header := c.GetHeader("Authorization")
if header == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized,
model.NewErrorResponse("missing authorization header"))
return
}
parts := strings.Split(header, " ")
if len(parts) != 2 || parts[0] != "Bearer" {
c.AbortWithStatusJSON(http.StatusUnauthorized,
model.NewErrorResponse("invalid authorization header"))
return
}
claims, err := m.authService.ValidateToken(parts[1])
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized,
model.NewErrorResponse("invalid token"))
return
}
// Set user context for downstream handlers
c.Set("user_id", claims.UserID)
c.Set("is_admin", claims.IsAdmin)
c.Next()
}
}
Role-Based Access
func (m *Middleware) AdminOnly() gin.HandlerFunc {
return func(c *gin.Context) {
isAdmin, exists := c.Get("is_admin")
if !exists || !isAdmin.(bool) {
c.AbortWithStatusJSON(http.StatusForbidden,
model.NewErrorResponse("admin access required"))
return
}
c.Next()
}
}
Request Logger
func Logger() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
path := c.Request.URL.Path
query := c.Request.URL.RawQuery
c.Next()
if query != "" {
path = path + "?" + query
}
log.Printf("[GIN] %3d | %13v | %15s | %-7s %s",
c.Writer.Status(), time.Since(start),
c.ClientIP(), c.Request.Method, path)
}
}
Request Binding and Validation
Binding Tags
Gin uses binding struct tags for request validation (backed by go-playground/validator).
// Create request — all fields required
type CreateUserRequest struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=8"`
FirstName string `json:"first_name" binding:"required,min=1,max=100"`
LastName string `json:"last_name" binding:"required,min=1,max=100"`
}
// Update request — all fields optional (pointer types)
type UpdateUserRequest struct {
FirstName *string `json:"first_name" binding:"omitempty,min=1,max=100"`
LastName *string `json:"last_name" binding:"omitempty,min=1,max=100"`
IsActive *bool `json:"is_active"`
}
Handler Binding Pattern
func (h *UserHandler) CreateUser(c *gin.Context) {
var req model.CreateUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, model.NewErrorResponse(err.Error()))
return
}
user, err := h.userService.Create(c.Request.Context(), &req)
if err != nil {
handleServiceError(c, err)
return
}
c.JSON(http.StatusCreated, model.NewSuccessResponse(user.ToResponse()))
}
Binding conventions:
- •Use
ShouldBindJSON(notBindJSON) to control error responses yourself - •Use pointer fields for optional/partial update DTOs
- •Always validate before passing to service layer
- •Separate request DTOs from domain models
Query Parameter Binding
func (h *UserHandler) GetUsers(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
users, total, err := h.userService.GetAll(c.Request.Context(), page, perPage)
if err != nil {
c.JSON(http.StatusInternalServerError, model.NewErrorResponse(err.Error()))
return
}
responses := make([]*model.UserResponse, len(users))
for i, user := range users {
responses[i] = user.ToResponse()
}
c.JSON(http.StatusOK, model.NewPaginatedResponse(responses, page, perPage, total))
}
Error Handling
Standardized Response Models
type Response struct {
Success bool `json:"success"`
Message string `json:"message,omitempty"`
Data interface{} `json:"data,omitempty"`
Error string `json:"error,omitempty"`
}
type PaginatedResponse struct {
Success bool `json:"success"`
Data interface{} `json:"data"`
Meta *PageMeta `json:"meta"`
}
type PageMeta struct {
Page int `json:"page"`
PerPage int `json:"per_page"`
Total int64 `json:"total"`
TotalPages int `json:"total_pages"`
}
func NewSuccessResponse(data interface{}) *Response {
return &Response{Success: true, Data: data}
}
func NewErrorResponse(message string) *Response {
return &Response{Success: false, Error: message}
}
Service Error Mapping
Define domain errors in the service layer, map them to HTTP status in handlers:
// service layer
var (
ErrUserNotFound = errors.New("user not found")
ErrUserAlreadyExists = errors.New("user already exists")
ErrInvalidCredentials = errors.New("invalid credentials")
)
// handler helper
func handleServiceError(c *gin.Context, err error) {
switch {
case errors.Is(err, service.ErrUserNotFound):
c.JSON(http.StatusNotFound, model.NewErrorResponse(err.Error()))
case errors.Is(err, service.ErrUserAlreadyExists):
c.JSON(http.StatusConflict, model.NewErrorResponse(err.Error()))
case errors.Is(err, service.ErrInvalidCredentials):
c.JSON(http.StatusUnauthorized, model.NewErrorResponse(err.Error()))
default:
c.JSON(http.StatusInternalServerError, model.NewErrorResponse("internal server error"))
}
}
Error handling rules:
- •Never expose internal errors to clients in production
- •Map domain errors to appropriate HTTP status codes
- •Use
errors.Isfor sentinel errors,errors.Asfor typed errors - •Always return the standardized
Responsestructure
Application Setup
Server with Graceful Shutdown
func main() {
cfg, err := config.Load()
if err != nil {
log.Fatalf("Failed to load config: %v", err)
}
if cfg.Environment == "production" {
gin.SetMode(gin.ReleaseMode)
}
// Wire layers: repo -> service -> handler
db, err := initDB(cfg.DatabaseURL)
if err != nil {
log.Fatalf("Failed to connect to database: %v", err)
}
repos := repository.NewRepositories(db)
services := service.NewServices(repos, cfg)
handlers := handler.NewHandlers(services)
r := router.Setup(handlers, middleware.NewMiddleware(cfg))
srv := &http.Server{
Addr: ":" + cfg.Port,
Handler: r,
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
}
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("Server failed: %v", 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 := srv.Shutdown(ctx); err != nil {
log.Fatalf("Server forced to shutdown: %v", err)
}
}
Commands Reference
# Initialize project go mod init myproject # Install dependencies go mod tidy # Run development server go run cmd/api/main.go # Build binary go build -o bin/api cmd/api/main.go # Run tests go test ./... go test -v -cover ./... # Run with race detection go test -race ./... # Lint golangci-lint run # Generate Swagger docs (with swaggo) swag init -g cmd/api/main.go # Database migrations (using golang-migrate) migrate -path migrations -database "$DATABASE_URL" up migrate -path migrations -database "$DATABASE_URL" down
Dependencies
// Core github.com/gin-gonic/gin v1.9.1 // Auth github.com/golang-jwt/jwt/v5 v5.0.0 golang.org/x/crypto v0.14.0 // Database gorm.io/gorm v1.25.5 gorm.io/driver/postgres v1.5.4 // Config github.com/joho/godotenv v1.5.1 // Testing github.com/stretchr/testify v1.8.4 // Docs github.com/swaggo/gin-swagger v1.6.0 github.com/swaggo/swag v1.16.2
Advanced Topics
For detailed patterns and full implementation examples, see:
- •references/patterns.md -- Handler implementations, database integration, authentication service, testing patterns, performance tuning