Handler → Service pattern
Why we split, what goes where, and how to keep handlers thin.
Every Grit endpoint follows the same two-layer pattern: handler talks to HTTP, service talks to the DB. Get this right and every future feature you build feels obvious. Get it wrong and your codebase rots.
The split — at a glance
Handler / Service responsibilities
Why split? Three concrete reasons
1. Testability
Services are plain Go functions with plain Go inputs. They're trivial to unit-test:
func TestRegisterRejectsDuplicate(t *testing.T) {s := NewAuthService(testDB(t))_, err := s.Register(ctx, RegisterInput{Email: "a@b.com", Password: "secret"})require.NoError(t, err)_, err = s.Register(ctx, RegisterInput{Email: "a@b.com", Password: "other"})require.ErrorIs(t, err, ErrEmailTaken) // domain error, not HTTP status}
If the handler held the DB calls, you'd have to spin up an HTTP server, send a request, parse a response — three layers of ceremony for one assertion. Service tests are direct.
2. Reuse
Logic in a service can be called from:
- An HTTP handler (the normal case).
- A CLI command (
grit seed,grit admin make-user). - A background job (the welcome-email worker).
- Another service (Order service calls UserService).
Logic in a handler can be called from… an HTTP request. Once. That's the whole point.
3. Single responsibility
When you read a handler, you should see the SHAPE of the endpoint: what it accepts, what HTTP status it returns. When you read a service, you should see the RULES of the business. Splitting them gives each file a clear job and a clear test.
Concrete example — register a user
The handler
func (h *AuthHandler) Register(c *gin.Context) {// 1. parse inputvar in services.RegisterInputif err := c.ShouldBindJSON(&in); err != nil {c.JSON(400, gin.H{"error": gin.H{"code": "invalid_body", "message": err.Error()}})return}// 2. call the service — that's the only line of business logic in this handleruser, token, err := h.auth.Register(c.Request.Context(), in)// 3. translate result + errors to HTTPswitch {case errors.Is(err, services.ErrEmailTaken):c.JSON(409, gin.H{"error": gin.H{"code": "email_taken", "message": "Email already in use"}})case err != nil:c.JSON(500, gin.H{"error": gin.H{"code": "internal", "message": "Something went wrong"}})default:c.JSON(201, gin.H{"data": gin.H{"user": user, "token": token}, "message": "Account created"})}}
Three sections, in order: parse → call → translate. Notice what's NOT here: no GORM, no password hashing, no email check. The handler doesn't know HOW; it knows WHAT to return.
The service
var ErrEmailTaken = errors.New("email already in use")type RegisterInput struct {Email string `json:"email" binding:"required,email"`Password string `json:"password" binding:"required,min=8"`Name string `json:"name" binding:"required"`}func (s *AuthService) Register(ctx context.Context, in RegisterInput) (models.User, string, error) {// 1. business rule: email must be uniquevar existing models.Userif err := s.db.WithContext(ctx).Where("email = ?", in.Email).First(&existing).Error; err == nil {return models.User{}, "", ErrEmailTaken}// 2. hash passwordhash, err := bcrypt.GenerateFromPassword([]byte(in.Password), bcrypt.DefaultCost)if err != nil { return models.User{}, "", err }// 3. createuser := models.User{Email: in.Email, PasswordHash: string(hash), Name: in.Name}if err := s.db.WithContext(ctx).Create(&user).Error; err != nil {return models.User{}, "", err}// 4. side effect: enqueue welcome email_ = s.jobs.Enqueue("send_welcome", map[string]any{"user_id": user.ID})// 5. issue tokentoken, err := s.jwt.Issue(user.ID)if err != nil { return models.User{}, "", err }return user, token, nil}
Notice the difference in vocabulary:
- Handler talks about 400, 409, 201, JSON.
- Service talks about email taken, hash password, enqueue welcome.
Different layers, different concerns. That separation is the whole pattern.
Domain errors instead of HTTP codes
The service returns ErrEmailTaken — a sentinel error. The handler maps it to HTTP 409. This indirection costs almost nothing and buys you:
- Services that work in non-HTTP contexts (CLI, jobs) — they don't care about status codes.
- A single place to change the mapping. If you decide email collision should be 422 instead of 409, you edit one line in the handler.
- Tests that assert business intent (
ErrEmailTaken), not transport details (409).
Where each piece lives
apps/api/internal/├── handlers/ ← thin, HTTP-aware│ ├── auth_handler.go│ ├── user_handler.go│ └── product_handler.go├── services/ ← business logic│ ├── auth_service.go│ ├── user_service.go│ └── product_service.go├── models/ ← GORM structs (the data shape)│ ├── user.go│ └── product.go├── routes/ ← routes.go wires handlers to URLs└── middleware/ ← auth, CORS, logger, rate limit
Same triple — model + service + handler — for every resource. grit generate resource generates all three in lockstep so you stop typing the boilerplate.
When the rule bends
Two rare exceptions:
- Pure echo / health endpoints.
GET /healthzreturning200 okdoesn't need a service. Skip the layer; it's ceremony. - Static file serving. If Gin is just shipping bytes from disk, no business logic is involved. No service.
Anything that touches the DB, calls another service, or applies a business rule — service. Always.
What about a Repository layer?
Some teams add a repository between service and GORM, so the service doesn't know about GORM. Pros: swappable DB layer. Cons: another file per resource, another interface, more clicks.
Grit's default is service → GORM, no repository. Add a repository ONLY if you have a real reason (e.g., you're going to swap to a different ORM, or you need both Postgres and a NoSQL store for the same data). For 95% of projects, the extra layer is overhead.
Quick check
Try it
Refactor a handler to enforce the split:
- In a fresh project, find any handler that calls
h.dbdirectly (some old code, your earlier CRUD lesson, or hand-written). - Move the DB calls into a new service method. The handler should call that method and only that method.
- If the handler had business validation ("email must be unique"), move that into the service too.
- If the handler had error mapping ifs, keep those in the handler but have the service return sentinel errors instead of HTTP statuses.
- Write ONE service-level unit test (no HTTP needed) for a business rule.
What's next
Next lesson — CRUD walkthrough. You've seen the pieces; now we apply them to all four CRUD operations on a real resource, end to end.
Spot a typo? Have an idea?
Help us improve this lesson. One click opens a GitHub issue with the lesson URL pre-filled — suggest clearer wording, report a bug, or request more depth. The course keeps improving thanks to learners like you.
Suggest an improvement on GitHub