DEV Community

Naphat1243
Naphat1243

Posted on

Production Go services: typed errors, consumer-side interfaces, and tests that need no database

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, ...)
}
Enter fullscreen mode Exit fullscreen mode

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} }
// ...
Enter fullscreen mode Exit fullscreen mode

Services return domain errors:

if taken {
    return User{}, apperror.Conflict("email already registered")
}
Enter fullscreen mode Exit fullscreen mode

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...
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode
// internal/repository/user.go ← the implementation, in its own package
var _ service.UserRepository = (*UserRepository)(nil) // compile-time check
Enter fullscreen mode Exit fullscreen mode

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. repository imports service, 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
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

Honorable mentions (small things that bite at 2 a.m.)

  • /healthz vs /readyz are 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.Shutdown and 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)