How to Implement the Service Layer Pattern in Go

The Service Layer Pattern in Go is implemented by defining an interface for business logic and a concrete struct that implements it, separating concerns from your handlers.

The tangled handler problem

You write an HTTP endpoint that fetches a user from a database, checks if they are active, calculates a discount, and returns JSON. It works. Then your team needs the same logic for a command-line backup tool, a background worker that emails inactive users, and a gRPC service for a mobile app. You copy the handler. You paste it three times. You tweak the SQL query in one place and forget the other two. The discount calculation drifts. Error handling becomes inconsistent. The codebase turns into a maintenance trap.

The service layer pattern exists to stop that duplication. It pulls business rules out of transport code and puts them into a single place that every part of your application can call. In Go, this separation is not enforced by a framework. It is built with interfaces, structs, and a few community conventions that keep the code readable and testable.

What a service layer actually does

A service layer owns the business logic. It decides how data is validated, transformed, and persisted. It does not know about HTTP requests, CLI flags, or gRPC streams. It only knows about the problem domain. Think of it like a kitchen in a restaurant. The waiter takes the order and talks to the customer. The kitchen decides how to cook the meal, checks the pantry, handles substitutions, and plates the food. The waiter never touches a knife. They just pass the order and get the plate back.

In Go, the waiter is your HTTP handler or CLI command. The kitchen is your service struct. The menu is the interface that defines what the kitchen can do. The handler depends on the interface, not the concrete struct. That dependency inversion is what makes the pattern work. You can swap the real database for an in-memory mock during tests without rewriting the handler. You can change the storage engine without touching the routing layer. The type system guarantees the contract is met at compile time.

The minimal contract

Here is the bare minimum to separate business logic from transport.

package service

import "context"

// User represents a domain entity with stable fields.
type User struct {
	ID   string `json:"id"`
	Name string `json:"name"`
}

// UserService defines the contract for user operations.
// Accept interfaces, return structs is the standard Go style.
type UserService interface {
	// GetUserByID fetches a user by identifier.
	GetUserByID(ctx context.Context, id string) (*User, error)
}

// userService holds dependencies needed to fulfill the contract.
type userService struct {
	// db provides database access.
	db *sql.DB
}

// GetUserByID queries the database and returns the user.
func (s *userService) GetUserByID(ctx context.Context, id string) (*User, error) {
	// ctx is always the first parameter by convention.
	// It carries deadlines, cancellation signals, and request-scoped values.
	var user User
	err := s.db.QueryRowContext(ctx, "SELECT id, name FROM users WHERE id = ?", id).Scan(&user.ID, &user.Name)
	if err != nil {
		// sql.ErrNoRows is the standard sentinel for missing records.
		if errors.Is(err, sql.ErrNoRows) {
			return nil, sql.ErrNoRows
		}
		// Wrap other errors to preserve the call stack.
		return nil, fmt.Errorf("query user %s: %w", id, err)
	}
	return &user, nil
}

The interface lists the methods the rest of the application needs. The struct holds the dependencies. The receiver name is s, matching the type name userService. Go developers use one or two letter receiver names by convention. You will rarely see this or self. The method signature puts context.Context first. That placement is not enforced by the compiler, but every standard library function and popular third-party package follows it. Breaking the convention makes your code harder to read and breaks tools that expect context in the first slot.

Keep the interface small. Add methods only when you actually need them. Go interfaces are satisfied implicitly. If your struct has the method, it implements the interface. No registration. No base classes. No boilerplate. The compiler checks the assignment at build time. If you rename a method or drop a parameter, the build fails immediately.

How the pieces connect at runtime

The handler does not import the service implementation. It imports the interface. That single line changes how the entire application is structured.

package handler

import (
	"encoding/json"
	"net/http"
)

// Handler holds the dependencies it needs to serve requests.
type Handler struct {
	// userService is the interface, not the concrete struct.
	userService service.UserService
}

// GetUser handles the HTTP request and delegates to the service.
func (h *Handler) GetUser(w http.ResponseWriter, r *http.Request) {
	// Extract the identifier from the query string.
	id := r.URL.Query().Get("id")
	// Pass the request context so deadlines and cancellation propagate.
	user, err := h.userService.GetUserByID(r.Context(), id)
	if err != nil {
		// Handle the expected missing-record case separately.
		if errors.Is(err, sql.ErrNoRows) {
			http.Error(w, "user not found", http.StatusNotFound)
			return
		}
		// Log the unexpected error and return a generic response.
		http.Error(w, "internal server error", http.StatusInternalServerError)
		return
	}
	// Marshal the domain entity to JSON.
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(user)
}

The handler extracts input, calls the service, handles errors, and writes the response. It does not touch SQL. It does not know about table schemas. It only knows that GetUserByID returns a *User or an error. When you run tests, you inject a mock that implements service.UserService. The mock returns predetermined values. The handler runs in milliseconds. No database. No network calls. No flaky integration tests.

Dependency injection happens at startup. You wire the concrete struct into the handler, then pass the handler to the router. The wiring code lives in one place, usually main.go or a dedicated wire package. Everything else depends on abstractions. That structure makes it trivial to swap implementations, add logging middleware, or inject rate limiters without rewriting business logic.

A realistic handler with proper error flow

Real applications need more than happy-path returns. They need deadlines, cancellation, and clear error messages. The service layer is the right place to enforce those rules.

package service

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
	"time"
)

// ValidateUser checks business rules before persisting or returning data.
func (s *userService) ValidateUser(ctx context.Context, u *User) error {
	// Check context early to avoid wasted work.
	if err := ctx.Err(); err != nil {
		return fmt.Errorf("validation cancelled: %w", err)
	}
	// Enforce domain constraints.
	if u.Name == "" {
		return errors.New("user name cannot be empty")
	}
	// Simulate a slow external check with a deadline.
	ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
	defer cancel()
	// Call external dependency with the derived context.
	return s.checkExternalAvailability(ctx, u.Name)
}

The service owns the validation rules. It checks the context before doing expensive work. It sets a timeout for external calls. It wraps errors with fmt.Errorf and the %w verb so callers can unwrap them later with errors.Is or errors.As. The handler never needs to know why validation failed. It just sees an error and returns a 400 or 500.

Go's error handling looks verbose. if err != nil { return err } repeats across the codebase. That repetition is intentional. It makes the unhappy path visible. You cannot accidentally swallow an error behind a silent catch block. Every failure is explicit. The compiler forces you to acknowledge it.

Where the pattern breaks

The service layer is not a cure-all. It adds indirection. Indirection costs readability when overused. You will see these mistakes repeatedly.

Forgetting to propagate context breaks cancellation and deadlines. If the service spawns a goroutine or calls an external API, it must pass ctx down the chain. The compiler will not stop you. You will get context deadline exceeded panics or hanging requests in production. Always run context through long-lived call sites.

Returning raw database errors leaks implementation details. pq: duplicate key value violates unique constraint tells the caller about PostgreSQL. Wrap it. fmt.Errorf("create user: %w", err) preserves the stack trace while hiding the driver. Callers can still check for specific sentinels with errors.Is.

Leaking goroutines happens when the service starts background work without a cancellation path. If a handler returns but the service keeps a goroutine alive waiting on a channel, memory grows until the process crashes. Always derive a child context with a timeout or cancellation function. Pass it to the goroutine. Close the channel when the work finishes. The worst goroutine bug is the one that never logs.

The compiler will catch structural mistakes early. If you assign a concrete struct to an interface field without implementing all methods, you get cannot use userService (type *userService) as type UserService in field value. If you forget to import a package, you get undefined: pkg. If you import it but never use it, you get imported and not used. These errors are plain text. Read them. Fix the signature. Move on.

Do not build a service layer for every tiny operation. A function that returns a constant or formats a string does not need an interface. Keep the abstraction at the boundary where business rules actually live.

When to reach for a service layer

Use a service layer when the same business rule runs across multiple transports like HTTP, CLI, and background workers. Use direct database calls in a handler when the endpoint is a thin pass-through with zero business logic. Use a repository pattern alongside services when you need to abstract storage details like caching, multiple data sources, or complex query composition. Skip the pattern entirely for tiny scripts or single-file tools where the overhead outweighs the benefit.

Keep interfaces small. Put them in the package that uses them, not the package that implements them. That placement prevents circular dependencies and keeps the contract close to the consumer. Trust the type system. Let the compiler enforce the boundary.

Where to go next