Handler → Service pattern

Why we split, what goes where, and how to keep handlers thin.

9 minmedium

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

┌─────────────────────────────────────────────────────────────┐ │ HANDLER (apps/api/internal/handlers/*.go) │ ├─────────────────────────────────────────────────────────────┤ │ - parse path / query / body → typed input │ │ - call ONE service method │ │ - translate result + errors → JSON + HTTP status │ │ │ │ No DB calls. No business rules. Thin. │ └─────────────────────────────────────────────────────────────┘ │ ▼ (typed input) ┌─────────────────────────────────────────────────────────────┐ │ SERVICE (apps/api/internal/services/*.go) │ ├─────────────────────────────────────────────────────────────┤ │ - validate business rules │ │ - GORM calls (read/write) │ │ - orchestrate other services (email, cache, jobs) │ │ - return DOMAIN value or DOMAIN error │ │ │ │ No HTTP types. No JSON. Just Go. │ └─────────────────────────────────────────────────────────────┘
The handler is the airlock between HTTP and your business logic. The service is the business logic.

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

apps/api/internal/handlers/auth_handler.go
func (h *AuthHandler) Register(c *gin.Context) {
// 1. parse input
var in services.RegisterInput
if 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 handler
user, token, err := h.auth.Register(c.Request.Context(), in)
// 3. translate result + errors to HTTP
switch {
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

apps/api/internal/services/auth_service.go
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 unique
var existing models.User
if err := s.db.WithContext(ctx).Where("email = ?", in.Email).First(&existing).Error; err == nil {
return models.User{}, "", ErrEmailTaken
}
// 2. hash password
hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), bcrypt.DefaultCost)
if err != nil { return models.User{}, "", err }
// 3. create
user := 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 token
token, 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.

The temptation: shortcut from handler to DB. "It's just one query, why bother with a service?" — because the day after, you need to fire an email too. And cache the result. And add a unique check. Suddenly the handler is 100 lines and you have to test it through HTTP. Save the future-you trip; service-out from the start.

When the rule bends

Two rare exceptions:

  • Pure echo / health endpoints. GET /healthz returning 200 ok doesn'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

A new feature: when a Product is created, send an email to all admins. Where do you put this code?

Try it

Refactor a handler to enforce the split:

  1. In a fresh project, find any handler that calls h.db directly (some old code, your earlier CRUD lesson, or hand-written).
  2. Move the DB calls into a new service method. The handler should call that method and only that method.
  3. If the handler had business validation ("email must be unique"), move that into the service too.
  4. If the handler had error mapping ifs, keep those in the handler but have the service return sentinel errors instead of HTTP statuses.
  5. 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