Backend (Go API)

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.

apps/api/internal/handlers/post.go
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:

apps/api/internal/routes/routes.go
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.

request_binding.go
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:

TagDescriptionExample
requiredField must be present and non-zerobinding:"required"
emailMust be a valid email addressbinding:"required,email"
min=NMinimum length (string) or value (number)binding:"min=3"
max=NMaximum length (string) or value (number)binding:"max=255"
gt=NGreater than (for numbers)binding:"gt=0"
oneof=a b cMust be one of the listed valuesbinding:"oneof=admin editor user"
urlMust be a valid URLbinding:"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.

pagination.go
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:

terminal
$ GET /api/posts?page=2&page_size=10

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:

search_sort_filter.go
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 ParamDefaultDescription
page1Page number (1-based)
page_size20Records per page (max 100)
search(empty)Full-text search across name + email
sort_bycreated_atColumn to sort by (whitelisted)
sort_orderdescSort 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

handlers/post.go -- 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

handlers/post.go -- 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

handlers/post.go -- 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

handlers/post.go -- 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_by parameter.
  • 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 code and message fields.
  • 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 >= 1 andpageSize <= 100 to prevent abuse.