Handlers
Handlers are the HTTP layer of your Grit API. They receive requests, validate input, call services or the database, and return JSON responses. Handlers should stay thin -- delegate business logic to services.
Handler Pattern
Every handler in Grit is a struct with a DB field (and optionally other dependencies). Methods on the struct correspond to HTTP endpoints.
package handlers
import (
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// PostHandler handles post-related endpoints.
type PostHandler struct {
DB *gorm.DB
}
// Each method on the struct is a handler function:
// func (h *PostHandler) List(c *gin.Context) { ... }
// func (h *PostHandler) GetByID(c *gin.Context) { ... }
// func (h *PostHandler) Create(c *gin.Context) { ... }
// func (h *PostHandler) Update(c *gin.Context) { ... }
// func (h *PostHandler) Delete(c *gin.Context) { ... }Handlers are instantiated in routes/routes.go and wired to their endpoints:
postHandler := &handlers.PostHandler{DB: db}
// Wire to routes
protected.GET("/posts", postHandler.List)
protected.GET("/posts/:id", postHandler.GetByID)
protected.POST("/posts", postHandler.Create)
protected.PUT("/posts/:id", postHandler.Update)
protected.DELETE("/posts/:id", postHandler.Delete)Request Binding with Gin
Gin's ShouldBindJSON method parses the request body into a Go struct and validates it using binding struct tags. If validation fails, it returns an error that you can send back to the client.
type createPostRequest struct {
Title string `json:"title" binding:"required,min=3,max=255"`
Body string `json:"body" binding:"required"`
Published bool `json:"published"`
}
func (h *PostHandler) Create(c *gin.Context) {
var req createPostRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusUnprocessableEntity, gin.H{
"error": gin.H{
"code": "VALIDATION_ERROR",
"message": err.Error(),
},
})
return
}
// req is now validated and ready to use
}Define request structs as private types (lowercase first letter) inside the handler file. This keeps them close to the handler that uses them and prevents external access.
Validation with Binding Tags
Gin uses the go-playground/validator library under the hood. Here are the most commonly used binding tags:
| Tag | Description | Example |
|---|---|---|
| required | Field must be present and non-zero | binding:"required" |
| Must be a valid email address | binding:"required,email" | |
| min=N | Minimum length (string) or value (number) | binding:"min=3" |
| max=N | Maximum length (string) or value (number) | binding:"max=255" |
| gt=N | Greater than (for numbers) | binding:"gt=0" |
| oneof=a b c | Must be one of the listed values | binding:"oneof=admin editor user" |
| url | Must be a valid URL | binding:"url" |
Pagination Pattern
All list endpoints in Grit use a consistent pagination pattern. Query parameters control the page number and page size, and the response includes a meta object with pagination details.
func (h *PostHandler) List(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
// Clamp values
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
query := h.DB.Model(&models.Post{})
// Count total records
var total int64
query.Count(&total)
// Fetch paginated results
var posts []models.Post
offset := (page - 1) * pageSize
query.Offset(offset).Limit(pageSize).Find(&posts)
pages := int(math.Ceil(float64(total) / float64(pageSize)))
c.JSON(http.StatusOK, gin.H{
"data": posts,
"meta": gin.H{
"total": total,
"page": page,
"page_size": pageSize,
"pages": pages,
},
})
}The client calls the endpoint like this:
Search, Sort & Filter
The Grit handler pattern supports search, sort, and filter out of the box. The built-in UserHandler.List demonstrates the full pattern:
func (h *UserHandler) List(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
search := c.Query("search")
sortBy := c.DefaultQuery("sort_by", "created_at")
sortOrder := c.DefaultQuery("sort_order", "desc")
// Validate sort order (prevent SQL injection)
if sortOrder != "asc" && sortOrder != "desc" {
sortOrder = "desc"
}
// Whitelist allowed sort columns
allowedSorts := map[string]bool{
"id": true, "name": true, "email": true,
"role": true, "created_at": true,
}
if !allowedSorts[sortBy] {
sortBy = "created_at"
}
query := h.DB.Model(&models.User{})
// Search across multiple fields
if search != "" {
query = query.Where(
"name ILIKE ? OR email ILIKE ?",
"%"+search+"%", "%"+search+"%",
)
}
// Count then paginate
var total int64
query.Count(&total)
var users []models.User
offset := (page - 1) * pageSize
query.Order(sortBy + " " + sortOrder).
Offset(offset).
Limit(pageSize).
Find(&users)
pages := int(math.Ceil(float64(total) / float64(pageSize)))
c.JSON(http.StatusOK, gin.H{
"data": users,
"meta": gin.H{
"total": total,
"page": page,
"page_size": pageSize,
"pages": pages,
},
})
}| Query Param | Default | Description |
|---|---|---|
| page | 1 | Page number (1-based) |
| page_size | 20 | Records per page (max 100) |
| search | (empty) | Full-text search across name + email |
| sort_by | created_at | Column to sort by (whitelisted) |
| sort_order | desc | Sort direction: asc or desc |
Full CRUD Handler
Here is a complete handler with all five CRUD operations. This is the pattern thatgrit generate resource produces for every new resource.
Create
type createPostRequest struct {
Title string `json:"title" binding:"required,min=3,max=255"`
Body string `json:"body" binding:"required"`
Published bool `json:"published"`
}
func (h *PostHandler) Create(c *gin.Context) {
var req createPostRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusUnprocessableEntity, gin.H{
"error": gin.H{
"code": "VALIDATION_ERROR",
"message": err.Error(),
},
})
return
}
// Get authenticated user from context
userID, _ := c.Get("user_id")
post := models.Post{
Title: req.Title,
Body: req.Body,
Published: req.Published,
AuthorID: userID.(uint),
}
if err := h.DB.Create(&post).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": gin.H{
"code": "INTERNAL_ERROR",
"message": "Failed to create post",
},
})
return
}
c.JSON(http.StatusCreated, gin.H{
"data": post,
"message": "Post created successfully",
})
}GetByID
func (h *PostHandler) GetByID(c *gin.Context) {
id := c.Param("id")
var post models.Post
if err := h.DB.Preload("Author").First(&post, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{
"error": gin.H{
"code": "NOT_FOUND",
"message": "Post not found",
},
})
return
}
c.JSON(http.StatusOK, gin.H{
"data": post,
})
}Update
func (h *PostHandler) Update(c *gin.Context) {
id := c.Param("id")
var post models.Post
if err := h.DB.First(&post, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{
"error": gin.H{
"code": "NOT_FOUND",
"message": "Post not found",
},
})
return
}
var req struct {
Title string `json:"title"`
Body string `json:"body"`
Published *bool `json:"published"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusUnprocessableEntity, gin.H{
"error": gin.H{
"code": "VALIDATION_ERROR",
"message": err.Error(),
},
})
return
}
// Build updates map (only include non-zero fields)
updates := map[string]interface{}{}
if req.Title != "" {
updates["title"] = req.Title
}
if req.Body != "" {
updates["body"] = req.Body
}
if req.Published != nil {
updates["published"] = *req.Published
}
if err := h.DB.Model(&post).Updates(updates).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": gin.H{
"code": "INTERNAL_ERROR",
"message": "Failed to update post",
},
})
return
}
h.DB.First(&post, id) // reload
c.JSON(http.StatusOK, gin.H{
"data": post,
"message": "Post updated successfully",
})
}Notice the use of a pointer for boolean fields (*bool). This lets you distinguish between "not sent" (nil) and "sent as false". Without the pointer, Go's zero value (false) would always overwrite the field.
Delete
func (h *PostHandler) Delete(c *gin.Context) {
id := c.Param("id")
var post models.Post
if err := h.DB.First(&post, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{
"error": gin.H{
"code": "NOT_FOUND",
"message": "Post not found",
},
})
return
}
if err := h.DB.Delete(&post).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": gin.H{
"code": "INTERNAL_ERROR",
"message": "Failed to delete post",
},
})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "Post deleted successfully",
})
}Because the Post model includes gorm.DeletedAt, callingdb.Delete() performs a soft delete. The row remains in the database with a deleted_at timestamp, but is excluded from all future queries.
Best Practices
- Keep handlers thin. A handler should parse input, call a service or the DB, and return a response. If your handler exceeds 50 lines, extract logic into a service.
- Always validate sort columns. Use a whitelist of allowed column names to prevent SQL injection through the
sort_byparameter. - Use pointers for optional boolean/numeric fields in update requests. This distinguishes "not provided" from "set to zero/false."
- Return consistent error responses. Always use the standard error envelope with
codeandmessagefields. - Preload relationships only when needed. Use
db.Preload("Author")in GetByID but not necessarily in List to keep list queries fast. - Clamp pagination values. Always enforce
page >= 1andpageSize <= 100to prevent abuse.