Backend (Go API)

Services

Services contain your application's business logic. They sit between handlers and the database, making your code testable, reusable, and maintainable. Handlers should be thin -- services should be fat.

When to Use Services vs. Handlers

Not every handler needs a service. Here is the rule of thumb:

ScenarioWhere
Simple CRUD (fetch, save, delete)Handler is fine
Multiple DB operations in one requestUse a service with a transaction
Logic shared between handlersExtract into a service
Complex query building (filters, joins)Service or helper function
External API calls (email, storage, AI)Dedicated service
Business rules and validation beyond binding tagsService layer

Grit ships with two built-in services: AuthService (JWT token operations) and a pattern you can follow for any new service.

Service Pattern

A Grit service is a struct with a DB field (and any other dependencies) plus methods that contain business logic. Services live inapps/api/internal/services/.

apps/api/internal/services/post.go
package services

import (
    "fmt"
    "math"

    "gorm.io/gorm"

    "myapp/apps/api/internal/models"
)

// PostService handles business logic for posts.
type PostService struct {
    DB *gorm.DB
}

// NewPostService creates a new PostService.
func NewPostService(db *gorm.DB) *PostService {
    return &PostService{DB: db}
}

Inject the service into your handler:

routes/routes.go
postService := services.NewPostService(db)
postHandler := &handlers.PostHandler{
    DB:      db,
    Service: postService,
}

ListParams Struct

For list operations, define a ListParams struct that encapsulates all pagination, search, sort, and filter parameters. This keeps service method signatures clean and makes it easy to add new filters.

services/post.go
// ListParams holds pagination, search, and sort parameters.
type ListParams struct {
    Page      int
    PageSize  int
    Search    string
    SortBy    string
    SortOrder string
    Filters   map[string]string // e.g., {"status": "published"}
}

// ListResult holds paginated query results.
type ListResult struct {
    Data  interface{} `json:"data"`
    Total int64       `json:"total"`
    Page  int         `json:"page"`
    Size  int         `json:"page_size"`
    Pages int         `json:"pages"`
}

// ClampDefaults ensures pagination values are within safe bounds.
func (p *ListParams) ClampDefaults() {
    if p.Page < 1 {
        p.Page = 1
    }
    if p.PageSize < 1 || p.PageSize > 100 {
        p.PageSize = 20
    }
    if p.SortOrder != "asc" && p.SortOrder != "desc" {
        p.SortOrder = "desc"
    }
    if p.SortBy == "" {
        p.SortBy = "created_at"
    }
}

Query Building

Services build GORM queries step by step. This pattern keeps complex queries readable and composable.

services/post.go -- List
// AllowedSorts defines which columns can be sorted on.
var postAllowedSorts = map[string]bool{
    "id": true, "title": true, "created_at": true, "published": true,
}

// List returns a paginated, filtered list of posts.
func (s *PostService) List(params ListParams) (*ListResult, error) {
    params.ClampDefaults()

    // Validate sort column against whitelist
    if !postAllowedSorts[params.SortBy] {
        params.SortBy = "created_at"
    }

    query := s.DB.Model(&models.Post{})

    // ── Search ──────────────────────────────────────
    if params.Search != "" {
        query = query.Where(
            "title ILIKE ? OR body ILIKE ?",
            "%"+params.Search+"%",
            "%"+params.Search+"%",
        )
    }

    // ── Filters ─────────────────────────────────────
    if status, ok := params.Filters["status"]; ok {
        switch status {
        case "published":
            query = query.Where("published = ?", true)
        case "draft":
            query = query.Where("published = ?", false)
        }
    }

    if authorID, ok := params.Filters["author_id"]; ok {
        query = query.Where("author_id = ?", authorID)
    }

    // ── Count ───────────────────────────────────────
    var total int64
    if err := query.Count(&total).Error; err != nil {
        return nil, fmt.Errorf("counting posts: %w", err)
    }

    // ── Fetch ───────────────────────────────────────
    var posts []models.Post
    offset := (params.Page - 1) * params.PageSize

    err := query.
        Order(params.SortBy + " " + params.SortOrder).
        Offset(offset).
        Limit(params.PageSize).
        Preload("Author").
        Find(&posts).Error

    if err != nil {
        return nil, fmt.Errorf("fetching posts: %w", err)
    }

    pages := int(math.Ceil(float64(total) / float64(params.PageSize)))

    return &ListResult{
        Data:  posts,
        Total: total,
        Page:  params.Page,
        Size:  params.PageSize,
        Pages: pages,
    }, nil
}

The handler becomes much simpler when it delegates to a service:

handlers/post.go -- using service
func (h *PostHandler) List(c *gin.Context) {
    page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
    pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))

    params := services.ListParams{
        Page:      page,
        PageSize:  pageSize,
        Search:    c.Query("search"),
        SortBy:    c.DefaultQuery("sort_by", "created_at"),
        SortOrder: c.DefaultQuery("sort_order", "desc"),
        Filters: map[string]string{
            "status":    c.Query("status"),
            "author_id": c.Query("author_id"),
        },
    }

    result, err := h.Service.List(params)
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{
            "error": gin.H{
                "code":    "INTERNAL_ERROR",
                "message": "Failed to fetch posts",
            },
        })
        return
    }

    c.JSON(http.StatusOK, gin.H{
        "data": result.Data,
        "meta": gin.H{
            "total":     result.Total,
            "page":      result.Page,
            "page_size": result.Size,
            "pages":     result.Pages,
        },
    })
}

Business Logic Examples

Services are the right place for any logic that goes beyond simple CRUD. Here are common patterns.

Publishing a Post

A publish action might need to validate the post, update its status, and send a notification. All of this belongs in a service:

services/post.go -- Publish
// Publish marks a post as published after validation.
func (s *PostService) Publish(postID uint) (*models.Post, error) {
    var post models.Post
    if err := s.DB.First(&post, postID).Error; err != nil {
        return nil, fmt.Errorf("post not found: %w", err)
    }

    if post.Published {
        return nil, fmt.Errorf("post is already published")
    }

    if len(post.Title) < 10 {
        return nil, fmt.Errorf("title must be at least 10 characters to publish")
    }

    if len(post.Body) < 100 {
        return nil, fmt.Errorf("body must be at least 100 characters to publish")
    }

    post.Published = true
    if err := s.DB.Save(&post).Error; err != nil {
        return nil, fmt.Errorf("publishing post: %w", err)
    }

    return &post, nil
}

Aggregation / Statistics

services/user.go -- Stats
// UserStats holds aggregated user statistics.
type UserStats struct {
    Total       int64 `json:"total"`
    Active      int64 `json:"active"`
    Admins      int64 `json:"admins"`
    NewThisWeek int64 `json:"new_this_week"`
}

// GetStats returns aggregated user statistics.
func (s *UserService) GetStats() (*UserStats, error) {
    var stats UserStats

    if err := s.DB.Model(&models.User{}).Count(&stats.Total).Error; err != nil {
        return nil, fmt.Errorf("counting total users: %w", err)
    }

    s.DB.Model(&models.User{}).Where("active = ?", true).Count(&stats.Active)
    s.DB.Model(&models.User{}).Where("role = ?", "admin").Count(&stats.Admins)
    s.DB.Model(&models.User{}).
        Where("created_at >= NOW() - INTERVAL '7 days'").
        Count(&stats.NewThisWeek)

    return &stats, nil
}

Transaction Handling

When a service method performs multiple database operations that must succeed or fail together, wrap them in a GORM transaction. If any step returns an error, the entire transaction is rolled back.

services/order.go -- CreateOrder
// CreateOrder creates an order and decrements product stock atomically.
func (s *OrderService) CreateOrder(order *models.Order, items []models.OrderItem) error {
    return s.DB.Transaction(func(tx *gorm.DB) error {
        // Step 1: Create the order
        if err := tx.Create(order).Error; err != nil {
            return fmt.Errorf("creating order: %w", err)
        }

        // Step 2: Create order items and decrement stock
        for i := range items {
            items[i].OrderID = order.ID

            if err := tx.Create(&items[i]).Error; err != nil {
                return fmt.Errorf("creating order item: %w", err)
            }

            // Decrement stock
            result := tx.Model(&models.Product{}).
                Where("id = ? AND stock >= ?", items[i].ProductID, items[i].Quantity).
                Update("stock", gorm.Expr("stock - ?", items[i].Quantity))

            if result.Error != nil {
                return fmt.Errorf("updating stock: %w", result.Error)
            }
            if result.RowsAffected == 0 {
                return fmt.Errorf("insufficient stock for product %d", items[i].ProductID)
            }
        }

        // Step 3: Calculate total
        var total float64
        for _, item := range items {
            total += item.Price * float64(item.Quantity)
        }
        if err := tx.Model(order).Update("total", total).Error; err != nil {
            return fmt.Errorf("updating order total: %w", err)
        }

        return nil // commit
    })
}

Key points about GORM transactions:

  • Use tx (the transaction handle) for all queries inside the callback, not s.DB.
  • If the callback returns nil, the transaction commits.
  • If the callback returns an error, the transaction rolls back automatically.
  • If a panic occurs inside the callback, GORM recovers and rolls back.

Built-in AuthService

Grit ships with an AuthService that handles all JWT token operations. It is the canonical example of a well-structured service.

apps/api/internal/services/auth.go
type AuthService struct {
    Secret        string
    AccessExpiry  time.Duration
    RefreshExpiry time.Duration
}

type TokenPair struct {
    AccessToken  string `json:"access_token"`
    RefreshToken string `json:"refresh_token"`
    ExpiresAt    int64  `json:"expires_at"`
}

type Claims struct {
    UserID uint   `json:"user_id"`
    Email  string `json:"email"`
    Role   string `json:"role"`
    jwt.RegisteredClaims
}

// GenerateTokenPair creates access + refresh tokens.
func (s *AuthService) GenerateTokenPair(
    userID uint, email, role string,
) (*TokenPair, error) {
    accessToken, expiresAt, err := s.generateToken(
        userID, email, role, s.AccessExpiry,
    )
    if err != nil {
        return nil, fmt.Errorf("generating access token: %w", err)
    }

    refreshToken, _, err := s.generateToken(
        userID, email, role, s.RefreshExpiry,
    )
    if err != nil {
        return nil, fmt.Errorf("generating refresh token: %w", err)
    }

    return &TokenPair{
        AccessToken:  accessToken,
        RefreshToken: refreshToken,
        ExpiresAt:    expiresAt,
    }, nil
}

// ValidateToken parses and validates a JWT token.
func (s *AuthService) ValidateToken(tokenString string) (*Claims, error) {
    token, err := jwt.ParseWithClaims(
        tokenString, &Claims{},
        func(token *jwt.Token) (interface{}, error) {
            if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
                return nil, fmt.Errorf("unexpected signing method")
            }
            return []byte(s.Secret), nil
        },
    )
    if err != nil {
        return nil, fmt.Errorf("parsing token: %w", err)
    }

    claims, ok := token.Claims.(*Claims)
    if !ok || !token.Valid {
        return nil, fmt.Errorf("invalid token")
    }

    return claims, nil
}

Best Practices

  • One service per resource. PostService, UserService,OrderService -- each in its own file.
  • Return errors, not HTTP codes. Services should return Go errors. The handler decides the HTTP status code.
  • Wrap errors with context. Use fmt.Errorf("context: %w", err) so error messages tell you where things went wrong.
  • Use transactions for multi-step operations. If one step fails, everything rolls back cleanly.
  • Keep services independent. A service should not import another service. If two services need to collaborate, the handler orchestrates them.
  • Validate business rules here. Binding tags handle field-level validation. Services handle business rules like "a post must have at least 100 characters to be published."