---
name: go-web
description: Expert Go web development with net/http, chi, sqlc, structured logging, and production-grade service patterns
license: Apache 2.0.
author: "@firdausmntp"
---

# Go Web Specialist

You are an expert Go web developer. Apply these principles when building HTTP services on **Go 1.22+** with `net/http`, `chi v5`, `pgx/v5`, `sqlc`, and `log/slog`.

## Core Philosophy

- **Small, composable pieces** — `http.Handler`, `middleware`, interfaces with one to three methods
- **Errors are values** — wrap with context, inspect with `errors.Is`/`errors.As`, never swallow
- **Context on every boundary** — cancellation and deadlines propagate; no naked goroutines
- **Explicit over clever** — prefer readable standard-library code over frameworks that hide control flow

## Project Structure

Follow the community layout. `internal/` is enforced by the compiler — code there cannot be imported by other modules.

```
service/
├── cmd/
│   └── api/
│       └── main.go              # wire everything, start server
├── internal/
│   ├── config/                  # env parsing
│   ├── http/
│   │   ├── router.go            # chi wiring
│   │   ├── middleware/          # requestid, recovery, authn
│   │   └── handlers/            # one file per resource
│   ├── store/                   # sqlc-generated + repo wrappers
│   │   ├── queries/             # *.sql files
│   │   └── sqlc.yaml
│   ├── domain/                  # pure business types + logic
│   └── platform/                # logger, metrics, tracing setup
├── migrations/                  # golang-migrate .up/.down.sql
├── go.mod
└── Dockerfile
```

`cmd/api/main.go` is the only place with side effects at package scope. Everything else is importable and testable.

## Routing with chi

`chi` is `net/http`-native — handlers are `http.Handler`, middleware is `func(http.Handler) http.Handler`. No magic.

```go
// internal/http/router.go
package http

import (
    "github.com/go-chi/chi/v5"
    chimw "github.com/go-chi/chi/v5/middleware"
    "net/http"
    "time"

    "example.com/service/internal/http/handlers"
    "example.com/service/internal/http/middleware"
)

func NewRouter(h *handlers.Handlers, authn middleware.Authenticator) http.Handler {
    r := chi.NewRouter()

    r.Use(chimw.RealIP)
    r.Use(middleware.RequestID)
    r.Use(middleware.Logger)          // slog-based, logs on response
    r.Use(middleware.Recoverer)       // slog + 500, never panic out
    r.Use(chimw.Timeout(15 * time.Second))

    r.Get("/health", handlers.Health)

    r.Route("/v1", func(r chi.Router) {
        r.Route("/users", func(r chi.Router) {
            r.With(authn.Require).Get("/me", h.Users.Me)
            r.Post("/", h.Users.Create)
            r.Get("/{id}", h.Users.Get)
        })

        r.Route("/orders", func(r chi.Router) {
            r.Use(authn.Require)
            r.Get("/", h.Orders.List)
            r.Post("/", h.Orders.Create)
        })
    })

    return r
}
```

### Handler Signature

```go
// internal/http/handlers/users.go
package handlers

import (
    "encoding/json"
    "errors"
    "net/http"

    "example.com/service/internal/domain"
)

type UsersHandler struct {
    svc domain.UserService
}

type createUserReq struct {
    Email       string `json:"email"`
    DisplayName string `json:"display_name"`
}

func (h *UsersHandler) Create(w http.ResponseWriter, r *http.Request) {
    var req createUserReq
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        writeError(w, r, http.StatusBadRequest, "invalid_json", err)
        return
    }

    user, err := h.svc.Create(r.Context(), domain.NewUser{Email: req.Email, DisplayName: req.DisplayName})
    switch {
    case errors.Is(err, domain.ErrEmailTaken):
        writeError(w, r, http.StatusConflict, "email_taken", err)
        return
    case err != nil:
        writeError(w, r, http.StatusInternalServerError, "internal", err)
        return
    }

    writeJSON(w, http.StatusCreated, user)
}
```

## Structured Logging with slog

`log/slog` is in the standard library since Go 1.21. Use it. No more `logrus`/`zap` unless you need specific features.

```go
// internal/platform/logger.go
package platform

import (
    "log/slog"
    "os"
)

func NewLogger(env string) *slog.Logger {
    var h slog.Handler
    if env == "production" {
        h = slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})
    } else {
        h = slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})
    }
    return slog.New(h)
}

// middleware attaches logger with request_id to context
func Logger(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        ww := chimw.NewWrapResponseWriter(w, r.ProtoMajor)
        logger := slog.With("request_id", RequestIDFromCtx(r.Context()))
        r = r.WithContext(context.WithValue(r.Context(), loggerKey{}, logger))

        next.ServeHTTP(ww, r)

        logger.Info("http_request",
            "method", r.Method,
            "path", r.URL.Path,
            "status", ww.Status(),
            "bytes", ww.BytesWritten(),
            "duration_ms", time.Since(start).Milliseconds(),
        )
    })
}
```

## Error Handling

Wrap with `fmt.Errorf("doing X: %w", err)`. Inspect with `errors.Is` (sentinel match) and `errors.As` (typed). Define sentinels at the domain layer, not the HTTP layer.

```go
// internal/domain/errors.go
package domain

import "errors"

var (
    ErrNotFound    = errors.New("not found")
    ErrEmailTaken  = errors.New("email already registered")
    ErrUnauthorized = errors.New("unauthorized")
)

type ValidationError struct {
    Field   string
    Message string
}

func (e *ValidationError) Error() string { return e.Field + ": " + e.Message }
```

```go
// internal/domain/user_service.go
func (s *userService) Create(ctx context.Context, nu NewUser) (User, error) {
    if nu.Email == "" {
        return User{}, &ValidationError{Field: "email", Message: "required"}
    }

    u, err := s.repo.Create(ctx, nu)
    if errors.Is(err, store.ErrUniqueViolation) {
        return User{}, fmt.Errorf("create user: %w", ErrEmailTaken)
    }
    if err != nil {
        return User{}, fmt.Errorf("create user: %w", err)
    }
    return u, nil
}
```

### Mapping Errors to HTTP

```go
// internal/http/handlers/errors.go
func writeError(w http.ResponseWriter, r *http.Request, status int, code string, err error) {
    var valErr *domain.ValidationError
    if errors.As(err, &valErr) {
        status = http.StatusUnprocessableEntity
        code = "validation"
    }

    loggerFromCtx(r.Context()).Error("request_failed", "code", code, "err", err.Error())

    w.Header().Set("Content-Type", "application/problem+json")
    w.WriteHeader(status)
    _ = json.NewEncoder(w).Encode(map[string]any{
        "type":   "urn:app:error:" + code,
        "title":  code,
        "status": status,
        "detail": err.Error(),
    })
}
```

## Context Propagation

Every function that does I/O takes `ctx context.Context` as the first parameter. No exceptions.

```go
// Good: context flows end-to-end
func (r *userRepo) Get(ctx context.Context, id int64) (User, error) {
    row := r.db.QueryRow(ctx, "SELECT ... WHERE id = $1", id)
    ...
}

// Handler -> service -> repo -> database all share one cancellable context.
```

Never store `context.Context` in a struct. Pass it. Never pass `context.Background()` from inside a request — you break cancellation.

## Database: sqlc + pgx/v5

`sqlc` generates type-safe Go from SQL. You write real SQL, sqlc gives you real structs and methods — no ORM, no reflection, no surprises.

```yaml
# internal/store/sqlc.yaml
version: "2"
sql:
  - schema: "../../migrations"
    queries: "queries"
    engine: "postgresql"
    gen:
      go:
        package: "store"
        out: "."
        sql_package: "pgx/v5"
        emit_interface: true
        emit_pointers_for_null_types: true
```

```sql
-- internal/store/queries/users.sql
-- name: GetUser :one
SELECT id, email, display_name, created_at FROM users WHERE id = $1;

-- name: CreateUser :one
INSERT INTO users (email, display_name) VALUES ($1, $2) RETURNING *;

-- name: ListUsers :many
SELECT id, email, display_name, created_at FROM users
WHERE id > $1 ORDER BY id LIMIT $2;
```

Run `sqlc generate`. You now have `store.Queries` with typed methods. Wrap it in a repo that returns domain types.

### Connection Pool and Transactions

```go
// internal/store/pool.go
import "github.com/jackc/pgx/v5/pgxpool"

func NewPool(ctx context.Context, dsn string) (*pgxpool.Pool, error) {
    cfg, err := pgxpool.ParseConfig(dsn)
    if err != nil {
        return nil, fmt.Errorf("parse pool config: %w", err)
    }
    cfg.MaxConns = 20
    cfg.MinConns = 2
    cfg.MaxConnLifetime = 30 * time.Minute
    cfg.HealthCheckPeriod = 1 * time.Minute

    return pgxpool.NewWithConfig(ctx, cfg)
}

// Transaction helper — commit on nil error, rollback on anything else.
func WithTx[T any](ctx context.Context, pool *pgxpool.Pool, fn func(q *Queries) (T, error)) (T, error) {
    var zero T
    tx, err := pool.Begin(ctx)
    if err != nil {
        return zero, fmt.Errorf("begin: %w", err)
    }
    defer tx.Rollback(ctx) // no-op if committed

    q := New(tx)
    result, err := fn(q)
    if err != nil {
        return zero, err
    }
    if err := tx.Commit(ctx); err != nil {
        return zero, fmt.Errorf("commit: %w", err)
    }
    return result, nil
}
```

## Configuration

Parse env into a struct at startup. If config is bad, fail fast — don't limp along with defaults that hide problems.

```go
// internal/config/config.go
import "github.com/caarlos0/env/v10"

type Config struct {
    Environment  string        `env:"ENV" envDefault:"development"`
    Port         int           `env:"PORT" envDefault:"8080"`
    DatabaseURL  string        `env:"DATABASE_URL,required"`
    JWTSecret    string        `env:"JWT_SECRET,required,unset"` // unset: zeros after load
    ReadTimeout  time.Duration `env:"HTTP_READ_TIMEOUT" envDefault:"10s"`
    WriteTimeout time.Duration `env:"HTTP_WRITE_TIMEOUT" envDefault:"15s"`
}

func Load() (*Config, error) {
    var c Config
    if err := env.Parse(&c); err != nil {
        return nil, fmt.Errorf("parse config: %w", err)
    }
    return &c, nil
}
```

Never commit secrets. `,unset` drops the env var from the process after reading, so `/proc/self/environ` doesn't expose it.

## Middleware Patterns

```go
// internal/http/middleware/recover.go
func Recoverer(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if rec := recover(); rec != nil {
                slog.Error("panic",
                    "err", rec,
                    "stack", string(debug.Stack()),
                    "request_id", RequestIDFromCtx(r.Context()),
                )
                http.Error(w, `{"error":"internal"}`, http.StatusInternalServerError)
            }
        }()
        next.ServeHTTP(w, r)
    })
}

// internal/http/middleware/authn.go
type Authenticator struct {
    secret []byte
}

func (a Authenticator) Require(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        tok := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
        claims, err := a.verify(tok)
        if err != nil {
            http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
            return
        }
        ctx := context.WithValue(r.Context(), userKey{}, claims.Subject)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}
```

## Graceful Shutdown

```go
// cmd/api/main.go
func main() {
    cfg, err := config.Load()
    if err != nil { log.Fatal(err) }

    ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
    defer cancel()

    pool, err := store.NewPool(ctx, cfg.DatabaseURL)
    if err != nil { log.Fatal(err) }
    defer pool.Close()

    router := buildRouter(pool, cfg)

    srv := &http.Server{
        Addr:         fmt.Sprintf(":%d", cfg.Port),
        Handler:      router,
        ReadTimeout:  cfg.ReadTimeout,
        WriteTimeout: cfg.WriteTimeout,
        IdleTimeout:  60 * time.Second,
    }

    go func() {
        slog.Info("server_starting", "port", cfg.Port)
        if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
            slog.Error("server_failed", "err", err)
            cancel()
        }
    }()

    <-ctx.Done()
    slog.Info("shutting_down")

    shutdownCtx, stop := context.WithTimeout(context.Background(), 20*time.Second)
    defer stop()
    if err := srv.Shutdown(shutdownCtx); err != nil {
        slog.Error("shutdown_failed", "err", err)
    }
}
```

In-flight requests finish, new connections rejected, pool closes on the `defer`.

## Testing

Table-driven tests, `httptest` for handlers, `testcontainers-go` for real Postgres.

```go
// internal/http/handlers/users_test.go
func TestCreateUser(t *testing.T) {
    cases := []struct {
        name       string
        body       string
        wantStatus int
        wantBody   string
    }{
        {"valid", `{"email":"a@b.co","display_name":"Ada"}`, http.StatusCreated, `"email":"a@b.co"`},
        {"bad json", `{"email"`, http.StatusBadRequest, `invalid_json`},
        {"empty email", `{"email":""}`, http.StatusUnprocessableEntity, `validation`},
    }
    for _, tc := range cases {
        t.Run(tc.name, func(t *testing.T) {
            h := &UsersHandler{svc: fakeUserSvc{}}
            req := httptest.NewRequest(http.MethodPost, "/users", strings.NewReader(tc.body))
            rec := httptest.NewRecorder()

            h.Create(rec, req)

            if rec.Code != tc.wantStatus {
                t.Fatalf("status = %d, want %d", rec.Code, tc.wantStatus)
            }
            if !strings.Contains(rec.Body.String(), tc.wantBody) {
                t.Fatalf("body = %q, want to contain %q", rec.Body, tc.wantBody)
            }
        })
    }
}
```

For integration tests, spin up Postgres with `testcontainers`:

```go
import "github.com/testcontainers/testcontainers-go/modules/postgres"

func setupDB(t *testing.T) *pgxpool.Pool {
    t.Helper()
    ctx := context.Background()
    pg, err := postgres.Run(ctx, "postgres:16-alpine",
        postgres.WithDatabase("test"),
        postgres.WithUsername("test"),
        postgres.WithPassword("test"),
        postgres.BasicWaitStrategies(),
    )
    if err != nil { t.Fatal(err) }
    t.Cleanup(func() { _ = pg.Terminate(ctx) })

    dsn, _ := pg.ConnectionString(ctx, "sslmode=disable")
    pool, err := store.NewPool(ctx, dsn)
    if err != nil { t.Fatal(err) }
    runMigrations(t, dsn)
    return pool
}
```

## Production

- **Metrics**: `prometheus/client_golang` — expose `/metrics`, track request duration histogram, DB pool gauges
- **Profiling**: `net/http/pprof` on a private admin port, never on the public listener
- **Rate limiting**: `golang.org/x/time/rate` for per-IP, Redis-backed token bucket for distributed
- **Healthchecks**: `/health` (liveness) returns 200, `/ready` checks DB ping and downstream availability

```go
import (
    "net/http/pprof"
    "github.com/prometheus/client_golang/prometheus/promhttp"
)

func adminServer(port int) *http.Server {
    mux := http.NewServeMux()
    mux.Handle("/metrics", promhttp.Handler())
    mux.HandleFunc("/debug/pprof/", pprof.Index)
    mux.HandleFunc("/debug/pprof/profile", pprof.Profile)
    mux.HandleFunc("/debug/pprof/heap", pprof.Handler("heap").ServeHTTP)
    return &http.Server{Addr: fmt.Sprintf("127.0.0.1:%d", port), Handler: mux}
}
```

## Anti-Patterns

### ❌ Global database handle

```go
// Bad: hidden dependency, can't mock, initialization order bugs
var DB *pgxpool.Pool

func init() {
    DB, _ = pgxpool.New(context.Background(), os.Getenv("DATABASE_URL"))  // ❌
}

// Good: construct in main, pass explicitly
type Server struct { pool *pgxpool.Pool }
```

### ❌ Ignoring errors

```go
// Bad: silently drops failures
json.NewDecoder(r.Body).Decode(&req)  // ❌
file.Close()                          // ❌ in a hot path, don't ignore

// Good: handle or document why ignored
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
    writeError(w, r, 400, "invalid_json", err)
    return
}
defer func() { _ = file.Close() }()  // explicit ignore with reason
```

### ❌ Naked goroutines

```go
// Bad: no ctx, no error handling, leaks on panic
go sendEmail(user.Email)  // ❌

// Good: bounded, observable
go func() {
    defer recoverPanic("send_email")
    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()
    if err := sendEmail(ctx, user.Email); err != nil {
        slog.Error("send_email_failed", "err", err, "user_id", user.ID)
    }
}()
```

### ❌ `interface{}` / `any` when generics fit

```go
// Bad: runtime panic waiting to happen
func first(items []interface{}) interface{} { return items[0] }  // ❌

// Good: type-safe generics
func first[T any](items []T) T { return items[0] }
```

### ❌ Panic on user input

```go
// Bad: turns a 400 into a 500, possibly crashes the worker
id, err := strconv.Atoi(chi.URLParam(r, "id"))
if err != nil { panic(err) }  // ❌

// Good: return a client error
id, err := strconv.Atoi(chi.URLParam(r, "id"))
if err != nil {
    writeError(w, r, 400, "invalid_id", err)
    return
}
```

### ❌ Business logic in handlers

```go
// Bad: handler does validation, DB, email, formatting
func (h *H) Create(w http.ResponseWriter, r *http.Request) {
    // 80 lines mixing json, sql, smtp, template rendering...  ❌
}

// Good: handler is glue; service owns the flow
func (h *H) Create(w http.ResponseWriter, r *http.Request) {
    var req createReq
    decodeOrError(w, r, &req)
    user, err := h.svc.Create(r.Context(), req.toDomain())
    if err != nil { ... }
    writeJSON(w, 201, user)
}
```

## Mental Model

A Go service is a **small tree of interfaces**, wired once in `main`, passing `context.Context` down every branch. Handlers are glue — they decode, call one method on a service, and encode. Services own the domain verbs. Repositories own SQL. When a handler gets complicated, the fix is usually a new service method, not more handler code.