Golang / GoLang Production Patterns and Web Standards Interview Questions
Go deliberately chose to make errors ordinary values rather than using a try-catch exception mechanism. The error interface has exactly one method:
type error interface { Error() string }Any function that can fail returns an error as its last return value. The caller must explicitly handle or propagate it. This design philosophy comes from Rob Pike's observation that exception-based code tends to produce programs where error handling is an afterthought — invisible control flow that developers skip over.
| Aspect | Go errors-as-values | Exception-based languages |
|---|---|---|
| Visibility | Error paths are explicit in every function signature | Error paths are hidden; callers may not know a function can fail |
| Handling | Compiler forces you to receive the error; ignoring requires _ | Uncaught exceptions terminate the program unexpectedly |
| Control flow | Linear — no hidden jumps | Non-local jumps complicate reasoning |
| Composition | Error values can carry rich context | Exception hierarchies can become complex |
| Performance | No stack unwinding — cheap | Stack unwinding has overhead |
// Every failing operation explicitly returns an error
func readConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("readConfig: %w", err)
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("readConfig unmarshal: %w", err)
}
return &cfg, nil
}
// Caller is forced to decide what to do with the error
cfg, err := readConfig("/etc/app/config.json")
if err != nil {
log.Fatalf("startup failed: %v", err)
}Go does have panic and recover, but they are reserved for truly unrecoverable situations (nil pointer dereferences, index out of range) or as an internal implementation detail in libraries that convert panics to errors at the public boundary.
Go 1.13 introduced the %w verb in fmt.Errorf and the errors.Is / errors.As functions to create and inspect error chains. Wrapping preserves the original error while adding context — callers can still check for specific error types or sentinel values anywhere in the chain.
import "errors"
// Sentinel errors — comparable with errors.Is
var (
ErrNotFound = errors.New("not found")
ErrPermission = errors.New("permission denied")
)
// Wrapping with %w — preserves the error chain
func fetchUser(id int) (*User, error) {
u, err := db.QueryUser(id)
if err != nil {
return nil, fmt.Errorf("fetchUser(id=%d): %w", id, err)
}
return u, nil
}
// errors.Is — checks the entire chain for a target value
err := fetchUser(42)
if errors.Is(err, ErrNotFound) {
http.Error(w, "user not found", http.StatusNotFound)
return
}
// Custom structured error type
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation error on %s: %s", e.Field, e.Message)
}
func validateAge(age int) error {
if age < 0 {
return fmt.Errorf("validateAge: %w",
&ValidationError{Field: "age", Message: "must be non-negative"})
}
return nil
}
// errors.As — extracts a specific error type from the chain
var ve *ValidationError
if errors.As(err, &ve) {
http.Error(w,
fmt.Sprintf("invalid field %s: %s", ve.Field, ve.Message),
http.StatusBadRequest)
}
// errors.Unwrap — one level of unwrapping
inner := errors.Unwrap(err) // returns the wrapped error
// Go 1.20+: errors.Join — wrap multiple errors in one
combined := errors.Join(err1, err2)
errors.Is(combined, err1) // trueKey difference: %v formats the error as a plain string — the wrapped error is lost for inspection purposes. %w both formats it AND preserves the wrapped error so errors.Is and errors.As can traverse the chain.
Custom error types are used when callers need to inspect error details beyond a simple message. The key decision is whether to use a pointer receiver (most common for structs) or a value receiver, and whether to implement the optional Unwrap() method to participate in the error chain.
// Pattern 1: simple sentinel error (no extra data)
var ErrRateLimited = errors.New("rate limited")
// Pattern 2: structured error with context
type HTTPError struct {
Code int
Message string
Cause error // optional wrapped cause
}
func (e *HTTPError) Error() string {
if e.Cause != nil {
return fmt.Sprintf("HTTP %d: %s: %v", e.Code, e.Message, e.Cause)
}
return fmt.Sprintf("HTTP %d: %s", e.Code, e.Message)
}
// Implement Unwrap() to participate in errors.Is / errors.As chain
func (e *HTTPError) Unwrap() error { return e.Cause }
// Constructor — always return concrete type as error interface
func newHTTPError(code int, msg string, cause error) error {
return &HTTPError{Code: code, Message: msg, Cause: cause}
}
// Usage
func callAPI(url string) error {
resp, err := http.Get(url)
if err != nil {
return newHTTPError(0, "request failed", err)
}
if resp.StatusCode == http.StatusTooManyRequests {
return newHTTPError(429, "rate limited", ErrRateLimited)
}
return nil
}
// Caller
err := callAPI("https://api.example.com/data")
var httpErr *HTTPError
if errors.As(err, &httpErr) {
log.Printf("HTTP error %d: %s", httpErr.Code, httpErr.Message)
}
if errors.Is(err, ErrRateLimited) {
time.Sleep(time.Second) // back off and retry
}
// ANTI-PATTERN: typed nil trap
// func getUser() error {
// var e *HTTPError
// return e // NOT nil interface! {type=*HTTPError, data=nil}
// }
// Always: return nil (bare), not a typed nil pointer
context.Context is Go's standard mechanism for propagating three things across API boundaries and goroutine boundaries: cancellation signals, deadlines/timeouts, and request-scoped values. Every blocking or long-running function should accept a Context as its first parameter.
// The full context.Context interface:
type Context interface {
Deadline() (deadline time.Time, ok bool) // zero time if no deadline
Done() <-chan struct{} // closed on cancel/timeout
Err() error // nil, Canceled, or DeadlineExceeded
Value(key any) any // request-scoped value lookup
}
// Root contexts (start of a context tree)
ctx := context.Background() // never cancelled, no deadline — use at program start
ctx = context.TODO() // placeholder when context not yet known
// Derived contexts — each returns a cancel function
ctx1, cancel1 := context.WithCancel(context.Background())
defer cancel1() // ALWAYS defer — prevents goroutine leak in the monitor goroutine
ctx2, cancel2 := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel2()
ctx3, cancel3 := context.WithDeadline(context.Background(),
time.Now().Add(30*time.Second))
defer cancel3()
// Attaching a value (use typed key to avoid collisions)
type ctxKey string
const traceIDKey ctxKey = "traceID"
ctx4 := context.WithValue(ctx, traceIDKey, "abc-123")
traceID := ctx4.Value(traceIDKey).(string)
// Propagate context to downstream functions
func processRequest(ctx context.Context, req Request) error {
if err := validateInput(ctx, req); err != nil {
return fmt.Errorf("validation: %w", err)
}
return persistToDB(ctx, req) // ctx carries cancellation + deadline
}Context tree: cancelling a parent cancels all descendants. A child context with a shorter deadline does not extend the parent's deadline — the effective deadline is always min(parent, child).
Since Go 1.7, every *http.Request carries a context accessible via r.Context(). This context is cancelled when the client disconnects or the server shuts down. Middleware and handlers should pass this context to all downstream calls — database queries, outbound HTTP calls, gRPC — so they all cancel when the request does.
// The request context is cancelled when the client disconnects
func userHandler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() // inherits client-disconnect cancellation
// Add a per-request deadline
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
// Pass ctx to ALL downstream blocking calls
userID := chi.URLParam(r, "id")
user, err := userService.FindByID(ctx, userID) // DB call respects deadline
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
http.Error(w, "request timed out", http.StatusGatewayTimeout)
return
}
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(user)
}
// Making an outbound HTTP call with the request context
func fetchExternalData(ctx context.Context, url string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("executing request: %w", err)
}
defer resp.Body.Close()
return io.ReadAll(resp.Body)
}
// Middleware: attach request-scoped values to context
func requestIDMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := r.Header.Get("X-Request-ID")
if id == "" {
id = uuid.New().String()
}
ctx := context.WithValue(r.Context(), requestIDKey, id)
w.Header().Set("X-Request-ID", id)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
context.WithValue should be used sparingly — only for request-scoped data that crosses API boundaries and would be impractical to pass as explicit parameters. It is not a general-purpose parameter-passing mechanism.
// ANTI-PATTERN: using a plain string as a context key
ctx = context.WithValue(ctx, "userID", 42)
// Any package can read/overwrite this key — key collisions guaranteed
// CORRECT: define an unexported typed key per package
// This prevents any other package from accidentally colliding
type contextKey string
const (
requestIDKey contextKey = "requestID"
userIDKey contextKey = "userID"
)
// Better: use a struct type as the key (common in stdlib)
type requestIDKeyType struct{}
func WithRequestID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, requestIDKeyType{}, id)
}
func RequestIDFromContext(ctx context.Context) (string, bool) {
id, ok := ctx.Value(requestIDKeyType{}).(string)
return id, ok
}
// GOOD USES of context values:
// - Request/trace IDs for logging and distributed tracing
// - Authentication tokens / user identity
// - Feature flags tied to the request
// BAD USES of context values:
// - Optional function parameters
// - Database connections (pass as explicit dependency)
// - Configuration that should be in a struct
// - Anything that the function should document as a dependency
// The Go proverb: 'Use context values only for request-scoped data
// that transits processes and API boundaries, not for passing
// optional parameters to functions.'Context value lookups are O(n) in the number of values stored — each WithValue wraps the context in a new layer. Storing many values is inefficient. If you have many related values, wrap them in a single struct: context.WithValue(ctx, reqKey{}, &RequestData{UserID: 1, TraceID: "abc"}).
Go's net/http package provides a production-capable HTTP server out of the box. Unlike Node.js or Python frameworks, you rarely need a third-party framework for basic HTTP serving — the standard library handles routing, TLS, graceful shutdown, and concurrent request handling.
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
mux := http.NewServeMux()
// Register handlers
mux.HandleFunc("GET /health", healthHandler) // Go 1.22+ method+path
mux.HandleFunc("GET /users/{id}", getUserHandler)
mux.HandleFunc("POST /users", createUserHandler)
// Apply middleware chain
handler := loggingMiddleware(requestIDMiddleware(mux))
srv := &http.Server{
Addr: ":8080",
Handler: handler,
ReadTimeout: 5 * time.Second, // time to read full request
WriteTimeout: 10 * time.Second, // time to write full response
IdleTimeout: 120 * time.Second, // keep-alive timeout
MaxHeaderBytes: 1 << 20, // 1 MB header limit
}
// Start server in a goroutine
go func() {
log.Printf("server listening on %s", srv.Addr)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("server error: %v", err)
}
}()
// Graceful shutdown on OS signal
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("shutting down server...")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Fatalf("forced shutdown: %v", err)
}
log.Println("server stopped")
}Always set timeouts on production HTTP servers. Without them, slow clients can exhaust goroutines: ReadTimeout prevents slow request bodies, WriteTimeout prevents slow response consumption, and IdleTimeout prevents idle keep-alive connections from accumulating.
Go middleware follows a consistent adapter pattern: a function that accepts an http.Handler and returns a new http.Handler that wraps it. The signature func(http.Handler) http.Handler is the community standard — used by virtually every Go HTTP library.
// The standard middleware type
type Middleware func(http.Handler) http.Handler
// Logging middleware
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// Wrap ResponseWriter to capture status code
lrw := &loggingResponseWriter{ResponseWriter: w, statusCode: http.StatusOK}
next.ServeHTTP(lrw, r) // call the next handler in the chain
log.Printf("%s %s %d %v",
r.Method, r.URL.Path, lrw.statusCode, time.Since(start))
})
}
type loggingResponseWriter struct {
http.ResponseWriter
statusCode int
}
func (lrw *loggingResponseWriter) WriteHeader(code int) {
lrw.statusCode = code
lrw.ResponseWriter.WriteHeader(code)
}
// Auth middleware
func authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Authorization")
if token == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return // short-circuit — next.ServeHTTP never called
}
next.ServeHTTP(w, r)
})
}
// Recovery middleware — catch panics in handlers
func recoveryMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
log.Printf("panic recovered: %v\n%s", rec, debug.Stack())
http.Error(w, "internal server error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
// Chaining middleware — applied right-to-left (outermost runs first)
handler := recoveryMiddleware(loggingMiddleware(authMiddleware(mux)))
// Request order: recovery → logging → auth → mux → handler
// Cleaner chain helper
func Chain(h http.Handler, middlewares ...Middleware) http.Handler {
for i := len(middlewares) - 1; i >= 0; i-- {
h = middlewares[i](h)
}
return h
}
handler = Chain(mux, recoveryMiddleware, loggingMiddleware, authMiddleware)
Go 1.22 significantly upgraded http.ServeMux with method-based routing, path parameters, and wildcard matching — reducing the need for third-party routers in many applications.
mux := http.NewServeMux()
// Go 1.22+ enhanced routing patterns:
// Method + exact path
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"ok"}`))
})
// Method + path with named parameter {id}
mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id") // extract path variable
fmt.Fprintf(w, "user %s", id)
})
// Method + path with wildcard {path...} (matches remainder)
mux.HandleFunc("GET /files/{path...}", func(w http.ResponseWriter, r *http.Request) {
path := r.PathValue("path")
http.ServeFile(w, r, filepath.Join("/static", path))
})
// POST with body
mux.HandleFunc("POST /users", createUserHandler)
// DELETE
mux.HandleFunc("DELETE /users/{id}", deleteUserHandler)
// Host-based routing
mux.HandleFunc("api.example.com/GET /v1/", apiV1Handler)
// Trailing slash subtree pattern (matches /api/ and all sub-paths)
mux.HandleFunc("/api/", apiHandler)
// Pre-1.22 pattern: no method prefix, manual method check
// mux.HandleFunc("/users", func(w http.ResponseWriter, r *http.Request) {
// switch r.Method {
// case http.MethodGet: listUsers(w, r)
// case http.MethodPost: createUser(w, r)
// default: http.Error(w, "method not allowed", 405)
// }
// })Go 1.22 routing precedence: more specific patterns win over less specific ones. A pattern with a method is more specific than one without. An exact path is more specific than a wildcard. If two patterns conflict (same specificity, overlapping matches), the registration panics at startup — catching routing mistakes at boot time rather than silently returning wrong responses.
Go's encoding/json package provides json.Marshal/json.Unmarshal for byte slices and json.NewEncoder/json.NewDecoder for streams. The stream-based API is preferred for HTTP handlers since it avoids loading the full body into memory.
// Struct tags control JSON field names and omission
type User struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
Password string `json:"-"` // always omit
CreatedAt time.Time `json:"created_at"`
Bio *string `json:"bio,omitempty"` // omit if nil
Score int `json:"score,omitempty"` // omit if zero
}
// Decoding HTTP request body (preferred: streaming)
func createUserHandler(w http.ResponseWriter, r *http.Request) {
var user User
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields() // reject unexpected JSON keys
if err := dec.Decode(&user); err != nil {
http.Error(w, fmt.Sprintf("invalid JSON: %v", err), http.StatusBadRequest)
return
}
// ... process user ...
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(user)
}
// Common pitfalls:
// 1. Unexported fields are silently ignored
type Hidden struct { public string; private string } // private won't encode
// 2. json.Number for large integers (avoid float64 precision loss)
var data map[string]any
dec := json.NewDecoder(r.Body)
dec.UseNumber() // numbers decoded as json.Number, not float64
dec.Decode(&data)
n, _ := data["id"].(json.Number).Int64()
// 3. Circular references cause Marshal to panic — avoid
// 4. Set Content-Type BEFORE WriteHeader
w.Header().Set("Content-Type", "application/json") // must come first
w.WriteHeader(http.StatusOK) // locks headers
The http.ResponseWriter interface has ordering rules that are easy to violate, leading to subtle bugs where headers are silently lost or the response is malformed.
// http.ResponseWriter interface:
// type ResponseWriter interface {
// Header() http.Header // returns the header map (modify before WriteHeader)
// WriteHeader(statusCode int) // sets status; can only be called once
// Write([]byte) (int, error) // writes body; implicitly calls WriteHeader(200)
// }
// CORRECT ordering:
func goodHandler(w http.ResponseWriter, r *http.Request) {
// 1. Set headers first
w.Header().Set("Content-Type", "application/json")
w.Header().Set("X-Request-ID", "abc-123")
// 2. Set status code
w.WriteHeader(http.StatusCreated) // 201
// 3. Write body
json.NewEncoder(w).Encode(map[string]string{"status": "created"})
}
// WRONG: setting headers after WriteHeader — silently ignored
func badHandler(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Header().Set("Content-Type", "application/json") // TOO LATE — ignored!
w.Write([]byte(`{"ok":true}`))
}
// WRONG: calling Write before setting Content-Type
// Write triggers an implicit WriteHeader(200) — headers lock
// Helper: structured JSON response
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(v); err != nil {
log.Printf("writeJSON: %v", err)
}
}
// IMPORTANT: returning from a handler does NOT automatically
// stop a response from being written. Use explicit return after error:
func handler(w http.ResponseWriter, r *http.Request) {
if err := process(r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return // MUST return — otherwise code below also runs
}
writeJSON(w, http.StatusOK, map[string]string{"ok": "true"})
}
Go's idiomatic approach to dependency injection is constructor-based: pass dependencies as arguments to constructors that return concrete types. This is simpler, more testable, and more readable than reflection-based DI frameworks.
// Define dependencies as interfaces (for testability)
type UserRepository interface {
FindByID(ctx context.Context, id int) (*User, error)
Save(ctx context.Context, user *User) error
}
type EmailSender interface {
Send(ctx context.Context, to, subject, body string) error
}
// Service accepts interfaces — can be mocked in tests
type UserService struct {
repo UserRepository
email EmailSender
logger *slog.Logger
}
// Constructor injection — explicit, no magic
func NewUserService(
repo UserRepository,
email EmailSender,
logger *slog.Logger,
) *UserService {
return &UserService{repo: repo, email: email, logger: logger}
}
func (s *UserService) Register(ctx context.Context, req RegisterRequest) (*User, error) {
user := &User{Name: req.Name, Email: req.Email}
if err := s.repo.Save(ctx, user); err != nil {
return nil, fmt.Errorf("UserService.Register: %w", err)
}
s.email.Send(ctx, user.Email, "Welcome!", "Thanks for joining.")
return user, nil
}
// Wire everything together in main()
func main() {
db, _ := sql.Open("postgres", os.Getenv("DATABASE_URL"))
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
repo := postgres.NewUserRepository(db)
email := smtp.NewEmailSender(os.Getenv("SMTP_HOST"))
svc := NewUserService(repo, email, logger)
h := handlers.NewUserHandler(svc)
mux := http.NewServeMux()
mux.Handle("/users/", h)
http.ListenAndServe(":8080", mux)
}Testing advantage: because dependencies are injected as interfaces, tests pass mock implementations without any framework or code generation. A mock UserRepository is just a struct implementing the interface with test-controlled responses.
log/slog was added to the standard library in Go 1.21 as the official structured logging solution. It produces machine-readable output (JSON or key-value pairs) and supports levels, attributes, and handler customisation.
import "log/slog"
// Default logger — uses text format to stderr
slog.Info("server starting", "port", 8080)
slog.Warn("connection slow", "latency_ms", 450, "host", "db.internal")
slog.Error("request failed", "error", err, "path", r.URL.Path)
// JSON handler for production (machine-parseable)
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo, // minimum level to emit
AddSource: true, // include file:line in output
}))
slog.SetDefault(logger)
// Output: {"time":"2024-01-15T...","level":"INFO","msg":"user created","id":42}
// Grouping related attributes
logger.Info("request completed",
slog.Group("request",
slog.String("method", r.Method),
slog.String("path", r.URL.Path),
slog.Int("status", 200),
slog.Duration("duration", time.Since(start)),
),
)
// Logger with pre-set attributes (child logger)
reqLogger := logger.With(
slog.String("request_id", requestID),
slog.String("user_id", userID),
)
reqLogger.Info("fetching user data")
reqLogger.Info("user data fetched", "count", len(users))
// Context-aware logging — attach logger to context
func withLogger(ctx context.Context, logger *slog.Logger) context.Context {
return context.WithValue(ctx, loggerKey{}, logger)
}
func loggerFromCtx(ctx context.Context) *slog.Logger {
if l, ok := ctx.Value(loggerKey{}).(*slog.Logger); ok { return l }
return slog.Default()
}
Go applications typically load configuration from environment variables (twelve-factor app pattern), config files, or a combination. The idiomatic approach is to load all configuration at startup, validate it, and inject it into components as a struct — not read environment variables throughout the codebase.
// Config struct — single source of truth
type Config struct {
HTTP struct {
Port int `env:"HTTP_PORT" default:"8080"`
ReadTimeout time.Duration `env:"HTTP_READ_TIMEOUT" default:"5s"`
WriteTimeout time.Duration `env:"HTTP_WRITE_TIMEOUT" default:"10s"`
}
Database struct {
URL string `env:"DATABASE_URL" required:"true"`
MaxConnections int `env:"DB_MAX_CONNECTIONS" default:"25"`
}
Auth struct {
JWTSecret string `env:"JWT_SECRET" required:"true"`
TokenTTL time.Duration `env:"TOKEN_TTL" default:"24h"`
}
}
// Load from environment — validate at startup
func loadConfig() (*Config, error) {
var cfg Config
// Use envconfig, viper, or manual os.Getenv
cfg.HTTP.Port = mustEnvInt("HTTP_PORT", 8080)
cfg.Database.URL = mustEnv("DATABASE_URL")
cfg.Auth.JWTSecret = mustEnv("JWT_SECRET")
if cfg.Auth.JWTSecret == "" {
return nil, errors.New("JWT_SECRET must not be empty")
}
return &cfg, nil
}
func mustEnv(key string) string {
v := os.Getenv(key)
if v == "" {
log.Fatalf("required environment variable %s is not set", key)
}
return v
}
func main() {
cfg, err := loadConfig()
if err != nil {
log.Fatalf("invalid configuration: %v", err)
}
// Inject cfg into all components
db := openDB(cfg.Database.URL, cfg.Database.MaxConnections)
srv := newServer(cfg.HTTP, db)
srv.Start()
}
Go's database/sql package provides a driver-agnostic interface for SQL databases. It manages a connection pool automatically. The key patterns are: always close rows, always use parameterised queries to prevent SQL injection, and pass context to every query.
import (
"database/sql"
_ "github.com/lib/pq" // side-effect import registers the driver
)
// Open — creates the pool, does NOT establish a connection yet
db, err := sql.Open("postgres", os.Getenv("DATABASE_URL"))
if err != nil {
log.Fatal(err)
}
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(25)
db.SetConnMaxLifetime(5 * time.Minute)
// Verify connection
if err := db.PingContext(ctx); err != nil {
log.Fatalf("database unreachable: %v", err)
}
// SELECT — always pass context
func getUserByID(ctx context.Context, db *sql.DB, id int) (*User, error) {
row := db.QueryRowContext(ctx,
"SELECT id, name, email FROM users WHERE id = $1", id)
var u User
err := row.Scan(&u.ID, &u.Name, &u.Email)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("getUserByID: %w", err)
}
return &u, nil
}
// SELECT multiple rows — MUST close rows
func listUsers(ctx context.Context, db *sql.DB) ([]User, error) {
rows, err := db.QueryContext(ctx, "SELECT id, name FROM users")
if err != nil {
return nil, fmt.Errorf("listUsers query: %w", err)
}
defer rows.Close() // ALWAYS close — frees connection back to pool
var users []User
for rows.Next() {
var u User
if err := rows.Scan(&u.ID, &u.Name); err != nil {
return nil, fmt.Errorf("listUsers scan: %w", err)
}
users = append(users, u)
}
return users, rows.Err() // check iteration error
}
// Transaction
func transferFunds(ctx context.Context, db *sql.DB, from, to, amount int) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil { return err }
defer tx.Rollback() // no-op if Commit succeeds
if _, err := tx.ExecContext(ctx,
"UPDATE accounts SET balance = balance - $1 WHERE id = $2", amount, from); err != nil {
return fmt.Errorf("debit: %w", err)
}
if _, err := tx.ExecContext(ctx,
"UPDATE accounts SET balance = balance + $1 WHERE id = $2", amount, to); err != nil {
return fmt.Errorf("credit: %w", err)
}
return tx.Commit()
}
Graceful shutdown means: stop accepting new connections, wait for in-flight requests to complete, then exit. This prevents data loss and connection resets for clients whose requests are mid-flight when a deployment or restart occurs.
func main() {
srv := &http.Server{
Addr: ":8080",
Handler: buildRouter(),
}
// Start server in background
serverErr := make(chan error, 1)
go func() {
log.Println("listening on :8080")
if err := srv.ListenAndServe(); err != nil {
serverErr <- err
}
}()
// Wait for shutdown signal or server error
shutdown := make(chan os.Signal, 1)
signal.Notify(shutdown, syscall.SIGINT, syscall.SIGTERM)
select {
case err := <-serverErr:
if !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("server error: %v", err)
}
case sig := <-shutdown:
log.Printf("received signal: %v — shutting down", sig)
}
// Give in-flight requests up to 30s to complete
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
// Deadline exceeded: force close remaining connections
log.Printf("forced shutdown: %v", err)
srv.Close()
}
// Clean up other resources
db.Close()
log.Println("shutdown complete")
}
// In Kubernetes: SIGTERM is sent ~30s before SIGKILL
// The 30s graceful period must be < terminationGracePeriodSeconds
// Also set preStop lifecycle hook to delay pod removal from endpoints
Go's net/http/httptest package provides httptest.NewRecorder() (a fake ResponseWriter) and httptest.NewServer() (a real TCP server on a random port) for testing HTTP code without network overhead.
import (
"net/http/httptest"
"testing"
)
// Unit test: test handler in isolation with mock dependencies
func TestGetUserHandler(t *testing.T) {
// Setup mock repository
mockRepo := &MockUserRepo{
FindByIDFunc: func(ctx context.Context, id int) (*User, error) {
return &User{ID: id, Name: "Alice"}, nil
},
}
svc := NewUserService(mockRepo)
h := NewUserHandler(svc)
// Build a request
req := httptest.NewRequest(http.MethodGet, "/users/1", nil)
req.SetPathValue("id", "1") // Go 1.22: set path params
// Capture the response
rec := httptest.NewRecorder()
h.GetUser(rec, req)
// Assert
res := rec.Result()
if res.StatusCode != http.StatusOK {
t.Errorf("expected 200, got %d", res.StatusCode)
}
if ct := res.Header.Get("Content-Type"); ct != "application/json" {
t.Errorf("expected application/json, got %s", ct)
}
var user User
json.NewDecoder(res.Body).Decode(&user)
if user.Name != "Alice" {
t.Errorf("expected Alice, got %s", user.Name)
}
}
// Integration test: use httptest.NewServer for real HTTP round-trip
func TestAPIIntegration(t *testing.T) {
srv := httptest.NewServer(buildRouter())
defer srv.Close() // frees the port
resp, err := http.Get(srv.URL + "/health")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("expected 200, got %d", resp.StatusCode)
}
}
Table-driven tests define multiple test cases as a slice of structs, then iterate over them running each case. This is Go's idiomatic testing pattern — it reduces duplication, makes it easy to add new cases, and provides clear failure messages identifying which case failed.
func divide(a, b float64) (float64, error) {
if b == 0 {
return 0, errors.New("division by zero")
}
return a / b, nil
}
func TestDivide(t *testing.T) {
tests := []struct {
name string
a, b float64
want float64
wantErr bool
}{
{name: "positive division", a: 10, b: 2, want: 5, wantErr: false},
{name: "negative result", a: -6, b: 3, want: -2, wantErr: false},
{name: "division by zero", a: 5, b: 0, want: 0, wantErr: true},
{name: "float division", a: 7, b: 2, want: 3.5, wantErr: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := divide(tt.a, tt.b)
if (err != nil) != tt.wantErr {
t.Errorf("divide(%v, %v) error = %v, wantErr = %v",
tt.a, tt.b, err, tt.wantErr)
return
}
if !tt.wantErr && got != tt.want {
t.Errorf("divide(%v, %v) = %v, want %v",
tt.a, tt.b, got, tt.want)
}
})
}
}
// Run specific subtests: go test -run TestDivide/division_by_zero
// Parallel subtests:
for _, tt := range tests {
tt := tt // capture range var (pre-Go 1.22)
t.Run(tt.name, func(t *testing.T) {
t.Parallel() // run subtests in parallel
// ...
})
}
Go's implicit interface satisfaction means any struct with the right methods satisfies an interface — no registration or code generation required. This makes hand-written mocks straightforward, readable, and dependency-free.
// Interface in the consumer package (not the producer)
type EmailService interface {
Send(ctx context.Context, to, subject, body string) error
}
// Production implementation
type SMTPEmailService struct { host string; port int }
func (s *SMTPEmailService) Send(ctx context.Context, to, subject, body string) error {
// ... real SMTP logic
return nil
}
// Hand-written mock for tests
type MockEmailService struct {
Calls []string // track what was called
ReturnErr error // control what error to return
}
func (m *MockEmailService) Send(ctx context.Context, to, subject, body string) error {
m.Calls = append(m.Calls, to)
return m.ReturnErr
}
// Functional mock — more flexible, inline in test
type EmailServiceFunc func(ctx context.Context, to, subject, body string) error
func (f EmailServiceFunc) Send(ctx context.Context, to, subject, body string) error {
return f(ctx, to, subject, body)
}
// Test using the mock
func TestWelcomeEmail(t *testing.T) {
mock := &MockEmailService{}
svc := NewUserService(nil, mock, slog.Default())
err := svc.Register(context.Background(), RegisterRequest{
Name: "Alice", Email: "alice@example.com",
})
if err != nil { t.Fatal(err) }
if len(mock.Calls) != 1 {
t.Errorf("expected 1 email sent, got %d", len(mock.Calls))
}
if mock.Calls[0] != "alice@example.com" {
t.Errorf("email sent to wrong address: %s", mock.Calls[0])
}
}
// Test error path
func TestWelcomeEmailFailure(t *testing.T) {
mock := &MockEmailService{ReturnErr: errors.New("smtp down")}
svc := NewUserService(nil, mock, slog.Default())
// Test that service handles email failure gracefully
}
Rate limiting protects services from overload. Go provides the token bucket algorithm via golang.org/x/time/rate. Production implementations typically rate-limit per client IP or API key, not globally.
import "golang.org/x/time/rate"
// Global limiter (simple, not per-client)
var globalLimiter = rate.NewLimiter(rate.Limit(100), 10)
// 100 requests/second with burst of 10
func globalRateLimitMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !globalLimiter.Allow() {
http.Error(w, "too many requests", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
// Per-client rate limiter (production pattern)
type IPRateLimiter struct {
mu sync.Mutex
limiters map[string]*rate.Limiter
r rate.Limit
b int
}
func NewIPRateLimiter(r rate.Limit, b int) *IPRateLimiter {
return &IPRateLimiter{limiters: make(map[string]*rate.Limiter), r: r, b: b}
}
func (l *IPRateLimiter) getLimiter(ip string) *rate.Limiter {
l.mu.Lock()
defer l.mu.Unlock()
if lim, ok := l.limiters[ip]; ok {
return lim
}
lim := rate.NewLimiter(l.r, l.b)
l.limiters[ip] = lim
return lim
}
func (l *IPRateLimiter) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip, _, _ := net.SplitHostPort(r.RemoteAddr)
if !l.getLimiter(ip).Allow() {
w.Header().Set("Retry-After", "1")
http.Error(w, "rate limit exceeded", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
// Blocking wait: hold request until token available (or ctx expires)
if err := limiter.Wait(ctx); err != nil {
http.Error(w, "request cancelled", http.StatusServiceUnavailable)
}
Cross-Origin Resource Sharing (CORS) is required when a browser-based frontend on one domain calls an API on a different domain. Go has no built-in CORS support — you implement it as middleware or use a library like rs/cors.
// Manual CORS middleware (for learning — use a library in production)
func corsMiddleware(allowedOrigins []string) func(http.Handler) http.Handler {
originSet := make(map[string]bool)
for _, o := range allowedOrigins { originSet[o] = true }
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if originSet[origin] {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Methods",
"GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers",
"Content-Type, Authorization, X-Request-ID")
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Max-Age", "86400") // 24h preflight cache
}
// Handle preflight OPTIONS request
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
}
// Usage
handler := corsMiddleware([]string{
"https://app.example.com",
"https://admin.example.com",
})(mux)
// Production: use github.com/rs/cors
// c := cors.New(cors.Options{
// AllowedOrigins: []string{"https://app.example.com"},
// AllowedMethods: []string{"GET", "POST", "PUT", "DELETE"},
// AllowedHeaders: []string{"Authorization", "Content-Type"},
// AllowCredentials: true,
// })
// handler = c.Handler(mux)Security note: never use Access-Control-Allow-Origin: * together with Access-Control-Allow-Credentials: true — browsers reject this combination. Always whitelist specific origins when credentials are involved.
The Go convention is clear: panics are for unrecoverable programmer errors (nil pointer dereference, index out of bounds, type assertion failure). Libraries should never let panics propagate to callers — they convert panics to errors at the public API boundary.
// Library pattern: convert internal panics to errors at the boundary
func safelyParseTemplate(tmpl string) (result string, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("template parse panicked: %v", r)
}
}()
// This might panic on invalid template syntax in some implementations
result = parseTemplate(tmpl)
return result, nil
}
// HTTP recovery middleware — catch handler panics gracefully
func recoveryMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
// Log the full stack trace
buf := make([]byte, 4096)
n := runtime.Stack(buf, false)
log.Printf("panic in %s %s: %v\n%s",
r.Method, r.URL.Path, err, buf[:n])
// Return 500 to the client
http.Error(w, "internal server error",
http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
// When panic IS appropriate:
// 1. Programmer invariant violation that cannot be recovered
func mustPositive(n int) int {
if n <= 0 { panic(fmt.Sprintf("expected positive, got %d", n)) }
return n
}
// 2. Impossible type assertion that should never fail if code is correct
// 3. Test helpers: t.Fatalf is analogous — immediate stop
// When NOT to use panic:
// - File not found, network failure, validation errors → return error
// - Any expected failure condition → return error
Production Go applications distinguish between environments (development, staging, production) through configuration — not through build tags or conditional compilation. Feature flags allow gradual rollouts without redeployment.
// Environment detection via config
type Environment string
const (
EnvDevelopment Environment = "development"
EnvStaging Environment = "staging"
EnvProduction Environment = "production"
)
type Config struct {
Env Environment
Debug bool
LogLevel slog.Level
// ...
}
func loadConfig() *Config {
env := Environment(os.Getenv("APP_ENV"))
if env == "" { env = EnvDevelopment }
cfg := &Config{Env: env}
switch env {
case EnvProduction:
cfg.LogLevel = slog.LevelInfo
cfg.Debug = false
default:
cfg.LogLevel = slog.LevelDebug
cfg.Debug = true
}
return cfg
}
// Simple feature flag implementation
type FeatureFlags struct {
mu sync.RWMutex
flags map[string]bool
}
func (f *FeatureFlags) IsEnabled(name string) bool {
f.mu.RLock()
defer f.mu.RUnlock()
return f.flags[name]
}
func (f *FeatureFlags) Set(name string, enabled bool) {
f.mu.Lock()
defer f.mu.Unlock()
f.flags[name] = enabled
}
// In a handler:
func userHandler(w http.ResponseWriter, r *http.Request) {
if flags.IsEnabled("new-user-dashboard") {
renderNewDashboard(w, r)
return
}
renderLegacyDashboard(w, r)
}
Health checks are mandatory for Kubernetes deployments. A liveness probe tells Kubernetes whether the process is running (should it restart?). A readiness probe tells Kubernetes whether the pod should receive traffic (is it ready to serve?).
type HealthChecker struct {
db *sql.DB
cache *redis.Client
start time.Time
}
func NewHealthChecker(db *sql.DB, cache *redis.Client) *HealthChecker {
return &HealthChecker{db: db, cache: cache, start: time.Now()}
}
// Liveness: is the process alive? (simple, no dependency checks)
func (h *HealthChecker) LivenessHandler(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"status": "ok",
"uptime": time.Since(h.start).String(),
})
}
// Readiness: can the service handle requests? (checks dependencies)
func (h *HealthChecker) ReadinessHandler(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
type depStatus struct {
Status string `json:"status"`
Error string `json:"error,omitempty"`
}
ready := true
deps := map[string]depStatus{}
if err := h.db.PingContext(ctx); err != nil {
deps["database"] = depStatus{Status: "unhealthy", Error: err.Error()}
ready = false
} else {
deps["database"] = depStatus{Status: "healthy"}
}
if err := h.cache.Ping(ctx).Err(); err != nil {
deps["cache"] = depStatus{Status: "unhealthy", Error: err.Error()}
ready = false
} else {
deps["cache"] = depStatus{Status: "healthy"}
}
status := http.StatusOK
if !ready { status = http.StatusServiceUnavailable }
writeJSON(w, status, map[string]any{
"ready": ready,
"dependencies": deps,
})
}
// Register
hc := NewHealthChecker(db, cache)
mux.HandleFunc("GET /healthz", hc.LivenessHandler) // liveness
mux.HandleFunc("GET /readyz", hc.ReadinessHandler) // readiness
Production Go services expose Prometheus metrics and OpenTelemetry traces. Both integrate with Go's standard HTTP server and context-based propagation.
// Prometheus metrics with the standard prometheus/client_golang library
import (
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
var (
httpRequestsTotal = prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "http_requests_total",
Help: "Total number of HTTP requests",
},
[]string{"method", "path", "status"},
)
httpRequestDuration = prometheus.NewHistogramVec(
prometheus.HistogramOpts{
Name: "http_request_duration_seconds",
Help: "HTTP request duration in seconds",
Buckets: prometheus.DefBuckets,
},
[]string{"method", "path"},
)
)
func init() {
prometheus.MustRegister(httpRequestsTotal, httpRequestDuration)
}
// Metrics middleware
func metricsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
lrw := &statusResponseWriter{ResponseWriter: w, status: 200}
next.ServeHTTP(lrw, r)
dur := time.Since(start).Seconds()
path := r.URL.Path
httpRequestsTotal.WithLabelValues(r.Method, path,
strconv.Itoa(lrw.status)).Inc()
httpRequestDuration.WithLabelValues(r.Method, path).Observe(dur)
})
}
// Expose metrics endpoint
mux.Handle("GET /metrics", promhttp.Handler())
// OpenTelemetry tracing (simplified)
// otel.SetTracerProvider(tp)
// tracer := otel.Tracer("myservice")
// ctx, span := tracer.Start(ctx, "operationName")
// defer span.End()
// span.SetAttributes(attribute.String("user.id", userID))
Build constraints (build tags) allow you to include or exclude source files from compilation based on OS, architecture, Go version, or custom conditions. They are used for platform-specific code and integration test separation.
// Modern syntax (Go 1.17+) — //go:build directive
// Must be the first non-blank, non-comment line in the file
// OS-specific implementation
// File: signals_unix.go
//go:build linux || darwin
package server
import "syscall"
func shutdownSignals() []os.Signal {
return []os.Signal{syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP}
}
// File: signals_windows.go
//go:build windows
package server
func shutdownSignals() []os.Signal {
return []os.Signal{os.Interrupt}
}
// Integration test exclusion
// File: integration_test.go
//go:build integration
package api_test
// Run only with: go test -tags integration ./...
func TestDatabaseIntegration(t *testing.T) {
db := openRealDB(t)
// ...
}
// Constraint operators:
// //go:build linux && amd64 — AND
// //go:build linux || darwin — OR
// //go:build !windows — NOT
// //go:build go1.21 — minimum Go version
// Filename convention (alternative, no build tag needed):
// file_linux.go — only on Linux
// file_windows_amd64.go — only on Windows/amd64
// file_test.go — only in test builds
The default http.DefaultClient is not suitable for production — it has no timeouts, uses a shared transport, and cannot be instrumented. Production code creates dedicated clients with explicit configuration.
// Production HTTP client — never use http.DefaultClient in libraries
func newHTTPClient() *http.Client {
return &http.Client{
Timeout: 30 * time.Second, // total request timeout including body read
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
// Keep-alive enabled by default
DisableKeepAlives: false,
},
}
}
// Reuse clients — they manage connection pools internally
var apiClient = newHTTPClient()
// Always use context for cancellation and timeout
func callAPI(ctx context.Context, url string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "myservice/1.0")
resp, err := apiClient.Do(req)
if err != nil {
return nil, fmt.Errorf("executing request: %w", err)
}
defer resp.Body.Close()
// Always read and close body — even on error status
body, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20)) // 10 MB limit
if err != nil {
return nil, fmt.Errorf("reading body: %w", err)
}
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, body)
}
return body, nil
}
// RETRY with exponential backoff for idempotent requests
func callWithRetry(ctx context.Context, url string, maxRetries int) ([]byte, error) {
var lastErr error
for attempt := 0; attempt < maxRetries; attempt++ {
if attempt > 0 {
delay := time.Duration(attempt*attempt) * 100 * time.Millisecond
select {
case <-time.After(delay):
case <-ctx.Done(): return nil, ctx.Err()
}
}
data, err := callAPI(ctx, url)
if err == nil { return data, nil }
lastErr = err
}
return nil, fmt.Errorf("after %d retries: %w", maxRetries, lastErr)
}
Go HTTP handlers cannot return errors — the function signature is func(http.ResponseWriter, *http.Request). Several patterns solve this: the custom handler type, the handler error interface, or a response helper pattern.
// Pattern 1: custom handler type that returns error
type HandlerFunc func(w http.ResponseWriter, r *http.Request) error
func (h HandlerFunc) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if err := h(w, r); err != nil {
handleError(w, r, err)
}
}
// Typed API errors
type APIError struct {
Status int `json:"-"`
Code string `json:"code"`
Message string `json:"message"`
}
func (e *APIError) Error() string { return e.Message }
// Central error handler
func handleError(w http.ResponseWriter, r *http.Request, err error) {
var apiErr *APIError
if errors.As(err, &apiErr) {
writeJSON(w, apiErr.Status, apiErr)
return
}
if errors.Is(err, ErrNotFound) {
writeJSON(w, http.StatusNotFound,
&APIError{Code: "NOT_FOUND", Message: "resource not found"})
return
}
// Unknown error — log and return generic 500
log.Printf("unhandled error %s %s: %v", r.Method, r.URL.Path, err)
writeJSON(w, http.StatusInternalServerError,
&APIError{Code: "INTERNAL", Message: "internal server error"})
}
// Handler using the custom type
mux.Handle("GET /users/{id}", HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
return &APIError{Status: 400, Code: "INVALID_ID",
Message: "id must be an integer"}
}
user, err := userService.FindByID(r.Context(), id)
if err != nil {
return fmt.Errorf("finding user: %w", err) // wraps ErrNotFound
}
return writeJSON(w, http.StatusOK, user)
}))
API versioning allows a service to evolve without breaking existing clients. Go supports several approaches, each with different trade-offs in URL clarity, header complexity, and routing ease.
// Strategy 1: URL path versioning (most common, most explicit)
mux.HandleFunc("GET /v1/users/{id}", v1GetUser)
mux.HandleFunc("GET /v2/users/{id}", v2GetUser) // new format, new endpoint
// Sub-router pattern for clean version grouping
func v1Routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /users/{id}", v1GetUser)
mux.HandleFunc("POST /users", v1CreateUser)
return mux
}
func v2Routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /users/{id}", v2GetUser)
mux.HandleFunc("POST /users", v2CreateUser)
return mux
}
mainMux := http.NewServeMux()
mainMux.Handle("/v1/", http.StripPrefix("/v1", v1Routes()))
mainMux.Handle("/v2/", http.StripPrefix("/v2", v2Routes()))
// Strategy 2: Accept header versioning (content negotiation)
// Accept: application/vnd.myapi.v2+json
func versionedHandler(w http.ResponseWriter, r *http.Request) {
accept := r.Header.Get("Accept")
switch {
case strings.Contains(accept, "vnd.myapi.v2"):
v2GetUser(w, r)
default:
v1GetUser(w, r)
}
}
// Strategy 3: Query parameter (least preferred)
// GET /users/1?version=2
// Backward compatibility within a version:
// - Add new optional fields (never remove)
// - Use omitempty on optional response fields
// - Return 422 for unrecognised required fields
// - Treat unknown JSON keys in requests as valid (ignore, don't error)
Go programs receive OS signals through the os/signal package. Signals are delivered to Go channels via signal.Notify. Common production uses: graceful shutdown (SIGTERM), config reload (SIGHUP), and heap dump triggering (SIGUSR1).
import (
"os"
"os/signal"
"syscall"
)
// Standard graceful shutdown
func waitForShutdown(srv *http.Server, cleanup func()) {
quit := make(chan os.Signal, 1)
// Always use a buffered channel — avoid missing a signal if handler is slow
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
defer signal.Stop(quit) // unregister when function exits
<-quit
log.Println("shutdown signal received")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
srv.Shutdown(ctx)
cleanup()
}
// SIGHUP: reload configuration without restart
func watchConfigReload(cfg *atomic.Pointer[Config]) {
reload := make(chan os.Signal, 1)
signal.Notify(reload, syscall.SIGHUP)
for range reload {
newCfg, err := loadConfig()
if err != nil {
log.Printf("config reload failed: %v", err)
continue
}
cfg.Store(newCfg)
log.Println("configuration reloaded")
}
}
// Multiple signals: select over several signal channels
func signalRouter() {
sigint := make(chan os.Signal, 1)
sigusr1 := make(chan os.Signal, 1)
signal.Notify(sigint, syscall.SIGINT, syscall.SIGTERM)
signal.Notify(sigusr1, syscall.SIGUSR1)
for {
select {
case <-sigint: initiateShutdown(); return
case <-sigusr1: dumpHeapProfile()
}
}
}
Go ships a built-in profiler accessible via HTTP when you import net/http/pprof. Adding this to a running service provides CPU profiles, heap snapshots, goroutine dumps, and block profiles without restarting the service.
// Add to main or a dedicated debug server
import _ "net/http/pprof" // side-effect import registers /debug/pprof/ handlers
// Expose on a separate internal port (never public-facing)
go func() {
log.Println(http.ListenAndServe("localhost:6060", nil))
}()
// Available endpoints:
// /debug/pprof/ — index page
// /debug/pprof/goroutine — goroutine dump
// /debug/pprof/heap — heap snapshot
// /debug/pprof/profile?seconds=30 — 30s CPU profile
// /debug/pprof/trace?seconds=5 — execution trace
// /debug/pprof/block — goroutine blocking
// /debug/pprof/mutex — mutex contention
// CLI usage:
// go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30
// > top10 — top CPU-consuming functions
// > web — opens flame graph in browser
// > list funcName — annotated source code
// Heap profile:
// go tool pprof http://localhost:6060/debug/pprof/heap
// > top10 -cum — cumulative allocations
// > alloc_space — total bytes allocated (not just live)
// > inuse_space — currently live bytes
// Goroutine leak check:
// curl http://localhost:6060/debug/pprof/goroutine?debug=2
// Shows full stack traces of all goroutines
// Programmatic profiling in tests:
// go test -bench=. -cpuprofile=cpu.out -memprofile=mem.out
// go tool pprof cpu.out
The Go module system (introduced in Go 1.11, stable in Go 1.13) manages dependencies through a go.mod file. It replaced GOPATH-based dependency management and provides reproducible builds through cryptographic checksums in go.sum.
// go.mod anatomy
module github.com/myorg/myservice
go 1.22
require (
github.com/lib/pq v1.10.7
golang.org/x/sync v0.6.0
)
require (
// Indirect dependencies (transitive)
github.com/some/dep v1.2.3 // indirect
)
// Key commands:
// Initialise a new module
// go mod init github.com/myorg/myservice
// Add or upgrade a dependency
// go get github.com/lib/pq@v1.10.7
// go get github.com/lib/pq@latest
// Remove unused and add missing dependencies
// go mod tidy
// Verify checksums
// go mod verify
// Vendor dependencies (for reproducible builds or air-gapped environments)
// go mod vendor
// go build -mod=vendor ./...
// Replace directive: use local fork during development
// replace github.com/myorg/lib => ../local-lib
// Minimum Version Selection (MVS):
// Go selects the MINIMUM version of each dependency that satisfies
// all requirements in the module graph — deterministic, no implicit upgrades
// Unlike npm/pip: go get explicitly upgrades, nothing upgrades silently
Input validation is a layered concern: structural validation (is the JSON well-formed?), field validation (are required fields present, within range?), and business validation (does the email already exist?). Go handles this without a framework using explicit checks and helper functions.
// Request and validation error types
type CreateUserRequest struct {
Name string `json:"name"`
Email string `json:"email"`
Age int `json:"age"`
}
type ValidationErrors map[string]string
func (ve ValidationErrors) Error() string {
msgs := make([]string, 0, len(ve))
for k, v := range ve { msgs = append(msgs, k+": "+v) }
return strings.Join(msgs, ", ")
}
// Validator function
func validateCreateUser(req CreateUserRequest) error {
errs := ValidationErrors{}
if strings.TrimSpace(req.Name) == "" {
errs["name"] = "is required"
} else if len(req.Name) > 100 {
errs["name"] = "must be 100 characters or fewer"
}
if req.Email == "" {
errs["email"] = "is required"
} else if !isValidEmail(req.Email) {
errs["email"] = "is not a valid email address"
}
if req.Age < 0 || req.Age > 150 {
errs["age"] = "must be between 0 and 150"
}
if len(errs) > 0 { return errs }
return nil
}
// Handler
func createUserHandler(w http.ResponseWriter, r *http.Request) error {
var req CreateUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
return &APIError{Status: 400, Code: "INVALID_JSON",
Message: "request body must be valid JSON"}
}
if err := validateCreateUser(req); err != nil {
var ve ValidationErrors
if errors.As(err, &ve) {
writeJSON(w, http.StatusUnprocessableEntity,
map[string]any{"errors": ve})
return nil
}
return err
}
user, err := userService.Create(r.Context(), req)
if err != nil { return err }
return writeJSON(w, http.StatusCreated, user)
}
Streaming is useful when the response is large, generated incrementally, or delivered in real-time (server-sent events, file downloads). Go's http.Flusher interface allows the handler to push buffered data to the client without waiting for the full response.
// Check if ResponseWriter supports flushing
func streamHandler(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming not supported", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
ctx := r.Context()
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return // client disconnected
case t := <-ticker.C:
fmt.Fprintf(w, "data: %s\n\n", t.Format(time.RFC3339))
flusher.Flush() // push to client immediately
}
}
}
// Large file download — stream without loading into memory
func downloadHandler(w http.ResponseWriter, r *http.Request) {
f, err := os.Open("/data/large-file.csv")
if err != nil {
http.Error(w, "file not found", http.StatusNotFound)
return
}
defer f.Close()
w.Header().Set("Content-Type", "text/csv")
w.Header().Set("Content-Disposition", `attachment; filename="data.csv"`)
// io.Copy streams from file to response in 32 KB chunks
// without loading the entire file into memory
if _, err := io.Copy(w, f); err != nil {
log.Printf("download error: %v", err)
}
}
// NDJSON streaming (newline-delimited JSON for large result sets)
func streamResults(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/x-ndjson")
enc := json.NewEncoder(w)
for _, item := range largeResultSet {
enc.Encode(item) // writes JSON + newline
flusher.Flush()
}
}
Go's testing package has built-in benchmark support. Benchmarks are functions with signature func BenchmarkXxx(b *testing.B) and run with go test -bench=.. The framework automatically calibrates the number of iterations.
// Benchmark function
func BenchmarkJSONMarshal(b *testing.B) {
user := User{ID: 1, Name: "Alice", Email: "alice@example.com"}
b.ResetTimer() // exclude setup time from measurement
for i := 0; i < b.N; i++ { // b.N is auto-calibrated
_, err := json.Marshal(user)
if err != nil {
b.Fatal(err)
}
}
}
// Memory allocations benchmark
func BenchmarkStringBuilder(b *testing.B) {
words := []string{"hello", "world", "foo", "bar"}
b.ReportAllocs() // show allocations in output
b.ResetTimer()
for i := 0; i < b.N; i++ {
var sb strings.Builder
for _, w := range words {
sb.WriteString(w)
sb.WriteByte(' ')
}
_ = sb.String()
}
}
// Run benchmarks:
// go test -bench=. -benchmem ./...
// -benchmem: show memory allocations
// -benchtime=5s: run for 5 seconds instead of default 1s
// -count=5: run 5 times for stable results
// Output:
// BenchmarkJSONMarshal-8 1234567 987 ns/op 256 B/op 3 allocs/op
// Columns: name, iterations, ns per op, bytes per op, allocs per op
// Compare benchmarks with benchstat:
// go test -bench=. -count=5 > before.txt
// # make changes
// go test -bench=. -count=5 > after.txt
// benchstat before.txt after.txt # shows % improvement and p-value
//go:embed (Go 1.16) embeds files and directories into the compiled binary at build time. This eliminates the need to distribute static assets separately — the binary is fully self-contained.
import "embed"
// Embed a single file as a string
//go:embed VERSION
var version string
// Embed a single file as bytes
//go:embed config/default.yaml
var defaultConfig []byte
// Embed multiple files as an FS
//go:embed static/*
var staticFiles embed.FS
// Embed entire directory tree (include hidden files with all:)
//go:embed all:templates
var templates embed.FS
// Serving embedded static files
func main() {
// Serve from embedded FS
staticFS, err := fs.Sub(staticFiles, "static")
if err != nil { log.Fatal(err) }
mux := http.NewServeMux()
mux.Handle("/static/",
http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))
// HTML template from embedded FS
tmpl := template.Must(
template.New("").ParseFS(templates, "templates/*.html"))
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
tmpl.ExecuteTemplate(w, "index.html", map[string]any{
"Version": version,
})
})
http.ListenAndServe(":8080", mux)
}
// embed.FS implements fs.FS — compatible with all fs.FS consumers
Context-based timeouts apply to any blocking operation — database queries, cache lookups, file operations, and external gRPC calls. The pattern is identical: create a child context with a deadline and pass it to every blocking call.
// Database query with timeout
func getUserFromDB(ctx context.Context, db *sql.DB, id int) (*User, error) {
// Add query-level timeout (in addition to any HTTP request timeout)
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
row := db.QueryRowContext(ctx,
"SELECT id, name, email FROM users WHERE id = $1", id)
var u User
if err := row.Scan(&u.ID, &u.Name, &u.Email); err != nil {
if errors.Is(err, context.DeadlineExceeded) {
return nil, fmt.Errorf("DB query timed out after 2s: %w", err)
}
return nil, fmt.Errorf("getUserFromDB: %w", err)
}
return &u, nil
}
// Multiple operations with individual timeouts
func getUserProfile(ctx context.Context, userID int) (*Profile, error) {
// DB: 2s timeout
dbCtx, dbCancel := context.WithTimeout(ctx, 2*time.Second)
defer dbCancel()
user, err := getUserFromDB(dbCtx, db, userID)
if err != nil { return nil, err }
// Cache: 500ms timeout
cacheCtx, cacheCancel := context.WithTimeout(ctx, 500*time.Millisecond)
defer cacheCancel()
prefs, _ := getPrefsFromCache(cacheCtx, userID) // non-fatal if cache misses
// External API: 3s timeout
apiCtx, apiCancel := context.WithTimeout(ctx, 3*time.Second)
defer apiCancel()
avatar, err := fetchAvatarFromCDN(apiCtx, user.AvatarURL)
if err != nil {
log.Printf("avatar fetch failed (non-fatal): %v", err)
}
return buildProfile(user, prefs, avatar), nil
}
A consistent error response format makes APIs predictable for clients. The RFC 7807 (Problem Details for HTTP APIs) standard provides a widely adopted structure. Go implementations typically define a consistent JSON error envelope.
// RFC 7807-inspired error envelope
type ProblemDetail struct {
Type string `json:"type"` // URI identifying the problem type
Title string `json:"title"` // short human-readable summary
Status int `json:"status"` // HTTP status code
Detail string `json:"detail"` // human-readable explanation
Instance string `json:"instance"` // URI of the specific occurrence
// Extension fields
Errors map[string]string `json:"errors,omitempty"` // field-level errors
TraceID string `json:"trace_id,omitempty"`
}
// Constructor helpers
func notFound(detail, instance string) *ProblemDetail {
return &ProblemDetail{
Type: "https://api.example.com/errors/not-found",
Title: "Resource Not Found",
Status: http.StatusNotFound,
Detail: detail,
Instance: instance,
}
}
func validationError(errs map[string]string) *ProblemDetail {
return &ProblemDetail{
Type: "https://api.example.com/errors/validation",
Title: "Validation Failed",
Status: http.StatusUnprocessableEntity,
Detail: "One or more fields failed validation",
Errors: errs,
}
}
// Middleware: attach trace ID to all error responses
func errorResponseMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/problem+json")
next.ServeHTTP(w, r)
})
}
// Usage in handler
user, err := svc.FindUser(r.Context(), id)
if errors.Is(err, ErrNotFound) {
prob := notFound(
fmt.Sprintf("user with id %d does not exist", id),
r.URL.Path,
)
w.Header().Set("Content-Type", "application/problem+json")
w.WriteHeader(prob.Status)
json.NewEncoder(w).Encode(prob)
return
}
Two common pagination strategies: offset-based (page/limit) and cursor-based (token-based). Cursor pagination scales better for large datasets and handles real-time data insertion without the duplicate/skip problems of offset pagination.
// Offset-based pagination (simple, common for small datasets)
type OffsetPagination struct {
Page int `json:"page"` // 1-indexed
PerPage int `json:"per_page"`
Total int `json:"total"`
}
func listUsersHandler(w http.ResponseWriter, r *http.Request) error {
page := parseIntParam(r, "page", 1)
perPage := parseIntParam(r, "per_page", 20)
if perPage > 100 { perPage = 100 } // cap per-page
offset := (page - 1) * perPage
users, total, err := userRepo.List(r.Context(), offset, perPage)
if err != nil { return err }
return writeJSON(w, http.StatusOK, map[string]any{
"data": users,
"pagination": OffsetPagination{Page: page, PerPage: perPage, Total: total},
})
}
// Cursor-based pagination (better for large/live datasets)
type CursorPage struct {
Data any `json:"data"`
NextCursor string `json:"next_cursor,omitempty"`
HasMore bool `json:"has_more"`
}
func listUsersCursor(w http.ResponseWriter, r *http.Request) error {
cursor := r.URL.Query().Get("cursor")
limit := parseIntParam(r, "limit", 20)
// Decode opaque cursor (e.g., base64(id+timestamp))
var afterID int
if cursor != "" {
afterID = decodeCursor(cursor)
}
users, err := userRepo.ListAfterID(r.Context(), afterID, limit+1)
if err != nil { return err }
hasMore := len(users) > limit
if hasMore { users = users[:limit] }
var nextCursor string
if hasMore {
nextCursor = encodeCursor(users[len(users)-1].ID)
}
return writeJSON(w, http.StatusOK, CursorPage{
Data: users, NextCursor: nextCursor, HasMore: hasMore,
})
}
JWT (JSON Web Token) authentication middleware validates the token on every request, extracts claims, and attaches them to the request context for use by downstream handlers.
import "github.com/golang-jwt/jwt/v5"
type Claims struct {
UserID int `json:"user_id"`
Role string `json:"role"`
jwt.RegisteredClaims
}
type contextKey string
const claimsKey contextKey = "claims"
func jwtMiddleware(secret []byte) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Extract token from Authorization: Bearer
auth := r.Header.Get("Authorization")
if !strings.HasPrefix(auth, "Bearer ") {
writeJSON(w, http.StatusUnauthorized,
map[string]string{"error": "missing bearer token"})
return
}
tokenString := strings.TrimPrefix(auth, "Bearer ")
// Parse and validate
var claims Claims
token, err := jwt.ParseWithClaims(tokenString, &claims,
func(token *jwt.Token) (any, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v",
token.Header["alg"])
}
return secret, nil
})
if err != nil || !token.Valid {
writeJSON(w, http.StatusUnauthorized,
map[string]string{"error": "invalid or expired token"})
return
}
// Attach claims to context
ctx := context.WithValue(r.Context(), claimsKey, &claims)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// Helper to extract claims in handlers
func claimsFromCtx(ctx context.Context) (*Claims, bool) {
c, ok := ctx.Value(claimsKey).(*Claims)
return c, ok
}
HTTP caching reduces load and improves response times. Go services implement caching at multiple levels: HTTP Cache-Control headers (browser/CDN caching), application-level caching (Redis/in-memory), and conditional requests (ETag/Last-Modified).
// HTTP Cache-Control headers
func publicDataHandler(w http.ResponseWriter, r *http.Request) {
// Cache in browser and CDN for 60s, stale for 10s
w.Header().Set("Cache-Control", "public, max-age=60, stale-while-revalidate=10")
w.Header().Set("Vary", "Accept-Encoding") // vary by encoding
// ... serve data
}
func privateDataHandler(w http.ResponseWriter, r *http.Request) {
// No CDN caching — only browser may cache, private to user
w.Header().Set("Cache-Control", "private, max-age=30")
// ... serve user-specific data
}
// ETag-based conditional caching
func userHandler(w http.ResponseWriter, r *http.Request) {
user, err := svc.GetUser(r.Context(), r.PathValue("id"))
if err != nil { /* handle error */ return }
// Compute ETag (hash of the content)
data, _ := json.Marshal(user)
etag := fmt.Sprintf(`"%x"`, md5.Sum(data))
w.Header().Set("ETag", etag)
w.Header().Set("Cache-Control", "private, must-revalidate")
// Check if client has current version
if r.Header.Get("If-None-Match") == etag {
w.WriteHeader(http.StatusNotModified) // 304: send no body
return
}
w.Header().Set("Content-Type", "application/json")
w.Write(data)
}
// In-memory LRU cache middleware
func cacheMiddleware(cache *lru.Cache, ttl time.Duration) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet { next.ServeHTTP(w, r); return }
key := r.URL.RequestURI()
if val, ok := cache.Get(key); ok {
w.Header().Set("X-Cache", "HIT")
w.Write(val.([]byte)); return
}
crw := &capturingResponseWriter{ResponseWriter: w}
next.ServeHTTP(crw, r)
cache.Add(key, crw.body)
})
}
}
gRPC is a high-performance RPC framework using Protocol Buffers (binary serialisation) over HTTP/2. Go has first-class gRPC support through google.golang.org/grpc. It is the standard for inter-service communication in Go microservices.
// user.proto defines the service contract
// service UserService {
// rpc GetUser(GetUserRequest) returns (User);
// rpc StreamUsers(Empty) returns (stream User);
// }
// Generated Go code — implement the server interface
type userServiceServer struct {
pb.UnimplementedUserServiceServer // embed for forward compatibility
repo UserRepository
}
func (s *userServiceServer) GetUser(
ctx context.Context,
req *pb.GetUserRequest,
) (*pb.User, error) {
user, err := s.repo.FindByID(ctx, int(req.Id))
if err != nil {
if errors.Is(err, ErrNotFound) {
return nil, status.Errorf(codes.NotFound, "user %d not found", req.Id)
}
return nil, status.Errorf(codes.Internal, "internal error: %v", err)
}
return &pb.User{Id: int64(user.ID), Name: user.Name, Email: user.Email}, nil
}
// Start gRPC server
func main() {
lis, err := net.Listen("tcp", ":9090")
if err != nil { log.Fatal(err) }
grpcServer := grpc.NewServer(
grpc.ChainUnaryInterceptor(
loggingInterceptor,
recoveryInterceptor,
),
)
pb.RegisterUserServiceServer(grpcServer, &userServiceServer{repo: repo})
grpcServer.Serve(lis)
}| Aspect | REST/JSON | gRPC/Protobuf |
|---|---|---|
| Serialisation | JSON (text, ~30 bytes/field) | Protobuf (binary, ~3 bytes/field) |
| Browser support | Native | Requires grpc-web proxy |
| Code generation | Optional | Required (protoc) |
| Streaming | SSE or WebSocket workarounds | Built-in bidirectional streaming |
| Best for | Public APIs, browser clients | Internal service-to-service RPC |
Go has excellent static analysis tooling built into the ecosystem. Combining multiple linters via golangci-lint catches common bugs, style violations, and security issues before code review.
| Tool | Purpose |
|---|---|
| go vet | Built-in: detects suspicious code (Printf format mismatches, unreachable code, mutex copies) |
| staticcheck | Advanced bug detection: deprecated API use, logic errors go vet misses |
| errcheck | Ensures error return values are not silently ignored |
| golangci-lint | Meta-linter runner: runs 50+ linters with one command and config |
| govulncheck | Security: finds known CVEs in dependencies |
| gofmt / goimports | Code formatting and import organisation — non-negotiable in CI |
| goleak | Test helper: detects goroutine leaks after test completion |
| shadow | Detects variable shadowing that can hide bugs |
# .golangci.yml — typical production configuration
linters:
enable:
- govet # static analysis
- errcheck # unchecked errors
- staticcheck # advanced analysis
- gosec # security issues
- misspell # spelling errors in comments/strings
- gofmt # formatting
- goimports # import ordering
- revive # style and best practices
- prealloc # suggest slice pre-allocation
- noctx # HTTP requests without context
linters-settings:
errcheck:
check-type-assertions: true # flag unchecked type assertions too
# Run:
# golangci-lint run ./...
# govulncheck ./...
# CI pipeline minimum:
# 1. go build ./...
# 2. go vet ./...
# 3. golangci-lint run ./...
# 4. go test -race -count=1 ./...
# 5. govulncheck ./...
Go does not mandate a project layout, but the community has converged on a practical structure that separates concerns without over-engineering. The key principle: packages should be named for what they contain, not what they do.
myservice/
├── cmd/
│ └── server/
│ └── main.go # entry point — only wires dependencies
├── internal/ # private packages — not importable by other modules
│ ├── api/ # HTTP handlers and middleware
│ │ ├── handler_user.go
│ │ └── middleware.go
│ ├── domain/ # business logic — no infrastructure dependencies
│ │ ├── user.go # User type, UserService interface
│ │ └── user_service.go # UserService implementation
│ ├── store/ # data access layer
│ │ ├── postgres/
│ │ │ └── user_store.go # postgres implementation of UserRepository
│ │ └── memory/
│ │ └── user_store.go # in-memory implementation for tests
│ └── config/
│ └── config.go # configuration loading and validation
├── pkg/ # reusable packages (can be imported externally)
│ └── httputil/
│ └── response.go # writeJSON, writeError helpers
├── migrations/ # database migration SQL files
├── docker/ # Dockerfile, docker-compose.yml
├── go.mod
├── go.sum
├── Makefile # build, test, lint targets
└── README.md
// Key decisions:
// internal/ prevents external packages from importing your internal logic
// cmd/ can have multiple binaries (server, worker, cli)
// domain/ has NO imports from store/ or api/ — dependency flows inward
// Tests live alongside the code they test (*_test.go files)
// main.go is thin — create, connect, start; no business logic
Load shedding protects a service from cascading failure under overload: when the system cannot keep up, it deliberately rejects additional requests rather than slowing down and failing all requests. Go implements this through semaphores and queue limits on the HTTP layer.
// Concurrency limiter: max N requests processed simultaneously
type concurrencyLimiter struct {
sem chan struct{}
}
func newConcurrencyLimiter(max int) *concurrencyLimiter {
return &concurrencyLimiter{sem: make(chan struct{}, max)}
}
func (l *concurrencyLimiter) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case l.sem <- struct{}{}: // acquire slot
default:
// Immediately reject if at capacity — no waiting
w.Header().Set("Retry-After", "1")
http.Error(w, "server busy — try again later",
http.StatusServiceUnavailable)
return
}
defer func() { <-l.sem }() // release slot
next.ServeHTTP(w, r)
})
}
// Request queue with timeout
type queuedLimiter struct {
queue chan struct{}
timeout time.Duration
}
func (l *queuedLimiter) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), l.timeout)
defer cancel()
select {
case l.queue <- struct{}{}: // wait up to timeout for a slot
case <-ctx.Done():
http.Error(w, "request queue full",
http.StatusServiceUnavailable)
return
}
defer func() { <-l.queue }()
next.ServeHTTP(w, r)
})
}
// Wire up
limiter := newConcurrencyLimiter(100) // max 100 concurrent requests
handler := limiter.Middleware(rateLimiter.Middleware(mux))
When multiple goroutines run in parallel, errors must be collected and propagated without data races or goroutine leaks. The idiomatic tools are errgroup for structured concurrency and buffered channels for ad-hoc patterns.
import "golang.org/x/sync/errgroup"
// Pattern 1: errgroup — run N tasks, fail fast on first error
func fetchUserProfile(ctx context.Context, userID int) (*Profile, error) {
g, ctx := errgroup.WithContext(ctx)
// gctx cancelled when any goroutine fails or g.Wait() returns
var user *User
g.Go(func() error {
var err error
user, err = userRepo.FindByID(ctx, userID)
return fmt.Errorf("fetching user: %w", err)
})
var orders []Order
g.Go(func() error {
var err error
orders, err = orderRepo.ListByUser(ctx, userID)
return fmt.Errorf("fetching orders: %w", err)
})
if err := g.Wait(); err != nil {
return nil, err // first error from any goroutine
}
return buildProfile(user, orders), nil
}
// Pattern 2: collect ALL errors (don't fail fast)
func validateItems(ctx context.Context, items []Item) error {
errs := make([]error, len(items))
var wg sync.WaitGroup
wg.Add(len(items))
for i, item := range items {
go func(idx int, it Item) {
defer wg.Done()
errs[idx] = validateItem(ctx, it) // each goroutine writes its own slot
}(i, item)
}
wg.Wait()
// Filter nil errors and join
var nonNil []error
for _, e := range errs { if e != nil { nonNil = append(nonNil, e) } }
return errors.Join(nonNil...)
}
// errgroup with concurrency limit (Go 1.20+)
g.SetLimit(10) // max 10 goroutines running at once
for _, item := range items {
item := item
g.Go(func() error { return process(ctx, item) })
}
Documentation is a first-class concern for production APIs. Go projects adopt either code-first (generate OpenAPI from code annotations) or design-first (write OpenAPI spec, generate server stubs) approaches.
// Approach 1: code-first with swaggo/swag annotations
// go install github.com/swaggo/swag/cmd/swag@latest
// swag init -g cmd/server/main.go → generates docs/swagger.json
// @Summary Get user by ID
// @Description Returns a single user by their unique identifier
// @Tags users
// @Accept json
// @Produce json
// @Param id path int true "User ID"
// @Success 200 {object} User
// @Failure 404 {object} ProblemDetail
// @Failure 500 {object} ProblemDetail
// @Router /users/{id} [get]
func getUserHandler(w http.ResponseWriter, r *http.Request) {
// handler implementation
}
// Approach 2: design-first with oapi-codegen
// Write openapi.yaml first, then generate:
// oapi-codegen -package api -generate types,server openapi.yaml > api/api.gen.go
// Generated interface — implement this in your handler
type StrictServerInterface interface {
GetUser(ctx context.Context, request GetUserRequestObject) (GetUserResponseObject, error)
CreateUser(ctx context.Context, request CreateUserRequestObject) (CreateUserResponseObject, error)
}
// Serve Swagger UI and spec
mux.Handle("GET /docs/",
http.StripPrefix("/docs/", swaggerui.Handler("openapi.yaml")))
mux.Handle("GET /openapi.yaml", func(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "openapi.yaml")
})Design-first advantages: the API contract is established before implementation — frontend and backend teams can work in parallel. Type safety is enforced by generated code. Breaking changes are visible in spec diffs before any code runs.
This summary covers the essential checks interviewers probe when asking 'is this service production-ready?' It combines all the topics from this set into a concise reference.
| Category | Requirement |
|---|---|
| Error handling | All errors wrapped with %w; errors.Is/As used for inspection; typed nil trap avoided |
| Context | context.Context propagated to all blocking operations; cancel always deferred |
| HTTP server | ReadTimeout, WriteTimeout, IdleTimeout configured; graceful shutdown implemented |
| Middleware | Recovery, logging, auth, metrics, rate limiting applied in correct order |
| Logging | Structured logging (slog) with request IDs; no sensitive data logged |
| Metrics | Prometheus /metrics endpoint; request count, latency, error rate tracked |
| Health checks | /healthz (liveness) and /readyz (readiness) endpoints |
| Configuration | Loaded and validated at startup; no os.Getenv in business logic |
| Security | JWT validated (alg checked); input sanitised; CORS configured |
| Database | Connection pool configured; all queries use context; rows.Close deferred |
| Graceful shutdown | SIGTERM handled; in-flight requests drained; connections closed |
| Testing | Table-driven tests; -race flag in CI; goleak in goroutine tests |
| Profiling | pprof on internal port; not public-facing |
| Linting | golangci-lint + govulncheck in CI pipeline |
// One-line checklist for interview answers:
// 1. Errors: always wrap (%w), never swallow, typed nil trap avoided
// 2. Context: propagate to all I/O, defer cancel(), use r.Context() in handlers
// 3. HTTP server: set all timeouts, graceful shutdown on SIGTERM
// 4. Middleware chain: recovery → logging → auth → rate limiting → handler
// 5. Configuration: load + validate at startup, inject as struct
// 6. Health: /healthz (alive?) + /readyz (ready?) separate endpoints
// 7. Observability: structured logs + Prometheus metrics + pprof
// 8. Security: validate JWT alg, sanitise inputs, whitelist CORS origins
// 9. Testing: table-driven, -race, httptest.NewRecorder, mock via interfaces
// 10. CI: vet + golangci-lint + govulncheck + go test -race
