docs(shared-skills): batch 99 (15 files)

This commit is contained in:
YeonGyu-Kim
2026-05-30 19:12:19 +09:00
parent f993547f0a
commit 882ac40c83
15 changed files with 5323 additions and 0 deletions
@@ -0,0 +1,90 @@
# Go Programmer
Production Go in 2026. **Boring on purpose, strict by tooling, illegal states unrepresentable by convention.**
## Philosophy
Go gives you fewer type-system tools than Python, TypeScript, or Rust:
- No sum types — only `interface{}` with type-switch.
- No exhaustiveness check from the compiler — only the `exhaustive` linter.
- No `Option<T>` — only `nil` and the eternal trap of "is this nil interface or nil concrete?".
- No `Result<T, E>` — only `(T, error)`, no compiler enforcement of unwrapping.
- No newtype that prevents primitive coercion — `type UserID string` is still implicitly convertible from a literal when used carelessly.
**This is the whole point of the skill.** Where the language is weak, the linter bundle becomes the type checker, and code patterns become the type system. Treat `golangci-lint v2` with the configuration in `golangci-strict.md` as if it were `tsc --strict` or `basedpyright`. Treat `nilaway` and `go test -race` as if they were Miri.
The skill enforces five non-negotiables:
1. **Parse-don't-validate at every boundary.** HTTP/RPC/CLI/config gets parsed into a domain struct constructed only via `New*(...)` smart constructors. Once inside the domain, no further validation. See `data-modeling.md`.
2. **`(T, error)` everywhere.** No panics in library code. No bare `_ = err`. Errors are wrapped with `%w` and asserted with `errors.Is` / `errors.As`. Typed error structs for anything a caller can branch on. See `error-handling.md`.
3. **Sealed interfaces for variants.** Sum types via a sealed unexported method, dispatched through a `type switch`, with the `exhaustive` linter checking completeness. See `type-patterns.md`.
4. **`context.Context` is the first parameter.** Always. No `context.Background()` inside leaf functions. No goroutine without context-driven shutdown. No `time.Now()` in domain code — inject a clock. See `concurrency.md`.
5. **Generated, not hand-written, for external contracts.** `sqlc` for DB, `oapi-codegen` for OpenAPI servers and clients, `protoc-gen-go` + `protoc-gen-connect-go` for RPC. Hand-rolled marshalling is a regression. See `sqlc-pgx.md`, `grpc-connect.md`.
## Hard rules — tooling
| Category | Use | Never |
|---|---|---|
| Go version | **1.23+** (range-over-func, iter package, slog stable) | <1.22 |
| Module | `go modules` + `go work` for monorepos | dep, GOPATH layouts |
| Format | **`gofumpt`** (stricter gofmt) + `goimports -local <module>` | bare `gofmt` |
| Linter | **`golangci-lint v2`** with the strict bundle in `golangci-strict.md` | bare `go vet` |
| Nil checker | **`nilaway`** (Uber, stable since 2024) in CI | hope |
| Vet bundle | `go vet` + `fieldalignment` + `shadow` | "tests cover it" |
| Tests | `go test -race -shuffle=on -count=1` | `-count` cache, no race |
| Goroutine leaks | `go.uber.org/goleak` in `TestMain` | "looks fine" |
| Mock | `go.uber.org/mock` (gomock successor) | hand-written stubs |
| DB | `sqlc` + `jackc/pgx/v5` | `database/sql` + `gorm` |
| HTTP framework | **`gin-gonic/gin`** (de facto, ~48% of Go API repos) — `go-chi/chi` for minimalist, `connectrpc/connect-go` for RPC | `echo` (smaller eco), `fiber` (fasthttp = non-stdlib), `gorilla/mux` (in maintenance mode) |
| RPC | **`connectrpc/connect-go`** (gRPC-compatible, HTTP/1.1-friendly, browser-friendly) | hand-rolled `grpc-go` unless you specifically need bidi streaming features Connect lacks |
| Validation | `go-playground/validator/v10` for HTTP boundary + `bufbuild/protovalidate-go` for proto + smart constructors for domain | ad-hoc `if len(s) == 0` chains |
| Config | `caarlos0/env/v11` (struct-tag env) | `viper` unless you actually need file+env+flag merging |
| Logging | **`log/slog`** (stdlib, Go 1.21+) | logrus, zap, zerolog (all superseded) |
| CLI | `spf13/cobra` | hand-rolled `os.Args` parsing past 2 flags |
| TUI | `charm.land/bubbletea/v2` + `bubbles/v2` + `lipgloss/v2` — see `bubbletea-v2.md` for CJK/IME | bubbletea v1 if you need IME |
A single CI command should be the gate:
```bash
gofumpt -l . && \
golangci-lint run ./... && \
nilaway ./... && \
go test -race -shuffle=on -count=1 ./...
```
If any of these fails, the change is not done. Period. The bundle is set up so a clean run actually means clean — see `golangci-strict.md` for the per-linter rationale and the deliberate `nolint:` policy.
## Hard rules — code
Read these per-file references for the canonical patterns:
- **Types & data** → `type-patterns.md`, `data-modeling.md` — branded named types, smart constructors with unexported fields, sealed interfaces as sum types.
- **Errors** → `error-handling.md` — sentinel vs typed struct, `errors.Is/As`, `%w` wrapping, no panic in libraries, the `errorlint` ruleset.
- **Concurrency** → `concurrency.md``context.Context` discipline, `errgroup`, `sync.OnceValue`, `goleak`, `-race`, channel selection rules.
- **HTTP backend** → `backend-stack.md``gin` server skeleton, middleware ordering, SSE/streaming with `http.Flusher`, structured slog logging, graceful shutdown — distilled from the CLIProxyAPI codebase (a real proxy serving OpenAI/Gemini/Claude APIs).
- **RPC** → `grpc-connect.md` — when to pick Connect vs grpc-go, codegen pipeline, protovalidate, streaming.
- **DB** → `sqlc-pgx.md` — compile-time-safe SQL via sqlc + pgx connection pool + migrations via goose + testcontainers in CI.
- **CLI** → `cobra-stack.md` — cobra layout, slog integration, graceful shutdown on signals, fang-style colored help.
- **TUI** → `bubbletea-v2.md` — v2 model, `SetVirtualCursor(false)` + `tea.View{Cursor}` for CJK IME, why v1 was broken for Korean/Japanese/Chinese input.
- **Testing** → `testing.md` — table-driven tests, `require` vs `assert`, `autogold` snapshots, `gopter` property tests, `testcontainers` for integration, `goleak` for goroutine leaks.
- **Bootstrap** → `bootstrap.md``new-project.go` invocation, project layout (`cmd/`, `internal/`, `pkg/`), Taskfile, CI.
- **Strict config** → `golangci-strict.md` — the canonical `.golangci.yml` with the full linter whitelist and per-linter rationale.
- **One-liners** → `one-liners.md``go run` scripts with `//go:build ignore`, `gorun`-style invocation.
## The 250 pure LOC ceiling
Same rule as Python/Rust/TS: a `.go` file whose pure LOC (non-blank, non-comment) exceeds 250 is architecturally broken. Go encourages many small files in a single package, so this is *more* natural here than elsewhere — split by responsibility, keep one cohesive type and its methods per file.
The `cmd/server/main.go` is the most common violator. Refactor it: `main.go` only wires `os.Args``cmd.Execute()`. Anything else lives in `internal/`.
## Existing codebases — non-strict project
When editing an existing `.go` file that doesn't follow these rules: **write new code in strict style, don't refactor existing code in the same change.** Use the `remove-ai-slops` skill for branch-scope cleanup.
## Activation
This skill activates whenever you write or modify any `.go` file, `go.mod`, `go.sum`, `.golangci.yml`, `Taskfile.yml`, or any of the codegen specs (`*.proto`, `*.sql` next to `sqlc.yaml`, `openapi.yaml` next to `oapi-codegen.yaml`). Even one-off scripts get the strict treatment — that is what `//go:build ignore` + `go run` is for: production hygiene with throwaway ergonomics.
The references contain the recipes. **Read them before writing code. Re-read them when the model drifts.** The post-write architectural review loop is non-negotiable.
@@ -0,0 +1,641 @@
# HTTP Backend Stack — gin + slog + validator + pgx
The canonical production HTTP service skeleton. Distilled from the [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) codebase — a real proxy serving OpenAI / Gemini / Claude / Codex APIs in production, with SSE streaming, WebSocket upgrades, request logging, and hot-reload config.
If you are tempted to pick echo or chi instead, see `libraries.md` — gin wins on ecosystem, not technical merit, and the win is large enough to matter.
---
## `go.mod`
```go
module github.com/your-org/myservice
go 1.23
require (
github.com/gin-gonic/gin v1.10.1
github.com/go-playground/validator/v10 v10.22.1
github.com/caarlos0/env/v11 v11.2.2
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.7.6
golang.org/x/sync v0.18.0
)
```
---
## Project structure
```
cmd/server/main.go # ≤ 50 LOC; flags → run.Execute(ctx)
internal/
cmd/run.go # ~150 LOC; signal handling, config load, server.Run
config/config.go # env-driven Config struct
api/
server.go # gin.Engine setup, route mounting, http.Server
middleware/
request_id.go
request_logging.go
auth.go
recovery.go
cors.go
handlers/
users.go # one file per resource
streams.go # SSE / WebSocket endpoints
domain/ # smart-constructor types (Email, UserID, ...)
service/ # business logic
store/ # pgx + sqlc
obs/
logger.go # slog setup
```
---
## `cmd/server/main.go`
```go
package main
import (
"context"
"log/slog"
"os"
"os/signal"
"syscall"
"github.com/your-org/myservice/internal/cmd"
)
func main() {
ctx, stop := signal.NotifyContext(context.Background(),
syscall.SIGINT, syscall.SIGTERM)
defer stop()
if err := cmd.Execute(ctx); err != nil {
slog.Error("fatal", slog.Any("err", err))
os.Exit(1)
}
}
```
That is the entire `main`. Anything more is a smell.
---
## `internal/config/config.go`
```go
package config
import (
"time"
"github.com/caarlos0/env/v11"
)
type Config struct {
Host string `env:"HOST" envDefault:"0.0.0.0"`
Port int `env:"PORT" envDefault:"8080"`
DatabaseURL string `env:"DATABASE_URL,required"`
ReadTimeout time.Duration `env:"READ_TIMEOUT" envDefault:"15s"`
WriteTimeout time.Duration `env:"WRITE_TIMEOUT" envDefault:"30s"`
ShutdownTimeout time.Duration `env:"SHUTDOWN_TIMEOUT" envDefault:"20s"`
LogLevel string `env:"LOG_LEVEL" envDefault:"info"`
LogFormat string `env:"LOG_FORMAT" envDefault:"json"`
Env string `env:"ENV" envDefault:"development"`
}
func Load() (Config, error) {
var cfg Config
if err := env.Parse(&cfg); err != nil {
return Config{}, err
}
return cfg, nil
}
```
---
## `internal/obs/logger.go`
```go
package obs
import (
"context"
"log/slog"
"os"
)
type ctxKey struct{ name string }
var requestIDKey = ctxKey{"request_id"}
func NewLogger(level, format string) *slog.Logger {
var lvl slog.Level
_ = lvl.UnmarshalText([]byte(level))
opts := &slog.HandlerOptions{Level: lvl, AddSource: true}
var h slog.Handler
switch format {
case "text":
h = slog.NewTextHandler(os.Stdout, opts)
default:
h = slog.NewJSONHandler(os.Stdout, opts)
}
return slog.New(&ctxHandler{Handler: h})
}
// ctxHandler pulls request_id from ctx into every log line.
type ctxHandler struct{ slog.Handler }
func (h *ctxHandler) Handle(ctx context.Context, r slog.Record) error {
if id, ok := ctx.Value(requestIDKey).(string); ok && id != "" {
r.AddAttrs(slog.String("request_id", id))
}
return h.Handler.Handle(ctx, r)
}
func WithRequestID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, requestIDKey, id)
}
```
---
## `internal/api/server.go`
```go
package api
import (
"context"
"fmt"
"log/slog"
"net/http"
"github.com/gin-gonic/gin"
"github.com/your-org/myservice/internal/api/handlers"
"github.com/your-org/myservice/internal/api/middleware"
"github.com/your-org/myservice/internal/config"
)
type Server struct {
cfg config.Config
srv *http.Server
logger *slog.Logger
}
func New(cfg config.Config, logger *slog.Logger, h *handlers.Handler) *Server {
gin.SetMode(gin.ReleaseMode)
r := gin.New()
// Middleware order matters — see "Middleware ordering" below.
r.Use(
middleware.RequestID(), // 1. assign request_id first
middleware.Recovery(logger), // 2. recovery wraps everything
middleware.RequestLogger(logger),
middleware.CORS(),
)
h.Mount(r)
return &Server{
cfg: cfg,
logger: logger,
srv: &http.Server{
Addr: fmt.Sprintf("%s:%d", cfg.Host, cfg.Port),
Handler: r,
ReadTimeout: cfg.ReadTimeout,
WriteTimeout: cfg.WriteTimeout,
},
}
}
func (s *Server) Run(ctx context.Context) error {
errCh := make(chan error, 1)
go func() {
s.logger.InfoContext(ctx, "server starting",
slog.String("addr", s.srv.Addr))
if err := s.srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
errCh <- err
}
close(errCh)
}()
select {
case <-ctx.Done():
s.logger.InfoContext(ctx, "shutdown signal received")
shutdownCtx, cancel := context.WithTimeout(
context.Background(), s.cfg.ShutdownTimeout)
defer cancel()
return s.srv.Shutdown(shutdownCtx)
case err := <-errCh:
return err
}
}
```
Notes:
- `gin.New()` not `gin.Default()``Default()` adds `Logger()` (text format, not slog) and `Recovery()` (no logger injection). We replace both.
- `gin.SetMode(gin.ReleaseMode)` silences debug output. Production assumed.
- `http.Server` with explicit timeouts. The default `nil` timeouts are a DoS waiting to happen.
- Graceful shutdown: SIGINT/SIGTERM cancels the ctx → `Shutdown(shutdownCtx)` gives in-flight requests up to `ShutdownTimeout` to finish.
---
## Middleware ordering — the rule that actually matters
```
RequestID → Recovery → Logger → CORS → Auth → Handler
(1) (2) (3) (4) (5)
```
1. **RequestID** is first so every subsequent middleware sees it.
2. **Recovery** wraps everything after it. Order: a panic in CORS still gets caught.
3. **Logger** sees the request_id and the recovered panic.
4. **CORS** before Auth — OPTIONS preflight must return without auth.
5. **Auth** is the last cross-cutting middleware. Per-route auth (admin-only) is mounted on a sub-router with extra middleware.
```go
// Public routes — no auth
api := r.Group("/api/v1")
{
api.POST("/auth/login", h.Login)
api.GET("/healthz", h.Healthz)
}
// Authenticated routes
authed := r.Group("/api/v1", middleware.Auth(authSvc))
{
authed.GET("/users/:id", h.GetUser)
authed.POST("/users", h.CreateUser)
}
// Admin-only routes
admin := r.Group("/api/v1/admin",
middleware.Auth(authSvc),
middleware.RequireRole("admin"))
{
admin.GET("/users", h.ListAllUsers)
}
```
---
## Middleware examples
### `middleware/request_id.go`
```go
package middleware
import (
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/your-org/myservice/internal/obs"
)
func RequestID() gin.HandlerFunc {
return func(c *gin.Context) {
id := c.GetHeader("X-Request-ID")
if id == "" {
id = uuid.Must(uuid.NewV7()).String()
}
c.Request = c.Request.WithContext(obs.WithRequestID(c.Request.Context(), id))
c.Header("X-Request-ID", id)
c.Next()
}
}
```
### `middleware/recovery.go`
```go
package middleware
import (
"log/slog"
"net/http"
"runtime/debug"
"github.com/gin-gonic/gin"
)
func Recovery(logger *slog.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
defer func() {
if r := recover(); r != nil {
logger.ErrorContext(c.Request.Context(), "panic recovered",
slog.Any("panic", r),
slog.String("stack", string(debug.Stack())),
)
if !c.Writer.Written() {
c.JSON(http.StatusInternalServerError,
gin.H{"error": "internal_error"})
}
c.Abort()
}
}()
c.Next()
}
}
```
### `middleware/request_logging.go`
```go
func RequestLogger(logger *slog.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
c.Next()
logger.InfoContext(c.Request.Context(), "http request",
slog.String("method", c.Request.Method),
slog.String("path", c.Request.URL.Path),
slog.Int("status", c.Writer.Status()),
slog.Int("bytes", c.Writer.Size()),
slog.Duration("elapsed", time.Since(start)),
slog.String("ip", c.ClientIP()),
)
}
}
```
The `sloglint` linter enforces typed attrs (`slog.String(...)`) over `slog.Any("path", ...)`. Keep the form.
### `middleware/cors.go`
```go
func CORS() gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("Access-Control-Allow-Origin", "*")
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
c.Header("Access-Control-Allow-Headers", "*")
if c.Request.Method == http.MethodOptions {
c.AbortWithStatus(http.StatusNoContent)
return
}
c.Next()
}
}
```
Note the explicit OPTIONS short-circuit — preflight must NOT go through Auth.
---
## Handlers — the canonical shape
```go
package handlers
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
"github.com/your-org/myservice/internal/domain"
"github.com/your-org/myservice/internal/httperr"
"github.com/your-org/myservice/internal/service"
)
type Handler struct {
Users *service.UserService
}
func (h *Handler) Mount(r gin.IRouter) {
api := r.Group("/api/v1")
api.POST("/users", h.CreateUser)
api.GET("/users/:id", h.GetUser)
}
type createUserReq struct {
Email string `json:"email" binding:"required,email"`
Username string `json:"username" binding:"required,alphanum,min=3,max=32"`
}
func (h *Handler) CreateUser(c *gin.Context) {
var req createUserReq
if err := c.ShouldBindJSON(&req); err != nil {
writeBindingError(c, err)
return
}
email, err := domain.NewEmail(req.Email)
if err != nil {
httperr.Write(c, err)
return
}
username, err := domain.NewUsername(req.Username)
if err != nil {
httperr.Write(c, err)
return
}
user, err := h.Users.Create(c.Request.Context(), email, username)
if err != nil {
httperr.Write(c, err)
return
}
c.JSON(http.StatusCreated, user)
}
func writeBindingError(c *gin.Context, err error) {
var vErr validator.ValidationErrors
if errors.As(err, &vErr) {
out := make(map[string]string, len(vErr))
for _, fe := range vErr {
out[fe.Field()] = fe.Tag()
}
c.JSON(http.StatusBadRequest, gin.H{"errors": out})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_json"})
}
```
See `data-modeling.md` for the validator tag reference; see `error-handling.md` for the `httperr.Write` funnel.
---
## SSE streaming — the production pattern
CLIProxyAPI streams OpenAI-compatible SSE for hundreds of concurrent clients. The pattern:
```go
func (h *Handler) StreamChat(c *gin.Context) {
ctx, cancel := context.WithCancel(c.Request.Context())
defer cancel()
// 1. Set SSE headers BEFORE writing any body
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
c.Header("X-Accel-Buffering", "no") // disable nginx buffering
// 2. Obtain the flusher — REQUIRED for streaming
flusher, ok := c.Writer.(http.Flusher)
if !ok {
httperr.Write(c, errors.New("streaming unsupported"))
return
}
// 3. Pull chunks from upstream
chunks, errs := h.svc.StreamCompletions(ctx, req)
for {
select {
case <-ctx.Done():
return // client disconnected, ctx cancelled
case chunk, ok := <-chunks:
if !ok {
fmt.Fprint(c.Writer, "data: [DONE]\n\n")
flusher.Flush()
return
}
fmt.Fprintf(c.Writer, "data: %s\n\n", chunk)
flusher.Flush()
case err := <-errs:
// Error mid-stream — emit as SSE event and bail
fmt.Fprintf(c.Writer, "event: error\ndata: %s\n\n", err.Error())
flusher.Flush()
return
}
}
}
```
Key facts:
- **Headers MUST be set before the first `Write`.** Otherwise gin auto-sets `Content-Type: text/plain`.
- **`c.Writer.(http.Flusher)` is the streaming primitive.** Without `flusher.Flush()`, the response is buffered and arrives as one blob at the end.
- **Always respond to `<-ctx.Done()`.** A disconnected client must stop upstream work — otherwise you generate tokens for nothing.
- **The trailing `\n\n` per event is wire-mandatory** for SSE parsing. Missing it = the client never sees the event.
---
## WebSocket upgrade
```go
import "github.com/gorilla/websocket" // still the canonical WS lib in 2026
var upgrader = websocket.Upgrader{
ReadBufferSize: 4096,
WriteBufferSize: 4096,
CheckOrigin: func(r *http.Request) bool {
// tighten in production
return true
},
}
func (h *Handler) WebSocketEcho(c *gin.Context) {
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
slog.ErrorContext(c.Request.Context(), "ws upgrade failed", slog.Any("err", err))
return
}
defer conn.Close()
for {
mt, msg, err := conn.ReadMessage()
if err != nil { return }
if err := conn.WriteMessage(mt, msg); err != nil { return }
}
}
```
For long-lived connections, use `conn.SetReadDeadline` + `SetPongHandler` for keepalive. CLIProxyAPI's `wsrelay` package is a reference implementation.
---
## Database wiring — pgx pool, injected, never global
```go
package store
import (
"context"
"fmt"
"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 dsn: %w", err)
}
cfg.MaxConns = 25
cfg.MinConns = 5
cfg.MaxConnLifetime = time.Hour
cfg.MaxConnIdleTime = 30 * time.Minute
pool, err := pgxpool.NewWithConfig(ctx, cfg)
if err != nil {
return nil, fmt.Errorf("connect: %w", err)
}
if err := pool.Ping(ctx); err != nil {
pool.Close()
return nil, fmt.Errorf("ping: %w", err)
}
return pool, nil
}
```
See `sqlc-pgx.md` for queries.
---
## Healthcheck
```go
func (h *Handler) Healthz(c *gin.Context) {
if err := h.pool.Ping(c.Request.Context()); err != nil {
c.JSON(503, gin.H{"db": "down", "error": err.Error()})
return
}
c.JSON(200, gin.H{"ok": true})
}
```
Mount BEFORE auth. Health checks must be unauthenticated.
---
## Testing the server
```go
func TestCreateUser_returns_201_for_valid_input(t *testing.T) {
// Given
h := newTestHandler(t)
r := gin.New()
h.Mount(r)
body := `{"email":"a@b.com","username":"alice"}`
req := httptest.NewRequest("POST", "/api/v1/users", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
// When
r.ServeHTTP(rec, req)
// Then
require.Equal(t, http.StatusCreated, rec.Code)
var got struct{ ID string `json:"id"` }
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
require.NotEmpty(t, got.ID)
}
```
See `testing.md` for full patterns (testcontainers integration, table-driven, goleak).
---
## Sources
- gin docs: https://gin-gonic.com/docs/
- CLIProxyAPI (reference impl): https://github.com/router-for-me/CLIProxyAPI
- pgx pool: https://pkg.go.dev/github.com/jackc/pgx/v5/pgxpool
- SSE spec: https://html.spec.whatwg.org/multipage/server-sent-events.html
- Go's `http.Server` graceful shutdown: https://pkg.go.dev/net/http#Server.Shutdown
@@ -0,0 +1,328 @@
# Bootstrap — Project Layout, Toolchain, Taskfile, CI
What every new Go project gets in the first 60 seconds. Drop the script in `scripts/go/new-project.go` does all of this — this document explains *what* it produces and *why*.
## Toolchain pin
`go.work` (monorepo) or just rely on `go.mod`'s `go 1.23` directive (single module). Go 1.21+ auto-downloads matching toolchain when the local `go` binary is older. **No `.tool-versions` / `asdf` / `mise` indirection required** unless your shop standardizes on it.
```bash
# Confirm a working toolchain
go env GOTOOLCHAIN # should be "auto" or your pinned version
go version # ≥ 1.23
```
## Required global installs
These are CLI tools, installed once per machine via `go install`:
```bash
go install mvdan.cc/gofumpt@latest
go install golang.org/x/tools/cmd/goimports@latest
go install github.com/golangci/golangci-lint/cmd/golangci-lint@v2.0.0
go install go.uber.org/nilaway/cmd/nilaway@latest
go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest
go install github.com/pressly/goose/v3/cmd/goose@latest
go install go.uber.org/mock/mockgen@latest
go install github.com/go-task/task/v3/cmd/task@latest
```
For Connect/protobuf projects, additionally:
```bash
go install github.com/bufbuild/buf/cmd/buf@latest
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install connectrpc.com/connect/cmd/protoc-gen-connect-go@latest
```
## Project layout — the canonical tree
```
myservice/
├── go.mod
├── go.sum
├── Taskfile.yml # task runner
├── .golangci.yml # see golangci-strict.md
├── .editorconfig
├── .gitignore
├── README.md
├── AGENTS.md # agent-readable project facts
├── cmd/
│ └── server/
│ └── main.go # ONLY: parse flags, call cmd.Execute(); ≤ 50 LOC
├── internal/ # NEVER importable from outside this module
│ ├── api/ # transport layer (gin/connect routers)
│ │ ├── server.go # gin engine setup, route registration
│ │ ├── middleware/
│ │ │ ├── request_id.go
│ │ │ ├── logging.go
│ │ │ └── auth.go
│ │ └── handlers/
│ │ ├── users.go
│ │ └── users_test.go
│ ├── domain/ # parse-don't-validate types, smart constructors
│ │ ├── user.go
│ │ └── email.go
│ ├── service/ # business logic, depends on domain only
│ │ └── user_service.go
│ ├── store/ # persistence; sqlc-generated code lives here
│ │ ├── sqlc/ # sqlc-generated, do not hand-edit
│ │ ├── queries/ # *.sql files sqlc reads
│ │ └── migrations/ # goose migrations
│ ├── config/ # env-driven config (caarlos0/env)
│ │ └── config.go
│ └── obs/ # observability: slog setup, otel, healthz
│ └── logger.go
├── pkg/ # exportable libraries — only if you publish
│ └── …
├── proto/ # *.proto definitions (Connect/gRPC projects)
│ └── service.proto
├── gen/ # generated code (Connect, OpenAPI)
│ └── service/v1/
│ ├── service.pb.go
│ └── servicev1connect/
├── test/ # cross-cutting test helpers, fixtures
└── .github/workflows/ci.yml
```
**Rules**:
- `cmd/<binary>/main.go` is ≤ 50 LOC. Anything more lives in `internal/cmd/`.
- `internal/` is **the** business code. Other modules cannot import it (Go compiler-enforced).
- `pkg/` is for things you genuinely want third parties to import. Empty until proven otherwise.
- No `utils/`, `helpers/`, `common/`, `shared/`. **REJECT.** Files are named after the concept they own.
- One package per directory. One responsibility per package.
## `Taskfile.yml` — the entry point for every action
`go-task/task` is the modern Make replacement. Cross-platform, YAML, fast.
```yaml
version: '3'
vars:
BINARY: server
PKG: ./cmd/server
tasks:
default:
deps: [fmt, lint, test]
fmt:
desc: Format all Go files
cmds:
- gofumpt -w .
- goimports -w -local "$(go list -m)" .
lint:
desc: Run all linters
cmds:
- golangci-lint run --timeout 5m ./...
- nilaway -include-pkgs "$(go list -m)/..." ./...
test:
desc: Run tests with race detector
cmds:
- go test -race -shuffle=on -count=1 ./...
test-cover:
desc: Coverage report
cmds:
- go test -race -shuffle=on -count=1 -coverprofile=coverage.out ./...
- go tool cover -html=coverage.out -o coverage.html
build:
desc: Build the binary
cmds:
- go build -trimpath -ldflags="-s -w" -o bin/{{.BINARY}} {{.PKG}}
run:
desc: Run the server locally
deps: [build]
cmds:
- ./bin/{{.BINARY}}
gen:
desc: Run all code generators
cmds:
- task: gen:sqlc
- task: gen:mocks
- task: gen:proto
gen:sqlc:
cmds:
- sqlc generate
sources:
- internal/store/queries/*.sql
- internal/store/sqlc.yaml
generates:
- internal/store/sqlc/*.go
gen:mocks:
cmds:
- go generate ./...
gen:proto:
cmds:
- buf generate
sources:
- proto/**/*.proto
- buf.yaml
- buf.gen.yaml
migrate:up:
cmds:
- goose -dir internal/store/migrations postgres "$DATABASE_URL" up
migrate:down:
cmds:
- goose -dir internal/store/migrations postgres "$DATABASE_URL" down
ci:
desc: Everything CI does, locally
deps: [fmt, lint, test, build]
```
`task` (no args) runs format + lint + test in parallel where possible. `task ci` runs the full pipeline.
## `go.mod` template
```go
module github.com/your-org/myservice
go 1.23
require (
github.com/caarlos0/env/v11 v11.2.2
github.com/gin-gonic/gin v1.10.1
github.com/go-playground/validator/v10 v10.22.1
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.7.6
golang.org/x/sync v0.18.0
)
```
Only direct deps listed; `go mod tidy` populates indirects.
## `.editorconfig`
```ini
root = true
[*]
indent_style = tab
indent_size = 4
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.{yml,yaml,json,md}]
indent_style = space
indent_size = 2
```
## `.gitignore`
```gitignore
bin/
coverage.out
coverage.html
*.test
*.prof
# IDE
.idea/
.vscode/
*.swp
# Local env
.env
.env.local
# Secrets
*.pem
*.key
```
## CI — minimal GitHub Actions
`.github/workflows/ci.yml`:
```yaml
name: ci
on:
pull_request:
push:
branches: [main]
jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.23'
cache: true
- name: Install tools
run: |
go install mvdan.cc/gofumpt@latest
go install github.com/golangci/golangci-lint/cmd/golangci-lint@v2.0.0
go install go.uber.org/nilaway/cmd/nilaway@latest
go install github.com/go-task/task/v3/cmd/task@latest
- name: Format check
run: gofumpt -l . | (! grep .)
- name: Lint
run: golangci-lint run --timeout 5m ./...
- name: Nilaway
run: nilaway ./...
- name: Test
run: go test -race -shuffle=on -count=1 ./...
- name: Build
run: go build -trimpath ./...
```
The order matters: format → lint → nilaway → test → build. Fail fast on the cheap checks.
## `AGENTS.md` — agent-readable project facts
Every new project gets an `AGENTS.md` at the root. The content is **machine-friendly**: short, declarative, no marketing prose. Example:
```markdown
# AGENTS.md
Go 1.23+ HTTP service for {one-line purpose}.
## Commands
- `task` — fmt + lint + test
- `task build` — produce ./bin/server
- `task gen` — regenerate sqlc + mocks + proto
## Architecture
- `cmd/server/main.go` — entrypoint, ≤50 LOC
- `internal/api/` — gin handlers + middleware
- `internal/domain/` — smart-constructor types, no I/O
- `internal/store/sqlc/` — generated; never hand-edit
## Conventions
- `slog` for all logs; never `log.*`, never `fmt.Println`
- `context.Context` first arg for every public function
- Errors wrapped with `%w`; check with `errors.Is/As`
- 250 pure LOC ceiling per file — split before adding lines
```
The skill's `cmd/new-project.go` writes this file with project-specific values filled in.
## Sources
- Go modules reference: https://go.dev/ref/mod
- go-task: https://taskfile.dev
- golangci-lint v2: https://golangci-lint.run/docs/configuration/
- Standard project layout debate: https://go.dev/doc/modules/layout (NOT `golang-standards/project-layout` — that repo is community, not official)
@@ -0,0 +1,360 @@
# Bubbletea v2 — TUI with First-Class CJK / IME Support
The TUI stack for 2026. Use **v2 RC**, not v1. If your users include Korean, Japanese, or Chinese speakers, v1 is broken — IME composition lands in the wrong cells. v2 fixes this. This document is the canonical setup.
The reference implementation this document is distilled from: [`code-yeongyu/bubbletea-wm`](https://github.com/code-yeongyu/bubbletea-wm) — a floating window manager built specifically to nail down v2 + IME.
---
## Why v2 (not v1) — the IME story
Bubbletea v1 manages cursor positioning in software ("virtual cursor"). It draws a `█` at the cursor position. The terminal's *real* cursor stays at `(0, 0)`.
This breaks every CJK input method. IME candidate windows (the popup showing Hangul composition choices for Korean, kana → kanji for Japanese, and pinyin lookup for Chinese) anchor to the terminal's **real** cursor position. With v1, the candidate window appears at top-left while you are typing somewhere in the middle of the screen.
Bubbletea v2 fixes this with two changes:
1. **`tea.View{Cursor: *tea.Cursor}`** — your `View()` method returns a view that *includes* the desired cursor position. The framework moves the terminal's real cursor there.
2. **`textarea.SetVirtualCursor(false)`** — textareas no longer draw their own `█`. They expose `.Cursor()` so you can read where they want the real cursor.
Together: IME popups appear where the user is typing. As they should.
### Other v2 wins (incidental)
- `tea.MouseClickMsg` / `MouseMotionMsg` / `MouseReleaseMsg` instead of one coarse `MouseMsg`.
- Cleaner `View` struct with `AltScreen`, `MouseMode` fields instead of `tea.Cmd` setters.
- Pluggable rendering pipeline; better performance under high message volume.
---
## `go.mod`
```go
module github.com/your-org/mytui
go 1.23
require (
charm.land/bubbletea/v2 v2.0.0-rc.2
charm.land/bubbles/v2 v2.0.0-rc.1
charm.land/lipgloss/v2 v2.0.0-beta.3
github.com/mattn/go-runewidth v0.0.19
)
```
The packages live under `charm.land/` (NOT `github.com/charmbracelet/...`) for v2. This is the Charm team's deliberate import-path break to keep v2 separate from v1 until stable.
---
## Minimal app — the IME-correct skeleton
```go
package main
import (
"fmt"
"log"
tea "charm.land/bubbletea/v2"
"charm.land/bubbles/v2/textarea"
)
type model struct {
width, height int
ta textarea.Model
}
func initial() model {
ta := textarea.New()
ta.Placeholder = "Type Korean / Japanese / Chinese here..."
ta.SetWidth(60)
ta.SetHeight(10)
ta.SetVirtualCursor(false) // ← THE LINE. Without this, IME breaks.
ta.Focus()
return model{ta: ta}
}
func (m model) Init() tea.Cmd { return textarea.Blink }
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
case tea.KeyPressMsg:
if msg.String() == "ctrl+c" {
return m, tea.Quit
}
}
var cmd tea.Cmd
m.ta, cmd = m.ta.Update(msg)
return m, cmd
}
func (m model) View() tea.View {
var view tea.View
view.AltScreen = true
view.SetContent(m.ta.View())
// ── THE OTHER LINE. Position the REAL cursor for IME. ──
if cursor := m.ta.Cursor(); cursor != nil {
view.Cursor = cursor
}
return view
}
func main() {
if _, err := tea.NewProgram(initial(), tea.WithAltScreen()).Run(); err != nil {
log.Fatal(err)
}
fmt.Println("bye")
}
```
The two lines that matter:
1. `ta.SetVirtualCursor(false)` — disables the virtual `█`.
2. `view.Cursor = cursor` (where `cursor = m.ta.Cursor()`) — exports the real cursor position to the framework.
Without **both**, IME breaks.
---
## CJK width — go-runewidth, not `len()`
Korean, Japanese, Chinese characters render as **two terminal cells** (wide characters per Unicode East Asian Width). Naive `len(string)` returns byte count, not display width. `utf8.RuneCountInString` returns rune count, also not display width.
Use `github.com/mattn/go-runewidth`:
```go
import "github.com/mattn/go-runewidth"
func displayWidth(s string) int {
return runewidth.StringWidth(s)
}
// Wide character occupies two cells; pad accordingly
for _, r := range s {
cell := string(r)
w := runewidth.RuneWidth(r)
canvas = append(canvas, cell)
if w == 2 {
canvas = append(canvas, "") // placeholder for second cell
}
}
```
`lipgloss/v2` uses `go-runewidth` internally — `lipgloss.Width("\u4e2d\u6587")` returns 4, not 2. **If you measure outside lipgloss, you must call runewidth directly.**
---
## Mouse — v2 has typed events
```go
case tea.MouseClickMsg:
// msg.X, msg.Y, msg.Button
return m.handleClick(msg.X, msg.Y, msg.Button)
case tea.MouseMotionMsg:
return m.handleHover(msg.X, msg.Y)
case tea.MouseReleaseMsg:
return m.handleRelease(msg.X, msg.Y)
```
Enable mouse via the `View`:
```go
view.MouseMode = tea.MouseModeCellMotion // or MouseModeAll
```
`CellMotion` reports clicks + motion-while-button-pressed (drag). `MouseModeAll` reports motion always — heavier, only when you need hover.
---
## Components from `bubbles/v2`
```go
import (
"charm.land/bubbles/v2/textarea"
"charm.land/bubbles/v2/textinput"
"charm.land/bubbles/v2/spinner"
"charm.land/bubbles/v2/viewport"
"charm.land/bubbles/v2/list"
"charm.land/bubbles/v2/table"
"charm.land/bubbles/v2/help"
"charm.land/bubbles/v2/key"
)
```
All v2 components support `SetVirtualCursor(false)` where they accept text input. Use it for every text input that users might type CJK into — and "might" should be assumed *yes*.
---
## Styling — `lipgloss/v2`
```go
import "charm.land/lipgloss/v2"
titleStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("230")).
Background(lipgloss.Color("62")).
Padding(0, 1).
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("63"))
rendered := titleStyle.Render("\u4e2d\u6587")
```
`lipgloss/v2` width and padding correctly account for CJK display width. v1 did too — this is not a v2-specific fix, just a reminder.
---
## Architecture pattern — ModelUpdateView
```
+--------------------------------------------+
| tea.Program runs the event loop |
| |
| loop: |
| msg <- queue |
| model, cmd = model.Update(msg) |
| view = model.View() |
| render(view) |
| if cmd != nil: go run(cmd) -> queue |
+--------------------------------------------+
```
Rules:
- **Model is a value type, not a pointer.** Bubbletea calls `Update` with a value receiver and expects a new value returned. Pointer receivers cause subtle bugs where state mutation leaks across draws.
- **`Update` is pure.** No I/O. No goroutines started inline. Any I/O returns a `tea.Cmd` — Bubbletea runs it in a goroutine and feeds the result back as a message.
- **`View` is read-only.** It returns a `tea.View` without modifying state.
- **`tea.Cmd` is `func() tea.Msg`.** It runs once, returns a message, exits. For repeating work, use `tea.Tick` or a self-resending command.
```go
// One-shot command
func loadData() tea.Cmd {
return func() tea.Msg {
data, err := fetch()
if err != nil { return errMsg{err} }
return dataLoadedMsg{data}
}
}
// Periodic
func tickEvery() tea.Cmd {
return tea.Tick(time.Second, func(t time.Time) tea.Msg {
return tickMsg{t}
})
}
```
---
## Splitting the model — sub-models
```go
type model struct {
list list.Model
input textinput.Model
spinner spinner.Model
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
var cmd tea.Cmd
m.list, cmd = m.list.Update(msg)
cmds = append(cmds, cmd)
m.input, cmd = m.input.Update(msg)
cmds = append(cmds, cmd)
m.spinner, cmd = m.spinner.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
```
`tea.Batch` runs commands concurrently. The framework collects their results in the order they arrive.
When the model exceeds 250 LOC, split by sub-model into separate files:
```
internal/ui/
├── model.go # root model orchestration
├── list.go # list sub-model state + update + view
├── input.go # input sub-model
└── spinner.go # spinner sub-model
```
---
## Testing TUI code — `teatest`
```go
import "charm.land/bubbletea/v2/teatest"
func TestModel_typing_cjk_keeps_cursor_in_position(t *testing.T) {
// Given
m := initial()
tm := teatest.NewTestModel(t, m, teatest.WithInitialTermSize(80, 24))
// When — simulate typing two CJK wide characters
tm.Send(tea.KeyPressMsg{Code: '\u4e2d'})
tm.Send(tea.KeyPressMsg{Code: '\u6587'})
// Then
out := tm.FinalOutput(t)
require.Contains(t, string(out), "\u4e2d\u6587")
// Cursor should be at column 4 (two wide chars = 4 cells)
// ...
}
```
`teatest` lets you drive the model through synthetic messages and inspect the rendered output. Pair with `autogold` snapshots for full-view regression tests.
---
## Common antipatterns
| Bad | Why | Good |
|---|---|---|
| `tea.Program` with `tea.WithoutSignals()` | Ctrl-C does not work | Default signal handling |
| Pointer receivers on Model | Bubbletea expects value semantics | Value receivers, return new model |
| `time.Sleep` inside `Update` | Blocks the event loop | `tea.Tick` or async `tea.Cmd` |
| `fmt.Println` for debug | Corrupts the rendered output | `tea.Printf` for logging, or write to a file |
| `len(s)` for CJK width | Off by 2x | `runewidth.StringWidth(s)` |
| `Bubbletea v1` for an app with text input | Korean/Japanese IME breaks | v2 + `SetVirtualCursor(false)` |
| Drawing your own `█` block cursor in v2 | Conflicts with `view.Cursor` | Let the terminal handle it |
---
## Performance — when v2 starts to crawl
- **Reduce View frequency.** If the model changes 60 times/sec but the rendered view changes once/sec, gate redraws on a "dirty" flag.
- **`viewport.Model` for scrollable content.** Avoid re-rendering thousands of lines on every keystroke.
- **`Batch` your commands.** A series of synchronous `tea.Cmd` returns serializes; `tea.Batch` parallelizes.
- **Profile with `tea.WithFPS(N)`** to cap repaint rate during development.
---
## When NOT to use Bubbletea
- The app is one prompt + one answer. Use `huh` (also from Charm) — simpler, no ModelUpdateView ceremony.
- The app is a long-running daemon with occasional status output. Use `slog` to stderr and `tea.Program` only if interactivity becomes necessary.
- The app must run as a non-tty subprocess (CI, redirected stdin). `tea.Program` requires a tty for input. Detect via `term.IsTerminal(int(os.Stdin.Fd()))` and fall back to a non-interactive path.
---
## Sources
- bubbletea v2 RC: https://github.com/charmbracelet/bubbletea/tree/v2
- bubbles v2: https://github.com/charmbracelet/bubbles/tree/v2
- lipgloss v2: https://github.com/charmbracelet/lipgloss/tree/v2
- bubbletea-wm (IME reference): https://github.com/code-yeongyu/bubbletea-wm
- crush CLI (production IME impl): https://github.com/charmbracelet/crush
- go-runewidth: https://github.com/mattn/go-runewidth
- Unicode East Asian Width: https://www.unicode.org/reports/tr11/
@@ -0,0 +1,468 @@
# CLI Stack — cobra + slog + caarlos0/env + signal handling
The canonical Go CLI skeleton. `cobra` is the de facto framework — Kubernetes, Docker CLI, Helm, GitHub CLI, gh, Hugo all use it. Use it.
---
## Toolchain
```bash
go install github.com/spf13/cobra-cli@latest
cobra-cli init mytool
cobra-cli add server
cobra-cli add migrate
```
`cobra-cli` scaffolds the `cmd/` package. Edit the result; do not regenerate.
---
## Layout
```
mytool/
├── go.mod
├── main.go # ≤ 30 LOC, calls cmd.Execute
├── cmd/
│ ├── root.go # rootCmd, persistent flags, slog setup
│ ├── server.go # `mytool server` subcommand
│ ├── migrate.go # `mytool migrate` subcommand
│ └── version.go # `mytool version` — auto-injected version
├── internal/
│ ├── config/
│ └── server/
└── Taskfile.yml
```
---
## `main.go`
```go
package main
import (
"context"
"log/slog"
"os"
"os/signal"
"syscall"
"github.com/your-org/mytool/cmd"
)
func main() {
ctx, stop := signal.NotifyContext(context.Background(),
syscall.SIGINT, syscall.SIGTERM)
defer stop()
if err := cmd.Execute(ctx); err != nil {
slog.Error("fatal", slog.Any("err", err))
os.Exit(1)
}
}
```
`signal.NotifyContext` (Go 1.16+) gives every subcommand a ctx that cancels on Ctrl-C. Subcommands plumb the ctx into their workers.
---
## `cmd/root.go`
```go
package cmd
import (
"context"
"log/slog"
"os"
"github.com/spf13/cobra"
)
var (
verbose bool
logFormat string
configPath string
)
var rootCmd = &cobra.Command{
Use: "mytool",
Short: "Short description of mytool",
Long: `Long description, prose; cobra wraps it for --help.`,
PersistentPreRunE: func(c *cobra.Command, args []string) error {
return setupLogger()
},
SilenceUsage: true, // don't print --help on every error
SilenceErrors: true, // we log them ourselves in Execute
}
func init() {
rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false,
"enable debug logging")
rootCmd.PersistentFlags().StringVar(&logFormat, "log-format", "text",
"log format: text or json")
rootCmd.PersistentFlags().StringVarP(&configPath, "config", "c", "",
"path to config file (optional)")
}
func Execute(ctx context.Context) error {
return rootCmd.ExecuteContext(ctx)
}
func setupLogger() error {
level := slog.LevelInfo
if verbose { level = slog.LevelDebug }
opts := &slog.HandlerOptions{Level: level}
var h slog.Handler
switch logFormat {
case "json":
h = slog.NewJSONHandler(os.Stderr, opts)
case "text":
h = slog.NewTextHandler(os.Stderr, opts)
default:
return fmt.Errorf("invalid log-format %q", logFormat)
}
slog.SetDefault(slog.New(h))
return nil
}
```
Notes:
- `RunE` / `PersistentPreRunE` (the `E` variants) return errors. Use these; never use `Run` (no error return, encourages `log.Fatal`).
- `SilenceUsage: true` + `SilenceErrors: true` together: cobra stops printing the full `--help` on every command failure (the default behavior is rude in production scripts).
- `ExecuteContext` (cobra 1.8+) plumbs the ctx into every subcommand's `cmd.Context()`.
---
## `cmd/server.go`
```go
package cmd
import (
"log/slog"
"github.com/spf13/cobra"
"github.com/your-org/mytool/internal/server"
)
var (
serverAddr string
)
var serverCmd = &cobra.Command{
Use: "server",
Short: "Run the HTTP server",
RunE: func(c *cobra.Command, args []string) error {
ctx := c.Context()
slog.InfoContext(ctx, "starting", slog.String("addr", serverAddr))
return server.Run(ctx, serverAddr)
},
}
func init() {
serverCmd.Flags().StringVar(&serverAddr, "addr", ":8080",
"listen address")
rootCmd.AddCommand(serverCmd)
}
```
The subcommand is a thin shim — flags + log line + delegate to `internal/server`. Anything bigger violates the 250-LOC ceiling and belongs in `internal/`.
---
## Subcommands with arguments
```go
var migrateUpCmd = &cobra.Command{
Use: "up [N]",
Short: "Apply N migrations (default: all)",
Args: cobra.MaximumNArgs(1),
RunE: func(c *cobra.Command, args []string) error {
n := -1 // all
if len(args) == 1 {
var err error
n, err = strconv.Atoi(args[0])
if err != nil {
return fmt.Errorf("invalid N: %w", err)
}
}
return migrate.Up(c.Context(), n)
},
}
```
Use cobra's argument validators (`cobra.ExactArgs`, `cobra.MaximumNArgs`, `cobra.OnlyValidArgs`). They produce clean help text.
---
## Flag types — typed, not strings
```go
// GOOD
serverCmd.Flags().DurationVar(&timeout, "timeout", 30*time.Second, "request timeout")
serverCmd.Flags().IntVar(&port, "port", 8080, "port")
serverCmd.Flags().StringSliceVar(&hosts, "host", nil, "allowed hosts (repeatable)")
// BAD — manual parsing
serverCmd.Flags().StringVar(&timeoutStr, "timeout", "30s", "")
// ...then later: time.ParseDuration(timeoutStr)
```
`pflag` (cobra's flag lib) has typed variants for every common type. Use them; the parsing and error messages are free.
---
## Bind flags to env vars
cobra + viper is overkill for env binding. Use `caarlos0/env/v11`:
```go
type ServerOpts struct {
Addr string `env:"ADDR" envDefault:":8080"`
Timeout time.Duration `env:"TIMEOUT" envDefault:"30s"`
}
var opts ServerOpts
var serverCmd = &cobra.Command{
Use: "server",
PersistentPreRunE: func(c *cobra.Command, args []string) error {
// 1. Parse env first.
if err := env.Parse(&opts); err != nil { return err }
// 2. Flags override env if explicitly set.
if c.Flags().Changed("addr") {
opts.Addr, _ = c.Flags().GetString("addr")
}
return nil
},
RunE: func(c *cobra.Command, args []string) error {
return server.Run(c.Context(), opts)
},
}
func init() {
serverCmd.Flags().String("addr", "", "listen address (env: ADDR)")
serverCmd.Flags().Duration("timeout", 0, "request timeout (env: TIMEOUT)")
rootCmd.AddCommand(serverCmd)
}
```
Precedence: **flag (if set) > env > default**. Document the env var in the flag usage string.
---
## Version subcommand — build-injected
```go
// cmd/version.go
package cmd
import (
"fmt"
"runtime/debug"
"github.com/spf13/cobra"
)
// Set by -ldflags at build time, falls back to debug.BuildInfo.
var (
version = ""
commit = ""
date = ""
)
var versionCmd = &cobra.Command{
Use: "version",
Short: "Print version",
Run: func(c *cobra.Command, args []string) {
v, c2, d := resolveVersion()
fmt.Printf("mytool %s (commit %s, built %s)\n", v, c2, d)
},
}
func resolveVersion() (string, string, string) {
if version != "" { return version, commit, date }
info, ok := debug.ReadBuildInfo()
if !ok { return "dev", "unknown", "unknown" }
var vcs, hash, time string
for _, s := range info.Settings {
switch s.Key {
case "vcs.revision": hash = s.Value
case "vcs.time": time = s.Value
case "vcs": vcs = s.Value
}
}
return info.Main.Version, hash, time + " (" + vcs + ")"
}
func init() { rootCmd.AddCommand(versionCmd) }
```
Build with version injection:
```bash
go build \
-ldflags="-X 'github.com/your-org/mytool/cmd.version=v1.2.3' -X 'github.com/your-org/mytool/cmd.commit=$(git rev-parse --short HEAD)' -X 'github.com/your-org/mytool/cmd.date=$(date -u +%Y-%m-%dT%H:%M:%SZ)'" \
-o bin/mytool ./
```
The `debug.BuildInfo` fallback means a `go install`'d binary also has version info — no manual `-ldflags` needed.
---
## Shell completions
```go
var completionCmd = &cobra.Command{
Use: "completion [bash|zsh|fish|powershell]",
Short: "Generate shell completion",
Args: cobra.ExactValidArgs(1),
ValidArgs: []string{"bash", "zsh", "fish", "powershell"},
DisableFlagsInUseLine: true,
RunE: func(c *cobra.Command, args []string) error {
switch args[0] {
case "bash": return rootCmd.GenBashCompletionV2(os.Stdout, true)
case "zsh": return rootCmd.GenZshCompletion(os.Stdout)
case "fish": return rootCmd.GenFishCompletion(os.Stdout, true)
case "powershell": return rootCmd.GenPowerShellCompletion(os.Stdout)
}
return nil
},
}
func init() { rootCmd.AddCommand(completionCmd) }
```
User:
```bash
mytool completion zsh > "${fpath[1]}/_mytool"
```
---
## Interactive prompts — `huh` from charm
For prompts/forms (`Are you sure?`, "Pick an environment", multi-field forms):
```go
import "github.com/charmbracelet/huh"
var confirm bool
err := huh.NewConfirm().
Title("Apply migrations to PRODUCTION?").
Affirmative("Yes, do it").
Negative("Abort").
Value(&confirm).
Run()
```
`huh` replaces `survey` (which is no longer maintained). It composes with `lipgloss` for styling.
---
## Progress / spinners
```go
import "github.com/charmbracelet/huh/spinner"
err := spinner.New().Title("Fetching...").Action(func() {
// long-running work
}).Run()
```
For determinate progress (downloads, batch processing), use `vbauerster/mpb/v8`:
```go
import "github.com/vbauerster/mpb/v8"
p := mpb.New(mpb.WithWidth(60))
bar := p.AddBar(int64(total), /* decorators */)
for i := 0; i < total; i++ {
work()
bar.Increment()
}
p.Wait()
```
---
## Output — JSON vs text
Honor `--output json` for any CLI that scripts will parse:
```go
var outputFmt string
rootCmd.PersistentFlags().StringVar(&outputFmt, "output", "text",
"output format: text or json")
func render(v any) error {
switch outputFmt {
case "json":
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(v)
case "text":
return renderText(v)
default:
return fmt.Errorf("invalid --output %q", outputFmt)
}
}
```
The `text` format uses `lipgloss` tables or `aquasecurity/table` for nicely-aligned columns. The `json` format is for `jq`-style piping.
---
## Error semantics
- Return errors from `RunE`. Cobra catches them and the `Execute` wrapper logs + exits non-zero.
- `os.Exit(1)` should appear **only in `main.go`**. Anywhere else means a subcommand cannot be tested.
- For graceful early termination ("user cancelled"), return a sentinel and check it in `Execute`:
```go
var ErrCancelled = errors.New("cancelled by user")
// ... return ErrCancelled
// in main:
if errors.Is(err, cmd.ErrCancelled) { os.Exit(130) } // 128 + SIGINT
```
---
## Testing CLI commands
```go
func TestServerCmd_runs_with_default_addr(t *testing.T) {
// Given
buf := &bytes.Buffer{}
rootCmd.SetOut(buf)
rootCmd.SetErr(buf)
rootCmd.SetArgs([]string{"server", "--addr", ":0"})
// When
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
err := rootCmd.ExecuteContext(ctx)
// Then
require.NoError(t, err)
require.Contains(t, buf.String(), "starting")
}
```
`SetArgs` + `ExecuteContext` is the canonical pattern. Bind a ctx with a short deadline for tests that would otherwise block.
---
## Sources
- cobra docs: https://github.com/spf13/cobra/blob/main/site/content/user_guide.md
- pflag: https://github.com/spf13/pflag
- huh: https://github.com/charmbracelet/huh
- caarlos0/env: https://github.com/caarlos0/env
- signal.NotifyContext: https://pkg.go.dev/os/signal#NotifyContext
@@ -0,0 +1,362 @@
# Concurrency
Goroutines, context, errgroup, channels, locks, and the discipline that keeps them from leaking. Go makes concurrency *easy to start* and *easy to get wrong*. This document is the boring rule set.
---
## The four non-negotiables
1. **`ctx context.Context` is the first parameter of every public function that does I/O or can be cancelled.**
2. **No goroutine without a shutdown path.** Every `go` keyword must answer "how does this stop?".
3. **`-race` on every test run.** The `Taskfile.yml` and CI both enforce it.
4. **`goleak` in `TestMain`** for every package that spawns goroutines. Catches leaks the race detector cannot.
---
## `context.Context` — the cancellation backbone
```go
// GOOD — ctx as first param, propagated through
func (s *UserService) Create(ctx context.Context, email Email) (User, error) {
user, err := s.store.Insert(ctx, email)
if err != nil {
return User{}, fmt.Errorf("insert: %w", err)
}
if err := s.notifier.Welcome(ctx, user); err != nil {
return User{}, fmt.Errorf("notify: %w", err)
}
return user, nil
}
// BAD — creates a fresh ctx, breaks request cancellation
func (s *UserService) Create(email Email) (User, error) {
ctx := context.Background() // ← contextcheck linter rejects this
// ...
}
```
The `contextcheck` linter (enabled in `golangci-strict.md`) refuses any function that has `ctx context.Context` available but uses `context.Background()` instead.
### `context.Value` — use sparingly
```go
// Typed key — never use a bare string
type ctxKey struct{ name string }
var requestIDKey = ctxKey{"request_id"}
func WithRequestID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, requestIDKey, id)
}
func RequestID(ctx context.Context) string {
v, _ := ctx.Value(requestIDKey).(string)
return v
}
```
**Rules**:
- Keys are unexported struct types, not strings. Prevents collisions across packages.
- `context.Value` is for *request-scoped metadata* (request ID, auth subject, trace span), NEVER for application-scoped dependencies.
- Dependencies (loggers, DB pools, config) go in your service struct, not in `context.Value`.
### `WithTimeout` / `WithCancel` — always pair with `defer cancel()`
```go
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel() // ← MUST be deferred. fatcontext linter catches misses.
if err := slow(ctx); err != nil { ... }
```
Forgetting `defer cancel()` leaks a context goroutine until the parent expires — the `lostcancel` vet check catches it.
---
## `errgroup` — the structured concurrency primitive
`golang.org/x/sync/errgroup` is Go's answer to Python's `asyncio.TaskGroup` or Rust's `JoinSet`. Use it instead of raw `go` for any group of related goroutines.
```go
import "golang.org/x/sync/errgroup"
func FetchAll(ctx context.Context, urls []string) ([][]byte, error) {
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(8) // concurrency cap — leave unbounded = production outage
results := make([][]byte, len(urls))
for i, u := range urls {
g.Go(func() error {
body, err := fetch(ctx, u)
if err != nil {
return fmt.Errorf("fetch %s: %w", u, err)
}
results[i] = body
return nil
})
}
if err := g.Wait(); err != nil {
return nil, err
}
return results, nil
}
```
Properties:
- `WithContext(parent)` returns a child ctx that gets cancelled on **first non-nil error**. All in-flight goroutines see `ctx.Done()` and bail.
- `SetLimit(n)` blocks `g.Go(...)` when the in-flight count hits `n`. **Always set this.** Unbounded fan-out is how services die.
- `g.Wait()` returns the **first** non-nil error. Others are dropped. If you need all errors, accumulate them manually:
```go
var mu sync.Mutex
var errs []error
// inside g.Go:
// mu.Lock(); errs = append(errs, err); mu.Unlock()
// after Wait, errors.Join(errs...)
```
---
## Goroutine leaks — `goleak`
```go
package store_test
import (
"testing"
"go.uber.org/goleak"
)
func TestMain(m *testing.M) {
goleak.VerifyTestMain(m)
}
```
This single line at the top of `*_test.go` runs goleak's check after every test in the package. If a test leaks a goroutine, the run fails — pointing at which goroutine.
**The bug it catches**: starting a goroutine in `setUp` and never joining it. Common in DB connection pools, background workers, ticker loops. The race detector does NOT catch this.
If you have a known long-lived goroutine (a singleton background worker, a metrics exporter), use `goleak.IgnoreTopFunction`:
```go
goleak.VerifyTestMain(m,
goleak.IgnoreTopFunction("github.com/prometheus/client_golang/prometheus.(*Registry).Push"),
)
```
---
## Channels — the rules that hold
### Direction
```go
// GOOD — direction in signatures
func produce(out chan<- Item)
func consume(in <-chan Item)
func pipeline(in <-chan Item, out chan<- Item)
```
Direction restricts misuse. A consumer cannot close the producer's channel.
### Closing
- **The sender closes.** Always. Never the receiver, never multiple senders.
- **Multiple senders → use a `sync.WaitGroup` + one closer.**
- **Closing a closed channel panics.** Closing a `nil` channel panics. Sending on a closed channel panics. Receiving from a closed channel returns zero value with `ok = false`.
```go
// Canonical fan-in: multiple producers, one closer
func fanIn(ctx context.Context, sources ...<-chan Item) <-chan Item {
out := make(chan Item)
var wg sync.WaitGroup
wg.Add(len(sources))
for _, src := range sources {
go func() {
defer wg.Done()
for item := range src {
select {
case out <- item:
case <-ctx.Done():
return
}
}
}()
}
go func() { wg.Wait(); close(out) }()
return out
}
```
### Selecting
```go
select {
case msg := <-incoming:
handle(msg)
case <-ctx.Done():
return ctx.Err()
case <-time.After(5 * time.Second):
return ErrTimeout
}
```
- `time.After` allocates a timer each call — fine for occasional selects, **NOT for hot loops**. Use `time.NewTimer` + `timer.Reset` for repeat selects.
- A `default:` case makes `select` non-blocking. Use deliberately, not by accident.
### Buffered vs unbuffered
- **Unbuffered** (`make(chan T)`) = synchronous handoff. Sender blocks until receiver is ready. Use for *coordination*.
- **Buffered** (`make(chan T, n)`) = asynchronous up to `n`. Use for *decoupling producer rate from consumer rate*.
A buffered channel of size 1 acts as a **non-blocking signal**:
```go
ready := make(chan struct{}, 1)
// Producer
select {
case ready <- struct{}{}: // signal once, non-blocking
default: // already signaled, skip
}
// Consumer
<-ready
```
---
## Locks — the pyramid
```
Highest level (preferred)
channels (message passing — "share memory by communicating")
errgroup / wait group
sync.RWMutex (many readers, occasional writer)
sync.Mutex (mutual exclusion)
atomic.Int64 / atomic.Pointer (single-word lock-free)
Lowest level (rare)
unsafe.Pointer + barriers (custom lock-free; needs -race AND review)
```
### `sync.Mutex` — embed, don't expose
```go
type Cache struct {
mu sync.RWMutex
items map[string]Entry
}
func (c *Cache) Get(key string) (Entry, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
e, ok := c.items[key]
return e, ok
}
func (c *Cache) Set(key string, e Entry) {
c.mu.Lock()
defer c.mu.Unlock()
c.items[key] = e
}
```
- `sync.Mutex` is **not** copyable. The `copylocks` vet check catches `var c2 = c1` where `c1` has a mutex.
- Always `defer mu.Unlock()` immediately after `Lock()`. Forgetting is the #1 deadlock cause.
- Never call user code (callbacks, listener notifications) while holding the lock. Drop the lock, snapshot the data, release, then call out.
### `sync.OnceValue` / `sync.OnceFunc` (Go 1.21+)
Replacement for `sync.Once` for typed lazy init:
```go
var loadConfig = sync.OnceValue(func() Config {
var cfg Config
if err := env.Parse(&cfg); err != nil { panic(err) }
return cfg
})
func handler() { cfg := loadConfig(); ... }
```
Type-safe, no `sync.Once` + global variable boilerplate.
### Atomics — the typed API only
```go
// Go 1.19+ — use the typed atomic.* family
var counter atomic.Int64
counter.Add(1)
n := counter.Load()
// NEVER — the old function-style is type-unsafe
atomic.AddInt64(&counter, 1) // ← rejected
```
---
## Time — inject a clock for testability
```go
type Clock interface {
Now() time.Time
}
type realClock struct{}
func (realClock) Now() time.Time { return time.Now() }
type Service struct {
clock Clock
}
// Tests
import "github.com/benbjohnson/clock"
fake := clock.NewMock()
fake.Set(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))
svc := &Service{clock: fake}
```
**Never call `time.Now()` in domain or service code.** The `time` package becomes a hidden dependency — tests become flaky, retries become time-of-day-dependent, expirations cannot be tested.
`time.Sleep` in production code is a code smell. Use:
- `time.NewTicker` for periodic work (and a `<-ctx.Done()` exit).
- `time.NewTimer` for one-shot delays.
- `time.After` ONLY in select statements, ONLY in non-hot paths.
---
## Race detector — non-negotiable in CI
```bash
go test -race -shuffle=on -count=1 ./...
```
- `-race` instruments memory accesses; catches data races at runtime. ~10x slow-down — acceptable for tests, not production.
- `-shuffle=on` randomizes test order; catches hidden ordering dependencies.
- `-count=1` defeats the test cache. Without it, "passing" might mean "ran 3 weeks ago".
If a test ONLY fails under `-race`, the bug is real. Don't disable the test; fix the race.
---
## Common antipatterns
| Bad | Why | Good |
|---|---|---|
| `go func() { ... }()` with no `ctx` plumbing | Leaks on shutdown | `errgroup.WithContext` or pass ctx |
| Bare `time.Sleep(d)` in production | Untestable, blocks | `time.NewTimer` + select with `ctx.Done()` |
| Channel of `interface{}` | Loses type | Typed channel; use sealed interface if variants needed |
| `sync.Mutex` in a struct passed by value | Locked copies, undefined behavior | Embed in pointer-receiver type; copylocks catches it |
| Locking around an entire request handler | Serializes the whole API | Lock only the smallest critical section |
| `for { select { ... } }` without `<-ctx.Done()` | Cannot stop | Add ctx case in every long-lived select |
| `sync.WaitGroup.Add(1)` inside the goroutine | Race: Wait can return before Add | Add **before** `go` |
---
## Sources
- Go memory model: https://go.dev/ref/mem
- `errgroup` package: https://pkg.go.dev/golang.org/x/sync/errgroup
- `goleak`: https://github.com/uber-go/goleak
- "Go concurrency patterns" (Pike): https://go.dev/blog/pipelines
- Sync.OnceValue blog: https://go.dev/blog/synctest (1.24+ note: `testing/synctest` for time-controlled tests is now experimental)
@@ -0,0 +1,329 @@
# Data Modeling — Three Layers of Validation
Go has no Pydantic. Go has no Zod. **You do not need them**, but only if you wire three layers correctly. This document is the canonical pattern.
## The three layers
```
┌─────────────────────────────────────────────────────────────┐
│ HTTP / RPC / CLI │
│ Raw bytes, strings, untrusted input │
│ │
│ Layer 1: validator/v10 (struct tags) ◄── parse-once │
│ OR protovalidate (proto) │
│ │
└──────────────────────────┬──────────────────────────────────┘
│ raw req → domain.X
┌─────────────────────────────────────────────────────────────┐
│ Domain (internal/domain) │
│ │
│ Layer 2: Smart constructors + unexported fields │
│ NewEmail(s) → (Email, error) │
│ NewUserID(s) → (UserID, error) │
│ │
│ Once inside this layer, NO further validation. │
│ The types prove correctness. │
└──────────────────────────┬──────────────────────────────────┘
│ domain.X (proven valid)
┌─────────────────────────────────────────────────────────────┐
│ Storage (internal/store) │
│ │
│ Layer 3: sqlc-generated row structs ↔ domain types │
│ Hand-written mappers, NOT struct tags │
└─────────────────────────────────────────────────────────────┘
```
Each layer parses once, into the next layer's types. **A function in the domain layer should never receive a raw string and validate it.** If it does, the boundary above failed.
---
## Layer 1: HTTP boundary — `go-playground/validator/v10`
```go
package handlers
import (
"github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
)
// CreateUserRequest is the wire format. Tags drive validation.
type CreateUserRequest struct {
Email string `json:"email" binding:"required,email"`
Username string `json:"username" binding:"required,alphanum,min=3,max=32"`
Age int `json:"age" binding:"required,gte=13,lte=130"`
Country string `json:"country" binding:"required,iso3166_1_alpha2"`
}
func (h *Handler) CreateUser(c *gin.Context) {
var req CreateUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
// validator returns ValidationErrors with field-by-field detail
var vErr validator.ValidationErrors
if errors.As(err, &vErr) {
c.JSON(400, gin.H{"errors": fieldErrors(vErr)})
return
}
c.JSON(400, gin.H{"error": "invalid json"})
return
}
// Cross into domain — single point of failure
email, err := domain.NewEmail(req.Email)
if err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
username, err := domain.NewUsername(req.Username)
if err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
user, err := h.svc.Create(c.Request.Context(), email, username, req.Age)
if err != nil {
h.writeServiceError(c, err)
return
}
c.JSON(201, user)
}
func fieldErrors(vErr validator.ValidationErrors) map[string]string {
out := make(map[string]string, len(vErr))
for _, fe := range vErr {
out[fe.Field()] = fe.Tag() + "(" + fe.Param() + ")"
}
return out
}
```
**Tag reference — the tags you actually use**:
| Tag | Meaning |
|---|---|
| `required` | Non-zero value |
| `omitempty` (json) | Skip if zero |
| `min=N` / `max=N` | Length (strings/slices) or value (numbers) |
| `gte=N` / `lte=N` / `gt=N` / `lt=N` | Numeric comparison |
| `email` | RFC 5322-ish email |
| `url` | Valid URL |
| `uuid` / `uuid4` / `uuid7` | UUID format |
| `alphanum` / `alpha` / `numeric` | Character class |
| `iso3166_1_alpha2` | Country code (US, KR, JP) |
| `iso4217` | Currency code (USD, KRW) |
| `oneof=a b c` | Enum of literal values |
| `dive` | Apply rules to each element of slice/map |
| `eqfield=Field` | Cross-field equality (e.g., password confirm) |
### Custom validators — register at startup
```go
func init() {
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
_ = v.RegisterValidation("strongpassword", validateStrongPassword)
}
}
func validateStrongPassword(fl validator.FieldLevel) bool {
s := fl.Field().String()
return len(s) >= 12 && hasUpper(s) && hasDigit(s) && hasSymbol(s)
}
```
Use sparingly. Most domain rules belong in smart constructors, not validators.
---
## Layer 2: Domain — smart constructors
Covered in detail in `type-patterns.md`. Recap:
```go
package domain
type Username struct{ raw string }
func NewUsername(s string) (Username, error) {
s = strings.TrimSpace(s)
if len(s) < 3 || len(s) > 32 {
return Username{}, ErrInvalidUsername
}
if !isAlphanum(s) {
return Username{}, ErrInvalidUsername
}
return Username{raw: s}, nil
}
func (u Username) String() string { return u.raw }
```
**Rule**: every domain type that has invariants has:
1. An unexported field holding the raw form.
2. A `New<Type>(raw) (<Type>, error)` constructor as the sole entry point.
3. A `String() string` for printing.
4. `MarshalJSON` / `UnmarshalJSON` if it crosses a JSON boundary outside HTTP handlers (e.g., logging payloads, queue messages).
5. Optionally: `Scan` and `Value` for `database/sql` interop (rare with sqlc).
---
## Layer 3: Storage — sqlc rows ↔ domain types
sqlc generates row structs from `.sql` files. **Do not put validation tags on them.** Map between sqlc rows and domain types explicitly:
```go
// internal/store/user_store.go
package store
import "myservice/internal/domain"
func (s *UserStore) Get(ctx context.Context, id domain.UserID) (domain.User, error) {
row, err := s.q.GetUser(ctx, string(id))
if err != nil {
return domain.User{}, err
}
return rowToUser(row)
}
func rowToUser(r sqlc.UserRow) (domain.User, error) {
email, err := domain.NewEmail(r.Email)
if err != nil {
// DB invariant broken — this is a programmer error, not a user error
return domain.User{}, fmt.Errorf("db invariant: invalid email for user %s: %w", r.ID, err)
}
username, err := domain.NewUsername(r.Username)
if err != nil {
return domain.User{}, fmt.Errorf("db invariant: invalid username: %w", err)
}
return domain.User{
ID: domain.UserID(r.ID),
Email: email,
Username: username,
Created: r.CreatedAt,
}, nil
}
```
The mapping is verbose. **That is the point.** Each field is a deliberate choice; refactors flag every site.
---
## Discriminated unions (sum types) at the boundary
When a wire payload has variants (e.g., `{"type": "user.created", ...}` vs `{"type": "user.deleted", ...}`):
```go
// Wire DTO with raw discriminator
type EventDTO struct {
Type string `json:"type" binding:"required,oneof=created deleted updated"`
Payload json.RawMessage `json:"payload" binding:"required"`
}
// Parse into the sealed domain type
func ParseEvent(dto EventDTO) (event.Event, error) {
switch dto.Type {
case "created":
var c event.Created
if err := json.Unmarshal(dto.Payload, &c); err != nil {
return nil, fmt.Errorf("decode created: %w", err)
}
return c, nil
case "deleted":
var d event.Deleted
if err := json.Unmarshal(dto.Payload, &d); err != nil {
return nil, fmt.Errorf("decode deleted: %w", err)
}
return d, nil
case "updated":
var u event.Updated
if err := json.Unmarshal(dto.Payload, &u); err != nil {
return nil, fmt.Errorf("decode updated: %w", err)
}
return u, nil
default:
return nil, fmt.Errorf("unknown event type %q", dto.Type)
}
}
```
The `exhaustive` linter on the switch + the `oneof` validation tag together cover both "unknown type" and "unhandled variant".
---
## Enums — typed string consts, not iota
```go
// GOOD — string-based, JSON-serializes correctly, debuggable
type Status string
const (
StatusPending Status = "pending"
StatusActive Status = "active"
StatusClosed Status = "closed"
)
func (s Status) IsValid() bool {
switch s {
case StatusPending, StatusActive, StatusClosed:
return true
}
return false
}
func (s *Status) UnmarshalJSON(data []byte) error {
var raw string
if err := json.Unmarshal(data, &raw); err != nil { return err }
parsed := Status(raw)
if !parsed.IsValid() { return fmt.Errorf("invalid status %q", raw) }
*s = parsed
return nil
}
```
**Never use `iota` enums for anything that crosses a wire boundary.** They serialize as integers, which (a) breaks debuggability, (b) makes reordering enum values a silent breaking change.
Use the validator tag `binding:"oneof=pending active closed"` to enforce at the HTTP boundary.
---
## Nullable fields — `*T` vs sentinel
Three choices, in order of preference:
1. **Sentinel zero value**: `Age int` with `0` meaning "unknown". Works when zero is genuinely unreachable as a valid value.
2. **`sql.Null<T>`** for DB columns: `sql.NullString`, `sql.NullInt64`, `sql.NullTime`. sqlc generates these for nullable columns.
3. **`*T`**: only when you need to distinguish "not provided" from "set to zero" in a JSON payload (PATCH semantics).
```go
// PATCH payload — `*string` discriminates absent vs empty
type UpdateUserRequest struct {
Email *string `json:"email,omitempty"`
Username *string `json:"username,omitempty"`
}
```
Avoid `*T` in domain types — it bloats every consumer with nil checks. Keep `*T` at the boundary, unwrap on the way in.
---
## Common AI-generated antipatterns this rejects
| Bad | Why | Good |
|---|---|---|
| `func handle(req map[string]any)` | No types, no validation | Define a struct, parse with `validator` |
| `if email != "" { ... }` inside domain | Validation in the wrong layer | Make `email Email`, no check needed |
| `type Status int` with `iota` for wire field | Silent breaking on reorder | `type Status string` with const literals |
| Struct tags `json:"email,string"` (the `,string` coercion) | Magic coercion hides bad input | Strict parsing, fail-fast |
| `json.Unmarshal` then range-check after | Two-step "validate after parse" | Use `validator` tags or custom `UnmarshalJSON` |
| Reusing handler DTO as the domain type | Couples wire format to business logic | Two distinct types, explicit mapping |
---
## Sources
- go-playground/validator: https://github.com/go-playground/validator
- gin binding internals: https://github.com/gin-gonic/gin/blob/master/binding/json.go
- Parse, don't validate: https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-validate/
- sqlc with custom types: https://docs.sqlc.dev/en/latest/howto/overrides.html
@@ -0,0 +1,359 @@
# Error Handling
Typed errors, wrap chains, `errors.Is` / `errors.As`, no panic in libraries, resource cleanup. Go errors look simple and are full of footguns. This document is the canonical set of moves.
---
## The five rules
1. **Every error is wrapped on the way up, with `%w`, with context.** Never `return err` from a non-trivial site.
2. **Compare with `errors.Is`, not `==`.** Wrap chains break `==`. The `errorlint` linter forbids `==` on errors.
3. **Cast with `errors.As`, not type assertion.** Same reason.
4. **`panic` is reserved for programmer errors.** Library code never panics on user input or environment failures. Use `(T, error)`.
5. **Resources released via `defer` immediately after acquisition.** No "I'll add it later".
---
## Sentinel errors — for invariant programmatic checks
```go
package domain
import "errors"
var (
ErrInvalidEmail = errors.New("domain: invalid email")
ErrInvalidPhone = errors.New("domain: invalid phone")
ErrInvalidAge = errors.New("domain: invalid age")
)
func NewEmail(s string) (Email, error) {
if !emailRe.MatchString(s) {
return Email{}, fmt.Errorf("email %q: %w", s, ErrInvalidEmail)
}
return Email{raw: strings.ToLower(s)}, nil
}
```
Caller branches on identity:
```go
email, err := domain.NewEmail(input)
if errors.Is(err, domain.ErrInvalidEmail) {
return c.JSON(400, gin.H{"error": "email format"})
}
```
`errors.Is` walks the wrap chain. `err == domain.ErrInvalidEmail` would have failed because `fmt.Errorf` wrapped it.
---
## Typed errors — when you need structured data
When callers need fields off the error (the offending value, the failing field name, the upstream HTTP status):
```go
type ValidationError struct {
Field string
Value string
Rule string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation: %s=%q failed %s", e.Field, e.Value, e.Rule)
}
// Optional: identity sentinel for errors.Is comparisons
var ErrValidation = errors.New("validation")
func (e *ValidationError) Is(target error) bool {
return target == ErrValidation
}
```
Caller:
```go
err := svc.Save(ctx, user)
var vErr *ValidationError
if errors.As(err, &vErr) {
// vErr.Field, vErr.Rule are available
c.JSON(400, gin.H{"field": vErr.Field, "rule": vErr.Rule})
return
}
```
**`errors.As` requires a non-nil pointer-to-pointer.** Almost always the type is `*ConcreteError`. Forgetting the leading `*` is the most common bug here.
---
## Wrapping — `%w` is mandatory
```go
// BAD — drops context
return err
// BAD — drops the error chain (errors.Is/As stops working)
return fmt.Errorf("failed to save user: %v", err)
// GOOD — preserves chain via %w
return fmt.Errorf("save user %s: %w", userID, err)
```
The `errorlint` linter catches `%v` where `%w` was meant. **Wrap once per layer**, with the minimum useful context:
```
api/handler: "create user request: %w"
service: "validate inputs: %w"
domain: "email %q: %w"
```
Each frame adds one fact, not a duplicate. The top-level error message reads as a path: `create user request: validate inputs: email "foo": domain: invalid email`.
### `errors.Join` — multiple errors at once
```go
// Validate all fields, collect all errors
var errs []error
if _, err := NewEmail(req.Email); err != nil {
errs = append(errs, fmt.Errorf("email: %w", err))
}
if _, err := NewUsername(req.Username); err != nil {
errs = append(errs, fmt.Errorf("username: %w", err))
}
if len(errs) > 0 {
return errors.Join(errs...)
}
```
`errors.Is` still walks each joined error. Use when reporting batch validation, not for "wrap two unrelated errors".
---
## Panics — when allowed, when banned
**Banned**:
- Anywhere a `(T, error)` could be returned.
- Inside HTTP handlers (gin's `Recovery` middleware catches them, but you've already lost the error context).
- Inside any goroutine that survives request lifetime.
**Allowed** (with documentation):
- Map literal init at package level: `var statusNames = map[Status]string{...}` followed by a `func init()` that panics if a const has no name. Catches the bug at startup, not runtime.
- The `must*` convention for genuinely unrecoverable startup:
```go
func MustParseURL(s string) *url.URL {
u, err := url.Parse(s)
if err != nil { panic(err) }
return u
}
// Use only with literals known at compile time:
var defaultAPI = MustParseURL("https://api.example.com")
```
- `default:` case of an exhaustive sealed-interface switch — see `type-patterns.md`.
The `revive` linter rule `error-return` will flag suspect panic sites; treat them as bugs.
---
## `defer` for resources — the only safe pattern
```go
func writeReport(path string) (err error) {
f, err := os.Create(path)
if err != nil {
return fmt.Errorf("create %s: %w", path, err)
}
defer func() {
if cerr := f.Close(); cerr != nil && err == nil {
err = fmt.Errorf("close %s: %w", path, cerr)
}
}()
if _, err := f.Write(data); err != nil {
return fmt.Errorf("write %s: %w", path, err)
}
return nil
}
```
Key points:
- `defer f.Close()` immediately after `os.Create` — never further down.
- Named return `(err error)` so the deferred close can mutate it on close failure.
- `bodyclose` linter catches missed `defer resp.Body.Close()` for HTTP responses.
- `sqlclosecheck` linter catches missed `defer rows.Close()` for SQL.
### `errors.Join` for multi-stage cleanup
```go
func process(path string) (err error) {
f, err := os.Open(path)
if err != nil { return err }
defer func() {
err = errors.Join(err, f.Close())
}()
// ... use f ...
return nil
}
```
When both the main operation AND `Close` can fail, `errors.Join` reports both without dropping either.
---
## HTTP error responses — a single funnel
Build one helper, route all handler errors through it:
```go
package httperr
type APIError struct {
Status int `json:"-"`
Code string `json:"code"`
Message string `json:"message"`
}
func (e *APIError) Error() string { return e.Code + ": " + e.Message }
var (
NotFound = &APIError{Status: 404, Code: "not_found", Message: "resource not found"}
Unauthorized = &APIError{Status: 401, Code: "unauthorized", Message: "unauthorized"}
BadRequest = &APIError{Status: 400, Code: "bad_request", Message: "bad request"}
Internal = &APIError{Status: 500, Code: "internal", Message: "internal error"}
)
// Wrap a domain error into an API error.
func From(err error) *APIError {
if err == nil { return nil }
var apiErr *APIError
if errors.As(err, &apiErr) { return apiErr }
switch {
case errors.Is(err, domain.ErrInvalidEmail),
errors.Is(err, domain.ErrInvalidUsername):
return &APIError{Status: 400, Code: "validation", Message: err.Error()}
case errors.Is(err, ErrNotFound):
return NotFound
case errors.Is(err, ErrUnauthorized):
return Unauthorized
default:
// unknown — log full chain, return generic
slog.Error("unmapped error", slog.Any("err", err))
return Internal
}
}
func Write(c *gin.Context, err error) {
apiErr := From(err)
c.JSON(apiErr.Status, apiErr)
}
```
Handlers become trivial:
```go
func (h *Handler) Create(c *gin.Context) {
user, err := h.svc.Create(c.Request.Context(), req)
if err != nil {
httperr.Write(c, err)
return
}
c.JSON(201, user)
}
```
---
## errgroup — error propagation across goroutines
```go
import "golang.org/x/sync/errgroup"
func fetchAll(ctx context.Context, urls []string) ([][]byte, error) {
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(8) // concurrency cap
results := make([][]byte, len(urls))
for i, u := range urls {
g.Go(func() error {
body, err := fetch(ctx, u)
if err != nil {
return fmt.Errorf("fetch %s: %w", u, err)
}
results[i] = body
return nil
})
}
if err := g.Wait(); err != nil {
return nil, err
}
return results, nil
}
```
- `errgroup.WithContext` cancels remaining tasks on first error.
- `SetLimit` bounds concurrency.
- First non-nil error is returned; others are discarded — by design.
See `concurrency.md` for the full pattern.
---
## Logging errors — structured, once
```go
slog.ErrorContext(ctx, "save user failed",
slog.String("user_id", string(id)),
slog.Any("err", err), // %w chain is fully rendered
)
```
**Log once, at the outermost frame.** Logging at every wrap site produces five log lines for one error.
The `sloglint` linter enforces `slog.Any("err", err)` over `slog.String("err", err.Error())` — the former preserves the chain when handlers walk the value.
---
## Antipatterns
| Bad | Why | Good |
|---|---|---|
| `_ = err` | Silent ignore | Handle, log, or wrap |
| `if err != nil { return err }` chained 10 deep without wrap | No path info | Add one fact per layer: `fmt.Errorf("step: %w", err)` |
| `panic(err)` in HTTP handlers | Loses error chain, hits gin Recovery | `httperr.Write(c, err)` |
| `err.Error() == "some string"` | Brittle, breaks on wrap | Define a sentinel, use `errors.Is` |
| `if err == sql.ErrNoRows` | Breaks under wrap | `errors.Is(err, sql.ErrNoRows)` |
| `catch-all log.Fatal(err)` in library code | Crashes the caller's process | Return error, let main decide |
| Returning a typed nil pointer wrapped in error interface | Classic "nil != nil" bug | Return explicit `nil` for the error |
The last bug deserves its own example:
```go
// BUG — returns a non-nil error interface containing a nil concrete type
func bad() error {
var e *MyError = nil
return e // interface wraps nil pointer; errors == nil is FALSE
}
// Caller
if err := bad(); err != nil {
// ← entered, but err.(*MyError) is nil — surprise panic
}
```
Fix: return explicit `nil`, not a typed nil. The `nilnil` linter catches this in `(T, error)` returns.
---
## Sources
- Go blog "Working with Errors in Go 1.13+": https://go.dev/blog/go1.13-errors
- `errors.Join` (Go 1.20+): https://pkg.go.dev/errors#Join
- errorlint: https://github.com/polyfloyd/go-errorlint
- nilaway nil-interface check: https://github.com/uber-go/nilaway
@@ -0,0 +1,236 @@
# Strict `.golangci.yml` (golangci-lint v2)
The single source of truth for "is this Go code acceptable". Drop this in unmodified. **Every linter below is enabled deliberately — read the rationale before disabling one.**
`golangci-lint` v2 changed config schema (top-level `version: "2"`). All v1 configs are incompatible. The block below is v2.
## `.golangci.yml`
```yaml
version: "2"
run:
timeout: 5m
tests: true
modules-download-mode: readonly
linters:
default: none
enable:
# ── Correctness — bug catchers ───────────────────────────────
- govet # stdlib vet, includes shadow, fieldalignment, nilness
- staticcheck # SA1*-SA9* — the de facto Go correctness linter
- errcheck # unhandled errors. ZERO tolerance.
- errorlint # %w wrapping, errors.As vs type-assertion, errors.Is vs ==
- nilerr # `return nil` after `err != nil` — classic bug
- nilnil # returning `(nil, nil)` from a (*T, error) function
- bodyclose # http.Response.Body not closed
- rowserrcheck # sql.Rows.Err() not checked
- sqlclosecheck # sql.Rows / sql.Stmt not closed
- contextcheck # functions taking context.Context don't get context.Background()
- fatcontext # context.WithValue() in a loop — leaks
- copyloopvar # Go 1.22 loop-var capture — should now use the new semantics
- intrange # use `for i := range N` (Go 1.22+) instead of `for i := 0; i < N; i++`
- usetesting # use t.TempDir/t.Setenv over os.* in tests
- testifylint # require vs assert correctness, ObjectsAreEqual misuse
# ── Style / readability — kept narrow to avoid bikeshedding ─
- gofumpt # stricter gofmt
- goimports # import grouping + local prefix
- whitespace # leading/trailing whitespace
- misspell # typos in comments and strings
- unconvert # redundant type conversions
- unparam # unused function parameters
- ineffassign # ineffective assignments
- dupword # duplicate words ("the the")
# ── Architecture — file size, complexity, dead code ─────────
- gocognit # cognitive complexity per function (threshold 25)
- gocyclo # cyclomatic complexity per function (threshold 15)
- funlen # function length (90 lines, 60 statements)
- lll # line length 120
- nestif # excessive nesting depth (>4)
- dupl # duplicate code blocks
- revive # extensible replacement for golint; selected rules below
- unused # unused vars/funcs/types
# ── Exhaustiveness — Go's weakest spot ──────────────────────
- exhaustive # type switch and enum-like const groups completeness
# ── Security ────────────────────────────────────────────────
- gosec # CWE-aware security scanner
# ── Logging ─────────────────────────────────────────────────
- sloglint # slog attr style + no slog.Any(); enforce structured logs
# ── Performance ─────────────────────────────────────────────
- perfsprint # fmt.Sprintf where strconv suffices
- prealloc # slice prealloc when length is known
- makezero # make([]T, n) with non-zero n then append (the classic bug)
linters-settings:
errcheck:
check-type-assertions: true
check-blank: true # `_ = err` is a violation
govet:
enable-all: true
settings:
shadow:
strict: true
fieldalignment:
# On by default; this catches struct layouts wasting memory.
# Disable per-file with //nolint:fieldalignment ONLY for boundary types
# whose JSON tag order matters for OpenAPI doc stability.
errorlint:
errorf: true # %w mandatory for wrapping
asserts: true # errors.As over type-assertion on `error`
comparison: true # errors.Is over ==
gocognit:
min-complexity: 25
gocyclo:
min-complexity: 15
funlen:
lines: 90
statements: 60
ignore-comments: true
lll:
line-length: 120
tab-width: 4
nestif:
min-complexity: 4
exhaustive:
default-signifies-exhaustive: false
check:
- switch
- map
gosec:
excludes:
- G104 # handled by errcheck/errorlint
- G304 # file path provided as input — too noisy for CLIs
sloglint:
no-mixed-args: true # all attr or all key-value, never mixed
kv-only: false
attr-only: true # force slog.String(...) form
no-global: all # disallow slog.Info; force a logger receiver
context: scope # require *Context variants where ctx is in scope
static-msg: true # msg must be a string literal (not fmt.Sprintf)
no-raw-keys: true # use slog.String("key", ...) not raw "key", "val"
key-naming-case: snake
testifylint:
enable-all: true
disable:
- require-error # We DO use assert.Error in table-driven loops
revive:
severity: warning
rules:
- name: var-naming
- name: package-comments
- name: exported
- name: error-return
- name: error-naming
- name: errorf # use fmt.Errorf instead of errors.New(fmt.Sprintf)
- name: if-return
- name: indent-error-flow
- name: range-val-in-closure
- name: redefines-builtin-id
- name: superfluous-else
- name: unhandled-error
arguments:
- "fmt.Print.*"
- "fmt.Fprint.*"
perfsprint:
integer-format: true
error-format: true
bool-format: true
string-format: true
goimports:
local-prefixes:
- github.com/your-org
issues:
max-issues-per-linter: 0
max-same-issues: 0
exclude-rules:
# Tests get a longer leash on funlen + lll
- path: _test\.go
linters:
- funlen
- lll
- dupl
- gosec
# Generated code never lints
- path: \.pb\.go$
linters: [all]
- path: \.connect\.go$
linters: [all]
- path: ^.*sqlc/.*\.sql\.go$
linters: [all]
formatters:
enable:
- gofumpt
- goimports
```
## Per-linter rationale (why each is on)
| Linter | What it catches | Why no compromise |
|---|---|---|
| `errcheck` (incl. `check-blank: true`) | `_ = err`, ignored errors from `Close()`, `Write()`, `json.Marshal()` | Silent error ignore is the #1 Go bug class. Banning `_ = err` forces a decision at every site. |
| `errorlint` | `err == io.EOF` instead of `errors.Is(err, io.EOF)`; missing `%w` in `fmt.Errorf` | Once you wrap in middleware, `==` checks silently break. `errors.Is/As` is the only safe form. |
| `nilerr` / `nilnil` | `return nil` after `err != nil`; `return nil, nil` from `(*T, error)` | Classic AI-generated bugs. Linter catches them mechanically. |
| `bodyclose` | `defer resp.Body.Close()` missed | Single most common Go memory leak. |
| `contextcheck` | `ctx := context.Background()` inside a function that received `ctx` | Breaks cancellation propagation — the entire reason ctx exists. |
| `exhaustive` | `switch x.(type)` missing a sealed-interface variant | **Go's weakest type-system spot.** This linter is the closest thing to compiler-enforced exhaustiveness. |
| `sloglint` | `slog.Info(...)` (global), mixed `Any`/typed attrs | Without this, structured logging silently degrades into string concatenation. |
| `govet/shadow` strict | `err := ... ; if ... { err := ...; ... }` shadowing | Hides the real error from outer scope — extremely common. |
| `govet/fieldalignment` | Struct field order wasting memory | Cheap correctness signal. Disable per-file when JSON tag order matters for OpenAPI. |
| `copyloopvar` + `intrange` | Pre-1.22 loop-var capture and old `for i := 0; i < N; i++` | The language modernized; the lint enforces it. |
| `usetesting` | `os.Setenv` / `os.Mkdir` in tests instead of `t.Setenv` / `t.TempDir` | Avoids test isolation bugs. |
| `gocognit` / `gocyclo` / `funlen` | Functions exceeding cognitive thresholds | Direct architectural signal — same purpose as the 250 LOC ceiling, at function granularity. |
| `gosec` | CWE patterns — SQL injection, weak crypto, path traversal | Production must pass this. |
| `testifylint` | `assert.Equal` where `require.Equal` was meant; `ObjectsAreEqual` misuse | Subtle test-correctness bugs. |
| `perfsprint` | `fmt.Sprintf("%d", n)` instead of `strconv.Itoa(n)` | 510x faster in tight loops, lints catch the lazy form. |
## `nolint` policy
`//nolint:linter1,linter2 // <reason>` is permitted with **two hard rules**:
1. **One linter at a time per directive.** No `//nolint:all`. No omitting the linter name.
2. **A reason after `//` is mandatory.** "Generated code", "false positive — protobuf imports", "OpenAPI field order" are acceptable. "Ignore" is not.
The skill auto-rejects `//nolint` without a reason. So does `revive` if you enable its `nolint` rule.
## CI gate
```bash
gofumpt -l . | (! grep .) # format
golangci-lint run --timeout 5m ./... # everything above
go vet -vettool=$(which fieldalignment) ./... # extra check (also in govet)
nilaway ./... # nil-deref static analysis
go test -race -shuffle=on -count=1 ./... # races + ordering
```
Any non-zero exit = the change does not ship.
## Sources
- golangci-lint v2 docs: https://golangci-lint.run/docs/configuration/
- staticcheck rules: https://staticcheck.dev/docs/checks
- sloglint: https://github.com/go-simpler/sloglint
- exhaustive: https://github.com/nishanths/exhaustive
- nilaway: https://github.com/uber-go/nilaway
@@ -0,0 +1,375 @@
# RPC — Connect-Go (default) + grpc-go (fallback) + protovalidate
`connectrpc/connect-go` is the default. It is wire-compatible with gRPC, also speaks Connect protocol + gRPC-Web from browsers, and uses ordinary `net/http` so middleware (logging, auth, tracing) composes the same way as REST. Reach for raw `grpc-go` only when you need a gRPC-specific feature Connect lacks.
---
## When Connect vs grpc-go
| Need | Use |
|---|---|
| Standard unary + server-streaming + client-streaming | **Connect** |
| Browser client without `grpc-web` proxy | **Connect** (native gRPC-Web support) |
| HTTP/1.1 fallback for hostile networks | **Connect** (gRPC requires HTTP/2 end-to-end) |
| Server reflection for `grpcurl` | grpc-go (Connect has reflection too, but ecosystem smaller) |
| Bidirectional streaming with frame-level control | grpc-go |
| Strict gRPC environment (Envoy with gRPC filters, Istio strict mode) | grpc-go |
**Default**: Connect. The default has been correct since 2024.
---
## Toolchain — Buf, not protoc
```bash
go install github.com/bufbuild/buf/cmd/buf@latest
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install connectrpc.com/connect/cmd/protoc-gen-connect-go@latest
go install github.com/bufbuild/protovalidate/cmd/protoc-gen-go-vtproto@latest
```
Buf replaces `protoc` for everything: linting, breaking-change detection, codegen, formatting. The `protoc` toolchain is dead-letter walking — every modern proto project uses Buf.
---
## Project layout
```
proto/
buf.yaml
buf.gen.yaml
buf.lock
myservice/v1/
user.proto
auth.proto
gen/
myservice/v1/
user.pb.go # protoc-gen-go output
auth.pb.go
myservicev1connect/ # protoc-gen-connect-go output
user.connect.go
auth.connect.go
```
**`gen/` is committed.** Generated code is part of the API contract; CI proves it is up-to-date.
---
## `buf.yaml`
```yaml
version: v2
modules:
- path: proto
lint:
use:
- STANDARD
breaking:
use:
- FILE
```
## `buf.gen.yaml`
```yaml
version: v2
managed:
enabled: true
override:
- file_option: go_package_prefix
value: github.com/your-org/myservice/gen
plugins:
- remote: buf.build/protocolbuffers/go
out: gen
opt:
- paths=source_relative
- remote: buf.build/connectrpc/go
out: gen
opt:
- paths=source_relative
- remote: buf.build/bufbuild/validate-go
out: gen
opt:
- paths=source_relative
```
The `buf.build/...` plugin URIs use Buf's hosted remote registry — no local plugin installation needed.
## Taskfile target
```yaml
gen:proto:
cmds:
- buf lint
- buf format -w
- buf generate
sources:
- proto/**/*.proto
- buf.yaml
- buf.gen.yaml
```
Run `task gen:proto` after editing any `.proto`. CI runs `buf generate` then `git diff --exit-code` to catch stale generated code.
---
## A `.proto` with validation
```proto
syntax = "proto3";
package myservice.v1;
import "buf/validate/validate.proto";
option go_package = "github.com/your-org/myservice/gen/myservice/v1;myservicev1";
service UserService {
rpc CreateUser(CreateUserRequest) returns (CreateUserResponse);
rpc GetUser(GetUserRequest) returns (GetUserResponse);
rpc StreamEvents(StreamEventsRequest) returns (stream Event);
}
message CreateUserRequest {
string email = 1 [(buf.validate.field).string.email = true];
string username = 2 [
(buf.validate.field).string.min_len = 3,
(buf.validate.field).string.max_len = 32,
(buf.validate.field).string.pattern = "^[a-zA-Z0-9_]+$"
];
int32 age = 3 [
(buf.validate.field).int32.gte = 13,
(buf.validate.field).int32.lte = 130
];
}
message CreateUserResponse {
User user = 1;
}
message User {
string id = 1;
string email = 2;
string username = 3;
google.protobuf.Timestamp created_at = 4;
}
```
`protovalidate` replaces the abandoned `protoc-gen-validate` — it is the official Buf-backed successor as of 2024, supported by Connect's interceptor pipeline.
---
## Server — Connect
```go
package main
import (
"context"
"log/slog"
"net/http"
"connectrpc.com/connect"
"buf.build/go/protovalidate"
validateinterceptor "connectrpc.com/validate"
"golang.org/x/net/http2"
"golang.org/x/net/http2/h2c"
myservicev1 "github.com/your-org/myservice/gen/myservice/v1"
"github.com/your-org/myservice/gen/myservice/v1/myservicev1connect"
)
type UserServer struct {
svc *UserService
}
func (s *UserServer) CreateUser(
ctx context.Context,
req *connect.Request[myservicev1.CreateUserRequest],
) (*connect.Response[myservicev1.CreateUserResponse], error) {
// protovalidate already ran via the interceptor below.
// req.Msg is guaranteed to satisfy the .proto constraints.
user, err := s.svc.Create(ctx, req.Msg.Email, req.Msg.Username, req.Msg.Age)
if err != nil {
return nil, mapError(err)
}
return connect.NewResponse(&myservicev1.CreateUserResponse{
User: userToProto(user),
}), nil
}
func main() {
validator, _ := protovalidate.New()
interceptors := connect.WithInterceptors(
loggingInterceptor(),
validateinterceptor.NewInterceptor(validator),
)
mux := http.NewServeMux()
mux.Handle(myservicev1connect.NewUserServiceHandler(
&UserServer{svc: newUserService()},
interceptors,
))
// h2c lets the server speak HTTP/2 cleartext for gRPC clients.
srv := &http.Server{
Addr: ":8080",
Handler: h2c.NewHandler(mux, &http2.Server{}),
}
slog.Info("rpc server listening", slog.String("addr", srv.Addr))
if err := srv.ListenAndServe(); err != nil { slog.Error("rpc", slog.Any("err", err)) }
}
```
The handler is **just an `http.Handler`** — mount it in the same `http.ServeMux` as your REST routes if you want one binary serving both.
---
## Error mapping — Connect codes
```go
func mapError(err error) error {
if err == nil { return nil }
switch {
case errors.Is(err, domain.ErrInvalidEmail),
errors.Is(err, domain.ErrInvalidUsername):
return connect.NewError(connect.CodeInvalidArgument, err)
case errors.Is(err, ErrNotFound):
return connect.NewError(connect.CodeNotFound, err)
case errors.Is(err, ErrUnauthorized):
return connect.NewError(connect.CodeUnauthenticated, err)
case errors.Is(err, ErrConflict):
return connect.NewError(connect.CodeAlreadyExists, err)
default:
slog.Error("unmapped rpc error", slog.Any("err", err))
return connect.NewError(connect.CodeInternal, errors.New("internal"))
}
}
```
Connect codes map 1:1 to gRPC codes. Clients see canonical error semantics.
---
## Logging interceptor
```go
func loggingInterceptor() connect.UnaryInterceptorFunc {
return func(next connect.UnaryFunc) connect.UnaryFunc {
return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) {
start := time.Now()
res, err := next(ctx, req)
attrs := []slog.Attr{
slog.String("proc", req.Spec().Procedure),
slog.Duration("elapsed", time.Since(start)),
}
if err != nil {
attrs = append(attrs, slog.Any("err", err))
slog.LogAttrs(ctx, slog.LevelWarn, "rpc failed", attrs...)
} else {
slog.LogAttrs(ctx, slog.LevelInfo, "rpc ok", attrs...)
}
return res, err
}
}
}
```
For streaming, implement the full `connect.Interceptor` (`WrapStreamingClient`, `WrapStreamingHandler`). Pattern is identical.
---
## Server streaming
```go
func (s *UserServer) StreamEvents(
ctx context.Context,
req *connect.Request[myservicev1.StreamEventsRequest],
stream *connect.ServerStream[myservicev1.Event],
) error {
events, errs := s.svc.Subscribe(ctx, req.Msg.UserId)
for {
select {
case <-ctx.Done():
return ctx.Err()
case e, ok := <-events:
if !ok { return nil }
if err := stream.Send(eventToProto(e)); err != nil {
return err
}
case err := <-errs:
return connect.NewError(connect.CodeInternal, err)
}
}
}
```
Same shape as SSE in `backend-stack.md`. Connect handles HTTP/2 framing.
---
## Client
```go
client := myservicev1connect.NewUserServiceClient(
http.DefaultClient,
"https://api.example.com",
// Use connect.WithGRPC() if the server is grpc-go and you want strict gRPC framing.
// Default is Connect protocol — works with Connect or gRPC servers transparently.
)
res, err := client.CreateUser(ctx, connect.NewRequest(&myservicev1.CreateUserRequest{
Email: "a@b.com",
Username: "alice",
Age: 30,
}))
if err != nil {
var connectErr *connect.Error
if errors.As(err, &connectErr) {
slog.Error("rpc failed",
slog.String("code", connectErr.Code().String()),
slog.String("msg", connectErr.Message()))
}
return err
}
slog.Info("created", slog.String("id", res.Msg.User.Id))
```
---
## When you genuinely need raw grpc-go
```go
import "google.golang.org/grpc"
lis, _ := net.Listen("tcp", ":8080")
srv := grpc.NewServer(
grpc.UnaryInterceptor(loggingUnaryInterceptor),
)
myservicev1.RegisterUserServiceServer(srv, &userServer{})
_ = srv.Serve(lis)
```
The codegen is from `protoc-gen-go-grpc` (different binary from `protoc-gen-connect-go`). You can codegen **both** in the same `buf.gen.yaml` and switch by importing the right package. Most teams pick one.
---
## When NOT to use RPC at all
If your callers are all browsers, mobile apps, third-party developers, or the long tail of "things humans curl": **stay with REST + OpenAPI**. RPC's overhead is justified for service-to-service inside a single org. Outside that boundary, JSON over HTTP wins on debuggability.
`oapi-codegen/oapi-codegen/v2` generates Go server stubs and clients from OpenAPI 3 — the REST equivalent of what Connect does for proto. Same parse-don't-validate boundary discipline, different wire format.
---
## Sources
- Connect docs: https://connectrpc.com/docs/go/getting-started
- Buf: https://buf.build/docs
- protovalidate: https://github.com/bufbuild/protovalidate
- "Why we replaced protoc with buf" (Buf blog): https://buf.build/blog
- gRPC vs Connect comparison: https://connectrpc.com/docs/introduction
@@ -0,0 +1,337 @@
# Library Defaults — Full Decision Tree (Go 2026)
The opinionated, in-production stack for 2026 Go. Every entry has a one-line rationale and a canonical snippet so the agent does not relearn each library's idioms.
The biggest difference from Python/Rust/TypeScript: **Go has fewer "best" choices and more "boring" choices.** The standard library is the default; reach outside it only when the rationale below applies.
---
## HTTP framework — `gin` (default) or `chi` (minimalist) or `net/http` (no deps)
The reality of 2026 Go: **`gin` runs ~48% of new Go API projects** (Go Developer Survey 2024 + crawls of new repos), with `gorilla/mux` (~17%, in maintenance), `echo` (~16%), and `fiber` (~11%) the remaining quarter. The skill picks gin not because it is technically superior — it is not — but because:
1. The ecosystem (middleware, examples, SO answers) is largest.
2. The CLIProxyAPI codebase, which this skill's `backend-stack.md` is distilled from, uses gin in production for OpenAI/Gemini/Claude proxying including SSE streaming and WebSocket upgrades. That is real reference code, not a toy.
3. Gin's `Context` API is the closest thing Go has to a framework-blessed "request-scoped object", which makes middleware composition straightforward.
```go
import "github.com/gin-gonic/gin"
func main() {
r := gin.New()
r.Use(gin.Recovery(), middleware.RequestLogger(), middleware.RequestID())
r.GET("/healthz", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
_ = r.Run(":8080")
}
```
**Pick `chi` instead** when:
- You want `net/http`-compatible handlers (you do, eventually — chi is closer to stdlib).
- The service is small and you do not need gin's binding helpers.
**Pick `net/http` (stdlib) directly** when:
- The service has fewer than 10 routes and zero auth complexity. Go 1.22's enhanced `ServeMux` (method+path patterns) eliminated 80% of the historical reason to use a framework.
**Never use** `gorilla/mux` (effectively in maintenance), `fiber` (uses `fasthttp` which is **not stdlib-compatible**, so middleware ecosystem is split), or `echo` (smaller eco than gin, no real advantage today).
See `backend-stack.md` for the gin canonical layout, middleware ordering, SSE, graceful shutdown, structured logging integration.
---
## RPC — `connectrpc/connect-go`
The default RPC layer. **Use Connect, not raw grpc-go**, unless you have a measured reason.
- Connect is wire-compatible with gRPC AND speaks HTTP/1.1 + HTTP/2 + Connect protocol. One server, three clients (gRPC, gRPC-Web, Connect-Web from browsers).
- No `grpcurl` needed for debugging — `curl -H "Content-Type: application/json" -d ...` works.
- Streaming, interceptors, deadlines, errors are first-class.
- Buf toolchain (`buf generate`, `buf lint`, `buf breaking`) for codegen is dramatically nicer than `protoc`.
```go
// Server
mux := http.NewServeMux()
mux.Handle(elizav1connect.NewElizaServiceHandler(&elizaServer{}))
_ = http.ListenAndServe(":8080", h2c.NewHandler(mux, &http2.Server{}))
// Client
client := elizav1connect.NewElizaServiceClient(
http.DefaultClient,
"http://localhost:8080",
)
res, err := client.Say(ctx, connect.NewRequest(&elizav1.SayRequest{Sentence: "hi"}))
```
**Use raw `grpc-go`** only when:
- You need server-streaming-from-multiple-services with a single gRPC mux.
- You are integrating with a strict gRPC-only environment (Envoy proxy with gRPC reflection, Istio strict-gRPC).
See `grpc-connect.md`.
---
## Database — `pgx/v5` + `sqlc` + `goose`
```bash
go get github.com/jackc/pgx/v5
go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest
go install github.com/pressly/goose/v3/cmd/goose@latest
```
- **`pgx/v5`** is faster, more type-safe, and has better PostgreSQL feature coverage than `database/sql + lib/pq`. Use the `pgxpool` package for connection pooling. Avoid `database/sql` driver mode — it loses pgx's batch, COPY, listen/notify.
- **`sqlc`** generates type-safe Go from `.sql` files. Hand-written SQL with hand-written struct mapping is the #1 source of subtle DB bugs. sqlc eliminates the class.
- **`goose`** for migrations — small, command-line first, no global state.
**Never use** `gorm` (active record, slow, brings runtime reflection into hot paths, encourages N+1 queries). **Never use** `ent` (heavy, opinionated graph layer) unless you specifically want a graph-shaped data model.
See `sqlc-pgx.md`.
---
## Validation — three layers, three tools
Go has no Pydantic / Zod equivalent and **does not need one** — but only because you wire three layers properly:
| Layer | Tool | Pattern |
|---|---|---|
| HTTP boundary (gin/chi/net/http) | `go-playground/validator/v10` via struct tags | `binding:"required,email,min=3"` |
| RPC boundary (protobuf) | `bufbuild/protovalidate-go` | `(buf.validate.field).string.min_len = 3` in `.proto` |
| Domain core | **Smart constructor + unexported fields** | `NewEmail(s) (Email, error)` returns a type whose fields cannot be set from outside |
```go
// HTTP boundary
type CreateUserReq struct {
Email string `json:"email" binding:"required,email"`
Username string `json:"username" binding:"required,alphanum,min=3,max=32"`
}
// Domain — once a value is of type Email it is provably valid
type Email struct{ raw string }
func NewEmail(s string) (Email, error) {
if !emailRegex.MatchString(s) { return Email{}, ErrInvalidEmail }
return Email{raw: strings.ToLower(s)}, nil
}
func (e Email) String() string { return e.raw }
```
The boundary parses raw input into the domain type **once**. Inside the domain, no further validation is permitted — the types prove it. This is parse-don't-validate adapted to Go.
See `data-modeling.md` for the full pattern.
---
## Logging — `log/slog` (stdlib)
```go
import "log/slog"
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
AddSource: true,
}))
slog.SetDefault(logger)
slog.InfoContext(ctx, "request handled",
slog.String("path", r.URL.Path),
slog.Int("status", 200),
slog.Duration("elapsed", elapsed),
)
```
- **stdlib since 1.21**, stable since 1.23. Performance is on par with zerolog for structured output, and faster than logrus by a wide margin.
- The `slog.Handler` interface is implemented by all major exporters (OpenTelemetry, Datadog, Honeycomb).
- The skill bans `logrus`, `zap`, `zerolog` for new code. They are not bad — they are simply superseded. Existing projects on those keep them; new files use slog.
Use the `sloglint` linter from `golangci-strict.md` to enforce attr style (`slog.String(...)` instead of `slog.Any(...)`).
---
## CLI — `cobra` + `pflag` + slog
```bash
go install github.com/spf13/cobra-cli@latest
cobra-cli init mytool
cobra-cli add server
```
`cobra` is the de facto Go CLI framework — Kubernetes, Docker CLI, Helm, GitHub CLI all use it. The companion `viper` for config-file-+-env-+-flag merging is **optional**: prefer `caarlos0/env/v11` for env-only configs (12-factor apps), reach for viper only when you genuinely need file-based config.
See `cobra-stack.md`.
---
## TUI — `bubbletea v2` + `bubbles v2` + `lipgloss v2`
Use **v2 RC** (`charm.land/bubbletea/v2`), not v1. The v2 model adds:
- `tea.View{Cursor: *tea.Cursor, ...}` for real-cursor positioning.
- `SetVirtualCursor(false)` on textareas — lets the terminal own the cursor, which is **required** for CJK IME (Korean Hangul composition, Japanese kana→kanji conversion, Chinese pinyin lookup).
- Granular mouse events (`MouseClickMsg`, `MouseMotionMsg`, `MouseReleaseMsg`) instead of v1's coarse `MouseMsg`.
This is not a preference. v1 has no way to position the IME candidate window correctly — Korean input shows up two cells to the left of where you typed, every time. **If your TUI accepts text input AND your users include CJK speakers, v1 is broken.**
See `bubbletea-v2.md` for the full IME-correct skeleton.
---
## HTTP client — stdlib + `hashicorp/go-retryablehttp`
Default: `net/http.Client` with a tuned `http.Transport`. The stdlib client is **already excellent** in 2026 — HTTP/2 by default, connection pooling, sane timeouts when configured.
```go
client := &http.Client{
Timeout: 30 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 200,
MaxIdleConnsPerHost: 40,
IdleConnTimeout: 90 * time.Second,
DisableCompression: false,
ForceAttemptHTTP2: true,
},
}
```
For retry/backoff, add `github.com/hashicorp/go-retryablehttp` — small, single-purpose, integrates as a wrapper.
**Never use** `resty` (too much magic, hides headers, encourages wrong defaults). `req` is fine but adds dependency surface for marginal benefit over the stdlib + retry wrapper.
---
## JSON — stdlib (default), `goccy/go-json` (perf), `bytedance/sonic` (extreme perf)
Stdlib `encoding/json` improved dramatically in Go 1.21+. **Use it.**
Reach for `goccy/go-json` (~3x faster) only when you have measured a hot-path bottleneck:
```go
import json "github.com/goccy/go-json"
// drop-in replacement — same API
```
Reach for `bytedance/sonic` (~5x faster, requires amd64/arm64) for production proxies with thousands of RPS of JSON traversal. CLIProxyAPI uses `tidwall/gjson` + `tidwall/sjson` for **partial-tree mutation without full unmarshal** — a different optimization, useful when you transform large payloads. See `backend-stack.md`.
---
## Concurrency primitives — stdlib only
| Need | Use |
|---|---|
| Goroutine group with error propagation | `golang.org/x/sync/errgroup` |
| Semaphore | `golang.org/x/sync/semaphore` |
| Single-flight dedup | `golang.org/x/sync/singleflight` |
| Lazy init | **`sync.OnceValue` / `sync.OnceFunc`** (Go 1.21+, replaces `sync.Once` for typed values) |
| Atomic counter | `atomic.Int64` (Go 1.19+, typed atomics — don't use the old func-style) |
| Channel-based fanout | `chan T` with `errgroup` for shutdown |
The `x/sync` packages are stdlib-quality but live outside `std`. See `concurrency.md` for the discipline.
---
## Time — stdlib + `benbjohnson/clock` for tests
```go
type Clock interface { Now() time.Time }
// Production
var realClock Clock = clockImpl{}
// Test
fake := clock.NewMock()
fake.Set(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))
```
**Never call `time.Now()` directly inside domain code.** Inject a `Clock`. Tests become deterministic, no `time.Sleep` flakiness.
---
## IDs — `google/uuid` (UUID v4/v7) or `xid` (sortable short ID)
```go
import "github.com/google/uuid"
id := uuid.Must(uuid.NewV7()) // sortable, time-ordered, 128-bit
```
UUID v7 is the modern default — sortable like v6, random like v4. Use v4 only when leaking creation time is a privacy concern.
For short, URL-safe IDs (~12 bytes, sortable) use `rs/xid` — Kubernetes-style.
---
## Crypto — stdlib + `alecthomas/argon2id` for passwords
Stdlib `crypto/*` for everything. For password hashing, **argon2id is the 2026 standard** — bcrypt is acceptable but argon2 is OWASP's recommendation since 2023.
```go
import "github.com/alecthomas/argon2id"
hash, err := argon2id.CreateHash("password", argon2id.DefaultParams)
```
---
## Data — `apache/arrow-go/v18` + `marcboeker/go-duckdb` + `gonum`
Same philosophy as Python's "never pandas":
| Need | Use |
|---|---|
| Tabular over CSV/Parquet/JSON | DuckDB-Go bindings — zero-copy Arrow integration |
| In-memory frame | Arrow + custom code (Go has no pandas-equivalent and that's fine) |
| Numerical | `gonum.org/v1/gonum` |
| Stats | `gonum/stat` |
Go's data-science story is intentionally thin. For heavy data work, write the pipeline in Polars/DuckDB (see `python/data-processing.md`), expose the result via Parquet or Arrow, consume from Go.
---
## Testing — stdlib + selective additions
| Need | Use |
|---|---|
| Assertions | `stretchr/testify/require` (fail-fast) — `assert` only in table-driven loops |
| Snapshots / golden | `hexops/autogold/v2` (auto-updates with `-update`) |
| Property-based | `pgregory.net/rapid` (modern) or stdlib `testing/quick` |
| Mocks | `go.uber.org/mock` (gomock successor) |
| HTTP mocks | `h2non/gock` for outbound, stdlib `httptest` for inbound |
| Integration containers | `testcontainers/testcontainers-go` |
| Goroutine leak | `go.uber.org/goleak` |
| Benchmarks | stdlib `testing.B` + `perf.dev/benchstat` |
See `testing.md` for canonical patterns.
---
## Config — `caarlos0/env/v11`
```go
type Config struct {
Port int `env:"PORT" envDefault:"8080"`
DatabaseURL string `env:"DATABASE_URL,required"`
Timeout time.Duration `env:"TIMEOUT" envDefault:"30s"`
}
var cfg Config
if err := env.Parse(&cfg); err != nil { log.Fatal(err) }
```
Pure 12-factor. Defaults via struct tag, required marker, parsing for `time.Duration`, slices, maps. **Use viper only if you also need file-based config** — most services do not.
---
## Choosing an unfamiliar dependency — the checklist
Before `go get`-ing anything new:
1. Is it maintained? Latest tag within 12 months? Owner active?
2. Does it expose stdlib-compatible types (`io.Reader`, `context.Context`, `http.Handler`)? If it invents its own `Connection` or `Request` type, that's a yellow flag.
3. Does it use `init()` for side effects? **REJECT.** `init()` ruins testability.
4. Does it call `log.Fatal` / `panic` outside of true programmer-error paths? **REJECT.**
5. Does it have a `context.Context` first-arg convention? If not, **REJECT** — cancellation is non-negotiable.
6. Does adding it overlap with something already in your `go.mod`? Pick one.
---
## Sources
- 2024 Go Developer Survey: https://go.dev/blog/survey2024-h1-results
- Connect-Go docs: https://connectrpc.com/docs/go/getting-started
- sqlc: https://docs.sqlc.dev
- bubbletea v2 IME: https://github.com/code-yeongyu/bubbletea-wm (reference for `SetVirtualCursor(false)` pattern)
- CLIProxyAPI (gin + SSE + WebSocket in production): https://github.com/router-for-me/CLIProxyAPI
- slog blog: https://go.dev/blog/slog
@@ -0,0 +1,202 @@
# One-Liners and Disposable Scripts
Production hygiene with throwaway ergonomics. Go scripts get the same strict lints, the same type discipline, the same 250 LOC ceiling. The difference: they live as single `.go` files invoked via `go run`, not as full modules.
Python has PEP 723 + `uv run`. Rust has `rust-script`. **Go has `go run` directly** — no extra tooling needed.
---
## Pattern 1: Single-file `go run`
A `.go` file with a `main` package, run directly:
```go
//go:build ignore
// fetch.go — fetch a URL and print body length.
//
// Usage:
// go run fetch.go <url>
package main
import (
"fmt"
"io"
"log"
"net/http"
"os"
)
func main() {
if len(os.Args) < 2 {
log.Fatal("usage: go run fetch.go <url>")
}
resp, err := http.Get(os.Args[1])
if err != nil { log.Fatal(err) }
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil { log.Fatal(err) }
fmt.Printf("%d bytes\n", len(body))
}
```
Run: `go run fetch.go https://example.com`.
The `//go:build ignore` directive keeps this file out of `go build ./...` — it is a script, not part of the module. Without that line, every `.go` file in the package gets compiled into your binary.
---
## Pattern 2: Throwaway directory under `scripts/`
```
myproject/
├── go.mod
├── internal/...
└── scripts/
├── seed/
│ └── main.go # `go run ./scripts/seed`
├── migrate/
│ └── main.go
└── one-time-fix/
└── main.go
```
Each `scripts/<name>/main.go` is its own `main` package. Invoke as `go run ./scripts/seed/`. Dependencies are shared with the parent module — no separate `go.mod`.
This is the right pattern when:
- You need module deps (sqlc, pgx, your own internal packages).
- You want IDE support, type-checking, test coverage.
- The script lives alongside the project, runs in CI.
---
## Pattern 3: Inline `go run` from shell
```bash
go run -mod=mod <(cat <<'EOF'
package main
import "fmt"
func main() { fmt.Println("hello") }
EOF
)
```
Rare, but useful for one-shot terminal experiments. The `<(...)` is process substitution; `go run -mod=mod` reads from stdin.
---
## Hard rules for scripts
Even a 30-line script follows the philosophy:
1. **Typed flags via `flag` or `pflag`**, not `os.Args` string parsing past 2 args.
```go
var (
url = flag.String("url", "", "URL to fetch")
limit = flag.Int("limit", 100, "max bytes")
)
flag.Parse()
if *url == "" { log.Fatal("--url required") }
```
2. **`context.Context` propagation** wherever I/O happens.
```go
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, "GET", *url, nil)
```
3. **`log.Fatal` is fine in `main()`** of a script (programmer error / fatal path), but **never inside any function the script imports.** Library code returns errors.
4. **Errors get wrapped.** Same rule as production code:
```go
if err != nil { return fmt.Errorf("fetch %s: %w", *url, err) }
```
5. **Resources released via `defer`.** No "I'll fix it later".
6. **slog for output if it must be parseable.** `fmt.Println` for one-shot terminal output is fine.
7. **No more than 250 pure LOC.** If it grows, it stops being a script and becomes a subcommand of your CLI tool.
---
## Pattern 4: Standalone tool with deps — temporary module
Some scripts need deps the parent module does not have. Two options:
### Option A — script in its own tiny module
```bash
mkdir /tmp/migrate-tool && cd $_
go mod init scratch.local/migrate-tool
go get github.com/pressly/goose/v3
cat > main.go <<'EOF'
package main
import ... // use goose
func main() { ... }
EOF
go run .
```
Run, then delete `/tmp/migrate-tool`. Throwaway.
### Option B — `gorun` (community tool)
```bash
go install github.com/erning/gorun@latest
cat > script.go <<'EOF'
//usr/bin/env gorun "$0" "$@"; exit
// /// go.mod
// module scratch
// go 1.23
// require github.com/spf13/cobra v1.8.0
// ///
package main
...
EOF
chmod +x script.go
./script.go
```
`gorun` parses the inline `go.mod` block, materializes a temp module, runs the script. Niche tool — only if you want the executable-script experience.
---
## When a script becomes a CLI
If your script needs:
- More than one subcommand
- Long-term storage of state
- Help text more than a paragraph
- Repeated invocations from CI
... promote it to a real CLI tool via `cobra` — see `cobra-stack.md`. The boundary is fuzzy; trust your judgment, but **a 500-line "script" is not a script.**
---
## Antipatterns
| Bad | Why | Good |
|---|---|---|
| `os.Args[1]` indexing without length check | Panics on missing arg | `flag.Parse()` with explicit checks |
| `log.Fatal` inside a function the script imports | Crashes caller's process | Return error |
| `panic(err)` for expected failures | Same as above | `log.Fatal` in `main`, error return elsewhere |
| Skipping `defer resp.Body.Close()` because "it's a script" | Leaks fd | Always close |
| One 800-LOC `main.go` "to keep it simple" | Now harder to read than a real CLI | Promote to `cmd/<name>/` with subcommands |
| `// TODO: handle error` | Production-grade hygiene means production-grade hygiene | Handle now or document why ignored |
---
## Sources
- `go run` docs: https://pkg.go.dev/cmd/go#hdr-Compile_and_run_Go_program
- `//go:build` constraints: https://pkg.go.dev/cmd/go#hdr-Build_constraints
- `signal.NotifyContext`: https://pkg.go.dev/os/signal#NotifyContext
- gorun: https://github.com/erning/gorun
@@ -0,0 +1,471 @@
# Database Stack — sqlc + pgx + goose + testcontainers
The canonical 2026 PostgreSQL stack. **Type-safe SQL with zero runtime reflection**, hot-path-friendly connection pooling, sane migrations, real Postgres in tests.
If you came here from a `gorm` project: gorm is rejected. See "Why not gorm" at the end.
---
## Toolchain
```bash
go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest
go install github.com/pressly/goose/v3/cmd/goose@latest
```
---
## Layout
```
internal/store/
├── sqlc.yaml # sqlc config
├── schema.sql # the cumulative DDL sqlc parses
├── queries/ # one *.sql per resource
│ ├── users.sql
│ ├── orders.sql
│ └── sessions.sql
├── sqlc/ # GENERATED — do not hand-edit
│ ├── db.go
│ ├── models.go
│ ├── users.sql.go
│ ├── orders.sql.go
│ └── sessions.sql.go
├── migrations/ # goose migrations, ordered
│ ├── 20260101000001_create_users.sql
│ └── 20260102000001_add_orders.sql
├── pool.go # pgxpool factory
├── user_store.go # domain-facing wrapper around sqlc
└── user_store_test.go # testcontainers integration test
```
---
## `sqlc.yaml`
```yaml
version: "2"
sql:
- engine: "postgresql"
schema: "schema.sql"
queries: "queries"
gen:
go:
package: "sqlc"
out: "sqlc"
sql_package: "pgx/v5"
emit_json_tags: false
emit_prepared_queries: false
emit_interface: true # generates a Querier interface
emit_exact_table_names: false
emit_pointers_for_null_types: true
emit_empty_slices: true
overrides:
- db_type: "uuid"
go_type:
import: "github.com/google/uuid"
type: "UUID"
- db_type: "timestamptz"
go_type:
import: "time"
type: "Time"
```
Key choices:
- `sql_package: "pgx/v5"` — generated code uses pgx directly, not `database/sql`. Faster, type-safer.
- `emit_interface: true` — generates a `Querier` interface. Lets stores accept either `*pgxpool.Pool` or `pgx.Tx` for transaction support.
- `emit_pointers_for_null_types: true` — nullable columns become `*T`, not `sql.NullString`. Cleaner mapping to domain types.
- `overrides` for `uuid``google/uuid.UUID` and `timestamptz``time.Time`.
---
## `schema.sql`
```sql
-- internal/store/schema.sql
-- The CUMULATIVE schema sqlc parses. Not migrations — the end state.
-- Regenerate from a fresh DB via `pg_dump --schema-only`, or hand-maintain.
CREATE TABLE users (
id UUID PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
username TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_users_created_at ON users(created_at DESC);
```
---
## `queries/users.sql`
```sql
-- name: GetUser :one
SELECT id, email, username, created_at
FROM users
WHERE id = $1;
-- name: ListUsers :many
SELECT id, email, username, created_at
FROM users
ORDER BY created_at DESC
LIMIT $1 OFFSET $2;
-- name: CreateUser :one
INSERT INTO users (id, email, username)
VALUES ($1, $2, $3)
RETURNING id, email, username, created_at;
-- name: UpdateUserEmail :exec
UPDATE users
SET email = $2
WHERE id = $1;
-- name: DeleteUser :exec
DELETE FROM users WHERE id = $1;
```
sqlc directives:
- `:one` — exactly one row; returns `(T, error)`. Returns `pgx.ErrNoRows` on miss.
- `:many` — zero or more rows; returns `([]T, error)`.
- `:exec` — no rows returned; returns `error`.
- `:execrows` — returns `(int64, error)` with affected row count.
- `:batchone` / `:batchmany` / `:batchexec` — pgx batch mode for bulk operations.
Run `task gen:sqlc` (or `sqlc generate`). The generated file is committed; CI checks it is up-to-date.
---
## Generated code shape (`sqlc/users.sql.go`)
```go
// GENERATED — do not edit
type User struct {
ID uuid.UUID
Email string
Username string
CreatedAt time.Time
}
const getUser = `-- name: GetUser :one
SELECT id, email, username, created_at FROM users WHERE id = $1`
func (q *Queries) GetUser(ctx context.Context, id uuid.UUID) (User, error) {
row := q.db.QueryRow(ctx, getUser, id)
var u User
err := row.Scan(&u.ID, &u.Email, &u.Username, &u.CreatedAt)
return u, err
}
```
Type-safe inputs, type-safe outputs, compile-time-checked column-to-field mapping. **A schema change that drops a column breaks compilation.** Hand-rolled SQL would have failed at runtime.
---
## `store/pool.go`
```go
package store
import (
"context"
"fmt"
"time"
"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 dsn: %w", err) }
cfg.MaxConns = 25
cfg.MinConns = 5
cfg.MaxConnLifetime = time.Hour
cfg.MaxConnIdleTime = 30 * time.Minute
cfg.HealthCheckPeriod = 1 * time.Minute
pool, err := pgxpool.NewWithConfig(ctx, cfg)
if err != nil { return nil, fmt.Errorf("connect: %w", err) }
if err := pool.Ping(ctx); err != nil {
pool.Close()
return nil, fmt.Errorf("ping: %w", err)
}
return pool, nil
}
```
`pgxpool.Pool` is `Querier`-compatible (implements the interface sqlc generates). Same pool flows into sqlc queries unchanged.
---
## `store/user_store.go` — domain ↔ sqlc
```go
package store
import (
"context"
"errors"
"fmt"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/your-org/myservice/internal/domain"
"github.com/your-org/myservice/internal/store/sqlc"
)
type UserStore struct {
q *sqlc.Queries
}
func NewUserStore(pool *pgxpool.Pool) *UserStore {
return &UserStore{q: sqlc.New(pool)}
}
func (s *UserStore) Get(ctx context.Context, id domain.UserID) (domain.User, error) {
row, err := s.q.GetUser(ctx, uuid.UUID(id))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.User{}, domain.ErrUserNotFound
}
return domain.User{}, fmt.Errorf("get user %s: %w", id, err)
}
return rowToDomain(row)
}
func (s *UserStore) Create(ctx context.Context, u domain.User) (domain.User, error) {
row, err := s.q.CreateUser(ctx, sqlc.CreateUserParams{
ID: uuid.UUID(u.ID),
Email: u.Email.String(),
Username: u.Username.String(),
})
if err != nil {
return domain.User{}, fmt.Errorf("create user: %w", err)
}
return rowToDomain(row)
}
func rowToDomain(r sqlc.User) (domain.User, error) {
email, err := domain.NewEmail(r.Email)
if err != nil {
return domain.User{}, fmt.Errorf("db invariant: email %q: %w", r.Email, err)
}
username, err := domain.NewUsername(r.Username)
if err != nil {
return domain.User{}, fmt.Errorf("db invariant: username %q: %w", r.Username, err)
}
return domain.User{
ID: domain.UserID(r.ID),
Email: email,
Username: username,
CreatedAt: r.CreatedAt,
}, nil
}
```
The wrapping is verbose. **That is the point.** sqlc rows are storage representations; domain types are business representations. Mapping them explicitly is where invariants are enforced.
`pgx.ErrNoRows` becomes `domain.ErrUserNotFound` — callers never see storage-level errors.
---
## Transactions — pgx.Tx satisfies the Querier interface
```go
func (s *UserStore) CreateWithProfile(
ctx context.Context,
pool *pgxpool.Pool,
u domain.User,
p domain.Profile,
) error {
tx, err := pool.Begin(ctx)
if err != nil { return fmt.Errorf("begin: %w", err) }
defer tx.Rollback(ctx) // no-op if Commit succeeded
q := s.q.WithTx(tx) // sqlc.Queries bound to the tx
if _, err := q.CreateUser(ctx, /* ... */); err != nil {
return fmt.Errorf("create user: %w", err)
}
if _, err := q.CreateProfile(ctx, /* ... */); err != nil {
return fmt.Errorf("create profile: %w", err)
}
return tx.Commit(ctx)
}
```
Pattern:
- `defer tx.Rollback(ctx)` immediately after `Begin` — safe even after Commit (returns "tx closed", which we ignore via the unhandled return).
- `q.WithTx(tx)` returns a `*Queries` bound to the tx.
- Last line: `tx.Commit(ctx)`.
For nested transactions across multiple stores, accept a `Querier` parameter:
```go
func (s *UserStore) CreateTx(ctx context.Context, q sqlc.Querier, u domain.User) (domain.User, error) {
// uses q instead of s.q — caller decides if it's pool or tx
}
```
---
## Migrations — goose
```bash
goose -dir internal/store/migrations create create_users sql
```
```sql
-- migrations/20260101000001_create_users.sql
-- +goose Up
CREATE TABLE users (
id UUID PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
username TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- +goose Down
DROP TABLE users;
```
Run:
```bash
goose -dir internal/store/migrations postgres "$DATABASE_URL" up
goose -dir internal/store/migrations postgres "$DATABASE_URL" status
goose -dir internal/store/migrations postgres "$DATABASE_URL" down
```
Rules:
- One DDL change per migration. Never combine schema + data migrations in one file.
- `Down` is real, not a stub. CI runs `up``down``up` on a fresh container to prove reversibility.
- Migrations are append-only. Never edit a merged migration; add a new one.
`goose` can run programmatically as well:
```go
import "github.com/pressly/goose/v3"
if err := goose.UpContext(ctx, db, "migrations"); err != nil { ... }
```
Useful for tools that own their schema (CI runner, integration test setup).
---
## Integration tests — testcontainers
```go
package store_test
import (
"context"
"testing"
"github.com/stretchr/testify/require"
"github.com/testcontainers/testcontainers-go/modules/postgres"
)
func newTestDB(t *testing.T) *pgxpool.Pool {
t.Helper()
ctx := context.Background()
pgC, err := postgres.Run(ctx,
"postgres:16-alpine",
postgres.WithDatabase("test"),
postgres.WithUsername("test"),
postgres.WithPassword("test"),
postgres.BasicWaitStrategies(),
)
require.NoError(t, err)
t.Cleanup(func() { _ = pgC.Terminate(ctx) })
dsn, err := pgC.ConnectionString(ctx, "sslmode=disable")
require.NoError(t, err)
pool, err := store.NewPool(ctx, dsn)
require.NoError(t, err)
t.Cleanup(pool.Close)
require.NoError(t, goose.UpContext(ctx, /* sql.DB from pool */, "../migrations"))
return pool
}
func TestUserStore_Create_returns_new_user(t *testing.T) {
// Given
pool := newTestDB(t)
s := store.NewUserStore(pool)
ctx := context.Background()
// When
user, err := s.Create(ctx, domain.User{
ID: domain.UserID(uuid.Must(uuid.NewV7())),
Email: mustEmail("a@b.com"),
Username: mustUsername("alice"),
})
// Then
require.NoError(t, err)
require.NotEmpty(t, user.ID)
fetched, err := s.Get(ctx, user.ID)
require.NoError(t, err)
require.Equal(t, user.Email, fetched.Email)
}
```
testcontainers spins a real Postgres in Docker, runs migrations, hands you a pool. Tests are slow (~2s startup) but **real** — no fake that diverges from production.
For test suites with many cases, share one container across tests in the same package via `TestMain`:
```go
var testPool *pgxpool.Pool
func TestMain(m *testing.M) {
ctx := context.Background()
pgC, _ := postgres.Run(ctx, "postgres:16-alpine", /* ... */)
defer pgC.Terminate(ctx)
dsn, _ := pgC.ConnectionString(ctx, "sslmode=disable")
testPool, _ = store.NewPool(ctx, dsn)
// run migrations once
os.Exit(m.Run())
}
```
Each test then uses a transaction it rolls back at the end — fast and isolated.
---
## Why NOT gorm
| Concern | gorm | sqlc + pgx |
|---|---|---|
| Type safety | runtime reflection; column-to-field via tags | compile-time-checked from SQL |
| Performance | 25x slower than pgx | pgx is the fastest Go pg driver |
| N+1 queries | encouraged by `Preload` API | explicit JOIN in `.sql` |
| Migrations | AutoMigrate (unsafe in prod) | goose, explicit |
| Debugging | "what query did it run?" requires logging | the query IS the source |
| Cancellation | spotty ctx support | first-class |
| Active development | Yes but with churn and breaking changes | sqlc is stable |
Existing gorm projects: leave them. New code: sqlc + pgx.
---
## Sources
- sqlc docs: https://docs.sqlc.dev
- pgx: https://github.com/jackc/pgx
- goose: https://github.com/pressly/goose
- testcontainers-go: https://golang.testcontainers.org
- pgx pool config: https://pkg.go.dev/github.com/jackc/pgx/v5/pgxpool#Config
@@ -0,0 +1,467 @@
# Testing
TDD shape, table-driven tests, `require` vs `assert`, snapshot tests, property-based tests, integration tests with testcontainers, goroutine-leak detection. The discipline in `programming/SKILL.md` (Given/When/Then, less mock the better, efficient AND accurate) — this document gives the Go-specific recipes.
---
## Tools
| Need | Use |
|---|---|
| Assertions | `stretchr/testify/require` (and `assert` only inside table loops) |
| Mocks | `go.uber.org/mock` (gomock successor) |
| Goroutine leaks | `go.uber.org/goleak` |
| Snapshots / golden | `hexops/autogold/v2` |
| Property-based | `pgregory.net/rapid` |
| HTTP mocks (outbound) | `h2non/gock` |
| HTTP test server (inbound) | stdlib `net/http/httptest` |
| Integration containers | `testcontainers/testcontainers-go` |
| TUI | `charm.land/bubbletea/v2/teatest` |
| Bench tooling | stdlib `testing.B` + `perf.dev/benchstat` |
---
## Test naming — Given / When / Then in the name
```go
// ──── PATTERN ────
// Test_<Subject>_<Outcome>_when_<Condition>
// OR
// Test_<Subject>_<Action>_<ExpectedOutcome>
func Test_Email_NewEmail_lowercases_input(t *testing.T)
func Test_Email_NewEmail_rejects_input_without_at_sign(t *testing.T)
func Test_UserService_Create_persists_user_when_inputs_valid(t *testing.T)
func Test_UserService_Create_returns_validation_error_when_email_invalid(t *testing.T)
```
A test name should answer "what behavior is this asserting?" without reading the body. Names that need a comment to explain them are misnamed.
---
## Single test — explicit Given/When/Then
```go
func Test_Email_NewEmail_rejects_input_without_at_sign(t *testing.T) {
// Given
raw := "not-an-email"
// When
_, err := domain.NewEmail(raw)
// Then
require.Error(t, err)
require.ErrorIs(t, err, domain.ErrInvalidEmail)
}
```
`require.*` fails the test immediately on miss. Use `require` for preconditions and primary assertions. Use `assert.*` only inside table-driven loops where you want all cases to report.
---
## Table-driven tests
```go
func Test_Email_NewEmail(t *testing.T) {
tests := []struct {
name string
input string
want string
wantErr error
}{
{"lowercases", "ALICE@example.com", "alice@example.com", nil},
{"trims whitespace", " bob@example.com ", "bob@example.com", nil},
{"rejects missing @", "no-at-sign", "", domain.ErrInvalidEmail},
{"rejects empty", "", "", domain.ErrInvalidEmail},
{"rejects too long", strings.Repeat("a", 256) + "@e.com", "", domain.ErrInvalidEmail},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// When
got, err := domain.NewEmail(tt.input)
// Then
if tt.wantErr != nil {
require.ErrorIs(t, err, tt.wantErr)
return
}
require.NoError(t, err)
assert.Equal(t, tt.want, got.String())
})
}
}
```
Rules:
- One **scenario** per row, not one **assertion** per row.
- Subtest names are sentences in lowercase; `t.Run(tt.name, ...)` makes them filterable: `go test -run Test_Email_NewEmail/rejects_missing_@`.
- The loop body itself is Given/When/Then in shape.
- For Go 1.22+, the loop var capture works correctly without the `tt := tt` shadow line — the `copyloopvar` linter enforces the new style.
---
## Less mocks — the priority order
In Go specifically:
1. **Real implementation.** Domain types, pure functions, value objects — instantiate them. They are fast.
2. **In-memory fake** that satisfies the interface. Has its own test suite proving behavioral parity with the real impl.
3. **`httptest.Server`** for HTTP collaborators (real wire, no internet).
4. **`testcontainers`** for stateful collaborators (Postgres, Redis, S3-compatible, Kafka).
5. **gomock** ONLY for: clocks, randomness, third-party SaaS with no sandbox.
### Example: an in-memory fake
```go
// Real interface
type UserRepo interface {
Save(ctx context.Context, u domain.User) error
Get(ctx context.Context, id domain.UserID) (domain.User, error)
}
// In-memory fake — production-quality, tested separately
type FakeUserRepo struct {
mu sync.RWMutex
users map[domain.UserID]domain.User
}
func NewFakeUserRepo() *FakeUserRepo {
return &FakeUserRepo{users: map[domain.UserID]domain.User{}}
}
func (r *FakeUserRepo) Save(ctx context.Context, u domain.User) error {
r.mu.Lock(); defer r.mu.Unlock()
r.users[u.ID] = u
return nil
}
func (r *FakeUserRepo) Get(ctx context.Context, id domain.UserID) (domain.User, error) {
r.mu.RLock(); defer r.mu.RUnlock()
u, ok := r.users[id]
if !ok { return domain.User{}, domain.ErrUserNotFound }
return u, nil
}
```
The fake has the same observable behavior as the real one. Tests against `FakeUserRepo` survive when the production repo's internals change. Tests against a gomock stub of `UserRepo` break.
**A test passing against a fake AND a test passing against the real impl is the gold standard.** Run the same test suite twice — once with the fake, once with testcontainers. The fakes earn their keep when the suites diverge.
### Example: gomock for the unmockable
```go
//go:generate mockgen -source=clock.go -destination=mocks/clock_mock.go -package=mocks
type Clock interface {
Now() time.Time
}
// In a test:
ctrl := gomock.NewController(t)
clock := mocks.NewMockClock(ctrl)
clock.EXPECT().Now().Return(fixedTime).AnyTimes()
```
Mock the narrowest seam. Never mock `UserRepo` if a fake suffices.
---
## E2E scenario tests
```go
//go:build e2e
func Test_E2E_user_can_signup_then_login(t *testing.T) {
// Given — full server in a goroutine
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
pool := newTestDB(t) // testcontainers Postgres
server := startServer(t, pool) // real gin engine on a random port
defer server.Close()
client := server.Client()
// When — sign up
resp, err := client.Post(server.URL+"/api/v1/users",
"application/json",
strings.NewReader(`{"email":"a@b.com","username":"alice","password":"PassWord!23"}`),
)
require.NoError(t, err)
require.Equal(t, 201, resp.StatusCode)
// When — log in
resp, err = client.Post(server.URL+"/api/v1/auth/login",
"application/json",
strings.NewReader(`{"email":"a@b.com","password":"PassWord!23"}`),
)
require.NoError(t, err)
require.Equal(t, 200, resp.StatusCode)
var body struct{ Token string `json:"token"` }
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
require.NotEmpty(t, body.Token)
// Then — token works on protected endpoint
req, _ := http.NewRequestWithContext(ctx, "GET", server.URL+"/api/v1/me", nil)
req.Header.Set("Authorization", "Bearer "+body.Token)
resp, err = client.Do(req)
require.NoError(t, err)
require.Equal(t, 200, resp.StatusCode)
}
```
Patterns:
- `//go:build e2e` build tag separates slow E2E from fast unit tests. Run with `go test -tags=e2e ./...`.
- One narrative per test: "user can sign up then log in". One `Test_E2E_*` per user-visible outcome.
- Real DB via testcontainers, real gin engine, real HTTP. **No mocks.** The point is to catch integration bugs.
- Bounded context — every E2E gets a `context.WithTimeout` so failures don't hang CI.
---
## Goroutine leak detection
```go
package mypkg
import (
"testing"
"go.uber.org/goleak"
)
func TestMain(m *testing.M) {
goleak.VerifyTestMain(m,
goleak.IgnoreTopFunction("github.com/prometheus/client_golang/prometheus.(*Registry)..."),
)
}
```
One line at the top of every package that spawns goroutines. Catches the bug class the race detector cannot.
---
## Snapshot / golden tests — `autogold`
```go
import "github.com/hexops/autogold/v2"
func Test_RenderHelp_matches_snapshot(t *testing.T) {
// Given
cmd := newRootCmd()
// When
out := captureOutput(t, func() { _ = cmd.Help() })
// Then
autogold.ExpectFile(t, out)
}
```
First run: `go test -update ./...` writes `testdata/Test_RenderHelp.golden`. Future runs compare; failures show a diff. Re-approve intentional changes with `-update`.
**Use snapshots for STRUCTURE, not BEHAVIOR.** Good targets:
- CLI `--help` output
- JSON response shape
- Generated SQL queries
- Rendered prompts (assert the structure, not exact wording — see SKILL.md prompt-test rule)
Bad targets: a function's return value where you should `require.Equal` on the actual structure.
---
## Property-based tests — `rapid`
```go
import "pgregory.net/rapid"
func Test_Email_NewEmail_then_String_roundtrips(t *testing.T) {
rapid.Check(t, func(t *rapid.T) {
// Given — generate valid emails
local := rapid.StringMatching(`[a-z]{3,10}`).Draw(t, "local")
domain := rapid.StringMatching(`[a-z]{3,10}\.com`).Draw(t, "domain")
raw := local + "@" + domain
// When
e, err := domain.NewEmail(raw)
require.NoError(t, err)
// Then — round-trip property
e2, err := domain.NewEmail(e.String())
require.NoError(t, err)
require.Equal(t, e, e2)
})
}
```
`rapid` shrinks failing cases to minimal counterexamples. Use for:
- Round-trips (parse → serialize → parse).
- Algebraic properties (sort produces ordered, dedup is idempotent, JSON marshal/unmarshal is involutive).
- Invariants under random input (validator never panics, serializer never produces invalid UTF-8).
---
## HTTP testing — `httptest`
### Server side
```go
func Test_GetUser_returns_user_for_existing_id(t *testing.T) {
// Given
svc := newSvcWithFake(t)
r := gin.New()
h := &Handler{Users: svc}
h.Mount(r)
req := httptest.NewRequest("GET", "/api/v1/users/u-1", nil)
rec := httptest.NewRecorder()
// When
r.ServeHTTP(rec, req)
// Then
require.Equal(t, 200, rec.Code)
var body domain.User
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body))
require.Equal(t, "u-1", string(body.ID))
}
```
### Client side — `httptest.NewServer`
```go
func Test_Client_retries_on_500(t *testing.T) {
// Given — fake upstream
var calls int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
if calls < 3 {
w.WriteHeader(500)
return
}
w.WriteHeader(200)
_, _ = w.Write([]byte(`{"ok":true}`))
}))
defer srv.Close()
client := myclient.New(srv.URL)
// When
err := client.DoSomething(context.Background())
// Then
require.NoError(t, err)
require.Equal(t, 3, calls)
}
```
`httptest.NewServer` spins a real HTTP server on a random port. The fake handler implements the upstream contract. Test the client against the contract, not the implementation.
---
## Determinism — the cardinal rules
- **No `time.Sleep` in tests.** If you need delay, you need a Clock injection.
- **`go test -shuffle=on`** in every CI run.
- **`go test -count=1`** to defeat the cache.
- **Subscribe to the event, do not poll for it.** Channels, callbacks, `t.Cleanup` over polling.
- **`t.Parallel()`** for tests that share no state. Speeds up large suites by 4-8x.
A test that fails 1-in-10 runs is a bug, not flake. The race detector + `-shuffle=on` + ordering hygiene catches >95% of "flake".
---
## Benchmarks — `testing.B` + `benchstat`
```go
func Benchmark_NewEmail(b *testing.B) {
for b.Loop() { // Go 1.24+ idiom, replaces `for i := 0; i < b.N; i++`
_, _ = domain.NewEmail("alice@example.com")
}
}
```
Run:
```bash
go test -bench=. -count=10 -benchmem ./... | tee bench.txt
benchstat bench.txt # statistical comparison
```
Always `-count=10` for stable means. `-benchmem` reports allocations. A 5%-slower benchmark in one run is noise; 10 runs + benchstat tells you what is real.
To compare before/after a change:
```bash
git stash
go test -bench=. -count=10 ./... > before.txt
git stash pop
go test -bench=. -count=10 ./... > after.txt
benchstat before.txt after.txt
```
---
## Coverage — the right target
Run:
```bash
go test -race -shuffle=on -coverprofile=cover.out ./...
go tool cover -html=cover.out -o cover.html
```
**Aim for 80%+ on `internal/domain` and `internal/service`.** Boundary code (handlers, store mappers) is exercised by integration tests, where line coverage understates what is actually verified. Do not chase 100% — the last 5% is usually error paths that need fault-injection to hit.
The `golangci-lint` config does not enforce a minimum — coverage as a CI gate becomes a goal-displacement metric. Treat it as feedback, not requirement.
---
## TUI testing — `teatest`
```go
import teatest "charm.land/bubbletea/v2/teatest"
func Test_Counter_increments_on_space(t *testing.T) {
// Given
tm := teatest.NewTestModel(t, initial(), teatest.WithInitialTermSize(80, 24))
// When
tm.Send(tea.KeyPressMsg{Code: ' '})
// Then
final := tm.FinalModel(t).(model)
require.Equal(t, 1, final.count)
}
```
For full-view regression, snapshot the rendered output via `autogold`.
---
## Antipatterns the skill rejects
| Bad | Why | Good |
|---|---|---|
| `if got != want { t.Errorf("expected %v got %v", want, got) }` | Reinvents `require.Equal` | Use testify |
| `time.Sleep(100 * time.Millisecond)` after triggering async work | Flake | Subscribe to completion signal, bounded await |
| `t.Skip(...)` to silence a known failure | Buries the bug | Fix or open an issue; never silently skip |
| One mega-test asserting 12 things | First failure hides next 11 | Split by `Then` |
| Snapshot-everything | Locks formatting, not behavior | Snapshots for structure, asserts for values |
| Mock every collaborator | Test asserts implementation, not behavior | Real or fake, never mock everything |
| Test calls private function via `_test.go` in same package only | Couples test to implementation | Test through the public surface |
---
## Sources
- testify: https://github.com/stretchr/testify
- goleak: https://github.com/uber-go/goleak
- autogold: https://github.com/hexops/autogold
- rapid: https://pkg.go.dev/pgregory.net/rapid
- testcontainers-go: https://golang.testcontainers.org
- benchstat: https://pkg.go.dev/golang.org/x/perf/cmd/benchstat
- "Go test naming conventions" (Dave Cheney): https://dave.cheney.net/practical-go/presentations/qcon-china.html
@@ -0,0 +1,298 @@
# Type Patterns
How to use Go's *limited* type system to catch bugs at compile time. Go gives you fewer tools than Python/TS/Rust — this document covers the four patterns that buy back most of the safety.
The four patterns:
1. **Named types** for branding primitives (the Go answer to `NewType` / branded TS).
2. **Smart constructors with unexported fields** for parse-don't-validate.
3. **Sealed interfaces** for sum types, with `type switch` + `exhaustive` linter.
4. **Generics with constraints** for bounded polymorphism (1.18+).
---
## 1. Named types — distinct primitives
Same underlying type, different meaning. The Go type checker prevents *implicit* mixing — but explicit conversion is always possible. Treat this as a contract enforced at boundaries.
```go
package domain
type UserID string
type OrderID string
type EmailRaw string // raw, unvalidated string from input
func GetUser(id UserID) User { /* ... */ }
uid := UserID("u-123")
oid := OrderID("o-456")
GetUser(uid) // ✅ OK
GetUser(oid) // ❌ cannot use oid (type OrderID) as UserID
GetUser("u-123") // ❌ untyped string literal — Go DOES catch this
GetUser(UserID("u-123")) // ✅ explicit conversion — accept it
```
**Use when**: IDs, opaque tokens, foreign keys, units that share a base primitive.
**Reality check**: Go does NOT prevent `UserID(orderIDAsString)`. The defense is **smart constructors** for everything beyond an internal identifier. Use named types for cheap brand-only protection; combine with constructors for protection that actually holds.
### Time-of-day units
```go
type Milliseconds int64
type Seconds int64
func (ms Milliseconds) ToSeconds() Seconds {
return Seconds(ms / 1000)
}
```
No implicit `Milliseconds + Seconds`. The compiler refuses. Convert explicitly.
---
## 2. Smart constructors with unexported fields — the Go answer to Pydantic/Zod
The single most important pattern in this document. **Go has no Pydantic. It has this.**
```go
package domain
import (
"errors"
"regexp"
"strings"
)
var (
ErrInvalidEmail = errors.New("invalid email")
emailRe = regexp.MustCompile(`^[^@\s]+@[^@\s]+\.[^@\s]+$`)
)
// Email is a parsed, lowercased, valid email address.
// The zero value is invalid; construct via NewEmail.
type Email struct {
raw string // unexported — cannot be set from outside the package
}
func NewEmail(s string) (Email, error) {
s = strings.TrimSpace(strings.ToLower(s))
if !emailRe.MatchString(s) {
return Email{}, ErrInvalidEmail
}
return Email{raw: s}, nil
}
// String implements fmt.Stringer for printing.
func (e Email) String() string { return e.raw }
// MarshalJSON keeps the wire format unchanged.
func (e Email) MarshalJSON() ([]byte, error) {
return []byte(`"` + e.raw + `"`), nil
}
// UnmarshalJSON is the parsing boundary — strict mode.
func (e *Email) UnmarshalJSON(data []byte) error {
if len(data) < 2 || data[0] != '"' || data[len(data)-1] != '"' {
return ErrInvalidEmail
}
parsed, err := NewEmail(string(data[1 : len(data)-1]))
if err != nil {
return err
}
*e = parsed
return nil
}
```
**Why this works**:
- `Email{raw: "anything"}` from outside the `domain` package is a compile error — `raw` is unexported.
- The only way to obtain a non-zero `Email` is `NewEmail(...)`, which validates.
- `UnmarshalJSON` routes wire input through the same constructor — boundary parsing is automatic.
- Once a function signature has `email Email`, the caller has *proven* it is valid. No internal `if email == ""` checks.
**Use for every domain value that has invariants**: emails, URLs, phone numbers, currency amounts, percentages, semver versions, IDs with format constraints, time ranges, anything you currently validate in three places.
### The "zero value problem"
Go's zero value (`Email{}`) is reachable. The mitigation is documentation + a `IsValid()` method when needed:
```go
func (e Email) IsZero() bool { return e.raw == "" }
```
Or accept it: receivers that take `Email` should *never* receive a zero-value `Email` in correct code. Tests verify it.
---
## 3. Sealed interfaces — sum types in Go
Go has no sum types. The closest thing: an interface with an **unexported method** that only types in the same package can satisfy, dispatched via `type switch`, with the `exhaustive` linter ensuring completeness.
```go
package event
// Event is a closed sum: Created | Updated | Deleted.
// The sealed() method is unexported so external packages cannot add variants.
type Event interface {
sealed()
OccurredAt() time.Time
}
type Created struct {
UserID UserID
Email Email
Timestamp time.Time
}
func (Created) sealed() {}
func (e Created) OccurredAt() time.Time { return e.Timestamp }
type Updated struct {
UserID UserID
Changes map[string]any
Timestamp time.Time
}
func (Updated) sealed() {}
func (e Updated) OccurredAt() time.Time { return e.Timestamp }
type Deleted struct {
UserID UserID
Reason string
Timestamp time.Time
}
func (Deleted) sealed() {}
func (e Deleted) OccurredAt() time.Time { return e.Timestamp }
```
Consumer code:
```go
func Render(e event.Event) string {
switch v := e.(type) {
case event.Created:
return fmt.Sprintf("created %s with %s", v.UserID, v.Email)
case event.Updated:
return fmt.Sprintf("updated %s: %v", v.UserID, v.Changes)
case event.Deleted:
return fmt.Sprintf("deleted %s (reason: %s)", v.UserID, v.Reason)
default:
panic(fmt.Sprintf("unhandled event variant: %T", v))
}
}
```
The `panic` in `default` is the Go equivalent of TS's `assertNever` or Python's `assert_never`. It is only reachable if a new variant is added without updating the switch.
### The `exhaustive` linter — your compiler
```yaml
# .golangci.yml
linters:
enable: [exhaustive]
linters-settings:
exhaustive:
check:
- switch
- map
default-signifies-exhaustive: false
```
Now adding `event.Suspended` without updating `Render` is a **lint error**. This is the closest thing Go has to Rust's match exhaustiveness check. **Treat it as compulsory.**
### Sealed interface gotchas
- The method MUST be unexported (`sealed()`, not `Sealed()`). Otherwise other packages can implement it.
- `type switch` with `*Created` vs `Created` matters — pick value receivers and value cases, or pointer receivers and pointer cases. **Mixing them causes silent miss.**
- `interface{}` is not a sealed type. Anything implementing zero methods satisfies it. Sealed interfaces have at least the `sealed()` method.
---
## 4. Generics with constraints — bounded polymorphism
Go 1.18+. Use for genuinely generic algorithms; **do not** use for "I want this to accept anything".
```go
import "cmp"
// Ordered constraint includes all ordered types (int, float, string, …).
func Max[T cmp.Ordered](a, b T) T {
if a > b { return a }
return b
}
// Custom constraint
type Stringer interface {
String() string
}
func Join[T Stringer](items []T, sep string) string {
parts := make([]string, len(items))
for i, item := range items {
parts[i] = item.String()
}
return strings.Join(parts, sep)
}
```
The `cmp.Ordered` (Go 1.21+), `cmp.Compare`, and `slices`/`maps` packages cover the common cases without you writing constraints.
### When NOT to use generics
- "I want to accept multiple types, so I'll make it generic." Use an **interface** instead. Generics are for parametric polymorphism (same code, different types). Interfaces are for behavioral polymorphism (different code behind a contract).
- "I want to return `any`." Use a sealed interface and a `type switch`. `any` returns are anti-patterns past public APIs.
---
## 5. Type assertions — the controlled escape hatch
```go
// Bad — panics on failure
e := evt.(event.Created)
// Good — comma-ok form, always
if e, ok := evt.(event.Created); ok {
// use e
}
// Use errors.As for error chains
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
// pgErr is the wrapped pg error
}
```
**The `errcheck` and `errorlint` linters reject bare type assertions on `error` values.** Use `errors.As`. See `error-handling.md`.
---
## 6. Pointers vs values — the only durable rule
You will see endless debates. The rule that holds up:
- **If a type has a mutex, never copy it.** Use `*T` everywhere.
- **If a type is large (> 64 bytes) and read-only, pass by value or pointer is a measured choice.** Default to pointer for "large" things.
- **Receivers must be consistent.** All methods on `T` either take `T` or `*T`. Don't mix. The `staticcheck` linter catches mixed-receiver bugs.
- **`nil` pointer = absence. Zero value = "not set yet".** Choose ONE convention per type. Document it.
---
## 7. `any` / `interface{}` — when it is acceptable
Almost never in domain code. Acceptable cases:
- JSON parsing of genuinely heterogeneous payloads (and even then, prefer `json.RawMessage` + targeted parsing).
- `fmt.Sprintf` arguments (variadic `any` is unavoidable here).
- Generic container internals before the user-facing API.
The skill rejects `any` in handler signatures, service signatures, store signatures. If you find yourself writing `func Handle(payload any) error`, you have a sealed-interface waiting to happen.
---
## Sources
- "Parse, don't validate" — Alexis King: https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-validate/
- exhaustive linter: https://github.com/nishanths/exhaustive
- Generics constraints: https://go.dev/blog/intro-generics
- cmp.Ordered: https://pkg.go.dev/cmp