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:
| Scenario | Where |
|---|---|
| Simple CRUD (fetch, save, delete) | Handler is fine |
| Multiple DB operations in one request | Use a service with a transaction |
| Logic shared between handlers | Extract 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 tags | Service 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/.
package servicesimport ("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:
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.
// ListParams holds pagination, search, and sort parameters.type ListParams struct {Page intPageSize intSearch stringSortBy stringSortOrder stringFilters 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.
// 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 whitelistif !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 int64if err := query.Count(&total).Error; err != nil {return nil, fmt.Errorf("counting posts: %w", err)}// ── Fetch ───────────────────────────────────────var posts []models.Postoffset := (params.Page - 1) * params.PageSizeerr := query.Order(params.SortBy + " " + params.SortOrder).Offset(offset).Limit(params.PageSize).Preload("Author").Find(&posts).Errorif 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:
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:
// Publish marks a post as published after validation.func (s *PostService) Publish(postID uint) (*models.Post, error) {var post models.Postif 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 = trueif err := s.DB.Save(&post).Error; err != nil {return nil, fmt.Errorf("publishing post: %w", err)}return &post, nil}
Aggregation / Statistics
// 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 UserStatsif 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.
// 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 orderif err := tx.Create(order).Error; err != nil {return fmt.Errorf("creating order: %w", err)}// Step 2: Create order items and decrement stockfor i := range items {items[i].OrderID = order.IDif err := tx.Create(&items[i]).Error; err != nil {return fmt.Errorf("creating order item: %w", err)}// Decrement stockresult := 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 totalvar total float64for _, 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, nots.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.
type AuthService struct {Secret stringAccessExpiry time.DurationRefreshExpiry 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."