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."