Every new Go service I start seems to begin with the same two weeks: wiring error handling, deciding where interfaces live, setting up tests that don't require Docker to be running, graceful shutdown, migrations... None of it is hard. All of it is decisions, and decisions cost more than code.
This post walks through the three decisions that pay off the most, with working code. Everything here comes from go-service-kit, an MIT-licensed starter I open-sourced — but the patterns stand on their own even if you never clone it.
1. Typed errors, mapped to HTTP in exactly one place
The most common rot I see in Go HTTP services: status codes scattered across every handler.
// the rot — every handler re-decides policy
if errors.Is(err, sql.ErrNoRows) {
return c.JSON(404, map[string]string{"error": "not found"})
}
if strings.Contains(err.Error(), "duplicate") { // 😱
return c.JSON(409, ...)
}
Handlers shouldn't know status codes. Services shouldn't know HTTP exists. The fix is a tiny apperror package with a closed set of kinds:
type Kind string
const (
KindInvalid Kind = "invalid_argument"
KindNotFound Kind = "not_found"
KindConflict Kind = "conflict"
KindInternal Kind = "internal"
)
type Error struct {
Kind Kind
Message string
err error // wrapped cause, never shown to clients
}
func Invalid(msg string) *Error { return &Error{Kind: KindInvalid, Message: msg} }
func NotFound(msg string) *Error { return &Error{Kind: KindNotFound, Message: msg} }
// ...
Services return domain errors:
if taken {
return User{}, apperror.Conflict("email already registered")
}
And one error handler — registered once on the router — owns the entire mapping to HTTP:
func HTTPErrorHandler(log *slog.Logger) echo.HTTPErrorHandler {
return func(err error, c echo.Context) {
var appErr *Error
if errors.As(err, &appErr) {
if appErr.Kind == KindInternal {
log.Error("internal error", "path", c.Path(), "err", err)
// clients get a generic message; the cause stays in logs
_ = c.JSON(500, response{Error: "internal", Message: "internal server error"})
return
}
_ = c.JSON(statusOf(appErr.Kind), response{Error: string(appErr.Kind), Message: appErr.Message})
return
}
// fallbacks for framework errors...
}
}
Handlers collapse to almost nothing — parse, call, return the error as-is:
func (h *UserHandler) create(c echo.Context) error {
var in service.CreateUserInput
if err := c.Bind(&in); err != nil {
return apperror.Invalid("malformed JSON body")
}
u, err := h.users.Create(c.Request().Context(), in)
if err != nil {
return err // the mapper decides what the client sees
}
return c.JSON(http.StatusCreated, u)
}
Three wins: response shapes are consistent across the whole API, internal details can never leak into a response, and changing error policy is a one-file change.
2. The repository interface belongs to the consumer
Where should UserRepository (the interface) live? Most codebases put it next to the Postgres implementation. I think that's backwards, and the Go proverb agrees: accept interfaces, return structs — and define interfaces where they're consumed.
// internal/service/user.go ← the interface lives with its CONSUMER
type UserRepository interface {
Create(ctx context.Context, u User) error
GetByID(ctx context.Context, id uuid.UUID) (User, error)
ExistsByEmail(ctx context.Context, email string) (bool, error)
// ...only what the service actually needs
}
// internal/repository/user.go ← the implementation, in its own package
var _ service.UserRepository = (*UserRepository)(nil) // compile-time check
Why this direction matters:
- The interface stays minimal. It can only contain what the service actually calls — no speculative methods, because the consumer defines the contract.
-
The dependency arrow points inward.
repositoryimportsservice, never the reverse. Your business logic has zero knowledge of pgx, SQL, or Postgres. - Tests become trivial, which brings us to...
3. Tests that pass with no database running
I stopped using mock frameworks in Go years ago. A hand-written fake is ~40 lines, reads like documentation of the contract, and never fights you with expectation DSLs:
type fakeUserRepo struct {
users map[uuid.UUID]User
failWith error // set to simulate infrastructure failure
}
func (f *fakeUserRepo) Create(_ context.Context, u User) error {
if f.failWith != nil {
return f.failWith
}
f.users[u.ID] = u
return nil
}
// ...the rest is equally boring, on purpose
Now the service tests read as behavior specs, and go test ./... passes on a laptop with nothing installed:
func TestCreate_RejectsDuplicateEmail(t *testing.T) {
svc := newTestService(newFakeUserRepo())
svc.Create(ctx, CreateUserInput{Email: "jo@example.com", Name: "Jo"})
_, err := svc.Create(ctx, CreateUserInput{Email: "JO@example.com", Name: "Other"})
if kindOf(t, err) != apperror.KindConflict {
t.Errorf("expected conflict, got %v", err)
}
}
Note the test also catches normalization (JO@ vs jo@) — the kind of behavior that silently regresses when tests require so much setup that nobody writes them.
One more detail that makes services deterministic to test: inject the clock.
type UserService struct {
repo UserRepository
now func() time.Time // tests pin this; prod uses time.Now
}
Honorable mentions (small things that bite at 2 a.m.)
-
/healthzvs/readyzare different questions. Liveness must be dependency-free — if your liveness probe pings the DB, a database blip makes the orchestrator restart perfectly healthy pods. Readiness pings the DB; liveness never does. -
Graceful shutdown is ~15 lines with
signal.NotifyContext+echo.Shutdownand a timeout — in-flight requests finish, deploys stop dropping traffic. - Run containers as non-root. A multi-stage build gets you a ~15MB image with a dedicated UID; most boilerplates still ship root.
Everything above, runnable
All of this lives in go-service-kit (MIT): a complete slice — handler → service → repository — with the tests, migrations, Docker setup, rate limiting, request logging and CI already wired. make dev boots Postgres, migrates, and serves a working CRUD in about five minutes.
I'm also building a Pro tier (JWT auth + RBAC, Stripe billing, observability, Helm, and a tuned setup for AI coding agents) — if that interests you, a ⭐ on the repo is the best way to follow the release.
What's your take on consumer-side interfaces — convention in your codebase, or still controversial on your team?
Top comments (0)