go-web-expert

$npx mdskill add existential-birds/beagle/go-web-expert

Enforces production-quality Go web code with five strict rules.

  • Eliminates global mutable state and forces explicit error handling.
  • Depends on go-playground/validator for input validation and httptest for testing.
  • Applies five non-negotiable rules in order, gating each step.
  • Outputs validated, documented, testable handler code with zero global state.

SKILL.md

.github/skills/go-web-expertView on GitHub ↗
---
name: go-web-expert
description: Comprehensive Go web development persona enforcing zero global state, explicit error handling, input validation, testability, and documentation conventions. Use when building Go web applications to ensure production-quality code from the start.
---

# Go Web Expert System

Five non-negotiable rules for production-quality Go web applications. Every handler, every service, every line of code must satisfy all five.

## Quick Reference

| Topic | Reference |
|-------|-----------|
| Validation tags, custom validators, nested structs, error formatting | [references/validation.md](references/validation.md) |
| httptest patterns, middleware testing, integration tests, fixtures | [references/testing-handlers.md](references/testing-handlers.md) |

## Rules of Engagement

| # | Rule | One-Liner |
|---|------|-----------|
| 1 | Zero Global State | All handlers are methods on a struct; no package-level `var` for mutable state |
| 2 | Explicit Error Handling | Every error is checked, wrapped with `fmt.Errorf("doing X: %w", err)` |
| 3 | Validation First | All incoming JSON validated with `go-playground/validator` at the boundary |
| 4 | Testability | Every handler has a `_test.go` using `httptest` with table-driven tests |
| 5 | Documentation | Every exported symbol has a Go doc comment starting with its name |

### Hard gates (new HTTP handler)

Apply **in order**. Do not treat the next step as done until the **Pass when** for the current step is satisfied (objective evidence on disk or in test output—not “I checked mentally”).

1. **Dependencies (Rule 1)** — **Pass when:** the handler is a method on a struct that holds every mutable dependency (`db`, logger, HTTP clients, caches); any new package-level `var` is only in the allowlist under [What Is Allowed at Package Level](#what-is-allowed-at-package-level). *Evidence:* constructor wires deps; no new forbidden globals from that list.

2. **Boundary (Rule 3)** — **Pass before** calling service/domain code: **Pass when:** the request decodes into a tagged struct and `validate.Struct` (or equivalent) runs; invalid JSON and validation failures have defined HTTP status bodies (e.g. 400/422). *Evidence:* decode + `validate.Struct` appear in the handler; tests or manual run show 422/400 for bad input.

3. **Errors (Rule 2)** — **Pass when:** no `_` discards on the handler path; `json.NewEncoder(w).Encode` errors are handled; errors passed up or logged use wrapping (`%w`) or mapped `AppError` as this skill prescribes. *Evidence:* review the diff for ignored errors and bare `return err` without context where wrapping is required.

4. **Tests (Rule 4)** — **Pass when:** a `_test.go` exists for the handler package and calls `ServeHTTP` with `httptest`, including at least one success case and one non-2xx case (validation, not found, or domain error). *Evidence:* test file path exists; `go test` for that package passes.

5. **Documentation (Rule 5)** — **Pass when:** every **new or changed** exported identifier in the change has a doc comment whose first line starts with that identifier’s name. *Evidence:* `go doc <pkg>` or the IDE/doc preview shows summaries for new exports.

---

## Rule 1: Zero Global State

All handlers must be methods on a server struct. No package-level `var` for databases, loggers, clients, or any mutable state.

```go
// FORBIDDEN
var db *sql.DB
var logger *slog.Logger

func handleGetUser(w http.ResponseWriter, r *http.Request) {
    user, err := db.QueryRow(...)  // global state -- untestable, unsafe
}

// REQUIRED
type Server struct {
    db     *sql.DB
    logger *slog.Logger
    router *http.ServeMux
}

func (s *Server) handleGetUser(w http.ResponseWriter, r *http.Request) {
    user, err := s.db.QueryRow(...)  // explicit dependency
}
```

### What Is Allowed at Package Level

- **Constants** -- `const maxPageSize = 100`
- **Pure functions** -- functions with no side effects that depend only on their arguments
- **Sentinel errors** -- `var ErrNotFound = errors.New("not found")`
- **Validator instance** -- `var validate = validator.New()` (stateless after init)

### What Is Forbidden at Package Level

- Database connections (`*sql.DB`, `*pgxpool.Pool`)
- Loggers (`*slog.Logger`)
- HTTP clients configured with timeouts or transport
- Configuration structs read from environment
- Caches, rate limiters, or any mutable shared resource

### Constructor Pattern

```go
func NewServer(db *sql.DB, logger *slog.Logger) *Server {
    s := &Server{
        db:     db,
        logger: logger,
        router: http.NewServeMux(),
    }
    s.routes()
    return s
}

func (s *Server) routes() {
    s.router.HandleFunc("GET /api/users/{id}", s.handleGetUser)
    s.router.HandleFunc("POST /api/users", s.handleCreateUser)
}

func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    s.router.ServeHTTP(w, r)
}
```

---

## Rule 2: Explicit Error Handling

Never ignore errors. Every error must be wrapped with context describing what was being attempted when the error occurred.

```go
// FORBIDDEN
result, _ := doSomething()
json.NewEncoder(w).Encode(data)  // error ignored

// REQUIRED
result, err := doSomething()
if err != nil {
    return fmt.Errorf("doing something for user %s: %w", userID, err)
}

if err := json.NewEncoder(w).Encode(data); err != nil {
    s.logger.Error("encoding response", "err", err, "request_id", reqID)
}
```

### Error Wrapping Convention

Format: `"<verb>ing <noun>: %w"` -- lowercase, no period, provides call-chain context.

```go
// Good wrapping -- each layer adds context
return fmt.Errorf("creating user: %w", err)
return fmt.Errorf("inserting user into database: %w", err)
return fmt.Errorf("hashing password for user %s: %w", email, err)

// Bad wrapping
return fmt.Errorf("error: %w", err)           // no context
return fmt.Errorf("Failed to create user: %w", err) // uppercase, verbose
return err                                      // no wrapping at all
```

### Structured Error Type for HTTP APIs

```go
type AppError struct {
    Code    int    `json:"-"`
    Message string `json:"error"`
    Detail  string `json:"detail,omitempty"`
}

func (e *AppError) Error() string {
    return fmt.Sprintf("%d: %s", e.Code, e.Message)
}

// Map domain errors to HTTP errors in one place
func handleError(w http.ResponseWriter, r *http.Request, err error) {
    var appErr *AppError
    if errors.As(err, &appErr) {
        writeJSON(w, appErr.Code, appErr)
        return
    }

    slog.Error("unhandled error",
        "err", err,
        "path", r.URL.Path,
    )
    writeJSON(w, 500, map[string]string{"error": "internal server error"})
}
```

### Common Mistakes

```go
// MISTAKE: not checking Close errors on writers
defer f.Close()  // at minimum, log Close errors for writable resources

// BETTER for writable resources:
defer func() {
    if err := f.Close(); err != nil {
        s.logger.Error("closing file", "err", err)
    }
}()

// OK for read-only resources where Close rarely fails:
defer resp.Body.Close()
```

---

## Rule 3: Validation First

Use `go-playground/validator` for all incoming JSON. Validate at the boundary, trust internal data.

```go
import "github.com/go-playground/validator/v10"

var validate = validator.New()

type CreateUserRequest struct {
    Name  string `json:"name"  validate:"required,min=1,max=100"`
    Email string `json:"email" validate:"required,email"`
    Age   int    `json:"age"   validate:"omitempty,gte=0,lte=150"`
}

func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) error {
    var req CreateUserRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        return &AppError{Code: 400, Message: "invalid JSON", Detail: err.Error()}
    }

    if err := validate.Struct(req); err != nil {
        return &AppError{Code: 422, Message: "validation failed", Detail: formatValidationErrors(err)}
    }

    // From here, req is trusted
    user, err := s.userService.Create(r.Context(), req.Name, req.Email)
    if err != nil {
        return fmt.Errorf("creating user: %w", err)
    }

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

### Validation Error Formatting

```go
func formatValidationErrors(err error) string {
    var msgs []string
    for _, e := range err.(validator.ValidationErrors) {
        msgs = append(msgs, fmt.Sprintf("field '%s' failed on '%s'", e.Field(), e.Tag()))
    }
    return strings.Join(msgs, "; ")
}
```

### Validation Boundary Rule

- **Validate at the edge** -- HTTP handlers, message consumers, CLI input
- **Trust internal data** -- service layer receives already-validated types
- **Never validate twice** -- if the handler validated, the service does not re-validate the same fields

See [references/validation.md](references/validation.md) for custom validators, nested struct validation, slice validation, and cross-field validation.

---

## Rule 4: Testability

Every handler must have a corresponding `_test.go` file using `httptest`. Test through the HTTP layer, not by calling handler methods directly.

```go
func TestServer_handleGetUser(t *testing.T) {
    mockStore := &MockUserStore{
        GetUserFunc: func(ctx context.Context, id string) (*User, error) {
            if id == "123" {
                return &User{ID: "123", Name: "Alice"}, nil
            }
            return nil, ErrNotFound
        },
    }
    srv := NewServer(mockStore, slog.Default())

    tests := []struct {
        name       string
        path       string
        wantStatus int
        wantBody   string
    }{
        {
            name:       "existing user",
            path:       "/api/users/123",
            wantStatus: http.StatusOK,
            wantBody:   `"name":"Alice"`,
        },
        {
            name:       "not found",
            path:       "/api/users/999",
            wantStatus: http.StatusNotFound,
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            req := httptest.NewRequest("GET", tt.path, nil)
            w := httptest.NewRecorder()

            srv.ServeHTTP(w, req)

            if w.Code != tt.wantStatus {
                t.Errorf("status = %d, want %d", w.Code, tt.wantStatus)
            }
            if tt.wantBody != "" && !strings.Contains(w.Body.String(), tt.wantBody) {
                t.Errorf("body = %q, want to contain %q", w.Body.String(), tt.wantBody)
            }
        })
    }
}
```

### Key Testing Principles

- **Test through HTTP** -- use `httptest.NewRequest` and `httptest.NewRecorder`, call `srv.ServeHTTP`
- **Interface-based mocks** -- define narrow interfaces at the consumer, create mock implementations for tests
- **Table-driven tests** -- one `[]struct` with test cases, one `t.Run` loop
- **Error paths matter** -- test 400s, 404s, 422s, and 500s, not just 200s
- **No global test state** -- each test creates its own server with its own mocks

See [references/testing-handlers.md](references/testing-handlers.md) for middleware testing, integration tests with real databases, file upload testing, and streaming response testing.

---

## Rule 5: Documentation

Every exported function, type, method, and constant must have a Go doc comment following standard conventions.

```go
// CreateUser creates a new user with the given name and email.
// It returns ErrDuplicateEmail if a user with the same email already exists.
func (s *UserService) CreateUser(ctx context.Context, name, email string) (*User, error) {
    // ...
}

// Server handles HTTP requests for the user API.
type Server struct {
    // ...
}

// NewServer creates a Server with the given dependencies.
// The logger must not be nil.
func NewServer(store UserStore, logger *slog.Logger) *Server {
    // ...
}

// ErrNotFound is returned when a requested resource does not exist.
var ErrNotFound = errors.New("not found")
```

### Doc Comment Conventions

- **Start with the name** -- `// CreateUser creates...` not `// This function creates...`
- **First sentence is the summary** -- shown in `go doc` listings and IDE tooltips
- **Mention important error returns** -- callers need to know which errors to check
- **Don't document the obvious** -- `// SetName sets the name` adds no value
- **Document why, not what** -- when behavior is non-obvious, explain the reasoning

### Package Documentation

```go
// Package user provides user management for the application.
// It handles creation, retrieval, and deletion of user accounts,
// with email uniqueness enforced at the database level.
package user
```

---

## Cross-Cutting Concerns

The five rules reinforce each other. Here is how they interact.

### Zero Global State Enables Testability

Because all dependencies are on the struct, tests can inject mocks:

```go
// Production
srv := NewServer(realDB, prodLogger)

// Test
srv := NewServer(mockStore, slog.Default())
```

If `db` were a global `var`, tests would need to mutate package state, causing race conditions in parallel tests.

### Validation First Simplifies Error Handling

When handlers validate at the boundary, the service layer can assume valid input. This means service-layer errors are always unexpected (database failures, network issues), and error handling becomes simpler:

```go
func (s *UserService) Create(ctx context.Context, name, email string) (*User, error) {
    // No need to check if name is empty -- handler already validated
    user := &User{Name: name, Email: email}
    if err := s.store.Insert(ctx, user); err != nil {
        return nil, fmt.Errorf("inserting user: %w", err)
    }
    return user, nil
}
```

### Documentation Makes Error Handling Discoverable

Doc comments that mention error returns tell callers what to handle:

```go
// Delete removes a user by ID.
// It returns ErrNotFound if the user does not exist.
// It returns ErrHasActiveOrders if the user has unfinished orders.
func (s *UserService) Delete(ctx context.Context, id string) error {
```

---

## Self-Review Checklist

Before considering any handler or service complete, verify all five rules:

### Zero Global State
- [ ] No package-level `var` for mutable state (db, logger, clients)
- [ ] All handlers are methods on a struct
- [ ] Dependencies injected through constructor

### Explicit Error Handling
- [ ] No `_` ignoring returned errors
- [ ] All errors wrapped with `fmt.Errorf("doing X: %w", err)`
- [ ] `json.NewEncoder(w).Encode(...)` error checked or logged
- [ ] Structured `AppError` used for HTTP error responses

### Validation First
- [ ] All request structs have `validate` tags
- [ ] `validate.Struct(req)` called before any business logic
- [ ] Validation errors return 422 with field-level detail
- [ ] Service layer does not re-validate handler-validated data

### Testability
- [ ] `_test.go` file exists for every handler file
- [ ] Tests use `httptest.NewRequest` and `httptest.NewRecorder`
- [ ] Table-driven tests cover happy path and error paths
- [ ] Mocks implement narrow interfaces, not concrete types

### Documentation
- [ ] Every exported function has a doc comment starting with its name
- [ ] Error return values are documented
- [ ] Package has a doc comment

## When to Load References

Load **validation.md** when:
- Adding new request types with validation tags
- Creating custom validators
- Validating nested structs, slices, or maps
- Formatting validation errors for API responses

Load **testing-handlers.md** when:
- Writing handler tests for the first time in a project
- Testing middleware chains or authentication
- Setting up integration tests with a real database
- Testing file uploads or streaming responses

More from existential-birds/beagle

SkillDescription
adr-decision-extractionUse when you need to mine a conversation, session transcript, or design discussion for architectural decisions before writing ADRs. Identifies problem-solution pairs, trade-off debates, technology choices, and explicit \"[ADR]\" tags. Triggers on \"what decisions did we make\", \"extract decisions from this chat\", \"find the choices in our discussion\", or \"summarize architectural decisions\". Also useful after long planning sessions to capture decisions that were made implicitly. Does NOT write ADR documents \u2014 use adr-writing or write-adr for that.
adr-writingUse when writing or formatting an ADR document using the MADR template, applying Definition of Done (E.C.A.D.R.) criteria, or verifying ADR completeness. Triggers on \"write the ADR\", \"format as MADR\", \"check ADR quality\", \"mark gaps in ADR\". Also triggers when a decision has been extracted and needs to become a document. Does NOT extract decisions from conversations (use adr-decision-extraction) or orchestrate the full extract-confirm-write workflow (use write-adr).
agent-architecture-analysisUse when auditing an agent codebase against the 12-Factor Agents methodology, reviewing LLM-powered system architecture, or assessing agentic app compliance. Triggers on \"analyze agent architecture\", \"12-factor audit\", \"how compliant is this agent\", or \"evaluate this LLM app\". Also applies when comparing frameworks or planning agent improvements. Not for quick checklists \u2014 this performs deep per-factor codebase analysis with file-level evidence.
app-intents-code-reviewReviews App Intents code for intent structure, entities, shortcuts, and parameters. Use when reviewing code with import AppIntents, @AppIntent, AppEntity, AppShortcutsProvider, or @Parameter.
artifact-analysisUse when the user wants a cited, structured read of local documents and project knowledge. Triggers on: \"analyze these docs\", \"scan my project for context\", \"read the docs folder\", \"summarize what's in .beagle/concepts/\", \"extract context from docs/\", \"what's in this folder\", \"go read everything in X and tell me what's there\". Also invoked programmatically by other beagle skills (prfaq-beagle Ignition, brainstorm-beagle reference points, strategy-interview context grounding) via the companion contract. Does NOT trigger on codebase lookups (\"find this function\", \"search the repo\"), web research (use web-research), LLM-as-judge evaluation (use llm-judge), or document editing (use humanize-beagle). Produces a written scan plan, parallel-subagent findings, and a cited synthesis report on disk — never inline prose, never unsourced claims.
axum-code-reviewReviews axum web framework code for routing patterns, extractor usage, middleware, state management, and error handling. Use when reviewing Rust code that uses axum, tower, or hyper for HTTP services. Covers axum 0.7+ patterns including State, Path, Query, Json extractors.
brainstorm-beagleUse when the user has a fuzzy idea and wants to shape it into a concrete project spec before planning or building. Triggers on: \"brainstorm this\", \"I have an idea for...\", \"help me think through this project\", \"what should I build\", \"spec this out\". Also catches vague feature descriptions needing structured questioning to clarify scope. Does NOT write code, plan implementation, review strategy docs, or run strategy interviews \u2014 produces a WHAT/WHY spec through dialogue, not a HOW plan.
bubbletea-code-reviewReviews BubbleTea TUI code for proper Elm architecture, model/update/view patterns, and Lipgloss styling. Use when reviewing terminal UI code using charmbracelet/bubbletea.
cloudkit-code-reviewReviews CloudKit code for container setup, record handling, subscriptions, and sharing patterns. Use when reviewing code with import CloudKit, CKContainer, CKRecord, CKShare, or CKSubscription.
combine-code-reviewReviews Combine framework code for memory leaks, operator misuse, and error handling. Use when reviewing code with import Combine, AnyPublisher, @Published, PassthroughSubject, or CurrentValueSubject.