towerops/docs/references/golang-best-practices.md
Graham McIntie 1590f78bdc fix: input validation, SSRF, API hardening, and cookie security
- LIKE wildcard injection: sanitize %, _ in search queries (devices, sites, gaiia)
- Jason.decode! → Jason.decode with error handling for untrusted input
- inspect() leak: replace with generic error messages, log details server-side
- SSRF protection: URL validator blocks private IPs, localhost, non-HTTP schemes
- SSRF validation added to HTTP monitoring executor and integration credentials
- GraphQL complexity limits: always applied, not just in prod
- GraphQL introspection: also check GET query params, not just body
- Stripe webhook: explicit nil/empty checks for signature and body
- Cookie security: secure flag for session (prod), http_only+secure for remember_me
- Honeybadger API key: read from env var with fallback
- String.to_integer → Integer.parse with fallback for URL params
- String.to_atom → whitelist map for HTTP methods
- Gaiia webhook: remove secret_len and expected signature from log
- Admin API: add rate limiting pipeline
- to_atom_keys: per-key fallback instead of all-or-nothing rescue
2026-03-14 14:48:59 -05:00

43 KiB
Raw Blame History

Go (Golang) Best Practices Reference

Comprehensive reference for idiomatic, production-grade Go. Concrete examples throughout.


Table of Contents

  1. Idiomatic Go
  2. Concurrency
  3. Project Structure
  4. Networking
  5. Testing
  6. Performance
  7. Observability
  8. Build & Deploy

1. Idiomatic Go

Error Handling

Wrapping errors with %w

Always wrap errors with context using fmt.Errorf and %w so callers can unwrap:

func readConfig(path string) (*Config, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, fmt.Errorf("readConfig %s: %w", path, err)
    }
    var cfg Config
    if err := json.Unmarshal(data, &cfg); err != nil {
        return nil, fmt.Errorf("readConfig unmarshal: %w", err)
    }
    return &cfg, nil
}

// Caller can inspect:
if errors.Is(err, os.ErrNotExist) {
    // handle missing file
}

Sentinel errors

Define package-level sentinel errors for conditions callers need to check:

package repo

import "errors"

var (
    ErrNotFound    = errors.New("not found")
    ErrConflict    = errors.New("conflict")
    ErrUnauthorized = errors.New("unauthorized")
)

func (r *Repo) GetDevice(id string) (*Device, error) {
    d, ok := r.devices[id]
    if !ok {
        return nil, fmt.Errorf("device %s: %w", id, ErrNotFound)
    }
    return d, nil
}

Custom error types

Use when you need structured error data beyond a message:

type ValidationError struct {
    Field   string
    Message string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("validation: %s — %s", e.Field, e.Message)
}

// Check with errors.As:
var ve *ValidationError
if errors.As(err, &ve) {
    log.Printf("bad field: %s", ve.Field)
}

Don't use errors.Is / errors.As for every error

Only sentinel/typed errors need programmatic inspection. Most errors just bubble up with wrapping — that's fine. Don't over-engineer.

Interface Design

Small interfaces

The best Go interfaces have 13 methods. The stdlib sets the standard:

type Reader interface {
    Read(p []byte) (n int, err error)
}

type Writer interface {
    Write(p []byte) (n int, err error)
}

Define your own small interfaces close to where they're consumed:

// In the handler package, not the repo package
type DeviceFinder interface {
    FindDevice(ctx context.Context, id string) (*model.Device, error)
}

type handler struct {
    finder DeviceFinder
}

Accept interfaces, return structs

// Good — accepts interface, returns concrete type
func NewService(repo DeviceRepo, logger *slog.Logger) *Service {
    return &Service{repo: repo, logger: logger}
}

// Bad — returning an interface hides the concrete type unnecessarily
func NewService(repo DeviceRepo) ServiceInterface { ... }

This lets callers benefit from the full concrete type while keeping your function testable via the interface parameter.

Don't define interfaces prematurely

"The bigger the interface, the weaker the abstraction." — Rob Pike

Define interfaces when you have ≥2 implementations OR need to mock in tests. Not before.

Embedding

Use struct embedding for composition, not inheritance:

type BaseModel struct {
    ID        string    `json:"id"`
    CreatedAt time.Time `json:"created_at"`
    UpdatedAt time.Time `json:"updated_at"`
}

type Device struct {
    BaseModel
    Name     string `json:"name"`
    IP       string `json:"ip"`
    SNMPPort int    `json:"snmp_port"`
}

// Device now has .ID, .CreatedAt, etc. promoted
d := Device{Name: "switch-01"}
d.ID = "dev_123"

Embed interfaces to signal partial implementation or to compose behaviors:

type ReadCloser struct {
    io.Reader
    io.Closer
}

Watch out: Embedding promotes all methods, including ones you don't want. If the embedded type has a String() method, your type now satisfies fmt.Stringer — maybe unintentionally.

Functional Options Pattern

For constructors with many optional parameters:

type Server struct {
    addr         string
    readTimeout  time.Duration
    writeTimeout time.Duration
    maxConns     int
    logger       *slog.Logger
}

type Option func(*Server)

func WithReadTimeout(d time.Duration) Option {
    return func(s *Server) { s.readTimeout = d }
}

func WithWriteTimeout(d time.Duration) Option {
    return func(s *Server) { s.writeTimeout = d }
}

func WithMaxConns(n int) Option {
    return func(s *Server) { s.maxConns = n }
}

func WithLogger(l *slog.Logger) Option {
    return func(s *Server) { s.logger = l }
}

func NewServer(addr string, opts ...Option) *Server {
    s := &Server{
        addr:         addr,
        readTimeout:  5 * time.Second,   // sensible defaults
        writeTimeout: 10 * time.Second,
        maxConns:     100,
        logger:       slog.Default(),
    }
    for _, opt := range opts {
        opt(s)
    }
    return s
}

// Usage:
srv := NewServer(":8080",
    WithReadTimeout(10*time.Second),
    WithLogger(myLogger),
)

This is clean, extensible, and self-documenting. Prefer it over config structs when options are genuinely optional.


2. Concurrency

Goroutines + Channels

Basic patterns

// Fire-and-forget (careful — no error handling)
go processEvent(event)

// Channel for result
ch := make(chan Result, 1)
go func() {
    ch <- expensiveComputation()
}()
result := <-ch

Always know how a goroutine ends

Every goroutine you start should have a clear termination path. Leaked goroutines are memory leaks.

// Bad — goroutine leaks if ctx is never cancelled and ch is never read
go func() {
    ch <- doWork(ctx)
}()

// Good — select with context
go func() {
    select {
    case ch <- doWork(ctx):
    case <-ctx.Done():
    }
}()

sync.WaitGroup

For waiting on a known set of goroutines:

func processAll(ctx context.Context, items []Item) error {
    var wg sync.WaitGroup
    errs := make(chan error, len(items))

    for _, item := range items {
        wg.Add(1)
        go func() {
            defer wg.Done()
            if err := process(ctx, item); err != nil {
                errs <- err
            }
        }()
    }

    wg.Wait()
    close(errs)

    for err := range errs {
        return err // return first error
    }
    return nil
}

Mutex vs Channels

Rule of thumb:

  • Mutex: Protecting shared state (maps, counters, caches)
  • Channels: Communicating between goroutines, coordinating work
// Mutex — protecting a shared map
type SafeMap struct {
    mu sync.RWMutex
    m  map[string]int
}

func (s *SafeMap) Get(key string) (int, bool) {
    s.mu.RLock()
    defer s.mu.RUnlock()
    v, ok := s.m[key]
    return v, ok
}

func (s *SafeMap) Set(key string, val int) {
    s.mu.Lock()
    defer s.mu.Unlock()
    s.m[key] = val
}

// Channel — pipeline stage
func square(in <-chan int) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for n := range in {
            out <- n * n
        }
    }()
    return out
}

Use sync.RWMutex when reads vastly outnumber writes.

context.Context

Cancellation

func main() {
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()

    go worker(ctx)

    // Cancel after signal
    sigCh := make(chan os.Signal, 1)
    signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
    <-sigCh
    cancel() // all workers using ctx will see Done()
}

func worker(ctx context.Context) {
    for {
        select {
        case <-ctx.Done():
            log.Println("worker shutting down:", ctx.Err())
            return
        default:
            doWork()
        }
    }
}

Timeouts

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() // always defer cancel to release resources

result, err := fetchFromAPI(ctx, url)
if errors.Is(err, context.DeadlineExceeded) {
    log.Println("API call timed out")
}

Context values — use sparingly

Only for request-scoped data that crosses API boundaries (trace IDs, auth tokens). Never for optional parameters.

type ctxKey string

const requestIDKey ctxKey = "request_id"

func WithRequestID(ctx context.Context, id string) context.Context {
    return context.WithValue(ctx, requestIDKey, id)
}

func RequestID(ctx context.Context) string {
    id, _ := ctx.Value(requestIDKey).(string)
    return id
}

errgroup

golang.org/x/sync/errgroup — the standard tool for concurrent work with error propagation:

import "golang.org/x/sync/errgroup"

func fetchAll(ctx context.Context, urls []string) ([]Response, error) {
    g, ctx := errgroup.WithContext(ctx)
    responses := make([]Response, len(urls))

    for i, url := range urls {
        g.Go(func() error {
            resp, err := fetch(ctx, url)
            if err != nil {
                return fmt.Errorf("fetch %s: %w", url, err)
            }
            responses[i] = resp // safe — each goroutine writes to unique index
            return nil
        })
    }

    if err := g.Wait(); err != nil {
        return nil, err
    }
    return responses, nil
}

With concurrency limit:

g, ctx := errgroup.WithContext(ctx)
g.SetLimit(10) // max 10 concurrent goroutines

Worker Pool

func workerPool(ctx context.Context, jobs <-chan Job, numWorkers int) <-chan Result {
    results := make(chan Result, numWorkers)
    var wg sync.WaitGroup

    for range numWorkers {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for job := range jobs {
                select {
                case <-ctx.Done():
                    return
                case results <- process(job):
                }
            }
        }()
    }

    go func() {
        wg.Wait()
        close(results)
    }()

    return results
}

Fan-Out / Fan-In

// Fan-out: distribute work to multiple goroutines
func fanOut(ctx context.Context, input <-chan Task, workers int) []<-chan Result {
    channels := make([]<-chan Result, workers)
    for i := range workers {
        channels[i] = worker(ctx, input)
    }
    return channels
}

// Fan-in: merge multiple channels into one
func fanIn(ctx context.Context, channels ...<-chan Result) <-chan Result {
    merged := make(chan Result)
    var wg sync.WaitGroup

    for _, ch := range channels {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for result := range ch {
                select {
                case merged <- result:
                case <-ctx.Done():
                    return
                }
            }
        }()
    }

    go func() {
        wg.Wait()
        close(merged)
    }()

    return merged
}

3. Project Structure

Standard Layout

myproject/
├── cmd/
│   ├── server/          # main application entry point
│   │   └── main.go
│   └── cli/             # CLI tool
│       └── main.go
├── internal/            # private packages — can't be imported by other modules
│   ├── server/          # HTTP/gRPC server setup
│   ├── handler/         # request handlers
│   ├── service/         # business logic
│   ├── repo/            # data access
│   ├── model/           # domain types
│   └── config/          # configuration
├── proto/               # protobuf definitions
│   └── v1/
├── migrations/          # database migrations
├── docs/
├── scripts/
├── Makefile
├── Dockerfile
├── go.mod
└── go.sum

The /pkg debate

Skip /pkg for most projects. It was popularized by early Go projects but adds no real value — internal/ enforces visibility; pkg/ is just convention. If you need to export packages for other modules, put them at the root or in a clearly named directory.

internal/ is your friend

Anything in internal/ can't be imported by other Go modules. Use it aggressively — you can always promote later.

Dependency Injection with Wire

Wire generates DI code at compile time — no runtime reflection.

// internal/server/wire.go
//go:build wireinject

package server

import "github.com/google/wire"

func InitializeApp(cfg *config.Config) (*App, error) {
    wire.Build(
        NewApp,
        handler.NewDeviceHandler,
        service.NewDeviceService,
        repo.NewPostgresRepo,
        db.NewPool,
    )
    return nil, nil
}

Run wire ./internal/server/ to generate the actual initialization code.

Alternative: For simpler projects, manual DI in main() is fine. Don't use Wire until you have enough components to justify it.

Config Management

envconfig — simple, env-var-based

import "github.com/kelseyhightower/envconfig"

type Config struct {
    Port        int           `envconfig:"PORT" default:"8080"`
    DatabaseURL string        `envconfig:"DATABASE_URL" required:"true"`
    LogLevel    string        `envconfig:"LOG_LEVEL" default:"info"`
    Timeout     time.Duration `envconfig:"TIMEOUT" default:"30s"`
}

func LoadConfig() (*Config, error) {
    var cfg Config
    if err := envconfig.Process("", &cfg); err != nil {
        return nil, fmt.Errorf("load config: %w", err)
    }
    return &cfg, nil
}

Alternatives

Library Best for
envconfig Simple env-var config, 12-factor apps
koanf Multi-source config (env, files, flags), lighter than Viper
viper Kitchen-sink config (heavy, pulls in many deps)
ff Flags-first config with env var fallback

Recommendation: Start with envconfig. Graduate to koanf if you need config files or multiple sources. Avoid Viper unless you're already using it.

Makefile Patterns

.PHONY: build test lint run clean proto generate

# Variables
BINARY    := myapp
GO        := go
GOFLAGS   := -trimpath
LDFLAGS   := -s -w -X main.version=$(shell git describe --tags --always --dirty)
CGO_ENABLED := 0

## Build
build:
	$(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o bin/$(BINARY) ./cmd/server

## Run locally
run:
	$(GO) run ./cmd/server

## Test
test:
	$(GO) test -race -count=1 ./...

test-integration:
	$(GO) test -race -count=1 -tags=integration -timeout=5m ./...

test-coverage:
	$(GO) test -race -coverprofile=coverage.out ./...
	$(GO) tool cover -html=coverage.out -o coverage.html

## Lint
lint:
	golangci-lint run ./...

## Proto generation
proto:
	buf generate

## Clean
clean:
	rm -rf bin/ coverage.out coverage.html

## Generate (wire, mocks, etc.)
generate:
	$(GO) generate ./...

## All — CI pipeline
all: lint test build

4. Networking

net/http Server Best Practices

Always set timeouts

srv := &http.Server{
    Addr:         ":8080",
    Handler:      router,
    ReadTimeout:  5 * time.Second,
    WriteTimeout: 10 * time.Second,
    IdleTimeout:  120 * time.Second,
    // For request body size limits:
    MaxHeaderBytes: 1 << 20, // 1 MB
}

Never use http.ListenAndServe(":8080", nil) in production — no timeouts = DDoS target.

Graceful shutdown

func run(ctx context.Context) error {
    srv := &http.Server{
        Addr:         ":8080",
        Handler:      newRouter(),
        ReadTimeout:  5 * time.Second,
        WriteTimeout: 10 * time.Second,
    }

    // Start server in goroutine
    errCh := make(chan error, 1)
    go func() {
        if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            errCh <- err
        }
    }()

    // Wait for interrupt
    select {
    case err := <-errCh:
        return fmt.Errorf("server error: %w", err)
    case <-ctx.Done():
    }

    // Graceful shutdown with timeout
    shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()

    if err := srv.Shutdown(shutdownCtx); err != nil {
        return fmt.Errorf("shutdown: %w", err)
    }
    return nil
}

Middleware chains

// Middleware type
type Middleware func(http.Handler) http.Handler

// Chain composes middleware
func Chain(h http.Handler, mw ...Middleware) http.Handler {
    for i := len(mw) - 1; i >= 0; i-- {
        h = mw[i](h)
    }
    return h
}

// Logging middleware
func LoggingMiddleware(logger *slog.Logger) Middleware {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            start := time.Now()
            wrapped := &responseWriter{ResponseWriter: w, statusCode: 200}
            next.ServeHTTP(wrapped, r)
            logger.Info("request",
                "method", r.Method,
                "path", r.URL.Path,
                "status", wrapped.statusCode,
                "duration", time.Since(start),
            )
        })
    }
}

// Recovery middleware
func RecoveryMiddleware(logger *slog.Logger) Middleware {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            defer func() {
                if err := recover(); err != nil {
                    logger.Error("panic recovered",
                        "error", err,
                        "stack", string(debug.Stack()),
                    )
                    http.Error(w, "internal server error", http.StatusInternalServerError)
                }
            }()
            next.ServeHTTP(w, r)
        })
    }
}

// Usage
handler := Chain(router,
    RecoveryMiddleware(logger),
    LoggingMiddleware(logger),
    CORSMiddleware(),
)

responseWriter wrapper for capturing status

type responseWriter struct {
    http.ResponseWriter
    statusCode int
}

func (rw *responseWriter) WriteHeader(code int) {
    rw.statusCode = code
    rw.ResponseWriter.WriteHeader(code)
}

gRPC + Protobuf

Proto file conventions

syntax = "proto3";

package towerops.device.v1;

option go_package = "github.com/towerops/towerops/gen/device/v1;devicev1";

service DeviceService {
  rpc GetDevice(GetDeviceRequest) returns (GetDeviceResponse);
  rpc ListDevices(ListDevicesRequest) returns (ListDevicesResponse);
  rpc WatchDevices(WatchDevicesRequest) returns (stream DeviceEvent);
}

message GetDeviceRequest {
  string id = 1;
}

message GetDeviceResponse {
  Device device = 1;
}

Code generation with buf

# buf.gen.yaml
version: v2
plugins:
  - remote: buf.build/protocolbuffers/go
    out: gen
    opt: paths=source_relative
  - remote: buf.build/grpc/go
    out: gen
    opt: paths=source_relative

gRPC interceptors (middleware equivalent)

import (
    "google.golang.org/grpc"
    "google.golang.org/grpc/codes"
    "google.golang.org/grpc/status"
)

// Unary interceptor for logging
func loggingInterceptor(logger *slog.Logger) grpc.UnaryServerInterceptor {
    return func(
        ctx context.Context,
        req any,
        info *grpc.UnaryServerInfo,
        handler grpc.UnaryHandler,
    ) (any, error) {
        start := time.Now()
        resp, err := handler(ctx, req)
        logger.Info("grpc",
            "method", info.FullMethod,
            "duration", time.Since(start),
            "error", err,
        )
        return resp, err
    }
}

// Recovery interceptor
func recoveryInterceptor(logger *slog.Logger) grpc.UnaryServerInterceptor {
    return func(
        ctx context.Context,
        req any,
        info *grpc.UnaryServerInfo,
        handler grpc.UnaryHandler,
    ) (resp any, err error) {
        defer func() {
            if r := recover(); r != nil {
                logger.Error("grpc panic", "error", r, "stack", string(debug.Stack()))
                err = status.Errorf(codes.Internal, "internal error")
            }
        }()
        return handler(ctx, req)
    }
}

// Server setup
srv := grpc.NewServer(
    grpc.ChainUnaryInterceptor(
        recoveryInterceptor(logger),
        loggingInterceptor(logger),
    ),
    grpc.ChainStreamInterceptor(
        // stream interceptors...
    ),
)

Server-side streaming

func (s *server) WatchDevices(
    req *pb.WatchDevicesRequest,
    stream pb.DeviceService_WatchDevicesServer,
) error {
    ctx := stream.Context()
    ch := s.deviceEvents.Subscribe()
    defer s.deviceEvents.Unsubscribe(ch)

    for {
        select {
        case <-ctx.Done():
            return ctx.Err()
        case event := <-ch:
            if err := stream.Send(event); err != nil {
                return fmt.Errorf("send event: %w", err)
            }
        }
    }
}

HTTP Client Patterns

Reuse clients — never create per-request

// Package-level or injected client with connection pooling
var httpClient = &http.Client{
    Timeout: 30 * time.Second,
    Transport: &http.Transport{
        MaxIdleConns:        100,
        MaxIdleConnsPerHost: 10,
        IdleConnTimeout:     90 * time.Second,
        TLSHandshakeTimeout: 10 * time.Second,
    },
}

Retry with exponential backoff

func doWithRetry(ctx context.Context, req *http.Request, maxRetries int) (*http.Response, error) {
    var resp *http.Response
    var err error
    backoff := 100 * time.Millisecond

    for attempt := range maxRetries {
        resp, err = httpClient.Do(req.Clone(ctx))
        if err != nil {
            // Network error — retry
        } else if resp.StatusCode < 500 && resp.StatusCode != 429 {
            return resp, nil
        } else {
            resp.Body.Close() // must close before retry
        }

        if attempt == maxRetries-1 {
            break
        }

        select {
        case <-ctx.Done():
            return nil, ctx.Err()
        case <-time.After(backoff):
        }
        backoff *= 2 // exponential
    }

    if err != nil {
        return nil, fmt.Errorf("after %d retries: %w", maxRetries, err)
    }
    return resp, nil
}

Always close response bodies

resp, err := httpClient.Do(req)
if err != nil {
    return err
}
defer resp.Body.Close()

// Drain body even if you don't need it — allows connection reuse
io.Copy(io.Discard, resp.Body)

5. Testing

Table-Driven Tests

The Go standard for test organization:

func TestParsePort(t *testing.T) {
    tests := []struct {
        name    string
        input   string
        want    int
        wantErr bool
    }{
        {name: "valid port", input: "8080", want: 8080},
        {name: "zero", input: "0", want: 0},
        {name: "max", input: "65535", want: 65535},
        {name: "negative", input: "-1", wantErr: true},
        {name: "too high", input: "65536", wantErr: true},
        {name: "not a number", input: "abc", wantErr: true},
        {name: "empty", input: "", wantErr: true},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            got, err := ParsePort(tt.input)
            if tt.wantErr {
                if err == nil {
                    t.Fatal("expected error, got nil")
                }
                return
            }
            if err != nil {
                t.Fatalf("unexpected error: %v", err)
            }
            if got != tt.want {
                t.Errorf("got %d, want %d", got, tt.want)
            }
        })
    }
}

testify vs stdlib

stdlib is fine for most cases. t.Errorf / t.Fatalf with clear messages work great.

testify helps when assertions get complex:

import (
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"
)

func TestDeviceService(t *testing.T) {
    svc := NewDeviceService(mockRepo)

    device, err := svc.Get(ctx, "dev_123")
    require.NoError(t, err)             // stops test on failure
    assert.Equal(t, "switch-01", device.Name)
    assert.NotEmpty(t, device.ID)
    assert.WithinDuration(t, time.Now(), device.CreatedAt, time.Minute)
}

require = fatal on failure (test stops). assert = log and continue. Use require for setup/preconditions, assert for actual checks.

httptest

Test HTTP handlers without a real server:

func TestGetDevice(t *testing.T) {
    // Setup
    repo := &mockRepo{
        devices: map[string]*Device{
            "dev_1": {ID: "dev_1", Name: "switch-01"},
        },
    }
    handler := NewDeviceHandler(repo)

    // Create request
    req := httptest.NewRequest(http.MethodGet, "/devices/dev_1", nil)
    rec := httptest.NewRecorder()

    // Serve
    handler.ServeHTTP(rec, req)

    // Assert
    assert.Equal(t, http.StatusOK, rec.Code)

    var got Device
    err := json.NewDecoder(rec.Body).Decode(&got)
    require.NoError(t, err)
    assert.Equal(t, "switch-01", got.Name)
}

For full integration tests with routing:

func TestAPI(t *testing.T) {
    srv := httptest.NewServer(newRouter())
    defer srv.Close()

    resp, err := http.Get(srv.URL + "/health")
    require.NoError(t, err)
    defer resp.Body.Close()
    assert.Equal(t, 200, resp.StatusCode)
}

Mocking with Interfaces (not frameworks)

Define the interface where it's consumed, implement a mock manually:

// In handler package
type DeviceRepo interface {
    Get(ctx context.Context, id string) (*model.Device, error)
    List(ctx context.Context) ([]*model.Device, error)
}

// In handler test file
type mockRepo struct {
    devices map[string]*model.Device
    err     error
}

func (m *mockRepo) Get(ctx context.Context, id string) (*model.Device, error) {
    if m.err != nil {
        return nil, m.err
    }
    d, ok := m.devices[id]
    if !ok {
        return nil, repo.ErrNotFound
    }
    return d, nil
}

func (m *mockRepo) List(ctx context.Context) ([]*model.Device, error) {
    if m.err != nil {
        return nil, m.err
    }
    var result []*model.Device
    for _, d := range m.devices {
        result = append(result, d)
    }
    return result, nil
}

This is simpler, more readable, and more maintainable than mock frameworks like gomock. For large interfaces, consider moq or mockery for code generation — but keep mocks simple.

Integration Tests with testcontainers-go

//go:build integration

package repo_test

import (
    "context"
    "testing"

    "github.com/testcontainers/testcontainers-go"
    "github.com/testcontainers/testcontainers-go/modules/postgres"
    "github.com/testcontainers/testcontainers-go/wait"
)

func TestPostgresRepo(t *testing.T) {
    ctx := context.Background()

    pgContainer, err := postgres.Run(ctx,
        "postgres:16-alpine",
        postgres.WithDatabase("testdb"),
        postgres.WithUsername("test"),
        postgres.WithPassword("test"),
        testcontainers.WithWaitStrategy(
            wait.ForLog("database system is ready to accept connections").
                WithOccurrence(2),
        ),
    )
    require.NoError(t, err)
    t.Cleanup(func() { pgContainer.Terminate(ctx) })

    connStr, err := pgContainer.ConnectionString(ctx, "sslmode=disable")
    require.NoError(t, err)

    // Run migrations
    runMigrations(t, connStr)

    // Create repo with real DB
    r, err := repo.NewPostgresRepo(ctx, connStr)
    require.NoError(t, err)

    // Test actual queries
    t.Run("create and get", func(t *testing.T) {
        device := &model.Device{Name: "test-switch"}
        err := r.Create(ctx, device)
        require.NoError(t, err)
        assert.NotEmpty(t, device.ID)

        got, err := r.Get(ctx, device.ID)
        require.NoError(t, err)
        assert.Equal(t, "test-switch", got.Name)
    })
}

go test Flags

# Race detector — always use in CI
go test -race ./...

# Disable test caching (important for flaky test detection)
go test -count=1 ./...

# Run tests in parallel (default is GOMAXPROCS)
go test -parallel=4 ./...

# Only run matching tests
go test -run TestGetDevice ./internal/handler/

# Verbose output
go test -v ./...

# Short mode (skip slow tests)
go test -short ./...

# Timeout
go test -timeout=5m ./...

# Integration tests only (with build tag)
go test -tags=integration ./...

# Combined for CI:
go test -race -count=1 -timeout=10m ./...

In test code, respect -short:

func TestSlowIntegration(t *testing.T) {
    if testing.Short() {
        t.Skip("skipping in short mode")
    }
    // slow test...
}

6. Performance

pprof Profiling

Enable in HTTP server

import _ "net/http/pprof"

// If using default mux, pprof is auto-registered.
// For custom mux, register explicitly:
mux.HandleFunc("/debug/pprof/", pprof.Index)
mux.HandleFunc("/debug/pprof/profile", pprof.Profile)
mux.HandleFunc("/debug/pprof/heap", pprof.Handler("heap").ServeHTTP)
mux.HandleFunc("/debug/pprof/goroutine", pprof.Handler("goroutine").ServeHTTP)

Capture and analyze

# CPU profile (30 seconds)
go tool pprof http://localhost:8080/debug/pprof/profile?seconds=30

# Heap profile
go tool pprof http://localhost:8080/debug/pprof/heap

# Goroutine dump
curl http://localhost:8080/debug/pprof/goroutine?debug=2

# Interactive commands in pprof:
# top20       — top 20 functions by CPU/memory
# list funcName — show annotated source
# web         — open flamegraph in browser

Benchmarking

func BenchmarkProcessEvent(b *testing.B) {
    event := createTestEvent()
    b.ResetTimer()

    for b.Loop() {
        processEvent(event)
    }
}

// With sub-benchmarks for different sizes
func BenchmarkParse(b *testing.B) {
    sizes := []int{10, 100, 1000, 10000}
    for _, size := range sizes {
        b.Run(fmt.Sprintf("size=%d", size), func(b *testing.B) {
            data := generateData(size)
            b.ResetTimer()
            for b.Loop() {
                parse(data)
            }
        })
    }
}

// Report allocations
func BenchmarkBuildString(b *testing.B) {
    b.ReportAllocs()
    for b.Loop() {
        buildString(1000)
    }
}
# Run benchmarks
go test -bench=. -benchmem ./...

# Compare benchmarks (install benchstat)
go test -bench=. -count=10 > old.txt
# ... make changes ...
go test -bench=. -count=10 > new.txt
benchstat old.txt new.txt

Memory Allocation Patterns

sync.Pool — reuse temporary objects

var bufPool = sync.Pool{
    New: func() any {
        return new(bytes.Buffer)
    },
}

func processRequest(data []byte) string {
    buf := bufPool.Get().(*bytes.Buffer)
    defer func() {
        buf.Reset()
        bufPool.Put(buf)
    }()

    buf.Write(data)
    // ... process ...
    return buf.String()
}

Pre-allocation

// Bad — grows the slice repeatedly
var result []string
for _, item := range items {
    result = append(result, item.Name)
}

// Good — allocate once
result := make([]string, 0, len(items))
for _, item := range items {
    result = append(result, item.Name)
}

// Same for maps
m := make(map[string]int, len(items))

Escape Analysis

Check what escapes to the heap:

go build -gcflags='-m -m' ./... 2>&1 | grep 'escapes to heap'

Tips to reduce heap allocations:

  • Return values instead of pointers when the struct is small
  • Use value receivers for small types
  • Avoid closures that capture variables (they often escape)
  • Pre-size slices and maps
// This might stay on stack (small struct, returned by value)
func newPoint(x, y int) Point {
    return Point{X: x, Y: y}
}

// This always escapes to heap (pointer returned)
func newPoint(x, y int) *Point {
    return &Point{X: x, Y: y}
}

String Building

// Bad — O(n²) concatenation
s := ""
for _, item := range items {
    s += item.Name + ","
}

// Good — strings.Builder
var b strings.Builder
b.Grow(len(items) * 20) // estimate capacity
for i, item := range items {
    if i > 0 {
        b.WriteByte(',')
    }
    b.WriteString(item.Name)
}
result := b.String()

// For simple joins
result := strings.Join(names, ",")

7. Observability

Structured Logging with slog (Go 1.21+)

import "log/slog"

// Setup
func setupLogger(level string) *slog.Logger {
    var lvl slog.Level
    switch level {
    case "debug":
        lvl = slog.LevelDebug
    case "warn":
        lvl = slog.LevelWarn
    case "error":
        lvl = slog.LevelError
    default:
        lvl = slog.LevelInfo
    }

    return slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
        Level:     lvl,
        AddSource: true, // adds file:line to log entries
    }))
}

// Usage
logger := setupLogger("info")

logger.Info("server started", "addr", ":8080", "version", version)

logger.Error("query failed",
    "error", err,
    "query", query,
    "duration", time.Since(start),
)

// With context (carries request ID, trace ID, etc.)
logger.InfoContext(ctx, "device updated",
    "device_id", device.ID,
    "changes", changes,
)

// Child logger with common fields
reqLogger := logger.With(
    "request_id", requestID,
    "method", r.Method,
    "path", r.URL.Path,
)

slog groups for structured fields

logger.Info("request",
    slog.Group("http",
        slog.String("method", r.Method),
        slog.String("path", r.URL.Path),
        slog.Int("status", status),
    ),
    slog.Group("timing",
        slog.Duration("total", totalDuration),
        slog.Duration("db", dbDuration),
    ),
)
// Output: {"http":{"method":"GET","path":"/devices","status":200},"timing":{"total":"12ms","db":"3ms"}}

OpenTelemetry

Tracing setup

import (
    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
    "go.opentelemetry.io/otel/sdk/resource"
    sdktrace "go.opentelemetry.io/otel/sdk/trace"
    semconv "go.opentelemetry.io/otel/semconv/v1.24.0"
)

func initTracer(ctx context.Context, serviceName, version string) (func(), error) {
    exporter, err := otlptracegrpc.New(ctx)
    if err != nil {
        return nil, fmt.Errorf("create exporter: %w", err)
    }

    res, err := resource.New(ctx,
        resource.WithAttributes(
            semconv.ServiceNameKey.String(serviceName),
            semconv.ServiceVersionKey.String(version),
        ),
    )
    if err != nil {
        return nil, fmt.Errorf("create resource: %w", err)
    }

    tp := sdktrace.NewTracerProvider(
        sdktrace.WithBatcher(exporter),
        sdktrace.WithResource(res),
        sdktrace.WithSampler(sdktrace.AlwaysSample()), // tune in prod
    )
    otel.SetTracerProvider(tp)

    cleanup := func() {
        ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
        defer cancel()
        tp.Shutdown(ctx)
    }
    return cleanup, nil
}

Using traces

var tracer = otel.Tracer("myapp/service")

func (s *DeviceService) GetDevice(ctx context.Context, id string) (*Device, error) {
    ctx, span := tracer.Start(ctx, "DeviceService.GetDevice",
        trace.WithAttributes(attribute.String("device.id", id)),
    )
    defer span.End()

    device, err := s.repo.Get(ctx, id)
    if err != nil {
        span.RecordError(err)
        span.SetStatus(codes.Error, err.Error())
        return nil, err
    }

    return device, nil
}

Prometheus Metrics

import (
    "github.com/prometheus/client_golang/prometheus"
    "github.com/prometheus/client_golang/prometheus/promauto"
    "github.com/prometheus/client_golang/prometheus/promhttp"
)

var (
    httpRequestsTotal = promauto.NewCounterVec(
        prometheus.CounterOpts{
            Name: "http_requests_total",
            Help: "Total HTTP requests",
        },
        []string{"method", "path", "status"},
    )

    httpRequestDuration = promauto.NewHistogramVec(
        prometheus.HistogramOpts{
            Name:    "http_request_duration_seconds",
            Help:    "HTTP request duration in seconds",
            Buckets: prometheus.DefBuckets,
        },
        []string{"method", "path"},
    )

    activeConnections = promauto.NewGauge(
        prometheus.GaugeOpts{
            Name: "active_connections",
            Help: "Number of active connections",
        },
    )
)

// Metrics middleware
func MetricsMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        wrapped := &responseWriter{ResponseWriter: w, statusCode: 200}

        next.ServeHTTP(wrapped, r)

        duration := time.Since(start).Seconds()
        httpRequestsTotal.WithLabelValues(r.Method, r.URL.Path, strconv.Itoa(wrapped.statusCode)).Inc()
        httpRequestDuration.WithLabelValues(r.Method, r.URL.Path).Observe(duration)
    })
}

// Expose metrics endpoint
mux.Handle("/metrics", promhttp.Handler())

Health Checks

type HealthChecker struct {
    db    *sql.DB
    redis *redis.Client
}

type HealthStatus struct {
    Status string            `json:"status"`
    Checks map[string]string `json:"checks"`
}

// Liveness — is the process alive? Keep it simple.
func (h *HealthChecker) Liveness(w http.ResponseWriter, r *http.Request) {
    w.WriteHeader(http.StatusOK)
    w.Write([]byte(`{"status":"ok"}`))
}

// Readiness — can the process handle traffic?
func (h *HealthChecker) Readiness(w http.ResponseWriter, r *http.Request) {
    ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
    defer cancel()

    status := HealthStatus{
        Status: "ok",
        Checks: make(map[string]string),
    }

    if err := h.db.PingContext(ctx); err != nil {
        status.Status = "degraded"
        status.Checks["database"] = err.Error()
    } else {
        status.Checks["database"] = "ok"
    }

    if err := h.redis.Ping(ctx).Err(); err != nil {
        status.Status = "degraded"
        status.Checks["redis"] = err.Error()
    } else {
        status.Checks["redis"] = "ok"
    }

    code := http.StatusOK
    if status.Status != "ok" {
        code = http.StatusServiceUnavailable
    }

    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(code)
    json.NewEncoder(w).Encode(status)
}

// Register
mux.HandleFunc("GET /healthz", health.Liveness)
mux.HandleFunc("GET /readyz", health.Readiness)

8. Build & Deploy

Multi-Stage Docker Builds

# Stage 1: Build
FROM golang:1.24-alpine AS builder

WORKDIR /app

# Cache dependencies
COPY go.mod go.sum ./
RUN go mod download

# Copy source and build
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build \
    -trimpath \
    -ldflags="-s -w -X main.version=$(cat VERSION)" \
    -o /app/server \
    ./cmd/server

# Stage 2: Runtime
FROM gcr.io/distroless/static-debian12:nonroot

COPY --from=builder /app/server /server

EXPOSE 8080
USER nonroot:nonroot
ENTRYPOINT ["/server"]

Key points:

  • CGO_ENABLED=0 for static binary (no libc dependency)
  • -trimpath removes local paths from binary
  • -ldflags="-s -w" strips debug info (smaller binary)
  • distroless or scratch for minimal attack surface
  • nonroot user for security

CGO Considerations

Default: Avoid CGO. CGO_ENABLED=0 gives you a static binary that runs anywhere.

When you need CGO (SQLite, certain crypto libs):

FROM golang:1.24-alpine AS builder
RUN apk add --no-cache gcc musl-dev
# CGO_ENABLED=1 is default when gcc is available
RUN go build -o /app/server ./cmd/server

FROM alpine:3.20
RUN apk add --no-cache ca-certificates
COPY --from=builder /app/server /server
ENTRYPOINT ["/server"]

Cross-Compilation

# Build for Linux AMD64
GOOS=linux GOARCH=amd64 go build -o myapp-linux-amd64 ./cmd/server

# Build for Linux ARM64
GOOS=linux GOARCH=arm64 go build -o myapp-linux-arm64 ./cmd/server

# Build for macOS ARM64 (Apple Silicon)
GOOS=darwin GOARCH=arm64 go build -o myapp-darwin-arm64 ./cmd/server

# Build for Windows
GOOS=windows GOARCH=amd64 go build -o myapp-windows-amd64.exe ./cmd/server

Cross-compilation only works cleanly with CGO_ENABLED=0. If you need CGO + cross-compile, use Docker or zig as CC.

GoReleaser

# .goreleaser.yaml
version: 2

builds:
  - id: server
    main: ./cmd/server
    binary: myapp
    env:
      - CGO_ENABLED=0
    goos:
      - linux
      - darwin
    goarch:
      - amd64
      - arm64
    ldflags:
      - -s -w
      - -X main.version={{.Version}}
      - -X main.commit={{.Commit}}
      - -X main.date={{.Date}}

archives:
  - format: tar.gz
    name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"

dockers:
  - image_templates:
      - "ghcr.io/myorg/myapp:{{ .Version }}-amd64"
    dockerfile: Dockerfile
    use: buildx
    build_flag_templates:
      - "--platform=linux/amd64"

changelog:
  sort: asc
  filters:
    exclude:
      - "^docs:"
      - "^test:"
      - "^ci:"
# Test locally
goreleaser release --snapshot --clean

# Release (triggered by git tag)
git tag v1.0.0
git push origin v1.0.0
# CI runs: goreleaser release

Linting with golangci-lint

# .golangci.yml
run:
  timeout: 5m

linters:
  enable:
    - errcheck        # unchecked errors
    - govet           # vet checks
    - staticcheck     # comprehensive static analysis
    - unused          # unused code
    - gosimple        # simplifications
    - ineffassign     # useless assignments
    - typecheck       # type checking
    - gocritic        # opinionated checks
    - revive          # flexible linter (replaces golint)
    - misspell        # spelling
    - prealloc        # pre-allocation suggestions
    - unconvert       # unnecessary conversions
    - noctx           # HTTP requests without context
    - bodyclose       # unclosed HTTP response bodies
    - errname         # error naming conventions
    - errorlint       # error wrapping checks
    - exhaustive      # exhaustive enum switches
    - copyloopvar     # loop variable capture bugs (Go <1.22)

linters-settings:
  gocritic:
    enabled-tags:
      - diagnostic
      - performance
    disabled-checks:
      - ifElseChain
  revive:
    rules:
      - name: exported
        arguments: [checkPrivateReceivers]
      - name: blank-imports
      - name: context-as-argument
      - name: error-return
      - name: error-naming
      - name: increment-decrement
      - name: var-naming
  govet:
    enable-all: true

issues:
  exclude-rules:
    - path: _test\.go
      linters:
        - errcheck
        - gocritic
  max-issues-per-linter: 0
  max-same-issues: 0
# Run
golangci-lint run ./...

# Fix auto-fixable issues
golangci-lint run --fix ./...

# Only new issues (great for CI on PRs)
golangci-lint run --new-from-rev=main ./...

Quick Reference: Go Proverbs

  • Don't communicate by sharing memory; share memory by communicating.
  • Concurrency is not parallelism.
  • The bigger the interface, the weaker the abstraction.
  • Make the zero value useful.
  • interface{} says nothing (use generics or concrete types).
  • A little copying is better than a little dependency.
  • Clear is better than clever.
  • Errors are values — program with them.
  • Don't just check errors, handle them gracefully.

Further Reading