Skip to main content

Go Pro Tips — Production Go Applications

Patterns, anti-patterns, and core principles that Go senior developers learn from real production experience. Master the idiomatic way to write Go.


Advanced Error Handling​

Sentinel Errors vs Custom Error Types​

package main

import (
"errors"
"fmt"
)

// 1. Sentinel errors — comparable error values
var (
ErrNotFound = errors.New("resource not found")
ErrUnauthorized = errors.New("unauthorized")
ErrInvalidInput = errors.New("invalid input")
)

// 2. Custom error types — include additional context
type ValidationError struct {
Field string
Value interface{}
Message string
}

func (e *ValidationError) Error() string {
return fmt.Sprintf("validation failed: field=%s, value=%v, message=%s",
e.Field, e.Value, e.Message)
}

// 3. Error wrapping — preserve context
func getUser(id int) error {
if id <= 0 {
return fmt.Errorf("getUser: %w", &ValidationError{
Field: "id",
Value: id,
Message: "must be positive",
})
}
// Simulate DB lookup failure
return fmt.Errorf("getUser: DB lookup failed (id=%d): %w", id, ErrNotFound)
}

func processUser(id int) error {
if err := getUser(id); err != nil {
return fmt.Errorf("processUser: %w", err) // Stack-like wrapping
}
return nil
}

func main() {
err := processUser(-1)
if err != nil {
fmt.Println("Error:", err)

// errors.Is: search error chain for sentinel error
if errors.Is(err, ErrNotFound) {
fmt.Println("→ Resource not found")
}

// errors.As: search error chain for type
var valErr *ValidationError
if errors.As(err, &valErr) {
fmt.Printf("→ Validation error: field=%s\n", valErr.Field)
}
}
}

Error Groups — Collect Errors from Multiple Goroutines​

package main

import (
"context"
"fmt"

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

func fetchData(ctx context.Context, url string) (string, error) {
// Simulate HTTP request
if url == "" {
return "", fmt.Errorf("empty URL")
}
return "data from " + url, nil
}

func main() {
ctx := context.Background()

// errgroup: handle multiple goroutine errors at once
g, ctx := errgroup.WithContext(ctx)

urls := []string{
"https://api1.example.com",
"https://api2.example.com",
"", // Error case
}

results := make([]string, len(urls))

for i, url := range urls {
i, url := i, url // Capture loop variables
g.Go(func() error {
data, err := fetchData(ctx, url)
if err != nil {
return fmt.Errorf("URL[%d] failed: %w", i, err)
}
results[i] = data
return nil
})
}

// Wait for all goroutines — returns first error
if err := g.Wait(); err != nil {
fmt.Println("Error occurred:", err)
return
}

fmt.Println("All results:", results)
}

Context Propagation Pattern​

package main

import (
"context"
"fmt"
"time"
)

// Context key type — prevents collisions
type contextKey string

const (
requestIDKey contextKey = "requestID"
userIDKey contextKey = "userID"
)

// Middleware: inject values into context
func withRequestID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, requestIDKey, id)
}

func withUserID(ctx context.Context, id int64) context.Context {
return context.WithValue(ctx, userIDKey, id)
}

// Helper functions for type-safe extraction
func getRequestID(ctx context.Context) string {
if id, ok := ctx.Value(requestIDKey).(string); ok {
return id
}
return "unknown"
}

func getUserID(ctx context.Context) int64 {
if id, ok := ctx.Value(userIDKey).(int64); ok {
return id
}
return 0
}

// Context cancellation propagation
func longRunningTask(ctx context.Context) error {
for i := 0; i < 10; i++ {
select {
case <-ctx.Done():
return ctx.Err() // context.Canceled or context.DeadlineExceeded
default:
fmt.Printf("[reqID=%s] Progress: %d/10\n",
getRequestID(ctx), i+1)
time.Sleep(100 * time.Millisecond)
}
}
return nil
}

func main() {
// Compose request context
ctx := context.Background()
ctx = withRequestID(ctx, "req-12345")
ctx = withUserID(ctx, 42)

// Add timeout
ctx, cancel := context.WithTimeout(ctx, 500*time.Millisecond)
defer cancel()

if err := longRunningTask(ctx); err != nil {
fmt.Printf("Task cancelled: %v\n", err) // context deadline exceeded
}
}

Interface Design Principles​

package main

import (
"fmt"
"io"
"os"
"strings"
)

// Go interface golden rule: small, single-responsibility

// Bad: oversized interface
type BadStorage interface {
Get(key string) ([]byte, error)
Set(key string, val []byte) error
Delete(key string) error
List() ([]string, error)
Flush() error
Stats() map[string]int
Close() error
Ping() error
}

// Good: small composable interfaces
type Getter interface {
Get(key string) ([]byte, error)
}

type Setter interface {
Set(key string, val []byte) error
}

type ReadWriter interface {
Getter
Setter
}

// Follow standard library like io.Reader
func processInput(r io.Reader) (int, error) {
buf := make([]byte, 1024)
total := 0
for {
n, err := r.Read(buf)
total += n
if err == io.EOF {
break
}
if err != nil {
return total, err
}
}
return total, nil
}

func main() {
// Unified by io.Reader: files, strings, networks, etc.
fileReader, _ := os.Open("go.mod")
defer fileReader.Close()
n1, _ := processInput(fileReader)
fmt.Printf("Read %d bytes from file\n", n1)

stringReader := strings.NewReader("hello world")
n2, _ := processInput(stringReader)
fmt.Printf("Read %d bytes from string\n", n2)
}

Advanced Concurrency Patterns​

Fan-Out / Fan-In Pipeline​

package main

import (
"fmt"
"sync"
)

// Generator — channel source
func generate(nums ...int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for _, n := range nums {
out <- n
}
}()
return out
}

// Stage — transformation
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
}

// Fan-Out — distribute to multiple workers
func fanOut(in <-chan int, workers int) []<-chan int {
channels := make([]<-chan int, workers)
for i := 0; i < workers; i++ {
channels[i] = square(in)
}
return channels
}

// Fan-In — merge multiple channels
func fanIn(channels ...<-chan int) <-chan int {
out := make(chan int)
var wg sync.WaitGroup

for _, ch := range channels {
ch := ch
wg.Add(1)
go func() {
defer wg.Done()
for v := range ch {
out <- v
}
}()
}

go func() {
wg.Wait()
close(out)
}()
return out
}

func main() {
// Pipeline: generate → Fan-Out(4 workers) → Fan-In → output
nums := generate(1, 2, 3, 4, 5, 6, 7, 8)
results := fanIn(fanOut(nums, 4)...)

for v := range results {
fmt.Printf("%d ", v)
}
fmt.Println()
}

Done Channel Pattern (Cancellation)​

package main

import (
"fmt"
"time"
)

// Cancellable worker
func worker(done <-chan struct{}, id int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for i := 0; ; i++ {
select {
case <-done:
fmt.Printf("Worker %d shutting down\n", id)
return
case out <- i:
time.Sleep(10 * time.Millisecond)
}
}
}()
return out
}

func main() {
done := make(chan struct{})

w1 := worker(done, 1)
w2 := worker(done, 2)

// Collect results for 300ms
timeout := time.After(300 * time.Millisecond)
for {
select {
case v := <-w1:
fmt.Printf("w1: %d\n", v)
case v := <-w2:
fmt.Printf("w2: %d\n", v)
case <-timeout:
close(done) // Cancel all workers
time.Sleep(50 * time.Millisecond) // Cleanup wait
fmt.Println("Done")
return
}
}
}

Configuration Management Best Practices​

// config/config.go
package config

import (
"fmt"
"os"
"strconv"
"time"
)

type Config struct {
Server ServerConfig
Database DatabaseConfig
Cache CacheConfig
}

type ServerConfig struct {
Host string
Port int
ReadTimeout time.Duration
WriteTimeout time.Duration
}

type DatabaseConfig struct {
DSN string
MaxOpenConns int
MaxIdleConns int
ConnMaxLifetime time.Duration
}

type CacheConfig struct {
RedisURL string
TTL time.Duration
}

// Load from environment variables (with defaults)
func Load() (*Config, error) {
cfg := &Config{
Server: ServerConfig{
Host: getEnv("SERVER_HOST", "0.0.0.0"),
Port: getEnvInt("SERVER_PORT", 8080),
ReadTimeout: getEnvDuration("SERVER_READ_TIMEOUT", 15*time.Second),
WriteTimeout: getEnvDuration("SERVER_WRITE_TIMEOUT", 15*time.Second),
},
Database: DatabaseConfig{
DSN: mustGetEnv("DATABASE_URL"),
MaxOpenConns: getEnvInt("DB_MAX_OPEN_CONNS", 25),
MaxIdleConns: getEnvInt("DB_MAX_IDLE_CONNS", 5),
ConnMaxLifetime: getEnvDuration("DB_CONN_MAX_LIFETIME", 5*time.Minute),
},
Cache: CacheConfig{
RedisURL: getEnv("REDIS_URL", "redis://localhost:6379"),
TTL: getEnvDuration("CACHE_TTL", 5*time.Minute),
},
}
return cfg, nil
}

func getEnv(key, defaultVal string) string {
if val := os.Getenv(key); val != "" {
return val
}
return defaultVal
}

func mustGetEnv(key string) string {
val := os.Getenv(key)
if val == "" {
panic(fmt.Sprintf("required environment variable missing: %s", key))
}
return val
}

func getEnvInt(key string, defaultVal int) int {
if val := os.Getenv(key); val != "" {
if n, err := strconv.Atoi(val); err == nil {
return n
}
}
return defaultVal
}

func getEnvDuration(key string, defaultVal time.Duration) time.Duration {
if val := os.Getenv(key); val != "" {
if d, err := time.ParseDuration(val); err == nil {
return d
}
}
return defaultVal
}

Structured Logging — slog (Go 1.21+)​

package main

import (
"context"
"log/slog"
"os"
"time"
)

func setupLogger(env string) *slog.Logger {
var handler slog.Handler

if env == "production" {
// JSON format (log aggregator friendly)
handler = slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
})
} else {
// Text format (human-readable for development)
handler = slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelDebug,
AddSource: true, // Include file:line
})
}

return slog.New(handler)
}

func main() {
logger := setupLogger("development")

// Set as default logger
slog.SetDefault(logger)

// Basic logging
slog.Info("server starting", "port", 8080, "env", "development")
slog.Debug("debug info", "goroutines", 10)
slog.Warn("slow query", "duration_ms", 250, "query", "SELECT *")
slog.Error("DB connection failed", "error", "connection refused", "retry", 3)

// Structured groups
slog.Info("request handled",
slog.Group("request",
slog.String("method", "GET"),
slog.String("path", "/users/123"),
slog.String("ip", "192.168.1.1"),
),
slog.Group("response",
slog.Int("status", 200),
slog.Duration("duration", 45*time.Millisecond),
),
)

// With context (propagate request ID)
ctx := context.WithValue(context.Background(), "reqID", "abc-123")
logger.InfoContext(ctx, "fetching user", "user_id", 42)

// Logger with common fields
requestLogger := logger.With(
"request_id", "req-789",
"user_id", 100,
)
requestLogger.Info("query starting")
requestLogger.Info("query completed", "rows", 5)
}

Code Quality Tool Chain​

# go vet — official static analysis
go vet ./...

# golangci-lint — integrated linter suite (recommended)
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
golangci-lint run ./...

# staticcheck — advanced static analysis
go install honnef.co/go/tools/cmd/staticcheck@latest
staticcheck ./...

# govulncheck — security vulnerability scanner
go install golang.org/x/vuln/cmd/govulncheck@latest
govulncheck ./...

# gofumpt — stricter version of gofmt
go install mvdan.cc/gofumpt@latest
gofumpt -l -w .

# deadcode — find unused code
go install golang.org/x/tools/cmd/deadcode@latest
deadcode -test ./...
# .golangci.yml
run:
timeout: 5m

linters:
enable:
- errcheck # Check error handling
- gosimple # Simplifiable code
- govet # go vet
- ineffassign # Inefficient assignment
- staticcheck # Static analysis
- unused # Unused code
- gofmt # Format check
- goimports # Import sorting
- misspell # Typo checking
- exhaustive # Switch completeness

linters-settings:
errcheck:
check-type-assertions: true
govet:
enable-all: true

Module and Dependency Management​

# Initialize module
go mod init github.com/myorg/myapp

# Add dependency
go get github.com/gin-gonic/gin@v1.9.1
go get golang.org/x/sync@latest

# Remove unused dependencies
go mod tidy

# Vendoring (offline/reproducible builds)
go mod vendor

# Audit dependencies for security
go list -m all | govulncheck -

# View dependency tree
go mod graph | head -20

# Check why a package is needed
go mod why github.com/some/package

# Upgrade dependencies
go get -u ./... # Upgrade all
go get -u=patch ./... # Patch version only
go get github.com/gin-gonic/gin # Upgrade to latest

Production Checklist​

Pre-deployment verification checklist:

Build & Tests
✅ go test -race ./... — Detect race conditions
✅ go vet ./... — Static analysis
✅ golangci-lint run ./... — Lint passing
✅ govulncheck ./... — No security vulnerabilities
✅ go test -cover (≥80%) — Test coverage sufficient

Code Quality
✅ Error handling: use errors.Is/As, wrap with fmt.Errorf("%w")
✅ Context propagation: context.Context as first parameter in all API funcs
✅ Resource cleanup: defer Close/Cancel to ensure cleanup
✅ Cancellation: handle ctx.Done() in long-running operations

Configuration & Security
✅ Secrets from environment variables (no hardcoding)
✅ Timeouts configured (HTTP client/server)
✅ Rate limiting applied
✅ Input validation in place

Operations
✅ Structured logging (JSON, slog)
✅ Health check endpoints (/health, /ready)
✅ Graceful shutdown (SIGTERM handling)
✅ Metrics exposed (Prometheus /metrics)
✅ Resource limits set (GOMEMLIMIT)

Key Takeaways​

AreaRecommended Pattern
Errorsfmt.Errorf("%w") wrapping, errors.Is/As checking
ContextFirst parameter, propagate timeouts/cancellation
InterfacesSmall focused interfaces, consumer-defined
ConfigEnvironment variables + defaults, validate at startup
Logginglog/slog JSON handler (Go 1.21+)
Concurrencyerrgroup, worker pools, done channel pattern
Qualitygolangci-lint, govulncheck, -race testing
  • Go Proverb: "Clear is better than clever" — prefer clear code over clever tricks
  • Context Rule: Never store context in structs; always pass as parameter
  • Interface Rule: Consumer (caller) defines interfaces, not implementation