Self-contained standards for Go CLIs, daemons, and HTTP services. Every template, config, and pattern needed to start a new project is embedded in this document — no external file references required.
Scope: single-binary CLIs, daemons, and HTTP API servers in Go. Not for shell scripts, one-shots, or experiments.
How to use this doc:
- Read sections 1–3 for the mental model.
- For a new project, work through the checklist in §16, copying the embedded templates verbatim and filling in the project name.
- Sections 4–11 are the living rules. Re-read when unsure.
- Section 15 is the list of things we've been burned by — consult before shipping.
<project>/
├── cmd/
│ └── <binary-name>/
│ └── main.go # thin: just wires cobra, nothing else
├── internal/ # everything that's not API surface
│ ├── config/
│ ├── db/ # or state/, store/
│ ├── <domain>/ # business logic, one dir per bounded area
│ └── ...
├── pkg/ # ONLY if anything is meant to be imported externally
├── api/ # ONLY for HTTP services
│ ├── handlers/
│ ├── routes/
│ └── middleware/
├── tests/ # integration tests (docker-compose lives here)
├── .github/workflows/
│ ├── ci.yml
│ └── release.yml
├── .golangci.yml
├── .gitignore
├── Makefile
├── go.mod
├── go.sum
├── README.md
└── LICENSE
Rules:
cmd/<name>/main.gois thin. No business logic. It wires Cobra commands and exits. ~200 lines is the soft ceiling; beyond that, extra subcommands live incmd/<name>/cmd_<verb>.goor equivalent, still inpackage main.internal/is the default home for new code. Only add topkg/when another repo actually imports it.- Don't nest
internal/more than two levels deep. Three levels is a smell — split into sibling packages. - One bounded concern per
internal/<dir>/— e.g.config,sync,embed,state. Avoid dumping-ground names likeutil,helpers,common.
go.modmodule path: match the actual import prefix. For GitHub-hosted personal code,github.com/<user>/<project>.- Go version: 1.25.x. The
godirective ingo.modandGO_VERSIONin CI must match. Ifgo mod initwrites a newer version (because your local toolchain is newer), editgo.modback togo 1.25. - Core dependency set (don't re-evaluate per project):
github.com/spf13/cobra— CLI frameworklog/slog(stdlib) — structured logginggithub.com/jackc/pgx/v5— PostgreSQL clientmodernc.org/sqlite— pure-Go SQLite (no CGO)github.com/go-chi/chi/v5— HTTP router
- Run
go mod tidyon every PR. CI checks (non-mutating — see §12). - Don't vendor. Don't set
GOFLAGSglobally.
Every cmd/<name>/main.go follows this skeleton. Copy verbatim, change
the Use, Short, and registered commands.
// Package main is the entry point for <binary-name>.
package main
import (
"context"
"fmt"
"os"
"os/signal"
"syscall"
"github.com/spf13/cobra"
)
var (
// Version is set at build time via ldflags.
Version = "dev"
// BuildTime is set at build time via ldflags.
BuildTime = "unknown"
)
func main() {
if err := run(); err != nil {
// Pre-logger error path — stderr is fine here.
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
}
func run() error {
rootCmd := &cobra.Command{
Use: "<name>",
Short: "<one-line description>",
SilenceUsage: true, // don't print usage on every runtime error
SilenceErrors: true, // main() handles the error print
}
rootCmd.AddCommand(newVersionCmd())
// ... register subcommands here ...
ctx, cancel := signal.NotifyContext(context.Background(),
syscall.SIGINT, syscall.SIGTERM)
defer cancel()
return rootCmd.ExecuteContext(ctx)
}
// newVersionCmd returns the `version` subcommand.
func newVersionCmd() *cobra.Command {
return &cobra.Command{
Use: "version",
Short: "Print version information",
// Run (not RunE) is OK here — version has no failure mode.
Run: func(_ *cobra.Command, _ []string) {
fmt.Printf("version %s (built %s)\n", Version, BuildTime)
},
}
}Why main() → run() → cobra:
run()is testable (unit-test command wiring).- Single error exit path, single error print format.
SilenceUsage/SilenceErrorson the root prevent Cobra from dumping usage text on every runtime failure.
Note on unused params: Cobra's handler signature includes *cobra.Command
and []string. When you don't use one, write _ instead of the name —
the canonical lint config flags named-but-unused params via
revive:unused-parameter.
Each subcommand is built by a newXxxCmd() function returning
*cobra.Command. Keeps wiring declarative and lets main.go read
like a table of contents. For state shared with the parent (config
path, logger), pass by pointer:
// newDaemonCmd returns the `daemon` subcommand.
func newDaemonCmd(configPath *string) *cobra.Command {
return &cobra.Command{
Use: "daemon",
Short: "Run the daemon",
RunE: func(cmd *cobra.Command, _ []string) error {
cfg, err := config.Load(*configPath)
if err != nil {
return fmt.Errorf("loading config: %w", err)
}
_ = cfg
// use cmd.Context() for cancellation
return nil
},
}
}For flags scoped to one subcommand, declare the variable inside the factory and bind via a closure:
// newGreetCmd returns the `greet --name <name>` subcommand.
func newGreetCmd() *cobra.Command {
var name string
cmd := &cobra.Command{
Use: "greet",
Short: "Greet someone",
RunE: func(_ *cobra.Command, _ []string) error {
fmt.Printf("Hello, %s!\n", name)
return nil
},
}
cmd.Flags().StringVar(&name, "name", "world", "who to greet")
return cmd
}Rules:
- Prefer
RunEoverRun. Exception: commands with no failure mode (version, help). - Use
cmd.Context()for ctx. Never a package-level global. - For shared state, pass by pointer through factory args.
- For local flags, declare in the factory and bind via closure.
- Global vars in
main.goare OK only for genuinely global things (verbose, loadedcfg,logger). Never in business-logic packages.
Put root-level flags on PersistentFlags; parse them once in
PersistentPreRunE. Skip setup for commands that don't need it
(version, help, completion) — otherwise --version dies when config
is missing.
// In run():
var (
configPath string
verbose bool
)
rootCmd.PersistentFlags().StringVarP(&configPath, "config", "c",
config.DefaultConfigPath(), "path to config file")
rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false,
"Enable verbose output")
rootCmd.PersistentPreRunE = func(cmd *cobra.Command, _ []string) error {
switch cmd.Name() {
case "version", "help", "completion":
return nil
}
cfg, err := config.Load(configPath)
if err != nil {
return fmt.Errorf("loading config: %w", err)
}
level := slog.LevelInfo
if verbose {
level = slog.LevelDebug
}
logger := slog.New(slog.NewTextHandler(os.Stderr,
&slog.HandlerOptions{Level: level}))
logger.Info("configured", "machine_id", cfg.MachineID)
_ = cfg
return nil
}Gotcha: only the deepest PersistentPreRunE in a command chain
runs. If a subcommand defines its own PersistentPreRunE, the root's
is skipped — your config/logger setup won't happen. Either don't
override on subcommands, or explicitly call the root's
PersistentPreRunE from the override.
One package (internal/config), one Load() function. Sources merge
in order: defaults → file → env vars (later overrides earlier).
The full package — runnable as-shown:
// Package config loads and validates application configuration.
package config
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
)
// Config is the top-level application configuration.
type Config struct {
MachineID string `json:"machine_id"`
Scope string `json:"scope"`
Postgres PostgresConfig `json:"postgres"`
}
// PostgresConfig describes a PostgreSQL connection.
type PostgresConfig struct {
Host string `json:"host"`
Port int `json:"port"`
User string `json:"user"`
Password string `json:"password"`
DB string `json:"db"`
}
// DSN returns the PostgreSQL connection string.
func (p PostgresConfig) DSN() string {
return fmt.Sprintf("postgres://%s:%s@%s:%d/%s",
p.User, p.Password, p.Host, p.Port, p.DB)
}
// ErrMissingConfig is returned when a required field is absent.
var ErrMissingConfig = errors.New("missing required config")
// DefaultConfigPath returns the default config file path
// (~/.config/<binary-name>/config.json). Replace <binary-name>.
func DefaultConfigPath() string {
home, err := os.UserHomeDir()
if err != nil {
return "config.json"
}
return filepath.Join(home, ".config", "<binary-name>", "config.json")
}
// Load reads config from path, overlays env vars, and validates.
// A missing file is OK (defaults are used); a malformed file is not.
func Load(path string) (*Config, error) {
cfg := defaults()
if err := loadFile(cfg, path); err != nil {
return nil, fmt.Errorf("loading config file %s: %w", path, err)
}
loadEnv(cfg)
if err := validate(cfg); err != nil {
return nil, fmt.Errorf("invalid config: %w", err)
}
return cfg, nil
}
func defaults() *Config {
return &Config{
Scope: "personal",
Postgres: PostgresConfig{
Host: "localhost",
Port: 5432,
},
}
}
// loadFile merges JSON file contents into cfg. Missing files are OK.
func loadFile(cfg *Config, path string) error {
data, err := os.ReadFile(path) // #nosec G304 -- path is config-sourced
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return fmt.Errorf("reading: %w", err)
}
if err := json.Unmarshal(data, cfg); err != nil {
return fmt.Errorf("parsing json: %w", err)
}
return nil
}
// loadEnv overlays environment variables using the APP_ prefix.
// Nested structs use underscore separation (e.g. APP_POSTGRES_HOST).
// No reflection — explicit mapping is clearer and easier to debug.
func loadEnv(cfg *Config) {
if v := os.Getenv("APP_MACHINE_ID"); v != "" {
cfg.MachineID = v
}
if v := os.Getenv("APP_SCOPE"); v != "" {
cfg.Scope = v
}
if v := os.Getenv("APP_POSTGRES_HOST"); v != "" {
cfg.Postgres.Host = v
}
if v := os.Getenv("APP_POSTGRES_PORT"); v != "" {
if p, err := strconv.Atoi(v); err == nil {
cfg.Postgres.Port = p
}
}
if v := os.Getenv("APP_POSTGRES_USER"); v != "" {
cfg.Postgres.User = v
}
if v := os.Getenv("APP_POSTGRES_PASSWORD"); v != "" {
cfg.Postgres.Password = v
}
if v := os.Getenv("APP_POSTGRES_DB"); v != "" {
cfg.Postgres.DB = v
}
}
// validate returns an error if cfg is incomplete or inconsistent.
func validate(cfg *Config) error {
if cfg.Postgres.Host == "" {
return fmt.Errorf("postgres.host: %w", ErrMissingConfig)
}
return nil
}Rules:
- Each subsystem's config is its own struct (
PostgresConfig,OllamaConfig) — not a flat bag of strings. - Each subsystem exposes its own connection helper (
DSN(),BaseURL(),ListenAddr()) rather than asking consumers to assemble the URL themselves. Load()validates and returns a complete config or an error. No partial configs escape into the wild.- No reflection for env-var mapping. Explicit per-field code is more verbose but far easier to debug than a reflection-based layer.
- Config file format is JSON. YAML if you have a reason; not by default.
- Secrets come from env vars or a secret manager. Never from a config file checked into git.
APP_prefix for env vars is convention; replace with project-specific prefix (e.g.WIDGETCTL_) in real projects.
Use log/slog. No third-party logging libraries.
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
logger.Info("starting",
"version", Version,
"machine_id", cfg.MachineID,
)Rules:
- One logger constructed in
PersistentPreRunE(for CLIs) or at the top of a daemon's run loop. - Pass as
*slog.Logger(pointer). Stdlib idiom. Don't copy loggers by value. - Don't stash loggers in
context.Contextunless ctx is genuinely request-scoped (HTTP handlers). - Handler choice:
TextHandlerfor CLIs and local daemons,JSONHandlerfor services shipping logs to a collector. Note that the two handlers emit different time formats — switching handler types mid-project breaks log ingesters. - Log keys are lowercase snake_case:
machine_id,elapsed_ms. - Levels:
Debug(chatty, off by default),Info(normal),Warn(recoverable),Error(action needed). No custom levels. - Structured attrs over formatted strings. Do:
logger.Info("connected", "dsn", dsn), notlogger.Info(fmt.Sprintf("connected to %s", dsn)).
Use pgxpool. Wrap pool creation to inject timeouts and validate the
connection:
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// Open creates a Postgres connection pool and pings it.
func Open(ctx context.Context, dsn string) (*pgxpool.Pool, error) {
cfg, err := pgxpool.ParseConfig(dsn)
if err != nil {
return nil, fmt.Errorf("parsing DSN: %w", err)
}
cfg.MaxConns = 10
cfg.MaxConnLifetime = 30 * time.Minute
pool, err := pgxpool.NewWithConfig(ctx, cfg)
if err != nil {
return nil, fmt.Errorf("creating pool: %w", err)
}
if err := pool.Ping(ctx); err != nil {
pool.Close()
return nil, fmt.Errorf("pinging db: %w", err)
}
return pool, nil
}Shutdown: pool.Close() blocks on in-flight queries. Daemons should
give it a bounded context via a goroutine + select or time out.
Put pragmas in the DSN, not in db.Exec(...). A single Exec
pragma only affects the one pooled connection that served it;
subsequent connections revert to defaults and you're back to
"database is locked". DSN pragmas apply to every connection opened
by the pool.
import (
"database/sql"
"fmt"
_ "modernc.org/sqlite" // registers "sqlite" driver
)
// OpenSQLite opens a SQLite database with WAL and busy-timeout pragmas
// set on every pooled connection.
func OpenSQLite(path string) (*sql.DB, error) {
dsn := fmt.Sprintf(
"file:%s?_pragma=busy_timeout(5000)&_pragma=journal_mode(wal)&_pragma=foreign_keys(on)",
path,
)
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("opening sqlite %s: %w", path, err)
}
if err := db.Ping(); err != nil {
_ = db.Close()
return nil, fmt.Errorf("pinging sqlite %s: %w", path, err)
}
return db, nil
}For read-only opens, append &mode=ro to the DSN.
Driver name gotcha: modernc.org/sqlite registers as "sqlite", not
"sqlite3" (which is mattn's CGO driver). sql.Open("sqlite3", ...)
with only the modernc import fails with "unknown driver".
Use go-chi/chi/v5. Always set timeouts, always graceful-shutdown.
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"time"
)
// NewServer constructs an http.Server with sane defaults.
func NewServer(addr string, handler http.Handler) *http.Server {
return &http.Server{
Addr: addr,
Handler: handler,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
}
}
// Run starts srv and blocks until ctx is canceled, then gracefully shuts down.
func Run(ctx context.Context, srv *http.Server, logger *slog.Logger) error {
errCh := make(chan error, 1)
go func() {
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
errCh <- err
return
}
errCh <- nil
}()
select {
case <-ctx.Done():
logger.Info("shutdown requested")
case err := <-errCh:
if err != nil {
return fmt.Errorf("server stopped: %w", err)
}
return nil
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
return fmt.Errorf("shutdown: %w", err)
}
return nil
}Handlers take dependencies as struct fields, not globals:
import (
"log/slog"
"net/http"
"github.com/jackc/pgx/v5/pgxpool"
)
// Handler groups HTTP handler dependencies.
type Handler struct {
DB *pgxpool.Pool
Logger *slog.Logger
}
// GetFoo handles GET /foo.
func (h *Handler) GetFoo(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("ok"))
}Middleware order matters. Canonical chain:
chi.RequestID → chi.RealIP → Logger → Recoverer → Timeout → routes.
Recoverer must go before Timeout. If a panic happens inside a
timed-out handler and Recoverer comes after, the panic isn't caught
and crashes the process.
Timeout cancels the request context but does not cancel the
handler's goroutine. If the handler does expensive work, check
r.Context().Done() yourself.
- Always wrap with
%w. Every error crossing a function boundary gets wrapped with context:if err := foo(); err != nil { return fmt.Errorf("doing foo with %s: %w", name, err) }
- Use
errors.Is/errors.Asto inspect. Never string-match error messages. - Don't log and return the same error. Pick one. Log at the top-level handler (cobra command, HTTP handler); return wrapped everywhere else.
- Define sentinel errors for cases callers need to branch on:
var ErrNotFound = errors.New("not found")
- No
panicfor recoverable conditions.paniconly for programmer errors (impossible state, invariant violation).
- Unit tests live next to the code in
*_test.go, same package. Usepackage foofor white-box tests,package foo_testfor black-box. - Table-driven by default:
func TestParse(t *testing.T) { tests := []struct { name string input string want Result wantErr bool }{ {"empty", "", Result{}, true}, {"happy", "valid", Result{Value: 42}, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := Parse(tt.input) if (err != nil) != tt.wantErr { t.Fatalf("err = %v, wantErr = %v", err, tt.wantErr) } if !tt.wantErr && !reflect.DeepEqual(got, tt.want) { t.Errorf("got %+v, want %+v", got, tt.want) } }) } }
- Helpers named
newTestFoo(t *testing.T, ...)— return the fixture and register cleanup viat.Cleanup(...). - Mock external APIs with
httptest.Server— not by stubbing the client's methods. If the client uses HTTPS, usehttptest.NewTLSServer. - Integration tests under
tests/integration/with the//go:build integrationtag, invoked viago test -tags=integration. May depend ontests/docker-compose.yml. - Always run with
-racein CI.go test -race ./.... - Stick to the stdlib
testingpackage. No third-party assertion libraries by default.
Copy this verbatim into .golangci.yml at repo root. Pin
golangci-lint v1.64.x in CI to match — v2 has a different schema
and this config won't load there.
run:
timeout: 5m
tests: true
linters:
enable:
# Bugs & correctness
- govet
- staticcheck # also covers simple/stylecheck/unused checks
- gosec
- bodyclose
- sqlclosecheck
- rowserrcheck
- nilerr
- noctx
- copyloopvar
# Error handling
- errcheck
- errorlint
- errname
- wrapcheck
# Complexity
- gocyclo
- gocognit
- funlen
- nestif
# Duplication
- dupl
# Style & formatting
- gofmt
- goimports
- revive
- unconvert
- unparam
- misspell
- godot
- exhaustive
# Performance
- prealloc
- gocritic
# Dead code & unused
- unused
- ineffassign
# Testing
- tparallel
linters-settings:
gocyclo:
min-complexity: 15
gocognit:
min-complexity: 15
funlen:
lines: 80
statements: 50
nestif:
min-complexity: 4
dupl:
threshold: 100
exhaustive:
default-signifies-exhaustive: true
gosec:
severity: medium
confidence: medium
errcheck:
check-type-assertions: true
check-blank: false
errorlint:
errorf: true
asserts: true
comparison: true
wrapcheck:
ignoreSigs:
- .Errorf(
- errors.New(
- errors.Unwrap(
- .Wrap(
- .Wrapf(
- .WithMessage(
- .WithStack(
revive:
rules:
- name: blank-imports
- name: context-as-argument
- name: context-keys-type
- name: dot-imports
- name: error-return
- name: error-strings
- name: error-naming
- name: exported
- name: if-return
- name: increment-decrement
- name: var-naming
- name: var-declaration
- name: package-comments
- name: range
- name: receiver-naming
- name: time-naming
- name: unexported-return
- name: indent-error-flow
- name: errorf
- name: empty-block
- name: superfluous-else
- name: unused-parameter
- name: unreachable-code
- name: redefines-builtin-id
gocritic:
enabled-tags:
- diagnostic
- style
- performance
disabled-checks:
- hugeParam
- ifElseChain
misspell:
locale: US
issues:
exclude-use-default: false
max-issues-per-linter: 0
max-same-issues: 0
exclude-rules:
# Test files — relax complexity, length, duplication, error checks
- path: _test\.go
linters:
- funlen
- gocyclo
- gocognit
- errcheck
- dupl
- unparam
# Command-registration code is inherently long and has duplication
# across sibling newXxxCmd factories. Anchor regex with ^.
- path: ^cmd/
linters:
- wrapcheck
- gocognit
- gocyclo
- dupl
# main.go itself is long by nature (command registration)
- path: ^cmd/.*/main\.go
linters:
- funlen
# Long-running state machines have inherent complexity.
# Add paths for YOUR daemon/sync dirs as they emerge. Example:
# - path: ^internal/(sync|extract)/
# linters:
# - gocognit
# - nestif
# File paths from env/config are expected — gosec G304 noise.
- linters:
- gosec
text: "G304"Notes on the rule set:
staticchecksubsumes whatgosimple,stylecheck, and theunusedcheck used to do separately — don't list the old names.- Complexity budgets are tight on purpose. If you hit them, the code usually wants a rewrite, not an exemption. Add per-path exemptions only when a specific pattern (e.g. a state-machine loop) genuinely can't be simplified.
- All path exclusions use anchored regex (
^cmd/) sointernal/foo/cmd/doesn't accidentally match. revive:exportedwill flag every exported type/func without a doc comment. Comment everything you export — the discipline is worth more than the lint bypass.
Copy and replace <binary-name> everywhere. The Makefile distinguishes
mutating targets (run during local dev — fmt, tidy) from
check targets (run in CI — fmt-check, tidy-check) so CI
catches drift without rewriting files.
.PHONY: all build
.PHONY: test test-unit test-integration test-all
.PHONY: fmt tidy vet lint security coverage
.PHONY: fmt-check tidy-check
.PHONY: check ci
.PHONY: docker-up docker-down
.PHONY: clean install-local install-local-force dev watch tools help
# Build configuration
BINARY_DIR := bin
BINARY_NAME := <binary-name>
MAIN_PACKAGE := ./cmd/$(BINARY_NAME)
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
BUILD_TIME := $(shell date -u '+%Y-%m-%dT%H:%M:%SZ')
LDFLAGS := -ldflags "-X main.Version=$(VERSION) -X main.BuildTime=$(BUILD_TIME)"
# Colors
GREEN := \033[0;32m
YELLOW := \033[0;33m
RED := \033[0;31m
NC := \033[0m
all: check build
# =============================================================================
# Build
# =============================================================================
build:
@mkdir -p $(BINARY_DIR)
go build $(LDFLAGS) -o $(BINARY_DIR)/$(BINARY_NAME) $(MAIN_PACKAGE)
@printf '$(GREEN)Built: $(BINARY_DIR)/$(BINARY_NAME)$(NC)\n'
# =============================================================================
# Test
# =============================================================================
# -race is always on; catches bugs that only appear under contention.
test-unit:
@printf '$(GREEN)Running unit tests...$(NC)\n'
go test -race -v ./...
test: test-unit
# Requires Docker + tests/docker-compose.yml; skip unless you have
# integration tests. Runs cleanup even on failure.
test-integration: docker-up
@printf '$(GREEN)Running integration tests...$(NC)\n'
@set -e; rc=0; \
(cd tests && docker compose run --rm test-runner \
go test -v -tags=integration ./integration/...) || rc=$$?; \
$(MAKE) docker-down; \
exit $$rc
test-all: test-unit test-integration
@printf '$(GREEN)All tests passed!$(NC)\n'
# =============================================================================
# Code quality — mutating (local dev)
# =============================================================================
fmt:
@printf '$(GREEN)Formatting code...$(NC)\n'
@if ! command -v goimports >/dev/null 2>&1; then \
printf '$(RED)goimports not installed. Run make tools.$(NC)\n'; exit 1; \
fi
go fmt ./...
goimports -w .
tidy:
go mod tidy
# =============================================================================
# Code quality — non-mutating (CI)
# =============================================================================
# Fails if any files need formatting. Lists offenders on stderr.
fmt-check:
@printf '$(GREEN)Checking formatting...$(NC)\n'
@out=$$(gofmt -l .); \
if [ -n "$$out" ]; then \
printf '$(RED)Files need gofmt:$(NC)\n%s\n' "$$out"; exit 1; \
fi
@if command -v goimports >/dev/null 2>&1; then \
out=$$(goimports -l .); \
if [ -n "$$out" ]; then \
printf '$(RED)Files need goimports:$(NC)\n%s\n' "$$out"; exit 1; \
fi; \
fi
# Fails if go.mod/go.sum would change after tidy.
tidy-check:
@printf '$(GREEN)Checking go.mod/go.sum...$(NC)\n'
@go mod tidy
@if ! git diff --quiet -- go.mod go.sum 2>/dev/null; then \
printf '$(RED)go.mod or go.sum changed after tidy; run make tidy and commit.$(NC)\n'; \
git diff -- go.mod go.sum; exit 1; \
fi
# =============================================================================
# Static analysis (non-mutating)
# =============================================================================
vet:
@printf '$(GREEN)Running go vet...$(NC)\n'
go vet ./...
lint:
@if ! command -v golangci-lint >/dev/null 2>&1; then \
printf '$(RED)golangci-lint not installed. Run make tools.$(NC)\n'; exit 1; \
fi
@printf '$(GREEN)Running golangci-lint...$(NC)\n'
golangci-lint run ./...
security:
@if ! command -v gosec >/dev/null 2>&1; then \
printf '$(RED)gosec not installed. Run make tools.$(NC)\n'; exit 1; \
fi
@printf '$(GREEN)Running gosec...$(NC)\n'
gosec -quiet ./...
coverage:
@printf '$(GREEN)Running tests with coverage...$(NC)\n'
go test -race -coverprofile=coverage.out -covermode=atomic ./...
@printf '\n$(GREEN)Coverage summary:$(NC)\n'
@go tool cover -func=coverage.out | grep -E "^total:|internal/"
@go tool cover -html=coverage.out -o coverage.html
@printf '$(GREEN)Full report: coverage.html$(NC)\n'
# =============================================================================
# CI pipelines
# =============================================================================
# `check` is the fast local pipeline. Mutates files (fmt, tidy). Use
# during active development.
check: fmt tidy vet test-unit build
@printf '$(GREEN)Quick check passed!$(NC)\n'
# `ci` is the non-mutating pipeline. Use in CI and pre-push hooks.
# Catches formatting/tidy drift that `check` would silently fix.
ci: fmt-check tidy-check vet lint security test-unit coverage build
@printf '\n$(GREEN)========================================$(NC)\n'
@printf '$(GREEN) CI Pipeline completed successfully! $(NC)\n'
@printf '$(GREEN)========================================$(NC)\n'
# =============================================================================
# Docker (only if tests/docker-compose.yml exists)
# =============================================================================
docker-up:
cd tests && docker compose up -d
@sleep 5
docker-down:
cd tests && docker compose down
# =============================================================================
# Utility
# =============================================================================
clean:
rm -rf $(BINARY_DIR)
rm -f coverage.out coverage.html
# Interactive install. Falls back to force-install under non-TTY.
install-local: build
@mkdir -p $$HOME/.local/bin
@if [ ! -t 0 ]; then \
cp $(BINARY_DIR)/$(BINARY_NAME) $$HOME/.local/bin/; \
printf '$(GREEN)Installed to ~/.local/bin/$(BINARY_NAME) (non-interactive).$(NC)\n'; \
exit 0; \
fi; \
if [ -x $$HOME/.local/bin/$(BINARY_NAME) ]; then \
printf '$(YELLOW)Current:$(NC) %s\n' "$$($$HOME/.local/bin/$(BINARY_NAME) version 2>/dev/null || echo unknown)"; \
printf '$(GREEN)New:$(NC) $(BINARY_NAME) version $(VERSION) (built $(BUILD_TIME))\n'; \
read -p "Replace existing installation? [y/N] " confirm; \
if [ "$$confirm" != "y" ] && [ "$$confirm" != "Y" ]; then \
printf '$(YELLOW)Cancelled$(NC)\n'; exit 1; \
fi; \
else \
printf '$(GREEN)Installing:$(NC) $(BINARY_NAME) version $(VERSION) (built $(BUILD_TIME))\n'; \
fi; \
cp $(BINARY_DIR)/$(BINARY_NAME) $$HOME/.local/bin/; \
printf '$(GREEN)Installed to ~/.local/bin/$(BINARY_NAME)$(NC)\n'
install-local-force: build
@mkdir -p $$HOME/.local/bin
cp $(BINARY_DIR)/$(BINARY_NAME) $$HOME/.local/bin/
@printf '$(GREEN)Installed to ~/.local/bin/$(BINARY_NAME)$(NC)\n'
dev: build
./$(BINARY_DIR)/$(BINARY_NAME)
# Rebuild on change. Requires entr (https://eradman.com/entrproject/).
watch:
@if ! command -v entr >/dev/null 2>&1; then \
printf '$(RED)entr not installed. See https://eradman.com/entrproject/.$(NC)\n'; exit 1; \
fi
find . -name '*.go' | entr -c make build
tools:
@printf '$(GREEN)Installing development tools...$(NC)\n'
go install golang.org/x/tools/cmd/goimports@latest
go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.64.8
go install github.com/securego/gosec/v2/cmd/gosec@latest
help:
@echo "Available targets:"
@echo " build Build the binary"
@echo " install-local Build and install to ~/.local/bin (TTY: confirm; non-TTY: force)"
@echo " install-local-force Install without confirmation"
@echo " test Run unit tests (-race)"
@echo " test-unit Run unit tests (-race)"
@echo " test-integration Run integration tests (requires Docker)"
@echo " coverage Generate coverage report"
@echo " fmt Format code (mutating)"
@echo " tidy go mod tidy (mutating)"
@echo " fmt-check Check formatting (non-mutating)"
@echo " tidy-check Check go.mod/go.sum (non-mutating)"
@echo " vet go vet"
@echo " lint golangci-lint"
@echo " security gosec"
@echo " check fmt + tidy + vet + test + build (local, mutates)"
@echo " ci Full non-mutating CI pipeline"
@echo " dev Build and run"
@echo " watch Rebuild on change (requires entr)"
@echo " clean Remove build artifacts"
@echo " tools Install dev tools (goimports, golangci-lint@v1.64.8, gosec)"
@echo " help Show this help"Ldflags for version injection are mandatory. <binary> version
should always print the real commit + build timestamp. Note: git describe
silently falls back to "dev" if run outside a git repo — always run
git init as the first checklist step.
~/.local/bin must be on $PATH for install-local to be useful.
Typical shell setup:
export PATH="$HOME/.local/bin:$PATH"Every CI job maps 1:1 to a make target, so the Makefile is the single
source of truth. CI and local behavior can't drift.
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
GO_VERSION: '1.25'
GOLANGCI_LINT_VERSION: 'v1.64.8'
jobs:
fmt:
name: Format check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
cache: true
- name: Install goimports
run: go install golang.org/x/tools/cmd/goimports@latest
- name: Check formatting
run: make fmt-check
tidy:
name: Tidy check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
cache: true
- name: Check go.mod / go.sum
run: make tidy-check
vet:
name: Vet
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
cache: true
- run: make vet
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
cache: true
- name: golangci-lint
uses: golangci/golangci-lint-action@v4
with:
version: ${{ env.GOLANGCI_LINT_VERSION }}
args: --timeout=5m
security:
name: Security
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
cache: true
- name: Install gosec
run: go install github.com/securego/gosec/v2/cmd/gosec@latest
- run: make security
test:
name: Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
cache: true
- run: make test-unit
coverage:
name: Coverage
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
cache: true
- name: Generate coverage
run: make coverage
# Codecov upload is optional; requires CODECOV_TOKEN secret for
# private repos. Skipped when no token is set.
- name: Upload coverage to Codecov
if: ${{ secrets.CODECOV_TOKEN != '' }}
uses: codecov/codecov-action@v4
with:
files: ./coverage.out
fail_ci_if_error: false
token: ${{ secrets.CODECOV_TOKEN }}
build:
name: Build
runs-on: ubuntu-latest
strategy:
matrix:
goarch: [amd64, arm64]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
cache: true
# CGO off for cross-compile — modernc.org/sqlite is pure Go and
# no other dep needs CGO. If you add a CGO dep, handle per-arch
# toolchains explicitly instead of flipping this on.
- name: Build
env:
GOOS: linux
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: 0
run: |
mkdir -p dist
go build -ldflags "-X main.Version=${{ github.sha }} -X main.BuildTime=$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \
-o dist/<binary-name>-linux-${{ matrix.goarch }} ./cmd/<binary-name>
- uses: actions/upload-artifact@v4
with:
name: <binary-name>-linux-${{ matrix.goarch }}
path: dist/<binary-name>-linux-${{ matrix.goarch }}.github/workflows/release.yml (only needed when cutting versioned
releases; tag as vX.Y.Z to trigger):
name: Release
on:
push:
tags:
- 'v*'
env:
GO_VERSION: '1.25'
permissions:
contents: write
jobs:
test:
name: Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
cache: true
- run: make test-unit
release:
name: Release
needs: test
runs-on: ubuntu-latest
strategy:
matrix:
goarch: [amd64, arm64]
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
cache: true
- name: Build
env:
GOOS: linux
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: 0
run: |
mkdir -p dist
go build -ldflags "-X main.Version=${{ github.ref_name }} -X main.BuildTime=$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \
-o dist/<binary-name>-linux-${{ matrix.goarch }} ./cmd/<binary-name>
- name: Upload binaries to release
uses: softprops/action-gh-release@v2
with:
files: dist/*
generate_release_notes: trueFor daemons that should run as a systemd user unit, include an install
target in the Makefile that drops a unit file into
~/.config/systemd/user/. Minimal template:
[Unit]
Description=<one-line description>
After=<dependency.service>
[Service]
Type=simple
ExecStart=%h/.local/bin/<binary-name> daemon
Restart=on-failure
RestartSec=10
Environment=HOME=%h
[Install]
WantedBy=default.targetInstall via systemctl --user daemon-reload && systemctl --user enable --now <binary-name>.service.
Type=simple on purpose. Type=notify requires the Go program to
call sd_notify(READY=1) via github.com/coreos/go-systemd/v22/daemon;
without that, the service hangs in activating and systemd kills it.
If you genuinely need notify + WatchdogSec, add the go-systemd
dependency and the notify calls, then flip the unit type.
User units only. Don't install system-level services.
- Cobra
PersistentPreRunEruns for everything, including--helpand--version. If it unconditionally loads config,--versiondies on a machine with no config. Always early-return onversion/help/completion. - Cobra
PersistentPreRunEinheritance: only the deepest override in a command chain runs. If a subcommand defines its own, the root's is skipped. Your config/logger setup silently doesn't happen. - SQLite pragmas via
db.Exec("PRAGMA ...")only affect one pooled connection. Other connections revert to defaults →database is lockedunder contention. Put pragmas in the DSN (?_pragma=...) so every connection gets them. modernc.org/sqliteregisters as"sqlite", not"sqlite3"(which is mattn/CGO).sql.Open("sqlite3", ...)with only_ "modernc.org/sqlite"fails "unknown driver".modernc.org/sqlitedoes NOT enablePRAGMA foreign_keys=ONby default. Add&_pragma=foreign_keys(on)to the DSN if you rely on FK enforcement.pgxpool.Pool.Close()blocks on in-flight queries. Daemons need a time-bounded shutdown (goroutine +time.Afterrace).pgxpool.ParseConfigsilently ignores unknown DSN parameters. Validate the fields of the returned config, or typo'd options will be invisible.wrapcheckfalse positives when wrapping errors from your own helpers. Either add toignoreSigsor return pre-wrapped.httptest.NewServervsNewTLSServer— if the client is hard-coded to HTTPS, the plain version gives an unhelpful "unknown protocol" error. Use the TLS variant.go-chi'sTimeoutmiddleware doesn't cancel the handler's goroutine — it only cancels the request context. If you have expensive work, checkr.Context().Done()yourself.middleware.Recoverermust come beforemiddleware.Timeout. A panic inside a timed-out handler otherwise escapes Recoverer's catch and crashes the process.slog.TextHandlerandJSONHandleremit different time formats. Switching handler types mid-project breaks log ingesters that expect a fixed shape.signal.NotifyContextonly covers the current process. If youos/execa subprocess, propagate the context viaexec.CommandContextor the child won't die on Ctrl-C.modernc.org/sqliteis pure Go but slower than CGO alternatives. For hot paths (embedding stores, high-QPS), profile before assuming it's fine.go buildwithout ldflags produces a binary that reportsversion dev (built unknown). Always build through the Makefile in local dev, or setLDFLAGSexplicitly.git describesilently falls back to"dev"outside a git repo. Alwaysgit initfirst so your version string reflects reality.go testwithout-racepasses locally but can fail in CI. The Makefile always passes-race; don't skip it.t.Parallel()+ shared state (mutated globals, shared DB) flakes nondeterministically. Either isolate state per test or don't parallelize that test.
Work through in order. Each step has a template earlier in this doc.
-
mkdir <project> && cd <project> -
git init -b main -
go mod init github.com/<user>/<project> -
Open
go.modand confirm/edit thegodirective togo 1.25(matchesGO_VERSIONin CI) -
mkdir -p cmd/<binary-name> internal/config .github/workflows -
cmd/<binary-name>/main.gofrom §3 skeleton (optionally with the §4.2 subcommand example) -
internal/config/config.gofrom §5 (replaceAPP_env prefix and<binary-name>placeholder) -
.golangci.ymlfrom §11 verbatim -
Makefilefrom §12, replace<binary-name> -
.github/workflows/ci.ymlfrom §13, replace<binary-name> -
.github/workflows/release.ymlif you plan to cut tagged releases -
.gitignore: ```gitignore # Binaries /bin/ /dist/# Test / coverage output coverage.out coverage.html *.test # Editor .vscode/ .idea/ *.swp # OS .DS_Store ``` -
README.md— at minimum: what it does, install, usage, config, dev. A one-page file is fine; long-form docs can live elsewhere. -
LICENSE— pick one; MIT is the default for personal projects. -
go get github.com/spf13/cobra(and any other deps you reference in the skeleton) -
go mod tidy -
Write at least one table-driven test (see §10).
make ciruns coverage; an empty test suite gives false reassurance. -
Ensure
~/.local/binis on$PATHif you wantmake install-local -
make toolsto install dev tools (goimports, golangci-lint@v1.64.8, gosec) -
git add . && git commit -m "initial commit"— needed sogit describeproduces something other than the"dev"fallback for subsequent builds. -
make cishould pass cleanly before push.
- Observability / metrics. No canonical Prometheus pattern yet. Add when one exists.
- Secrets management. Varies by environment (1Password CLI, Vault, AWS SM, plain env). Pick per-project; don't put secrets in config files regardless.
- Frontend / UI. Out of scope.
- SQL migrations. No preferred tool yet (golang-migrate vs sqlc vs atlas). Pick per-project, document in the project's README.
- Dockerfile. Write one when you need it; no canonical template worth enforcing.
- Project-scaffolding script. Copy-paste from this doc is the canonical workflow. A cookiecutter is on the wishlist.