
Go Development
- 76 installs
- 9 repo stars
- Updated August 3, 2026
- netresearch/go-development-skill
Helps with ai & agent building tasks.
About
go-development is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- go-development
- AI & Agent Building
- AI-coding skill
Go Development by the numbers
- 76 all-time installs (skills.sh)
- +5 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #5,442 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/netresearch/go-development-skill --skill go-developmentAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 76 |
|---|---|
| repo stars | ★ 9 |
| Last updated | August 3, 2026 |
| Repository | netresearch/go-development-skill ↗ |
What it does
Helps with ai & agent building tasks.
Files
Go Development Patterns
Required Workflow
For reviews, invoke related skills: security-audit (OWASP), enterprise-readiness (OpenSSF/SLSA), github-project (branch protection). All are required.
Core Principles
Type Safety
- Avoid:
interface{}(useany),sync.Map, scattered type assertions, reflection - Prefer: Generics
[T any],errors.AsType[T](Go 1.26), concrete types - Run
go fix ./...after upgrades
Consistency
- One pattern per problem domain
- Match existing codebase patterns
- Refactor holistically or not at all
- Config precedence: defaults < config file < env vars < flags
Testing
- Build tags isolate test tiers: unit (default),
integration,e2e - Always use
t.Parallel(),t.Helper(), table-driven subtests - Use
log/slogdirectly -- never wrap it in custom Logger interfaces
Conventions
- Errors: lowercase, no punctuation (
errors.New("invalid input")) - Naming: ID, URL, HTTP (not Id, Url, Http)
- Error wrapping:
fmt.Errorf("failed to process: %w", err)
References
Git hooks: ls lefthook.yml 2>/dev/null && lefthook install || echo "Add lefthook — see references/lefthook-template.md"
Load as needed:
| Reference | Purpose |
|---|---|
references/architecture.md | Package structure, config management, middleware chains |
references/logging.md | Structured logging with log/slog, migration from logrus |
references/cron-scheduling.md | go-cron patterns: named jobs, runtime updates, context, resilience |
references/resilience.md | Retry logic, graceful shutdown, context propagation |
references/docker.md | Docker client patterns, buffer pooling |
references/ldap.md | LDAP/Active Directory integration |
references/testing.md | Test strategies, build tags, table-driven tests |
references/linting.md | golangci-lint v2, staticcheck, code quality |
references/api-design.md | Bitmask options, functional options, builders |
references/fuzz-testing.md | Go fuzzing patterns, security seeds |
references/contracts-and-invariants.md | Contracts, invariants, property tests |
references/mutation-testing.md | Gremlins configuration, test quality measurement |
references/makefile.md | Standard Makefile interface for CI/CD |
references/modernization.md | Go 1.26 modernizers, go fix, errors.AsType[T], wg.Go() |
references/lefthook-template.md | Ready-to-use lefthook.yml for Go project git hooks |
references/reusable-workflows.md | Reusable Actions workflow callers, permission propagation, release-gate outputs |
references/single-build-release.md | Single-build release: cross-compile once, reuse for release+container |
references/awesome-go-submission.md | awesome-go submission: CI-parsed PR body, entry format, name collisions |
Quality Gates
Run before completing any review:
golangci-lint run --timeout 5m # Linting
go vet ./... # Static analysis
staticcheck ./... # Additional checks
govulncheck ./... # Vulnerability scan
go test -race ./... # Race detectionStdlib Vulnerability Fixes
When govulncheck reports stdlib vulnerabilities: check fix version via vuln.go.dev, update go X.Y.Z in go.mod, run go mod tidy. Use PR branches for repos with branch protection.
---
Contributing: Submit improvements to https://github.com/netresearch/go-development-skill
# Checkpoints for go-development skill
# Validates Go project structure, tooling, and best practices
version: 1
skill_id: go-development
preconditions:
- type: file_exists
target: go.mod
mechanical:
# === PROJECT STRUCTURE ===
- id: GD-01
type: file_exists
target: go.mod
severity: error
desc: "go.mod must exist"
- id: GD-02
type: file_exists
target: go.sum
severity: error
desc: "go.sum must exist (dependencies must be tracked)"
- id: GD-03
type: file_exists
target: Makefile
severity: warning
desc: "Makefile should exist for standard build interface"
- id: GD-04
type: file_exists
target: .gitignore
severity: warning
desc: ".gitignore should exist"
# === LINTING CONFIGURATION ===
- id: GD-05
type: command
pattern: "test -f .golangci.yml || test -f .golangci.yaml || test -f .golangci.toml"
severity: warning
desc: "golangci-lint config should exist (.golangci.yml or similar)"
# === TESTS ===
- id: GD-06
type: command
pattern: "find . -name '*_test.go' -not -path './vendor/*' | head -1 | grep -q ."
severity: error
desc: "Project must have test files (*_test.go)"
- id: GD-07
type: command
pattern: "find . -name '*_test.go' -not -path './vendor/*' | xargs grep -l 'func Test' | head -1 | grep -q ."
severity: error
desc: "Test files must contain test functions"
# === MAKEFILE TARGETS ===
- id: GD-08
type: contains
target: Makefile
pattern: "test:"
severity: warning
desc: "Makefile should have a test target"
- id: GD-09
type: contains
target: Makefile
pattern: "build:"
severity: warning
desc: "Makefile should have a build target"
- id: GD-10
type: contains
target: Makefile
pattern: "lint:"
severity: warning
desc: "Makefile should have a lint target"
# === GO MODULE HYGIENE ===
- id: GD-11
type: regex
target: go.mod
pattern: "^go \\d+\\.\\d+"
severity: error
desc: "go.mod must specify Go version"
- id: GD-12
type: regex
target: go.mod
pattern: "^module "
severity: error
desc: "go.mod must declare module path"
# === CODE QUALITY ===
- id: GD-13
type: command
pattern: "! find . -name '*.go' -not -path './vendor/*' | xargs grep -l 'interface{}' | head -1 | grep -q . 2>/dev/null"
severity: info
desc: "Prefer 'any' over 'interface{}' (Go 1.18+)"
- id: GD-14
type: command
pattern: "! find . -name '*.go' -not -path './vendor/*' | xargs grep -l 'sync.Map' | head -1 | grep -q . 2>/dev/null"
severity: info
desc: "Prefer generic typed maps over sync.Map"
# === RACE DETECTION ===
- id: GD-15
type: regex
target: Makefile
pattern: "-race"
severity: warning
desc: "Makefile test target should include -race flag"
# === DOCKER (if applicable) ===
- id: GD-16
type: regex
target: Makefile
pattern: "-trimpath"
severity: info
desc: "Build flags should include -trimpath for reproducibility"
# === VULNERABILITY SCANNING ===
- id: GD-17
type: regex
target: Makefile
pattern: "govulncheck"
severity: warning
desc: "Makefile should have a govulncheck target for vulnerability scanning"
# === GOLANGCI-LINT V2 CONFIG ===
- id: GD-18
type: command
pattern: "test -f .golangci.yml && grep -q 'version:' .golangci.yml || test -f .golangci.yaml && grep -q 'version:' .golangci.yaml || true"
severity: info
desc: "golangci-lint config should declare version field (v2 format)"
# === FUZZ TESTS ===
- id: GD-19
type: command
pattern: "find . -name '*_test.go' -not -path './vendor/*' | xargs grep -l 'func Fuzz' 2>/dev/null | head -1 | grep -q . || true"
severity: info
desc: "Project should have fuzz test functions (Fuzz*) for parser/input code"
# === MAKEFILE ALL TARGET ===
- id: GD-23
type: contains
target: Makefile
pattern: "all:"
severity: info
desc: "Makefile should have an 'all' target combining lint, test, and build"
# === COVERPROFILE ===
- id: GD-24
type: regex
target: Makefile
pattern: "-coverprofile"
severity: info
desc: "Makefile test target should generate coverage profile"
# === GIT HOOKS ===
- id: GD-26
type: file_exists
target: lefthook.yml
severity: info
desc: "lefthook.yml should exist for pre-commit/pre-push git hooks"
# === ERROR VARIABLE NAMING ===
- id: GD-25
type: command
pattern: "! grep -rlE 'var [A-Z]\\w+ = errors\\.New' --include='*.go' . | xargs grep -vl 'var Err' | head -1 | grep -q ."
severity: info
desc: "Exported error variables should use Err prefix (e.g., ErrInvalidInput)"
llm_reviews:
- id: GD-20
domain: go-quality
prompt: |
Review the Go project for code quality and best practices:
1. Does go.mod use a reasonable Go version (not outdated)?
2. Is the golangci-lint config comprehensive (errcheck, govet, staticcheck enabled)?
3. Does the Makefile provide standard targets (test, build, lint, all)?
4. Are error messages lowercase without punctuation (Go convention)?
5. Is error wrapping used consistently with fmt.Errorf and %w?
6. Are acronyms properly cased (ID, URL, HTTP not Id, Url, Http)?
severity: warning
desc: "Go code quality and convention adherence"
- id: GD-21
domain: go-quality
prompt: |
Review the Go project for testing completeness:
1. Do packages with business logic have corresponding test files?
2. Are table-driven tests used for functions with multiple input scenarios?
3. Do test helpers call t.Helper()?
4. Is t.Parallel() used where safe?
5. Are tests independent (no shared mutable state between tests)?
6. Is test coverage reasonable for critical paths?
severity: warning
desc: "Go testing patterns and coverage"
- id: GD-22
domain: go-quality
prompt: |
Review the Go project for security and resilience:
1. Are HTTP requests made with context (no noctx violations)?
2. Are HTTP response bodies always closed?
3. Is graceful shutdown implemented for servers?
4. Are goroutines properly managed (no leaks)?
5. Is context propagation consistent throughout?
severity: info
desc: "Go security and resilience patterns"
[
{
"name": "setup_new_go_project",
"prompt": "Set up a new Go project with testing, linting, and a Makefile",
"assertions": [
{
"type": "content",
"pattern": "(go mod init|go\\.mod|Makefile)"
},
{
"type": "content",
"pattern": "(golangci-lint|go test|go vet)"
}
]
},
{
"name": "add_ldap_integration",
"prompt": "Add LDAP integration to this Go project for user authentication",
"assertions": [
{
"type": "content",
"pattern": "(ldap|go-ldap|LDAP|Active Directory)"
},
{
"type": "content",
"pattern": "(Bind|Search|TLS|connection pool)"
}
]
},
{
"name": "setup_cron_scheduler",
"prompt": "Implement a cron-based job scheduler in Go with named jobs and resilience",
"assertions": [
{
"type": "content",
"pattern": "(go-cron|netresearch/go-cron|cron\\.New)"
},
{
"type": "content",
"pattern": "(AddFunc|AddJob|WithName|RetryWithBackoff|RetryOnError)"
}
]
},
{
"name": "docker_client_integration",
"prompt": "Write a Go Docker client that executes containers with buffer pooling",
"assertions": [
{
"type": "content",
"pattern": "(go-dockerclient|fsouza|docker\\.NewClient)"
},
{
"type": "content",
"pattern": "(sync\\.Pool|bufferPool|Buffer)"
}
]
},
{
"name": "retry_with_backoff",
"prompt": "Implement exponential backoff retry logic in Go with jitter and context cancellation",
"assertions": [
{
"type": "content",
"pattern": "(RetryConfig|MaxAttempts|BackoffFactor|Jitter)"
},
{
"type": "content",
"pattern": "(context\\.Done|ctx\\.Err|time\\.After)"
}
]
},
{
"name": "graceful_shutdown",
"prompt": "Implement graceful shutdown for a Go HTTP server with signal handling",
"assertions": [
{
"type": "content",
"pattern": "(signal\\.Notify|os\\.Signal|SIGTERM|SIGINT)"
},
{
"type": "content",
"pattern": "(Shutdown|context\\.WithTimeout|srv\\.Shutdown)"
}
]
},
{
"name": "table_driven_tests",
"prompt": "Write table-driven tests for a Go function that parses user input strings",
"assertions": [
{
"type": "content",
"pattern": "(tests?\\s*:?=?\\s*\\[?\\]?struct|tc\\.|tt\\.|test\\.name)"
},
{
"type": "content",
"pattern": "(t\\.Run|t\\.Parallel|t\\.Helper)"
}
]
},
{
"name": "fuzz_testing",
"prompt": "Add fuzz tests to a Go URL parser to find edge cases and security issues",
"assertions": [
{
"type": "content",
"pattern": "(func Fuzz|f\\.Fuzz|f\\.Add|testing\\.F)"
},
{
"type": "content",
"pattern": "(go test.*-fuzz|fuzz\\s+build\\s+tag|corpus)"
}
]
},
{
"name": "mutation_testing_setup",
"prompt": "Set up mutation testing for a Go project to measure test quality",
"assertions": [
{
"type": "content",
"pattern": "(gremlins|go-gremlins|\\.gremlins\\.yaml)"
},
{
"type": "content",
"pattern": "(mutation.*score|unleash|mutant)"
}
]
},
{
"name": "golangci_lint_v2_config",
"prompt": "Create a golangci-lint v2 configuration for a production Go project",
"assertions": [
{
"type": "content",
"pattern": "(version:\\s*\"?2|golangci-lint.*v2)"
},
{
"type": "content",
"pattern": "(errcheck|staticcheck|govet|bodyclose|noctx)"
}
]
},
{
"name": "error_handling_conventions",
"prompt": "Review this Go code for error handling: `var InvalidInput = errors.New(\"Invalid input.\")` and `return fmt.Errorf(\"Failed to process\")`",
"assertions": [
{
"type": "content",
"pattern": "(lowercase|no punctuation|ErrInvalidInput|Err\\s*prefix)"
},
{
"type": "content",
"pattern": "(%w|errors\\.New|fmt\\.Errorf|wrap)"
}
]
},
{
"name": "slog_structured_logging",
"prompt": "Migrate a Go project from logrus to structured logging with log/slog",
"assertions": [
{
"type": "content",
"pattern": "(log/slog|slog\\.New|slog\\.Logger|TextHandler|JSONHandler)"
},
{
"type": "content",
"pattern": "(LevelVar|AddSource|slog\\.Info|slog\\.Error)"
}
]
},
{
"name": "api_design_functional_options",
"prompt": "Design a Go API using the functional options pattern for a configurable HTTP client",
"assertions": [
{
"type": "content",
"pattern": "(Option|func\\(.*\\)|With\\w+|functional option)"
},
{
"type": "content",
"pattern": "(WithTimeout|WithRetry|apply|opts)"
}
]
},
{
"name": "makefile_standard_targets",
"prompt": "Create a Makefile for a Go project with standard CI targets",
"assertions": [
{
"type": "content",
"pattern": "(test:|build:|lint:|all:)"
},
{
"type": "content",
"pattern": "(-race|-coverprofile|govulncheck|-trimpath)"
}
]
},
{
"name": "go_modernization",
"prompt": "Modernize a Go 1.20 codebase to use Go 1.26 features like generics and go fix",
"assertions": [
{
"type": "content",
"pattern": "(go fix|modernize|errors\\.AsType)"
},
{
"type": "content",
"pattern": "(any|interface\\{\\}.*any|generics|\\[T)"
}
]
},
{
"name": "package_structure",
"prompt": "Design the package structure for a Go microservice with HTTP API, job scheduler, and external integrations",
"assertions": [
{
"type": "content",
"pattern": "(cmd/|core/|internal/|web/|config/)"
},
{
"type": "content",
"pattern": "(main\\.go|handler|middleware|domain)"
}
]
},
{
"name": "context_propagation",
"prompt": "Review this Go code for proper context usage: HTTP handlers that spawn goroutines without passing context",
"assertions": [
{
"type": "content",
"pattern": "(context\\.Context|ctx|context\\.Background|context\\.WithCancel)"
},
{
"type": "content",
"pattern": "(noctx|propagat|goroutine|request.*context)"
}
]
},
{
"name": "setup_lefthook",
"prompt": "Set up git hooks for a Go project using lefthook with pre-commit and pre-push stages",
"assertions": [
{
"type": "content",
"pattern": "(lefthook|lefthook\\.yml)"
},
{
"type": "content",
"pattern": "(pre-commit|pre-push|golangci-lint|gofmt|go vet)"
}
]
},
{
"name": "vulnerability_scanning",
"prompt": "A Go project's govulncheck reports stdlib vulnerabilities. How do I fix them?",
"assertions": [
{
"type": "content",
"pattern": "(govulncheck|vuln\\.go\\.dev|go\\.mod)"
},
{
"type": "content",
"pattern": "(go mod tidy|go X\\.Y\\.Z|fix version|patch)"
}
]
},
{
"name": "config_management",
"prompt": "Implement configuration management for a Go service with defaults, file, env vars, and flags",
"assertions": [
{
"type": "content",
"pattern": "(defaults|config.*file|env|flag)"
},
{
"type": "content",
"pattern": "(precedence|override|os\\.Getenv|viper|kong)"
}
]
},
{
"name": "integration_test_docker",
"prompt": "Write integration tests for a Go service that depend on a PostgreSQL database using Docker",
"assertions": [
{
"type": "content",
"pattern": "(integration|build tag|testcontainers|docker)"
},
{
"type": "content",
"pattern": "(TestMain|setup|teardown|t\\.Cleanup)"
}
]
},
{
"name": "awesome_go_submission",
"prompt": "Prepare a pull request to add a Go library to the awesome-go list",
"assertions": [
{
"type": "content",
"pattern": "(Forge link|pkg\\.go\\.dev|goreportcard)"
},
{
"type": "content",
"pattern": "(alphabetical|non-promotional|ends with a period|single item|one (package|item)|exact project name)"
}
]
}
]
Go API Design Patterns
Bitmask Options Pattern
The bitmask pattern allows combining multiple options into a single value, enabling flexible API configuration.
Defining Options
// ParseOption represents parser configuration flags.
type ParseOption int
const (
Second ParseOption = 1 << iota // Enable seconds field
SecondOptional // Seconds field is optional
Minute // Enable minutes field
Hour // Enable hours field
Dom // Day of month
Month // Month field
Dow // Day of week
DowOptional // Day of week is optional
Descriptor // Allow @hourly, @daily, etc.
Year // Year field support
Hash // Jenkins-style H expressions
)Using Combined Options
// Users can combine options with bitwise OR
parser := NewParser(Minute | Hour | Dom | Month | Dow | Descriptor)
// Check if option is enabled
func (p Parser) hasOption(opt ParseOption) bool {
return p.options&opt != 0
}
// Common presets
const (
StandardParser = Minute | Hour | Dom | Month | Dow | Descriptor
ExtendedParser = Second | Minute | Hour | Dom | Month | Dow | Descriptor
)Variadic Options Alternative
For simpler APIs, use variadic functional options:
// Option is a function that configures Parser
type Option func(*Parser)
// WithSeconds enables seconds field
func WithSeconds() Option {
return func(p *Parser) {
p.parseSeconds = true
}
}
// WithHash enables hash expressions with a key
func WithHash(key string) Option {
return func(p *Parser) {
p.hashEnabled = true
p.hashKey = key
}
}
// Usage
parser := NewParser(
WithSeconds(),
WithHash("my-job"),
)Comparison
| Pattern | Best For | Example |
|---|---|---|
| Bitmask | Many boolean flags, performance-critical | `Minute \ |
| Functional Options | Complex configuration, optional params | WithTimeout(30*time.Second) |
| Builder | Step-by-step construction, validation | NewBuilder().WithX().WithY().Build() |
Functional Options Pattern
For APIs with many optional parameters:
// Config holds parser configuration
type Config struct {
timeout time.Duration
location *time.Location
hashKey string
maxJobs int
}
// Option configures the parser
type Option func(*Config)
// WithTimeout sets operation timeout
func WithTimeout(d time.Duration) Option {
return func(c *Config) {
c.timeout = d
}
}
// WithLocation sets timezone
func WithLocation(loc *time.Location) Option {
return func(c *Config) {
c.location = loc
}
}
// WithHashKey enables hash expressions
func WithHashKey(key string) Option {
return func(c *Config) {
c.hashKey = key
}
}
// NewParser creates a parser with options
func NewParser(opts ...Option) *Parser {
// Start with defaults
cfg := &Config{
timeout: 30 * time.Second,
location: time.Local,
maxJobs: 100,
}
// Apply options
for _, opt := range opts {
opt(cfg)
}
return &Parser{config: cfg}
}
// Usage
parser := NewParser(
WithTimeout(1*time.Minute),
WithLocation(time.UTC),
WithHashKey("my-job"),
)Builder Pattern with Chaining
For complex object construction with validation:
// ParserBuilder constructs Parser instances
type ParserBuilder struct {
options ParseOption
hashKey string
location *time.Location
err error
}
// NewParserBuilder starts building a parser
func NewParserBuilder() *ParserBuilder {
return &ParserBuilder{
location: time.Local,
}
}
// WithOptions sets parsing options
func (b *ParserBuilder) WithOptions(opts ParseOption) *ParserBuilder {
b.options = opts
return b
}
// WithHashKey enables and sets hash key
func (b *ParserBuilder) WithHashKey(key string) *ParserBuilder {
if key == "" {
b.err = errors.New("hash key cannot be empty")
return b
}
b.options |= Hash
b.hashKey = key
return b
}
// WithLocation sets timezone
func (b *ParserBuilder) WithLocation(loc *time.Location) *ParserBuilder {
if loc == nil {
b.err = errors.New("location cannot be nil")
return b
}
b.location = loc
return b
}
// Build creates the parser or returns an error
func (b *ParserBuilder) Build() (*Parser, error) {
if b.err != nil {
return nil, b.err
}
// Validate configuration
if b.options&Hash != 0 && b.hashKey == "" {
return nil, errors.New("hash option requires hash key")
}
return &Parser{
options: b.options,
hashKey: b.hashKey,
location: b.location,
}, nil
}
// Usage
parser, err := NewParserBuilder().
WithOptions(Minute | Hour | Dom | Month | Dow).
WithHashKey("my-job").
WithLocation(time.UTC).
Build()Method Chaining for Parser Configuration
Combine builder-style configuration with immediate use:
// Parser supports method chaining
type Parser struct {
options ParseOption
hashKey string
}
// NewParser creates a parser with base options
func NewParser(opts ParseOption) Parser {
return Parser{options: opts}
}
// WithHashKey returns a new parser with hash support
func (p Parser) WithHashKey(key string) Parser {
return Parser{
options: p.options | Hash,
hashKey: key,
}
}
// Parse parses a cron expression
func (p Parser) Parse(spec string) (Schedule, error) {
// Implementation
}
// ParseWithHashKey parses with explicit hash key
func (p Parser) ParseWithHashKey(spec, key string) (Schedule, error) {
return p.WithHashKey(key).Parse(spec)
}
// Usage - multiple styles
parser := NewParser(Minute | Hour | Dom | Month | Dow | Descriptor)
// Style 1: Method chaining
schedule, err := parser.WithHashKey("job1").Parse("H * * * *")
// Style 2: Direct method
schedule, err := parser.ParseWithHashKey("H * * * *", "job1")
// Style 3: Reusable configured parser
hashParser := parser.WithHashKey("default-job")
schedule, err := hashParser.Parse("H H * * *")Error Design
Custom Error Types
// ValidationError provides detailed validation feedback
type ValidationError struct {
Message string
Field string // Which field caused the error
Value string // The invalid value
}
func (e *ValidationError) Error() string {
if e.Field != "" {
return e.Message + " in " + e.Field + ": " + e.Value
}
return e.Message
}
// Sentinel errors for common cases
var (
ErrEmptySpec = &ValidationError{Message: "empty spec string"}
ErrInvalidFormat = &ValidationError{Message: "invalid format"}
)
// Usage with errors.Is/AsType (Go 1.26+)
if errors.Is(err, ErrEmptySpec) {
// Handle empty spec
}
if validationErr, ok := errors.AsType[*ValidationError](err); ok {
fmt.Printf("Field %s is invalid: %s\n", validationErr.Field, validationErr.Value)
}Error Strings Convention (ST1005)
// BAD - Capitalized, has punctuation
return errors.New("Invalid input provided.")
// GOOD - Lowercase, no punctuation
return errors.New("invalid input provided")
// BAD - Starts with uppercase
return fmt.Errorf("Failed to parse: %w", err)
// GOOD - Starts with lowercase
return fmt.Errorf("failed to parse: %w", err)Interface Design
Small, Focused Interfaces
// BAD - Kitchen sink interface
type Scheduler interface {
AddJob(spec string, cmd func()) (EntryID, error)
RemoveJob(id EntryID)
Start()
Stop()
Running() bool
Entries() []Entry
Location() *time.Location
// ... many more methods
}
// GOOD - Focused interfaces
type JobAdder interface {
AddJob(spec string, cmd func()) (EntryID, error)
}
type JobRemover interface {
RemoveJob(id EntryID)
}
type Lifecycle interface {
Start()
Stop()
Running() bool
}
// Composed when needed
type Scheduler interface {
JobAdder
JobRemover
Lifecycle
}Accept Interfaces, Return Structs
// GOOD - Accept interface for flexibility
func ProcessSchedule(s Schedule) error {
next := s.Next(time.Now())
// ...
}
// GOOD - Return concrete type for usability
func Parse(spec string) (*SpecSchedule, error) {
// Users get full type with all methods
}Validation API Pattern
Provide both quick validation and detailed analysis:
// Quick validation - returns error or nil
func ValidateSpec(spec string, opts ...ParseOption) error {
parser := getParserForOptions(opts)
_, err := parser.Parse(spec)
return err
}
// Detailed analysis - returns rich result
type SpecAnalysis struct {
Valid bool
Error error
NextRun time.Time
Location *time.Location
Fields map[string]string
IsDescriptor bool
Interval time.Duration
Schedule Schedule
}
func AnalyzeSpec(spec string, opts ...ParseOption) SpecAnalysis {
result := SpecAnalysis{Fields: make(map[string]string)}
parser := getParserForOptions(opts)
schedule, err := parser.Parse(spec)
if err != nil {
result.Error = err
return result
}
result.Valid = true
result.Schedule = schedule
result.NextRun = schedule.Next(time.Now())
// ... populate other fields
return result
}
// Usage
if err := ValidateSpec(userInput); err != nil {
return fmt.Errorf("invalid cron: %w", err)
}
// Or for detailed feedback
analysis := AnalyzeSpec(userInput)
if !analysis.Valid {
log.Printf("Invalid: %v", analysis.Error)
} else {
log.Printf("Next run: %v, Fields: %v", analysis.NextRun, analysis.Fields)
}Enum & Status Type Safety
Defensive handling for enum / status types so invalid values cannot be silently mishandled:
- Add a
Valid()method that returnsfalsefor unknown values. - Always include a
defaultcase inswitchstatements over the type. - Write tests for unknown/zero values, not just the known ones.
type Policy int
const (
PolicyUnknown Policy = iota // zero value — explicitly invalid, so an
PolicyRetry // uninitialized Policy is rejected by Valid()
PolicySkip
PolicyFail
)
// Valid reports whether p is a known policy. Values from deserialized data,
// API input, or a future enum addition that isn't handled here return false.
func (p Policy) Valid() bool {
switch p {
case PolicyRetry, PolicySkip, PolicyFail:
return true
default:
return false
}
}
func (p Policy) String() string {
switch p {
case PolicyUnknown:
return "unknown"
case PolicyRetry:
return "retry"
case PolicySkip:
return "skip"
case PolicyFail:
return "fail"
default:
return fmt.Sprintf("Policy(%d)", int(p)) // never panic on unknown
}
}Why: the zero value of an int-backed enum is always a valid int but may be a meaningless policy. A Valid() guard at trust boundaries (config load, API input, DB read) turns a silent wrong-branch bug into an explicitly rejected value.
func TestPolicy_Valid_RejectsUnknown(t *testing.T) {
if Policy(0).Valid() { // the zero value must be rejected
t.Error("zero-value Policy(0) should be invalid")
}
if Policy(99).Valid() {
t.Error("Policy(99) should be invalid")
}
}Go Architecture Patterns
Package Structure
Standard Layout
project/
├── cmd/ # Entry points
│ ├── server/
│ │ └── main.go
│ └── cli/
│ └── main.go
├── core/ # Core business logic
│ ├── job.go # Domain types
│ ├── scheduler.go # Core orchestration
│ ├── resilient_job.go # Wrapper with retry
│ └── docker_client.go # External integrations
├── cli/ # CLI commands
│ ├── daemon.go
│ ├── validate.go
│ └── config.go
├── web/ # HTTP layer
│ ├── server.go
│ ├── handlers.go
│ ├── middleware.go
│ └── auth.go
├── config/ # Configuration
│ ├── config.go
│ ├── validator.go
│ └── sanitizer.go
├── middlewares/ # Cross-cutting concerns
│ ├── logging.go
│ ├── metrics.go
│ └── notifications.go
├── metrics/ # Observability
│ └── prometheus.go
├── internal/ # Private packages
│ └── helpers/
└── test/ # Test utilities
├── fixtures/
└── helpers.goPackage Responsibilities
| Package | Purpose | Dependencies |
|---|---|---|
cmd/ | Entry points, wire up | All |
core/ | Business logic | Minimal |
cli/ | User interface | core, config |
web/ | HTTP API | core, config |
config/ | Configuration | None |
middlewares/ | Cross-cutting | core |
internal/ | Private helpers | None |
Job Abstraction Hierarchy
Interface Definition
// BareJob defines the minimal job interface
type BareJob interface {
GetName() string
GetSchedule() string
Run(ctx context.Context) error
}
// JobConfig contains common job configuration
type JobConfig struct {
Name string
Schedule string
Command []string
Environment map[string]string
Timeout time.Duration
}Implementation Patterns
// ExecJob executes in a running container
type ExecJob struct {
JobConfig
Container string
Client DockerClient
}
func (j *ExecJob) Run(ctx context.Context) error {
return j.Client.ExecInContainer(ctx, j.Container, j.Command)
}
// RunJob starts a new container
type RunJob struct {
JobConfig
Image string
Client DockerClient
}
func (j *RunJob) Run(ctx context.Context) error {
containerID, err := j.Client.CreateContainer(ctx, j.Image, j.Command)
if err != nil {
return err
}
defer j.Client.RemoveContainer(ctx, containerID)
return j.Client.StartContainer(ctx, containerID)
}
// LocalJob executes on the host
type LocalJob struct {
JobConfig
}
func (j *LocalJob) Run(ctx context.Context) error {
cmd := exec.CommandContext(ctx, j.Command[0], j.Command[1:]...)
return cmd.Run()
}State Mutation Completeness
When an operation changes an object's state, update all tracking fields in the same place — not just the one you came to change. Partial updates leave the object internally inconsistent and produce bugs that are hard to trace back to their cause.
// After a run, update every field that describes "what happened",
// on both the success and failure paths:
func (j *Job) recordRun(start time.Time, err error) {
j.LastRunTime = start
j.LastDuration = time.Since(start)
j.RunCount++
j.LastError = err
if err != nil {
j.FailureCount++
j.Status = StatusFailed
} else {
j.Status = StatusCompleted
}
}Anti-pattern: bumping RunCount but forgetting LastError/Status, so a failed run still reports as "completed". Keep the mutation in one method so the full set is always updated together.
Resilient Wrapper
// ResilientJob wraps any BareJob with retry logic
type ResilientJob struct {
Job BareJob
MaxRetries int
RetryDelay time.Duration
OnError func(error, int)
}
func (r *ResilientJob) Run(ctx context.Context) error {
var lastErr error
for attempt := 1; attempt <= r.MaxRetries; attempt++ {
if err := r.Job.Run(ctx); err == nil {
return nil
} else {
lastErr = err
if r.OnError != nil {
r.OnError(err, attempt)
}
if attempt < r.MaxRetries {
select {
case <-time.After(r.RetryDelay):
case <-ctx.Done():
return ctx.Err()
}
}
}
}
return fmt.Errorf("job failed after %d attempts: %w", r.MaxRetries, lastErr)
}Configuration Management
5-Layer Precedence
type Config struct {
// Layer 1: Built-in defaults (struct tags)
LogLevel string `default:"info"`
PollInterval int `default:"10"`
// Layer 2: File configuration
ConfigFile string `flag:"config" default:"/etc/app/config.ini"`
// Layer 3: External sources (Docker labels, K8s)
// Loaded dynamically
// Layer 4: CLI flags
Verbose bool `flag:"verbose" short:"v"`
// Layer 5: Environment variables (highest priority)
// PREFIX_LOG_LEVEL, PREFIX_POLL_INTERVAL
}
func LoadConfig() (*Config, error) {
cfg := &Config{}
// 1. Apply defaults
applyDefaults(cfg)
// 2. Load from config file
if err := loadFromFile(cfg, cfg.ConfigFile); err != nil {
log.Warn("Config file not found, using defaults")
}
// 3. Load from external sources (if applicable)
loadFromExternalSources(cfg)
// 4. Parse CLI flags
parseFlags(cfg)
// 5. Override with environment variables
loadFromEnv(cfg, "APP")
return cfg, validate(cfg)
}Dynamic Configuration Reloading
type ConfigWatcher struct {
path string
config atomic.Value
onChange func(*Config)
}
func (w *ConfigWatcher) Watch(ctx context.Context) {
watcher, _ := fsnotify.NewWatcher()
watcher.Add(w.path)
for {
select {
case event := <-watcher.Events:
if event.Op&fsnotify.Write == fsnotify.Write {
if cfg, err := LoadConfigFromFile(w.path); err == nil {
w.config.Store(cfg)
if w.onChange != nil {
w.onChange(cfg)
}
}
}
case <-ctx.Done():
return
}
}
}Middleware Chain Pattern
Implementation
type Middleware func(Job) Job
type MiddlewareChain struct {
middlewares []Middleware
}
func (c *MiddlewareChain) Use(m Middleware) {
c.middlewares = append(c.middlewares, m)
}
func (c *MiddlewareChain) Wrap(job Job) Job {
wrapped := job
for i := len(c.middlewares) - 1; i >= 0; i-- {
wrapped = c.middlewares[i](wrapped)
}
return wrapped
}Common Middlewares
// Logging middleware (see references/logging.md for comprehensive slog patterns)
func WithLogging(logger *slog.Logger) Middleware {
return func(next Job) Job {
return JobFunc(func(ctx context.Context) error {
start := time.Now()
logger.Info("Starting job", "job", next.GetName())
err := next.Run(ctx)
attrs := []any{
"job", next.GetName(),
"duration", time.Since(start),
}
if err != nil {
logger.Error("Job failed", append(attrs, "error", err)...)
} else {
logger.Info("Job completed", attrs...)
}
return err
})
}
}
// Metrics middleware
func WithMetrics(registry *prometheus.Registry) Middleware {
duration := prometheus.NewHistogramVec(...)
counter := prometheus.NewCounterVec(...)
return func(next Job) Job {
return JobFunc(func(ctx context.Context) error {
timer := prometheus.NewTimer(duration.WithLabelValues(next.GetName()))
defer timer.ObserveDuration()
err := next.Run(ctx)
if err != nil {
counter.WithLabelValues(next.GetName(), "error").Inc()
} else {
counter.WithLabelValues(next.GetName(), "success").Inc()
}
return err
})
}
}
// Notification middleware
func WithSlackNotification(webhookURL string, onlyOnError bool) Middleware {
return func(next Job) Job {
return JobFunc(func(ctx context.Context) error {
err := next.Run(ctx)
if err != nil || !onlyOnError {
sendSlackNotification(webhookURL, next.GetName(), err)
}
return err
})
}
}Scheduler Architecture
See also: references/cron-scheduling.md for comprehensive go-cron patterns.Core Loop Pattern
Use `github.com/netresearch/go-cron` — it has built-in named jobs, runtime updates, per-entry context, and resilience wrappers. No need to maintain your own job registry:
type Scheduler struct {
cron *cron.Cron
}
func NewScheduler(ctx context.Context) *Scheduler {
return &Scheduler{
cron: cron.New(
cron.WithContext(ctx), // Parent context for graceful shutdown
cron.WithChain(
cron.Recover(logger), // Catch panics
cron.SkipIfStillRunning(logger),
),
),
}
}
func (s *Scheduler) AddJob(name, schedule string, job cron.Job) (cron.EntryID, error) {
return s.cron.AddJob(schedule, job,
cron.WithName(name), // Built-in O(1) lookup by name
)
}
func (s *Scheduler) Start() {
s.cron.Start()
}
func (s *Scheduler) Stop() {
s.cron.StopAndWait() // Block until all running jobs finish
}Dynamic Job Management
go-cron provides atomic operations — no manual remove+re-add or external map tracking:
// Atomic create-or-update by name
func (s *Scheduler) UpsertJob(name, schedule string, job cron.Job) (cron.EntryID, error) {
return s.cron.UpsertJob(schedule, job, cron.WithName(name))
}
// Graceful replacement of long-running jobs
func (s *Scheduler) ReplaceJob(name, schedule string, job cron.Job) (cron.EntryID, error) {
s.cron.WaitForJobByName(name) // Wait for current execution to finish
return s.cron.UpsertJob(schedule, job, cron.WithName(name))
}
// Remove and list use built-in name support
func (s *Scheduler) RemoveJob(name string) {
s.cron.RemoveByName(name)
}
func (s *Scheduler) ListJobs() []cron.Entry {
return s.cron.Entries()
}Web API Architecture
Standard Endpoints
// API endpoint structure
GET /api/jobs // List all jobs
GET /api/jobs/{name} // Get job details
POST /api/jobs // Create new job
PUT /api/jobs/{name} // Update job
DELETE /api/jobs/{name} // Delete job
POST /api/jobs/{name}/run // Trigger job manually
GET /api/jobs/{name}/history // Execution history
GET /health // Health check
GET /metrics // Prometheus metricsHandler Pattern
type Handler struct {
scheduler *Scheduler
logger *slog.Logger
}
func (h *Handler) ListJobs(w http.ResponseWriter, r *http.Request) {
jobs := h.scheduler.ListJobs()
json.NewEncoder(w).Encode(jobs)
}
func (h *Handler) TriggerJob(w http.ResponseWriter, r *http.Request) {
name := chi.URLParam(r, "name")
job, err := h.scheduler.GetJob(name)
if err != nil {
http.Error(w, "Job not found", http.StatusNotFound)
return
}
go func() {
if err := job.Run(context.Background()); err != nil {
h.logger.Error("Manual job execution failed", "job", name, "error", err)
}
}()
w.WriteHeader(http.StatusAccepted)
}Error Handling Patterns
Domain Errors
// Custom error types
type JobNotFoundError struct {
Name string
}
func (e *JobNotFoundError) Error() string {
return fmt.Sprintf("job not found: %s", e.Name)
}
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation failed for %s: %s", e.Field, e.Message)
}
// Error checking (Go 1.26+: use errors.AsType for type-safe extraction)
func HandleJob(name string) error {
job, err := scheduler.GetJob(name)
if err != nil {
if _, ok := errors.AsType[*JobNotFoundError](err); ok {
// Handle not found
return nil
}
return err
}
return job.Run(context.Background())
}Sentinel Errors
var (
ErrJobNotFound = errors.New("job not found")
ErrInvalidSchedule = errors.New("invalid schedule expression")
ErrContainerDied = errors.New("container died unexpectedly")
)
func GetJob(name string) (Job, error) {
job, ok := jobs[name]
if !ok {
return nil, fmt.Errorf("%w: %s", ErrJobNotFound, name)
}
return job, nil
}Submitting a Go Project to awesome-go
How to get a Go library accepted into avelino/awesome-go on the first try. The list is curated and gated by an automated CI suite plus maintainer review; most rejections are mechanical (PR-body format, alphabetical order) rather than quality. This doc captures the exact format the CI parses and the gotchas that aren't in the contributing guide.
Quick index
| Piece | Where |
|---|---|
| Is the project eligible? | Eligibility |
| What the CI validates automatically | Automated checks |
| The single biggest rejection cause | PR body |
| README entry format + name collisions | README entry |
| End-to-end submission steps | Process |
| Reading the bot's report | After opening |
Eligibility
Verify all of these before starting — most are blocking CI checks:
- ≥ 5 months of repository history (since first commit). Hard gate; nothing else matters until this passes.
- Open-source license — any OSI-approved license. No license = all-rights-reserved = ineligible, even if the repo is public.
- `go.mod` at repo root and ≥ 1 SemVer tag (
vX.Y.Z). - `pkg.go.dev` page is live for the module (visit it once /
GOPROXY=https://proxy.golang.org go get <module>@<tag>to trigger indexing). - Go Report Card grade A-, A, or A+ — visit
goreportcard.com/report/<module>and click Refresh so it reflects your latest tag (the score caches on an old version otherwise). - A reachable coverage-service link (Codecov/Coveralls) — a README badge is not enough; the bot fetches the URL.
- Category must have ≥ 3 items (only relevant if creating a new category).
Automated checks
On PR open, a github-actions bot posts a sticky "Automated Quality Checks" + "PR Diff Validation" report. Know which are blocking:
Blocking (PR cannot merge): repo accessible · go.mod present · SemVer tag · pkg.go.dev reachable · Go Report Card ≥ A- · required links present in PR body · single item per PR · README link matches forge link · description ends with a period · alphabetical order · no duplicate link · entry-format regex · category ≥ 3.
Warnings only: OSS license detected · 5-month maturity · CI/CD present · README present · coverage link reachable · link text matches repo name · non-promotional description · only README.md changed.
The repo-wideRunning testjob (TestAlpha,TestDuplicatedLinks) fails on almost every PR becausemainitself carries pre-existing alphabetical drift and duplicate links in unrelated categories. If your category and project name do not appear in that log, the failure is not yours — maintainers merge despite it. Don't try to "fix" it in your PR.
PR body (#1 rejection cause)
The most common rejection is a PR body the CI can't parse. It does not read prose; it extracts the four required links from the template's labeled lines. Fill the current template and put the visible URL on each line (not inside an HTML comment):
## Required links
- [x] Forge link (github.com, gitlab.com, etc): https://github.com/<org>/<project>
- [x] pkg.go.dev: https://pkg.go.dev/github.com/<org>/<project>
- [x] goreportcard.com: https://goreportcard.com/report/github.com/<org>/<project>
- [x] Coverage service link (codecov, coveralls, etc.): https://app.codecov.io/gh/<org>/<project>
## Pre-submission checklist
- [x] I have read the Contribution Guidelines
- [x] I have read the Quality Standards
## Repository requirements
- [x] `go.mod` file and SemVer release (vX.Y.Z)
- [x] Open source license (<LICENSE>)
- [x] pkg.go.dev link in docs
- [x] goreportcard link (grade A- or better)
- [x] Coverage service link
- [x] Continuous integration (GitHub Actions)
## Pull Request content
- [x] Adds only one package.
- [x] Added in alphabetical order.
- [x] Link text is the exact project name.
- [x] Description is clear, concise, non-promotional, and ends with a period.
- [x] The link in README.md matches the forge link above.Keep it concise — do not add a marketing "About" section; the non-promotional check scans the whole body. Fetch the live template first in case it changed (the raw media type avoids base64, which is non-portable across GNU/BSD): gh api repos/avelino/awesome-go/contents/.github/PULL_REQUEST_TEMPLATE.md -H 'Accept: application/vnd.github.raw'.
README entry
One bullet, in the target category, alphabetical by visible link text (case-insensitive), link text = exact project name, description non-promotional and ending with a period:
- [<project>](https://github.com/<org>/<project>) - Concise factual description ending with a period.The non-promotional linter rejects superlatives ("blazing fast", "powerful", "production-grade", "world-class"). State capabilities, not adjectives.
Same-name collisions: a different repo with the same project name may already be listed (e.g. two go-crons). This is allowed — the duplicate check is URL-based, and the list already carries cases like two scheduler entries. Handle it by:
- Placing your entry alphabetically adjacent to the existing one.
- Optionally using `org/project` as link text to disambiguate — precedented in-list (e.g.
tickstem/cron) and a likely reviewer request. This still passes the blocking entry-format regex; it only trips the non-blocking "link text matches repo name" warning, so it won't fail CI. - Not bundling a removal of a stale/abandoned same-name entry into your add PR — the one-item-per-PR rule forbids it; file removals separately (and consider whether it's worth the friction).
Process
# 1. Fork (no clone) + shallow-clone your fork — the repo history is large
gh repo fork avelino/awesome-go --clone=false
git clone --depth 1 --single-branch https://github.com/<you>/awesome-go.git
cd awesome-go && git checkout -b add-<project>
# 2. Edit README.md: add the single bullet in the right category, alphabetically.
# Touch ONLY that one line — any unrelated diff hunk gets the PR rejected.
# 3. Verify the diff is exactly one insertion
git diff --stat # expect: README.md | 1 +
# 4. Clean commit (no attribution/co-author trailers), push
git commit -am "Add <project> to <Category>"
git push -u origin add-<project>
# 5. Save the PR body (the template under "PR body" above) to pr-body.md,
# then open the PR
gh pr create --repo avelino/awesome-go --base main \
--head <you>:add-<project> \
--title "Add <project> to <Category>" --body-file pr-body.mdAfter opening
- Wait for the bot's sticky report; fix any blocking red check and push to the same branch.
- "Detect PR type" should pass as a package PR; "Skip quality checks (non-package PR)" showing
skippingis normal. - Ignore the legacy
Running testfailure unless your project/category appears in its log (see above). - awesome-go has a large backlog; merges can take weeks. Don't open duplicate PRs or ping aggressively.
Contracts & Invariants
Encode preconditions, postconditions, and invariants as runtime checks in the code path. Treat them as the bridge between a spec sentence and the tests that verify it.
When to Use
Use contracts where invariants are crystalline and a violation is a bug, not user error:
- State machines (workflows, consensus, session lifecycle, leases)
- Protocols (Paxos/Raft, two-phase commit, request/response correlation)
- Concurrency primitives (locks, queues, pools, supervised goroutines)
- Money, quantities, identifiers (never-negative, monotonic, bounded ranges)
- Data migrations (row count preserved, no orphaned references)
- Authorization boundaries (see security-audit-skill cross-link)
When NOT to Use
- Plain CRUD glue, HTTP handler plumbing, config parsing — use input validation, not contracts
- Anything driven by external input — that is validation (return error), not an invariant (panic)
- Frontend/template code, doc generation, scripts
A useful test: would a violation indicate the program is in an impossible state? Yes → contract. No → validation.
Contract Types
| Kind | Where | If violated |
|---|---|---|
| Precondition | First lines of a function | Caller bug — panic |
| Postcondition | Just before return | Implementation bug — panic |
| Invariant | At every public entry/exit of a stateful type | Either — panic |
External-input checks are separate: return a typed error, do not panic.
Go Idioms
Doc convention
Document contracts inline so reviewers (and AI) see intent next to code:
// Withdraw debits amount from the account.
//
// Contract:
// pre: amount > 0 // caller bug if violated → panic
// validation: amount <= balance // caller's mistake → typed error
// post: balance == old(balance) - amount
// inv: balance >= 0
func (a *Account) Withdraw(amount Money) error {
invariant.Assertf(amount > 0, "precondition: amount > 0, got %v", amount)
if amount > a.balance {
return ErrInsufficientFunds // validation, not a contract
}
before := a.balance
a.balance -= amount
invariant.Assertf(a.balance == before-amount, "postcondition violated")
invariant.Assertf(a.balance >= 0, "invariant: balance >= 0")
return nil
}Assertion helper
Keep one helper. Do not scatter ad-hoc panics:
// Package internal/invariant
package invariant
import "fmt"
// Assert panics with msg when cond is false.
// Use only for impossible states. Use returned errors for user input.
func Assert(cond bool, msg string) {
if !cond {
panic("invariant: " + msg)
}
}
func Assertf(cond bool, format string, args ...any) {
if !cond {
panic("invariant: " + fmt.Sprintf(format, args...))
}
}Strip in release (optional)
For hot paths where the check itself is expensive, gate with a build tag:
//go:build assertions
package invariant
func Assert(cond bool, msg string) { if !cond { panic("invariant: " + msg) } }
func Assertf(cond bool, format string, args ...any) { if !cond { panic("invariant: " + fmt.Sprintf(format, args...)) } }//go:build !assertions
package invariant
func Assert(cond bool, msg string) {}
func Assertf(cond bool, format string, args ...any) {}Run tests and staging with -tags=assertions; ship release builds without. Most code should keep checks always-on — only strip when profiling proves cost.
Constructors fail fast
Establish invariants at construction so methods can rely on them:
func NewAccount(initial Money) (*Account, error) {
if initial < 0 {
return nil, fmt.Errorf("initial balance: %w", ErrNegative)
}
return &Account{balance: initial}, nil
}Validation at the boundary → typed error. Invariant inside → panic.
Property Tests from Contracts
A postcondition is a property. A property test asserts the postcondition holds for many inputs.
stdlib testing/quick (lightweight)
func TestWithdraw_PreservesNonNegativeBalance(t *testing.T) {
f := func(initial, amount uint32) bool {
a, _ := NewAccount(Money(initial))
_ = a.Withdraw(Money(amount))
return a.balance >= 0
}
if err := quick.Check(f, &quick.Config{MaxCount: 1000}); err != nil {
t.Fatal(err)
}
}pgregory.net/rapid (state machines, recommended for protocols)
import "pgregory.net/rapid"
func TestAccount_Properties(t *testing.T) {
rapid.Check(t, func(t *rapid.T) {
initial := rapid.Uint32Range(0, 1_000_000).Draw(t, "initial")
a, _ := NewAccount(Money(initial))
ops := rapid.SliceOf(rapid.Uint32Range(0, 10_000)).Draw(t, "ops")
for _, op := range ops {
_ = a.Withdraw(Money(op))
// No explicit assert here: the contract inside Withdraw panics on
// any invariant violation, which rapid.Check surfaces with the
// failing input sequence.
}
})
}For protocols, model the state machine and let rapid drive transitions. The contract panics inside the implementation will surface any reachable invariant violation.
Common Mistakes
| Mistake | Fix |
|---|---|
| Panicking on user input | Return a typed error; reserve panic for impossible states |
Sprinkling assert decoratively in glue code | Gate by domain — state machines, protocols, money, authz |
| Postcondition that re-implements the function | Postcondition states the property, not the steps |
| Asserting against external systems mid-RPC | Network failure ≠ invariant violation; handle as error |
| Catching panics from contracts to "keep serving" | Don't. An invariant violation means state is corrupt — let it crash, restart cleanly |
Cross-References
references/testing.md— table-driven tests, build tagsreferences/fuzz-testing.md— input-driven discovery (complementary to property tests)references/resilience.md— panic recovery boundaries (only at goroutine roots, never around contracts)- security-audit-skill
references/authentication-patterns.md— authorization invariants
Cron Scheduling with go-cron
`github.com/netresearch/go-cron` is a maintained fork of robfig/cron — the most popular cron library for Go — with bug fixes, runtime schedule updates, per-entry context, resilience middleware, and modern toolchain support.
Installation
go get github.com/netresearch/go-cronimport cron "github.com/netresearch/go-cron"Drop-in replacement for robfig/cron/v3 — just change the import path.
Basic Usage
c := cron.New()
c.AddFunc("0 9 * * *", func() {
fmt.Println("Every day at 9am")
})
c.AddFunc("@every 5m", func() {
fmt.Println("Every 5 minutes")
})
c.Start()
defer c.Stop()Named Jobs and Lookup
Assign names and tags for O(1) lookup, update, and removal:
id, _ := c.AddFunc("0 9 * * *", dailyReport,
cron.WithName("daily-report"),
cron.WithTags("reports", "daily"),
)
// Lookup by name (O(1))
entry := c.EntryByName("daily-report")
// Filter by tag
entries := c.EntriesByTag("reports")
// Remove by name
c.RemoveByName("daily-report")Runtime Updates
Update schedules and jobs atomically without remove+re-add:
// Update schedule only (preserves job, options, and context)
c.UpdateScheduleByName("daily-report", cron.Every(5*time.Minute))
// Update both schedule and job atomically (cancels old entry context)
c.UpdateEntryJobByName("daily-report", "30 10 * * *", newJob)
// Create-or-update in one call
id, err := c.UpsertJob("0 9 * * *", myJob, cron.WithName("my-job"))Graceful Job Replacement
For long-running jobs, wait for the current execution to finish before replacing:
c.WaitForJobByName("my-job") // Block until current execution finishes
c.UpsertJob(newSpec, newJob, cron.WithName("my-job"))Check if a job is currently running:
if c.IsJobRunningByName("my-job") {
log.Println("Job is still running, will wait")
c.WaitForJobByName("my-job")
}Per-Entry Context
Each entry gets its own context.Context derived from the Cron's base context. The context is automatically canceled when the entry is removed or its job is replaced.
c.AddJob("@every 1m", cron.FuncJobWithContext(func(ctx context.Context) {
select {
case <-ctx.Done():
return // Entry removed or job replaced
case <-time.After(10 * time.Second):
// Work completed
}
}))Context Hierarchy
caller's context
└─ cron context (canceled by Stop())
└─ entry context (canceled by Remove/UpdateEntry/UpsertJob)cron.New(cron.WithContext(parentCtx)) derives a child context. Stop() cancels the child, not the caller's context.
Job Wrappers (Middleware)
Concurrency Wrappers
These implement JobWithContext and propagate context to inner jobs:
// Apply to all jobs via Cron options
c := cron.New(cron.WithChain(
cron.Recover(logger), // Catch panics
cron.SkipIfStillRunning(logger), // Skip if previous still running
cron.DelayIfStillRunning(logger), // Queue until previous finishes
cron.Timeout(30*time.Second, nil), // Abandon after duration
cron.TimeoutWithContext(30*time.Second, nil), // Cancel context after duration
cron.Jitter(5*time.Second), // Random delay
))
// Apply to specific job
job := cron.NewChain(
cron.Recover(logger),
cron.DelayIfStillRunning(logger),
).Then(myJob)Resilience Wrappers
These return FuncJob and do NOT forward context:
// Retry on panic with exponential backoff
retryJob := cron.RetryWithBackoff(myJob, cron.RetryConfig{
MaxRetries: 3,
InitialDelay: 100 * time.Millisecond,
MaxDelay: 30 * time.Second,
Multiplier: 2.0,
})
// Retry on error return (job must implement ErrorJob)
retryJob := cron.RetryOnError(myErrorJob, cron.RetryOnErrorConfig{
MaxRetries: 3,
Delay: time.Second,
})
// Circuit breaker — stop after consecutive failures
cbJob := cron.CircuitBreaker(myJob, cron.CircuitBreakerConfig{
Threshold: 5,
ResetTimeout: time.Minute,
})ErrorJob Interface
For retry-on-error, implement ErrorJob:
type myJob struct{}
func (j *myJob) Run() {}
func (j *myJob) RunWithError() error {
// Return error to trigger retry
return doWork()
}Or use the convenience wrapper:
cron.FuncErrorJob(func() error {
return doWork()
})Observability
Monitor cron operations with hooks:
c := cron.New(cron.WithObservability(cron.ObservabilityHooks{
OnJobStart: func(id cron.EntryID, name string, scheduled time.Time) {
jobsStarted.WithLabelValues(name).Inc()
},
OnJobComplete: func(id cron.EntryID, name string, dur time.Duration, recovered any) {
jobDuration.WithLabelValues(name).Observe(dur.Seconds())
if recovered != nil {
jobPanics.WithLabelValues(name).Inc()
}
},
}))Validation
Validate cron expressions before scheduling:
// Quick validation
if err := cron.ValidateSpec("0 9 * * MON-FRI"); err != nil {
log.Fatal(err)
}
// Instance-level (uses configured parser)
c := cron.New(cron.WithSeconds())
if err := c.ValidateSpec("0 30 * * * *"); err != nil {
log.Fatal(err)
}
// Detailed analysis
result := cron.AnalyzeSpec("0 9 * * MON-FRI")
fmt.Println("Next run:", result.NextRun)
fmt.Println("Fields:", result.Fields)Missed Job Catch-Up
Handle jobs missed during downtime:
lastRun := loadFromDatabase("daily-report")
c.AddFunc("0 9 * * *", dailyReport,
cron.WithPrev(lastRun),
cron.WithMissedPolicy(cron.MissedRunOnce),
cron.WithMissedGracePeriod(2*time.Hour),
)Policies: MissedSkip (default), MissedRunOnce, MissedRunAll.
Graceful Shutdown
// Block until all running jobs finish
c.StopAndWait()
// With timeout
if !c.StopWithTimeout(30 * time.Second) {
log.Println("Warning: some jobs did not complete within 30s")
}Testing with FakeClock
go-cron includes a built-in FakeClock for deterministic testing without real time waits:
fakeClock := cron.NewFakeClock(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))
c := cron.New(cron.WithClock(fakeClock))
executed := make(chan struct{}, 1)
c.AddFunc("0 * * * *", func() {
executed <- struct{}{}
})
c.Start()
defer c.Stop()
fakeClock.BlockUntil(1) // Wait for scheduler to register timer
fakeClock.Advance(time.Hour) // Trigger the job instantly
select {
case <-executed:
// Job ran successfully
case <-time.After(time.Second):
t.Fatal("job did not execute")
}No wrapper needed — cron.NewFakeClock returns a type that satisfies the cron.Clock interface directly.
Common Options
c := cron.New(
cron.WithSeconds(), // Enable seconds field
cron.WithLocation(time.UTC), // Default timezone
cron.WithContext(parentCtx), // Parent context
cron.WithCapacity(100), // Pre-allocate internals
cron.WithMaxEntries(1000), // Limit max entries
cron.WithRunImmediately(), // Run @every jobs on Start
cron.WithLogger(cron.NewSlogLogger(slog.Default())),
cron.WithChain(cron.Recover(logger)), // Default wrappers
cron.WithObservability(hooks), // Metrics hooks
)Patterns from Production Usage
Dynamic Job Management (weaviate pattern)
func (m *Manager) RescheduleJob(name, newSpec string, newJob cron.Job) error {
// Atomic create-or-update — no manual "check then add/update" needed
_, err := m.cron.UpsertJob(newSpec, newJob, cron.WithName(name))
return err
}Graceful Replacement of Long-Running Jobs
func (m *Manager) ReplaceJob(name, spec string, job cron.Job) error {
// Wait for current execution to finish before replacing
m.cron.WaitForJobByName(name)
_, err := m.cron.UpsertJob(spec, job, cron.WithName(name))
return err
}Service Integration with Shutdown
func main() {
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
c := cron.New(cron.WithContext(ctx))
c.AddFunc("@every 5m", healthCheck, cron.WithName("health-check"))
c.AddFunc("0 * * * *", syncData, cron.WithName("hourly-sync"))
c.Start()
<-ctx.Done()
if !c.StopWithTimeout(30 * time.Second) {
log.Println("Warning: jobs did not complete within 30s")
}
}Context-Aware Long-Running Job
c.AddJob("@every 1m", cron.FuncJobWithContext(func(ctx context.Context) {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
log.Println("Job canceled, cleaning up")
return
case <-ticker.C:
if err := processNextItem(ctx); err != nil {
log.Printf("Error: %v", err)
}
}
}
}), cron.WithName("item-processor"))Migration from robfig/cron
Drop-in replacement — just change the import:
// Before
import "github.com/robfig/cron/v3"
// After
import cron "github.com/netresearch/go-cron"Key behavior differences:
- DOM/DOW matching: Uses AND logic (both must match) instead of OR
- DST spring-forward: Jobs in skipped hour run immediately instead of being silently skipped
- Chain execution:
Entry.Run()properly invokes chain wrappers
See the migration guide for full details.
Docker Integration Patterns in Go
Optimized Docker Client
Client with Connection Pooling
package core
import (
"context"
"sync"
docker "github.com/fsouza/go-dockerclient"
)
type OptimizedDockerClient struct {
client *docker.Client
bufferPool *sync.Pool
mu sync.RWMutex
endpoint string
}
func NewOptimizedDockerClient(endpoint string) (*OptimizedDockerClient, error) {
if endpoint == "" {
endpoint = "unix:///var/run/docker.sock"
}
client, err := docker.NewClient(endpoint)
if err != nil {
return nil, fmt.Errorf("failed to create Docker client: %w", err)
}
return &OptimizedDockerClient{
client: client,
endpoint: endpoint,
bufferPool: &sync.Pool{
New: func() any {
return NewCircularBuffer(64 * 1024) // 64KB buffers
},
},
}, nil
}
func NewOptimizedDockerClientFromEnv() (*OptimizedDockerClient, error) {
client, err := docker.NewClientFromEnv()
if err != nil {
return nil, err
}
return &OptimizedDockerClient{
client: client,
bufferPool: &sync.Pool{
New: func() any {
return NewCircularBuffer(64 * 1024)
},
},
}, nil
}
func (c *OptimizedDockerClient) Close() error {
// fsouza/go-dockerclient doesn't require explicit close
// but we can clean up the buffer pool
return nil
}Buffer Pooling
Circular Buffer Implementation
type CircularBuffer struct {
data []byte
size int
head int
tail int
count int
mu sync.Mutex
}
func NewCircularBuffer(size int) *CircularBuffer {
return &CircularBuffer{
data: make([]byte, size),
size: size,
}
}
func (b *CircularBuffer) Write(p []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
n := len(p)
if n > b.size {
// Only keep the last 'size' bytes
p = p[n-b.size:]
n = b.size
}
for _, byte := range p {
b.data[b.tail] = byte
b.tail = (b.tail + 1) % b.size
if b.count < b.size {
b.count++
} else {
b.head = (b.head + 1) % b.size
}
}
return n, nil
}
func (b *CircularBuffer) String() string {
b.mu.Lock()
defer b.mu.Unlock()
if b.count == 0 {
return ""
}
result := make([]byte, b.count)
if b.head < b.tail {
copy(result, b.data[b.head:b.tail])
} else {
n := copy(result, b.data[b.head:])
copy(result[n:], b.data[:b.tail])
}
return string(result)
}
func (b *CircularBuffer) Reset() {
b.mu.Lock()
defer b.mu.Unlock()
b.head = 0
b.tail = 0
b.count = 0
}
func (b *CircularBuffer) Len() int {
b.mu.Lock()
defer b.mu.Unlock()
return b.count
}Using Buffer Pool
func (c *OptimizedDockerClient) ExecInContainer(ctx context.Context, containerID string, cmd []string) (string, string, error) {
// Get buffers from pool
stdoutBuf := c.bufferPool.Get().(*CircularBuffer)
stderrBuf := c.bufferPool.Get().(*CircularBuffer)
defer func() {
stdoutBuf.Reset()
stderrBuf.Reset()
c.bufferPool.Put(stdoutBuf)
c.bufferPool.Put(stderrBuf)
}()
// Create exec instance
exec, err := c.client.CreateExec(docker.CreateExecOptions{
Container: containerID,
Cmd: cmd,
AttachStdout: true,
AttachStderr: true,
Context: ctx,
})
if err != nil {
return "", "", fmt.Errorf("failed to create exec: %w", err)
}
// Start exec and capture output
err = c.client.StartExec(exec.ID, docker.StartExecOptions{
OutputStream: stdoutBuf,
ErrorStream: stderrBuf,
Context: ctx,
})
if err != nil {
return "", "", fmt.Errorf("failed to start exec: %w", err)
}
// Check exec exit code
inspect, err := c.client.InspectExec(exec.ID)
if err != nil {
return stdoutBuf.String(), stderrBuf.String(), fmt.Errorf("failed to inspect exec: %w", err)
}
if inspect.ExitCode != 0 {
return stdoutBuf.String(), stderrBuf.String(),
fmt.Errorf("command exited with code %d", inspect.ExitCode)
}
return stdoutBuf.String(), stderrBuf.String(), nil
}Container Operations
Create and Run Container
func (c *OptimizedDockerClient) RunContainer(ctx context.Context, image string, cmd []string, env map[string]string) (string, error) {
// Convert env map to slice
envSlice := make([]string, 0, len(env))
for k, v := range env {
envSlice = append(envSlice, fmt.Sprintf("%s=%s", k, v))
}
// Create container
container, err := c.client.CreateContainer(docker.CreateContainerOptions{
Config: &docker.Config{
Image: image,
Cmd: cmd,
Env: envSlice,
},
HostConfig: &docker.HostConfig{
AutoRemove: true,
},
Context: ctx,
})
if err != nil {
return "", fmt.Errorf("failed to create container: %w", err)
}
// Start container
if err := c.client.StartContainer(container.ID, nil); err != nil {
// Cleanup on error
c.client.RemoveContainer(docker.RemoveContainerOptions{
ID: container.ID,
Force: true,
})
return "", fmt.Errorf("failed to start container: %w", err)
}
return container.ID, nil
}
func (c *OptimizedDockerClient) WaitContainer(ctx context.Context, containerID string) (int, error) {
exitCode, err := c.client.WaitContainer(containerID)
if err != nil {
return -1, fmt.Errorf("failed to wait for container: %w", err)
}
return exitCode, nil
}
func (c *OptimizedDockerClient) RemoveContainer(ctx context.Context, containerID string, force bool) error {
return c.client.RemoveContainer(docker.RemoveContainerOptions{
ID: containerID,
Force: force,
RemoveVolumes: true,
Context: ctx,
})
}Container Monitoring
type ContainerStats struct {
CPUPercent float64
MemoryUsage uint64
MemoryLimit uint64
MemoryPercent float64
NetworkRx uint64
NetworkTx uint64
}
func (c *OptimizedDockerClient) GetContainerStats(ctx context.Context, containerID string) (*ContainerStats, error) {
statsCh := make(chan *docker.Stats)
errCh := make(chan error)
go func() {
err := c.client.Stats(docker.StatsOptions{
ID: containerID,
Stats: statsCh,
Stream: false,
Context: ctx,
})
errCh <- err
}()
select {
case stats := <-statsCh:
cpuDelta := float64(stats.CPUStats.CPUUsage.TotalUsage - stats.PreCPUStats.CPUUsage.TotalUsage)
systemDelta := float64(stats.CPUStats.SystemCPUUsage - stats.PreCPUStats.SystemCPUUsage)
cpuPercent := 0.0
if systemDelta > 0 {
cpuPercent = (cpuDelta / systemDelta) * float64(len(stats.CPUStats.CPUUsage.PercpuUsage)) * 100
}
return &ContainerStats{
CPUPercent: cpuPercent,
MemoryUsage: stats.MemoryStats.Usage,
MemoryLimit: stats.MemoryStats.Limit,
MemoryPercent: float64(stats.MemoryStats.Usage) / float64(stats.MemoryStats.Limit) * 100,
NetworkRx: stats.Network.RxBytes,
NetworkTx: stats.Network.TxBytes,
}, nil
case err := <-errCh:
return nil, err
case <-ctx.Done():
return nil, ctx.Err()
}
}Docker Events
Event Listener
type EventHandler func(event *docker.APIEvents)
func (c *OptimizedDockerClient) ListenEvents(ctx context.Context, handler EventHandler) error {
listener := make(chan *docker.APIEvents)
err := c.client.AddEventListener(listener)
if err != nil {
return fmt.Errorf("failed to add event listener: %w", err)
}
defer c.client.RemoveEventListener(listener)
for {
select {
case event := <-listener:
if event == nil {
return nil
}
handler(event)
case <-ctx.Done():
return ctx.Err()
}
}
}
// Usage: React to container events
func handleDockerEvent(event *docker.APIEvents) {
switch event.Status {
case "start":
log.WithField("container", event.ID).Info("Container started")
case "die":
log.WithField("container", event.ID).Info("Container died")
case "destroy":
log.WithField("container", event.ID).Info("Container destroyed")
}
}Docker Labels for Configuration
Reading Labels
type JobFromLabels struct {
Type string
Name string
Schedule string
Command []string
Container string
}
func (c *OptimizedDockerClient) GetJobsFromLabels(ctx context.Context) ([]JobFromLabels, error) {
containers, err := c.client.ListContainers(docker.ListContainersOptions{
Context: ctx,
})
if err != nil {
return nil, err
}
var jobs []JobFromLabels
for _, container := range containers {
// Look for labels like: ofelia.job-exec.job-name.schedule
for key, value := range container.Labels {
if !strings.HasPrefix(key, "ofelia.") {
continue
}
parts := strings.Split(key, ".")
if len(parts) < 4 {
continue
}
jobType := parts[1] // job-exec, job-run, etc.
jobName := parts[2] // user-defined name
param := parts[3] // schedule, command, etc.
// Build job config from labels
job := findOrCreateJob(jobs, jobName, jobType)
switch param {
case "schedule":
job.Schedule = value
case "command":
job.Command = strings.Split(value, " ")
case "container":
job.Container = value
}
}
}
return jobs, nil
}Health Check
func (c *OptimizedDockerClient) Ping(ctx context.Context) error {
return c.client.PingWithContext(ctx)
}
func (c *OptimizedDockerClient) IsHealthy(ctx context.Context) bool {
return c.Ping(ctx) == nil
}
// Health check for API endpoint
func DockerHealthCheck(client *OptimizedDockerClient) func(context.Context) error {
return func(ctx context.Context) error {
if err := client.Ping(ctx); err != nil {
return fmt.Errorf("docker daemon unavailable: %w", err)
}
return nil
}
}Image Operations
func (c *OptimizedDockerClient) PullImage(ctx context.Context, image string) error {
return c.client.PullImage(docker.PullImageOptions{
Repository: image,
Context: ctx,
}, docker.AuthConfiguration{})
}
func (c *OptimizedDockerClient) ImageExists(ctx context.Context, image string) bool {
_, err := c.client.InspectImage(image)
return err == nil
}
func (c *OptimizedDockerClient) EnsureImage(ctx context.Context, image string) error {
if c.ImageExists(ctx, image) {
return nil
}
return c.PullImage(ctx, image)
}Go Fuzz Testing
Go 1.18+ includes built-in fuzzing support. This guide covers patterns for security-focused fuzz testing.
When to Use Fuzz Testing
- Input parsing (URLs, queries, content types)
- Data validation and sanitization
- Security-sensitive operations (XSS prevention, path traversal detection)
- Protocol handling and serialization
- Cache key generation
Basic Pattern
//go:build fuzz
package mypackage
import (
"testing"
"unicode/utf8"
)
func FuzzMyFunction(f *testing.F) {
// 1. Seed with known edge cases
f.Add("normal input")
f.Add("") // Empty
f.Add("\x00null") // Null bytes
f.Add("../../../etc/passwd") // Path traversal
f.Add("<script>alert(1)") // XSS attempt
f.Add("' OR 1=1--") // SQL injection
// 2. Define the fuzz target
f.Fuzz(func(t *testing.T, input string) {
// Skip invalid UTF-8 if needed
if !utf8.ValidString(input) {
return
}
// Exercise the function - should not panic
result, err := MyFunction(input)
// Validate invariants
if err == nil {
// Check properties that should always hold
if result == nil {
t.Error("nil result without error")
}
}
})
}Security-Focused Seeds
URL/Path Handling
f.Add("/users")
f.Add("/users/john%20doe")
f.Add("/%2e%2e/etc/passwd") // Path traversal
f.Add("/%00null") // Null byte injection
f.Add("/users/../../../etc/passwd") // Directory traversal
f.Add("/%252e%252e/") // Double encoding
f.Add("/路径/用户") // Unicode paths
f.Add("//double//slashes//")
f.Add("/users;id") // Command injection
f.Add("/users|ls") // Pipe injectionQuery Parameters
f.Add("key=value")
f.Add("key=")
f.Add("=value")
f.Add("key")
f.Add("")
f.Add("key=value&key=value2") // Duplicate keys
f.Add("key=%00") // Null byte
f.Add("key=<script>") // XSS
f.Add("key=' OR 1=1--") // SQL injection
f.Add("key[]=value1&key[]=value2") // Array syntaxXSS Payloads
f.Add("<script>alert(1)</script>")
f.Add("<img src=x onerror=alert(1)>")
f.Add("javascript:alert(1)")
f.Add("<svg onload=alert(1)>")
f.Add("{{.}}") // Template injection
f.Add("${7*7}") // Expression injectionRunning Fuzz Tests
# Run specific fuzz test (30 seconds)
go test -fuzz=FuzzMyFunction -fuzztime=30s ./...
# Run all fuzz tests in package
go test -fuzz=. -fuzztime=1m ./path/to/package
# Run with race detector (slower but thorough)
go test -fuzz=FuzzMyFunction -fuzztime=30s -race ./...
# Reproduce a failing case from testdata
go test -run=FuzzMyFunction/failing_case ./...CI Integration
Add to Makefile:
.PHONY: fuzz
fuzz:
@echo "Running fuzz tests..."
@for pkg in $$(go list ./... | grep -v /vendor/); do \
for fuzz in $$(go test -list='^Fuzz' $$pkg 2>/dev/null | grep '^Fuzz'); do \
echo "Fuzzing $$fuzz in $$pkg..."; \
go test -fuzz=$$fuzz -fuzztime=30s $$pkg || exit 1; \
done; \
doneBest Practices
1. Use Build Tags: Isolate fuzz tests with //go:build fuzz 2. Seed Edge Cases: Include security payloads, boundary values, unicode 3. Validate UTF-8: Skip invalid strings early if your code expects valid UTF-8 4. Check Invariants: Assert properties that should always hold 5. No Panics: Primary goal is proving code doesn't panic on any input 6. Limit Resource Usage: Skip extremely long inputs to prevent timeouts
File Organization
package/
├── handler.go
├── handler_test.go # Unit tests
└── handler_fuzz_test.go # Fuzz tests (//go:build fuzz)Related
- Go Fuzzing Documentation
references/testing.md- General testing patternsreferences/mutation-testing.md- Complementary test quality measurement
LDAP/Active Directory Integration in Go
Client Setup
Basic LDAP Client
package ldap
import (
"crypto/tls"
"fmt"
"github.com/go-ldap/ldap/v3"
)
type Client struct {
conn *ldap.Conn
baseDN string
bindDN string
bindPW string
userFilter string
}
type Config struct {
Host string
Port int
BaseDN string
BindDN string
BindPW string
UseTLS bool
SkipVerify bool
}
func NewClient(cfg Config) (*Client, error) {
address := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
var conn *ldap.Conn
var err error
if cfg.UseTLS {
tlsConfig := &tls.Config{
InsecureSkipVerify: cfg.SkipVerify,
ServerName: cfg.Host,
}
conn, err = ldap.DialTLS("tcp", address, tlsConfig)
} else {
conn, err = ldap.Dial("tcp", address)
}
if err != nil {
return nil, fmt.Errorf("failed to connect to LDAP: %w", err)
}
// Bind with credentials
if err := conn.Bind(cfg.BindDN, cfg.BindPW); err != nil {
conn.Close()
return nil, fmt.Errorf("failed to bind: %w", err)
}
return &Client{
conn: conn,
baseDN: cfg.BaseDN,
bindDN: cfg.BindDN,
bindPW: cfg.BindPW,
}, nil
}
func (c *Client) Close() error {
if c.conn != nil {
c.conn.Close()
}
return nil
}Connection Pool
type ClientPool struct {
cfg Config
pool chan *Client
maxSize int
}
func NewClientPool(cfg Config, maxSize int) *ClientPool {
return &ClientPool{
cfg: cfg,
pool: make(chan *Client, maxSize),
maxSize: maxSize,
}
}
func (p *ClientPool) Get() (*Client, error) {
select {
case client := <-p.pool:
// Test connection
if err := client.conn.Bind(p.cfg.BindDN, p.cfg.BindPW); err == nil {
return client, nil
}
// Connection dead, create new
client.Close()
default:
// Pool empty
}
return NewClient(p.cfg)
}
func (p *ClientPool) Put(client *Client) {
select {
case p.pool <- client:
// Returned to pool
default:
// Pool full, close connection
client.Close()
}
}User Operations
User Model
type User struct {
DN string
CN string
SAMAccountName string
UserPrincipalName string
Email string
DisplayName string
FirstName string
LastName string
Department string
Title string
Manager string
MemberOf []string
Enabled bool
LastLogon time.Time
}
func userFromEntry(entry *ldap.Entry) *User {
user := &User{
DN: entry.DN,
CN: entry.GetAttributeValue("cn"),
SAMAccountName: entry.GetAttributeValue("sAMAccountName"),
UserPrincipalName: entry.GetAttributeValue("userPrincipalName"),
Email: entry.GetAttributeValue("mail"),
DisplayName: entry.GetAttributeValue("displayName"),
FirstName: entry.GetAttributeValue("givenName"),
LastName: entry.GetAttributeValue("sn"),
Department: entry.GetAttributeValue("department"),
Title: entry.GetAttributeValue("title"),
Manager: entry.GetAttributeValue("manager"),
MemberOf: entry.GetAttributeValues("memberOf"),
}
// Parse userAccountControl for enabled status
uac := entry.GetAttributeValue("userAccountControl")
if uac != "" {
uacInt, _ := strconv.Atoi(uac)
user.Enabled = (uacInt & 0x2) == 0 // ACCOUNTDISABLE flag
}
return user
}Find Users
var userAttributes = []string{
"dn", "cn", "sAMAccountName", "userPrincipalName",
"mail", "displayName", "givenName", "sn",
"department", "title", "manager", "memberOf",
"userAccountControl",
}
func (c *Client) FindUserBySAM(samAccountName string) (*User, error) {
filter := fmt.Sprintf("(&(objectClass=user)(sAMAccountName=%s))",
ldap.EscapeFilter(samAccountName))
return c.findUser(filter)
}
func (c *Client) FindUserByEmail(email string) (*User, error) {
filter := fmt.Sprintf("(&(objectClass=user)(mail=%s))",
ldap.EscapeFilter(email))
return c.findUser(filter)
}
func (c *Client) FindUserByDN(dn string) (*User, error) {
result, err := c.conn.Search(&ldap.SearchRequest{
BaseDN: dn,
Scope: ldap.ScopeBaseObject,
Filter: "(objectClass=user)",
Attributes: userAttributes,
})
if err != nil {
return nil, err
}
if len(result.Entries) == 0 {
return nil, ErrUserNotFound
}
return userFromEntry(result.Entries[0]), nil
}
func (c *Client) findUser(filter string) (*User, error) {
result, err := c.conn.Search(&ldap.SearchRequest{
BaseDN: c.baseDN,
Scope: ldap.ScopeWholeSubtree,
Filter: filter,
Attributes: userAttributes,
})
if err != nil {
return nil, fmt.Errorf("search failed: %w", err)
}
if len(result.Entries) == 0 {
return nil, ErrUserNotFound
}
return userFromEntry(result.Entries[0]), nil
}
func (c *Client) ListUsers(filter string, limit int) ([]*User, error) {
if filter == "" {
filter = "(objectClass=user)"
}
result, err := c.conn.Search(&ldap.SearchRequest{
BaseDN: c.baseDN,
Scope: ldap.ScopeWholeSubtree,
Filter: filter,
Attributes: userAttributes,
SizeLimit: limit,
})
if err != nil {
return nil, err
}
users := make([]*User, 0, len(result.Entries))
for _, entry := range result.Entries {
users = append(users, userFromEntry(entry))
}
return users, nil
}Authentication
Validate Credentials
func (c *Client) Authenticate(username, password string) (*User, error) {
// First, find the user
user, err := c.FindUserBySAM(username)
if err != nil {
return nil, fmt.Errorf("user not found: %w", err)
}
// Try to bind with user's credentials
err = c.conn.Bind(user.DN, password)
if err != nil {
// Re-bind as service account
c.conn.Bind(c.bindDN, c.bindPW)
return nil, ErrInvalidCredentials
}
// Re-bind as service account for subsequent operations
c.conn.Bind(c.bindDN, c.bindPW)
return user, nil
}
// Alternative: Create new connection for auth
func (c *Client) AuthenticateWithNewConn(username, password string) (*User, error) {
user, err := c.FindUserBySAM(username)
if err != nil {
return nil, err
}
// Create separate connection for auth
cfg := Config{
Host: c.cfg.Host,
Port: c.cfg.Port,
BaseDN: c.baseDN,
BindDN: user.DN,
BindPW: password,
UseTLS: c.cfg.UseTLS,
}
authClient, err := NewClient(cfg)
if err != nil {
return nil, ErrInvalidCredentials
}
authClient.Close()
return user, nil
}Password Operations
Change Password
func (c *Client) ChangePassword(userDN, oldPassword, newPassword string) error {
// AD requires the password in a specific format
oldPwdEncoded := encodePassword(oldPassword)
newPwdEncoded := encodePassword(newPassword)
modifyRequest := ldap.NewModifyRequest(userDN, nil)
modifyRequest.Delete("unicodePwd", []string{oldPwdEncoded})
modifyRequest.Add("unicodePwd", []string{newPwdEncoded})
return c.conn.Modify(modifyRequest)
}
func (c *Client) ResetPassword(userDN, newPassword string) error {
// Admin reset - doesn't require old password
newPwdEncoded := encodePassword(newPassword)
modifyRequest := ldap.NewModifyRequest(userDN, nil)
modifyRequest.Replace("unicodePwd", []string{newPwdEncoded})
return c.conn.Modify(modifyRequest)
}
func encodePassword(password string) string {
// AD requires UTF-16LE encoded password surrounded by quotes
utf16 := utf16.Encode([]rune("\"" + password + "\""))
pwBytes := make([]byte, len(utf16)*2)
for i, v := range utf16 {
pwBytes[i*2] = byte(v)
pwBytes[i*2+1] = byte(v >> 8)
}
return string(pwBytes)
}Group Operations
Group Model
type Group struct {
DN string
CN string
Description string
Members []string
MemberOf []string
}
func (c *Client) FindGroup(cn string) (*Group, error) {
filter := fmt.Sprintf("(&(objectClass=group)(cn=%s))", ldap.EscapeFilter(cn))
result, err := c.conn.Search(&ldap.SearchRequest{
BaseDN: c.baseDN,
Scope: ldap.ScopeWholeSubtree,
Filter: filter,
Attributes: []string{"dn", "cn", "description", "member", "memberOf"},
})
if err != nil {
return nil, err
}
if len(result.Entries) == 0 {
return nil, ErrGroupNotFound
}
entry := result.Entries[0]
return &Group{
DN: entry.DN,
CN: entry.GetAttributeValue("cn"),
Description: entry.GetAttributeValue("description"),
Members: entry.GetAttributeValues("member"),
MemberOf: entry.GetAttributeValues("memberOf"),
}, nil
}
func (c *Client) AddUserToGroup(userDN, groupDN string) error {
modifyRequest := ldap.NewModifyRequest(groupDN, nil)
modifyRequest.Add("member", []string{userDN})
return c.conn.Modify(modifyRequest)
}
func (c *Client) RemoveUserFromGroup(userDN, groupDN string) error {
modifyRequest := ldap.NewModifyRequest(groupDN, nil)
modifyRequest.Delete("member", []string{userDN})
return c.conn.Modify(modifyRequest)
}
func (c *Client) IsUserInGroup(userDN, groupCN string) (bool, error) {
user, err := c.FindUserByDN(userDN)
if err != nil {
return false, err
}
for _, groupDN := range user.MemberOf {
if strings.Contains(strings.ToLower(groupDN), strings.ToLower("CN="+groupCN)) {
return true, nil
}
}
return false, nil
}Error Handling
var (
ErrUserNotFound = errors.New("user not found")
ErrGroupNotFound = errors.New("group not found")
ErrInvalidCredentials = errors.New("invalid credentials")
ErrConnectionFailed = errors.New("LDAP connection failed")
ErrPermissionDenied = errors.New("permission denied")
)
func translateLDAPError(err error) error {
if ldapErr, ok := err.(*ldap.Error); ok {
switch ldapErr.ResultCode {
case ldap.LDAPResultNoSuchObject:
return ErrUserNotFound
case ldap.LDAPResultInvalidCredentials:
return ErrInvalidCredentials
case ldap.LDAPResultInsufficientAccessRights:
return ErrPermissionDenied
}
}
return err
}Computer Objects
type Computer struct {
DN string
CN string
DNSHostName string
OperatingSystem string
OSVersion string
LastLogon time.Time
Enabled bool
}
func (c *Client) ListComputers(filter string) ([]*Computer, error) {
if filter == "" {
filter = "(objectClass=computer)"
}
result, err := c.conn.Search(&ldap.SearchRequest{
BaseDN: c.baseDN,
Scope: ldap.ScopeWholeSubtree,
Filter: filter,
Attributes: []string{
"dn", "cn", "dNSHostName",
"operatingSystem", "operatingSystemVersion",
"lastLogonTimestamp", "userAccountControl",
},
})
if err != nil {
return nil, err
}
computers := make([]*Computer, 0, len(result.Entries))
for _, entry := range result.Entries {
computers = append(computers, &Computer{
DN: entry.DN,
CN: entry.GetAttributeValue("cn"),
DNSHostName: entry.GetAttributeValue("dNSHostName"),
OperatingSystem: entry.GetAttributeValue("operatingSystem"),
OSVersion: entry.GetAttributeValue("operatingSystemVersion"),
})
}
return computers, nil
}
## Common Gotchas
### simple-ldap-go "localhost" Mock Detection
The `simple-ldap-go` library's `isExampleServerName()` function treats `"localhost"` as a mock/example server name. When this is detected, the library returns fake connections that fail with `"connection to example server not available"`.
// BAD - simple-ldap-go treats "localhost" as a mock server cfg := simpleldap.Config{ Server: "localhost", Port: 1389, } // Returns: "connection to example server not available"
// GOOD - Use 127.0.0.1 to avoid mock detection cfg := simpleldap.Config{ Server: "127.0.0.1", Port: 1389, }
This applies to any context using `simple-ldap-go`, including integration tests against a real LDAP server running on localhost.
### IPv6-Safe Host:Port Formatting
Use `net.JoinHostPort` instead of `fmt.Sprintf` for constructing address strings. `go vet` flags `fmt.Sprintf("%s:%d", host, port)` because it produces invalid addresses for IPv6 hosts (e.g., `::1:389` instead of `[::1]:389`).
// BAD - Fails with IPv6 addresses, flagged by go vet address := fmt.Sprintf("%s:%d", host, port)
// GOOD - Handles IPv4, IPv6, and hostnames correctly address := net.JoinHostPort(host, strconv.Itoa(port))
## Testing LDAP with Testcontainers
### LDAP Lazy Binding Behavior
**Critical**: LDAP connections use **lazy binding**. The `Dial()` or `DialTLS()` call only establishes a TCP connection - authentication is not validated until the first LDAP operation.
// Connection succeeds even with invalid credentials! conn, err := ldap.Dial("tcp", "ldap.example.com:389") if err != nil { // Only fails on network/DNS errors, NOT auth errors }
// Auth is validated HERE, on first operation err = conn.Bind("cn=admin,dc=example,dc=com", "wrong-password") // NOW you get auth errors
### OpenLDAP Anonymous Reads
OpenLDAP allows anonymous read access by default. This affects health checks:
// Health check using Search works WITHOUT authentication func (c Client) HealthCheck() error { _, err := c.conn.Search(&ldap.SearchRequest{ BaseDN: c.baseDN, Scope: ldap.ScopeBaseObject, Filter: "(objectClass=)", Attributes: []string{"1.1"}, // Request no attributes SizeLimit: 1, }) return err // Works even without Bind! }
// For true auth validation, use Bind explicitly func (c *Client) ValidateCredentials() error { return c.conn.Bind(c.bindDN, c.bindPassword) }
### Testcontainers Pattern for LDAP
Use ephemeral OpenLDAP containers for integration tests:
//go:build integration
package ldap_test
import ( "context" "testing"
"github.com/testcontainers/testcontainers-go" "github.com/testcontainers/testcontainers-go/wait" )
func setupOpenLDAPContainer(t *testing.T) (host string, port int, cleanup func()) { ctx := context.Background()
req := testcontainers.ContainerRequest{ Image: "osixia/openldap:1.5.0", // Pin version! ExposedPorts: []string{"389/tcp"}, Env: map[string]string{ "LDAP_ORGANISATION": "Test Org", "LDAP_DOMAIN": "example.com", "LDAP_ADMIN_PASSWORD": "admin", // OK for ephemeral test container "LDAP_BASE_DN": "dc=example,dc=com", }, WaitingFor: wait.ForListeningPort("389/tcp"), }
container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ ContainerRequest: req, Started: true, }) if err != nil { t.Fatalf("Failed to start container: %v", err) }
mappedPort, _ := container.MappedPort(ctx, "389") hostIP, _ := container.Host(ctx)
return hostIP, mappedPort.Int(), func() { container.Terminate(ctx) } }
func TestLDAPIntegration(t *testing.T) { host, port, cleanup := setupOpenLDAPContainer(t) defer cleanup()
client, err := NewClient(Config{ Host: host, Port: port, BindDN: "cn=admin,dc=example,dc=com", BindPW: "admin", BaseDN: "dc=example,dc=com", }) require.NoError(t, err) defer client.Close()
// Test operations... }
### CI Service Container Pattern (Without Testcontainers)
For CI environments where testcontainers are not available, use GitHub Actions service containers with a `skipIfNoLDAP` pattern:
package web_test
import ( "net" "strconv" "testing" "time"
ldapv3 "github.com/go-ldap/ldap/v3" )
const ( ldapHost = "127.0.0.1" // NOT "localhost" — avoids simple-ldap-go mock detection ldapPort = 1389 ldapBaseDN = "dc=test,dc=local" ldapAdmin = "cn=admin,dc=test,dc=local" ldapPass = "admin" )
// skipIfNoLDAP skips the test if the LDAP server is not reachable. func skipIfNoLDAP(t *testing.T) { t.Helper()
address := net.JoinHostPort(ldapHost, strconv.Itoa(ldapPort)) conn, err := (&net.Dialer{Timeout: 2 * time.Second}).Dial("tcp", address) if err != nil { t.Skipf("LDAP server not available at %s: %v", address, err) }
_ = conn.Close() }
// seedLDAPData uses go-ldap/ldap/v3 directly to create test entries. func seedLDAPData(t *testing.T) { t.Helper()
address := net.JoinHostPort(ldapHost, strconv.Itoa(ldapPort)) conn, err := ldapv3.DialURL("ldap://" + address) if err != nil { t.Fatalf("failed to connect to LDAP: %v", err) } defer func() { _ = conn.Close() }()
if err := conn.Bind(ldapAdmin, ldapPass); err != nil { t.Fatalf("failed to bind: %v", err) }
// Add OUs, users, groups as needed addReq := ldapv3.NewAddRequest("ou=users,"+ldapBaseDN, nil) addReq.Attribute("objectClass", []string{"organizationalUnit"}) addReq.Attribute("ou", []string{"users"})
if err := conn.Add(addReq); err != nil { // It's okay if the entry already exists on re-runs. if ldapErr, ok := err.(*ldapv3.Error); !ok || ldapErr.ResultCode != ldapv3.LDAPResultEntryAlreadyExists { t.Fatalf("failed to add seed data: %v", err) } } }
GitHub Actions service container configuration:
services: openldap: image: osixia/openldap:1.5.0 ports:
- 1389:389
env: LDAP_ORGANISATION: "Test Org" LDAP_DOMAIN: "test.local" LDAP_ADMIN_PASSWORD: "admin" LDAP_BASE_DN: "dc=test,dc=local"
**Key patterns:**
- Use `127.0.0.1` not `localhost` to avoid simple-ldap-go mock detection
- Use `net.Dialer` (not `net.DialTimeout`) to satisfy the `noctx` linter
- Use `go-ldap/ldap/v3` directly for seeding test data (independent of app's LDAP library)
- Make handler assertions resilient: accept success OR error when LDAP session credentials may be stale
- Close connections with `defer func() { _ = conn.Close() }()` to satisfy `errcheck`
### Security Note: Test Credentials
Hardcoded credentials in testcontainer setup are acceptable because:
1. Containers are ephemeral (destroyed after test)
2. Run on isolated localhost ports
3. Contain no real data
**Never** use production credentials in tests. Always use dedicated test accounts with minimal permissions.
Lefthook Template for Go Projects
Install: go install github.com/evilmartians/lefthook@latest && lefthook install Or add to Makefile: make setup
lefthook.yml
# Go project git hooks - powered by lefthook
# https://github.com/evilmartians/lefthook
pre-commit:
parallel: true
commands:
go-mod-tidy:
run: go mod tidy -diff 2>/dev/null || (echo "Run: go mod tidy" && exit 1)
go-vet:
glob: "*.go"
run: go vet ./...
gofmt:
glob: "*.go"
run: |
unformatted=$(gofmt -l $(git ls-files '*.go'))
[ -z "$unformatted" ] || (echo "Unformatted: $unformatted" && exit 1)
commit-msg:
commands:
conventional-commits:
run: |
msg=$(cat {1})
echo "$msg" | grep -qE "^(feat|fix|docs|style|refactor|test|chore|perf|ci|build|revert)(\(.+\))?: .+" || \
echo "Warning: not conventional commits format"
signoff:
run: |
grep -qE "^Signed-off-by: .+ <.+>" {1} || \
(echo "Missing --signoff" && exit 1)
pre-push:
parallel: true
commands:
lint:
glob: "*.go"
run: golangci-lint run --timeout=3m
test:
run: go test -short -timeout=60s ./...Customize per project: add security scanning (gosec), mutation testing, etc.
Go Linting and Code Quality
golangci-lint v2 Configuration
golangci-lint v2 uses a new YAML structure. Here's a production-ready configuration:
# .golangci.yml
version: "2"
run:
tests: true
linters:
default: none
enable:
# Bugs & Correctness (Critical)
- govet # Go vet checks
- staticcheck # Comprehensive static analysis
- errcheck # Unchecked errors
- errorlint # Error wrapping issues
- bodyclose # HTTP response body close
- noctx # HTTP requests without context
- durationcheck # Detects time.Second * time.Second bugs
- nilerr # Catches return nil when err != nil
- nilnesserr # Checks err != nil but returns different nil
- fatcontext # Detects nested contexts in loops
- contextcheck # Non-inherited context usage
- copyloopvar # Loop variable copy issues (Go 1.22+)
- forcetypeassert # Unchecked type assertions (panic risk)
- makezero # Slice with non-zero initial length bugs
# Security
- gosec # Security issues
# Performance
- prealloc # Slice preallocation suggestions
- unconvert # Unnecessary type conversions
- perfsprint # Faster sprintf alternatives
# Style & Maintainability
- gocyclo # Cyclomatic complexity
- gocognit # Cognitive complexity
- funlen # Function length limits
- nestif # Nested if statement depth
- ineffassign # Ineffective assignments
- unused # Unused code detection
- misspell # Spelling mistakes
- revive # Fast, configurable linter
- gocritic # Opinionated linter
# Modernization (Go 1.22+)
- intrange # Use for range n
- usestdlibvars # Use http.StatusOK instead of 200
- modernize # Modern Go features
# Testing Quality
- thelper # Test helpers should call t.Helper()
- tparallel # Correct t.Parallel() usage
settings:
gocyclo:
min-complexity: 15
gocognit:
min-complexity: 30
funlen:
lines: 80
statements: 50
nestif:
min-complexity: 4
misspell:
locale: US
errcheck:
check-type-assertions: true
check-blank: false # Allow explicit _ = err
exclusions:
generated: lax
presets:
- comments
- common-false-positives
- legacy
- std-error-handling
rules:
# Exclude complexity checks in test files
- linters:
- gocyclo
- gocognit
- funlen
- nestif
path: _test\.go
# Example: Exclude inherently complex functions
# - linters:
# - gocyclo
# - gocognit
# path: parser\.go
# text: "(parse|complexFunction)"
formatters:
enable:
- gci # Import grouping
- gofumpt # Stricter gofmt
settings:
gci:
sections:
- standard
- default
- prefix(github.com/your-org/your-project)Linter Selection Strategy
By Category
| Category | Linters | Priority |
|---|---|---|
| Bugs | govet, staticcheck, errcheck, nilerr | Critical |
| Security | gosec, bidichk | High |
| Performance | prealloc, unconvert, perfsprint | Medium |
| Style | gocyclo, funlen, revive, gocritic | Medium |
| Modernization | intrange, modernize, usestdlibvars | Low |
Adding Exclusions Properly
When a linter flags inherently complex code that cannot be simplified:
exclusions:
rules:
# Document WHY the exclusion is needed
# Next() and Prev() have inherent complexity due to
# multi-field time calculation with wraparound logic
- linters:
- gocognit
- gocyclo
path: spec\.go
text: "(Next|Prev)"Best Practice: Always add a comment explaining why the exclusion is justified.
Common staticcheck/revive Fixes
ST1005: Error String Formatting
Error strings should NOT be capitalized or end with punctuation:
// BAD - Will trigger ST1005
return errors.New("H expressions require a hash key")
return fmt.Errorf("Invalid input: %s.", input)
// GOOD
return errors.New("h expressions require a hash key")
return fmt.Errorf("invalid input: %s", input)Rationale: Error messages are often wrapped or concatenated. Lowercase prevents awkward capitalization like "failed: Invalid input".
ST1003: Naming Conventions
// BAD
var serverId string // Should be serverID
func GetUserId() {} // Should be GetUserID
type HttpClient struct // Should be HTTPClient
// GOOD
var serverID string
func GetUserID() {}
type HTTPClient structgosec G104: Unhandled Errors
For functions that always return nil errors (like hash.Hash.Write):
// BAD - gosec G104 warning
h := fnv.New64a()
h.Write([]byte(key)) // Error unhandled
// GOOD - Explicitly acknowledge the ignored return
h := fnv.New64a()
_, _ = h.Write([]byte(key)) // hash.Hash.Write never returns errorWhen to use `_, _ =`:
hash.Hash.Write()- Never returns error per specbytes.Buffer.Write()- Never returns errorstrings.Builder.WriteString()- Never returns error
revive: Error Naming
// BAD
var InvalidInput = errors.New("invalid input") // Should start with Err
type ValidationFailed struct{} // Should end with Error
// GOOD
var ErrInvalidInput = errors.New("invalid input")
type ValidationError struct{}revive: Stdlib Package Name Conflicts
The var-naming rule flags package names that conflict with Go stdlib packages. Common conflicts and safe alternatives:
| Avoid | Conflicts with | Use instead |
|---|---|---|
rpc | net/rpc | rpchandler, rpcapi |
jsonrpc | net/rpc/jsonrpc | jsonrpchandler, jsonrpcapi |
http | net/http | httputil, server |
log | log | logger, logging |
Check with: go list std | grep -w <name>
Also verify type names don't stutter after rename:
// BAD - stutters: rpchandler.JSONRPCResponse
type JSONRPCResponse struct { ... }
// GOOD - clean: rpchandler.Response
type Response struct { ... }golangci-lint: CI vs Local Version Drift
When CI uses version: latest in the golangci-lint-action, linter behavior may differ from local runs:
- gosec rules (e.g., G704 SSRF) may fire in CI but not locally due to version differences
- Use
//nolint:gosec,nolintlintto suppress in both environments nolintlintcomplains about unused directives when the target linter doesn't fire locally
// Suppresses gosec in CI and nolintlint locally when gosec doesn't fire
resp, err := client.Do(req) //nolint:gosec,nolintlint // G704: URL is a compile-time constantBest practice: Pin the golangci-lint version in CI to match local, or accept the dual-nolint pattern for edge cases.
Common Linter Gotchas
Specific linter rules that frequently trip up developers:
dupl: Test Function Duplication
The dupl linter flags test functions with similar structure (threshold ~30 lines). Extract shared logic into helpers:
// BAD - Two test functions with nearly identical structure triggers dupl
func TestHandlerA(t *testing.T) {
app := setupApp(t)
req := httptest.NewRequest(http.MethodGet, "/a", nil)
resp, err := app.Test(req)
require.NoError(t, err)
assert.Equal(t, 200, resp.StatusCode)
// ... 30+ lines of similar setup
}
// GOOD - Extract common pattern into a helper
func testEndpoint(t *testing.T, app *fiber.App, method, path string, wantStatus int) {
t.Helper()
req := httptest.NewRequest(method, path, nil)
resp, err := app.Test(req)
require.NoError(t, err)
assert.Equal(t, wantStatus, resp.StatusCode)
}nlreturn: Blank Line Before Return
The nlreturn linter requires a blank line before return statements, including inside closures and anonymous functions:
// BAD
func example() error {
result := compute()
return result // nlreturn: missing blank line before return
}
// GOOD
func example() error {
result := compute()
return result
}noctx: Network Dialing
The noctx linter flags net.DialTimeout(). Use net.Dialer instead:
// BAD - noctx flags this
conn, err := net.DialTimeout("tcp", address, 2*time.Second)
// GOOD - Use Dialer struct
conn, err := (&net.Dialer{Timeout: 2 * time.Second}).Dial("tcp", address)revive unused-parameter: Underscore Convention
For intentionally unused parameters, rename to _ prefix or bare _:
// BAD - revive flags unused parameter
func setup(t *testing.T) { /* t not used */ }
// GOOD - Explicitly mark as unused
func setup(_ *testing.T) { /* intentionally unused */ }Note: Only use _ for parameters that are truly unused. If you need t.Helper(), t.Cleanup(), etc., keep the parameter named.
errcheck: Deferred Close
The errcheck linter requires handling errors from Close() even in defer:
// BAD - errcheck flags this
defer conn.Close()
// GOOD - Explicitly discard the error
defer func() { _ = conn.Close() }()revive: Package Names with Underscores
Go convention discourages underscores in package names. When they are intentional (e.g., test packages with build tags), suppress with a nolint comment:
//nolint:revive // underscore in package name is intentional for build tag isolation
package integration_testgo fix — Automated Modernization (Go 1.26+)
Go 1.26 ships a rewritten go fix with 22 built-in modernizers. Run after Go upgrades:
go fix -diff ./... # Preview changes
go fix ./... # Apply changesKey modernizers: any, rangeint, slicescontains, mapsloop, minmax, waitgroup, testingcontext, reflecttypefor, stringscutprefix, stringsseq, stringsbuilder.
Always run linters after `go fix` — it may leave unused imports, redundant variables, or gofumpt issues.
See references/modernization.md for the full modernizer reference and manual migrations like errors.AsType[T].
Running Linters
Development Workflow
# Quick check during development
golangci-lint run --fast
# Full check before commit
golangci-lint run
# Check specific files
golangci-lint run ./pkg/...
# Auto-fix where possible
golangci-lint run --fixCI Configuration
# GitHub Actions
- name: golangci-lint
uses: golangci/golangci-lint-action@v6
with:
version: v1.62
args: --timeout 5mgci Import-Order Pre-commit (Recurring CI Friction)
gci import ordering differences between local formats and CI are the most common blocker across Go repos. Enforce the exact ordering that matches .golangci.yml before commit.
The lefthook block below is a fragment to merge into an existing `lefthook.yml` — not a standalone file. See references/lefthook-template.md for a complete starter config that this block slots into.
# One-shot fix across the whole module
gci write --skip-generated -s standard -s default -s localmodule .
# Pre-commit hook fragment (merge into existing lefthook.yml under pre-commit.commands)
pre-commit:
parallel: true
commands:
gci:
glob: "*.go"
run: gci write --skip-generated -s standard -s default -s localmodule {staged_files}
lint:
glob: "*.go"
run: golangci-lint runThe -s standard -s default -s localmodule ordering must match the sections: list under formatters.settings.gci in .golangci.yml. If the .golangci.yml uses prefix(github.com/org/project) instead of localmodule, the pre-commit gci call must match exactly.
Install: go install github.com/daixiang0/gci@latest
Pre-commit Hook (lefthook)
# .lefthook.yml
pre-commit:
parallel: true
commands:
lint:
glob: "*.go"
run: golangci-lint run --new-from-rev=HEAD~1Complexity Guidelines
| Metric | Threshold | Action if Exceeded |
|---|---|---|
| Cyclomatic (gocyclo) | 15 | Refactor or document why justified |
| Cognitive (gocognit) | 30 | Simplify or add exclusion with rationale |
| Function Length | 80 lines | Extract helper functions |
| Nesting Depth | 4 levels | Refactor with early returns |
When Complexity is Justified
Some functions have inherent complexity that cannot be reduced without fragmenting the algorithm:
1. Parser functions - Multiple input formats require branching 2. Time calculations - Multi-field wraparound (year/month/day/hour/minute/second) 3. State machines - Multiple states and transitions
In these cases, add an exclusion with clear documentation.
Makefile Integration
.PHONY: lint lint-full lint-fix
# Quick lint for development
lint:
golangci-lint run --fast
# Full lint for CI
lint-full:
golangci-lint run --timeout 5m
# Auto-fix issues
lint-fix:
golangci-lint run --fix
# Run specific linters only
lint-security:
golangci-lint run -E gosec,bidichk
lint-bugs:
golangci-lint run -E govet,staticcheck,errcheck,nilerrStructured Logging with log/slog
Why slog Over logrus
log/slog is Go's stdlib structured logging package (since Go 1.21). It replaces third-party loggers like logrus (maintenance mode since 2020) and zap.
Benefits of slog:
- Zero external dependencies
- Structured key-value pairs by design
- Pluggable handlers (
TextHandler,JSONHandler, custom) - Runtime-mutable log levels via
slog.LevelVar AddSource: truereplaces manualruntime.Callerhacks- Direct use as dependency —
*slog.LoggerIS the interface, no wrapper needed
Anti-pattern: Custom Logger interfaces wrapping slog. Don't create type Logger interface { Debug(msg string, args ...any) } — just use *slog.Logger directly. It already is a clean, well-designed interface. Custom wrappers block slog's handler ecosystem and add indirection for no benefit.
Setup
Basic Logger with LevelVar
func buildLogger(level string) (*slog.Logger, *slog.LevelVar) {
levelVar := &slog.LevelVar{}
// Map level string to slog level
switch strings.ToLower(level) {
case "trace", "debug":
levelVar.Set(slog.LevelDebug)
case "", "info":
levelVar.Set(slog.LevelInfo)
case "warning", "warn":
levelVar.Set(slog.LevelWarn)
case "error", "fatal", "panic", "critical":
levelVar.Set(slog.LevelError)
default:
levelVar.Set(slog.LevelInfo)
}
handler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
AddSource: true,
Level: levelVar,
})
return slog.New(handler), levelVar
}Key points:
slog.LevelVarenables runtime level changes without rebuilding the loggerAddSource: trueautomatically addssource=file.go:42— noruntime.Callerneeded- Store
levelVaralongside logger for commands that needApplyLogLevel
Runtime Level Changes
func ApplyLogLevel(level string, lv *slog.LevelVar) error {
if level == "" {
return nil
}
switch strings.ToLower(level) {
case "trace", "debug":
lv.Set(slog.LevelDebug)
case "info":
lv.Set(slog.LevelInfo)
case "warning", "warn":
lv.Set(slog.LevelWarn)
case "error", "fatal", "panic", "critical":
lv.Set(slog.LevelError)
default:
return fmt.Errorf("invalid log level %q", level)
}
return nil
}Backward compatibility note: slog's Level.UnmarshalText only recognizes DEBUG, INFO, WARN, ERROR. If migrating from logrus, add a pre-mapping for logrus level names like trace, warning, fatal, panic, notice, critical.
Structured Logging Patterns
Use Structured Attributes, Not fmt.Sprintf
// BAD: Buries structured data in formatted string
logger.Info(fmt.Sprintf("Scheduler started with %d jobs", jobCount))
// GOOD: Structured attributes enable machine parsing and filtering
logger.Info("Scheduler started", "jobCount", jobCount)
// BAD: Error details lost in string formatting
logger.Error(fmt.Sprintf("Job %s failed: %v", name, err))
// GOOD: Each field is independently queryable
logger.Error("Job failed", "job", name, "error", err)Key Naming Conventions
// Use camelCase for attribute keys (Go convention)
logger.Info("Request completed",
"method", r.Method,
"path", r.URL.Path,
"statusCode", resp.StatusCode,
"duration", time.Since(start),
)
// Group related attributes
logger.Info("Job completed",
slog.Group("job",
slog.String("name", job.GetName()),
slog.String("type", "exec"),
),
slog.Group("execution",
slog.Duration("duration", d),
slog.Bool("failed", false),
),
)Passing Loggers Through Structs
// Use *slog.Logger directly in struct fields — it IS the interface
type Scheduler struct {
Logger *slog.Logger
LevelVar *slog.LevelVar // Only if runtime level changes needed
// ...
}
type Context struct {
Logger *slog.Logger
Execution *Execution
Job Job
}
// Create child loggers with additional context
func (s *Scheduler) runJob(job Job) {
jobLogger := s.Logger.With("job", job.GetName())
jobLogger.Info("Starting job")
// ...
jobLogger.Info("Job completed", "duration", elapsed)
}Middleware Logging
// Logging middleware using slog
func WithLogging(logger *slog.Logger) Middleware {
return func(next Job) Job {
return JobFunc(func(ctx context.Context) error {
start := time.Now()
logger.Info("Starting job", "job", next.GetName())
err := next.Run(ctx)
attrs := []any{
"job", next.GetName(),
"duration", time.Since(start),
}
if err != nil {
logger.Error("Job failed", append(attrs, "error", err)...)
} else {
logger.Info("Job completed", attrs...)
}
return err
})
}
}Web Handler Logging
type Handler struct {
scheduler *Scheduler
logger *slog.Logger
}
func (h *Handler) TriggerJob(w http.ResponseWriter, r *http.Request) {
name := chi.URLParam(r, "name")
reqLogger := h.logger.With("handler", "TriggerJob", "jobName", name)
job, err := h.scheduler.GetJob(name)
if err != nil {
reqLogger.Warn("Job not found")
http.Error(w, "Job not found", http.StatusNotFound)
return
}
go func() {
if err := job.Run(context.Background()); err != nil {
reqLogger.Error("Manual job execution failed", "error", err)
}
}()
w.WriteHeader(http.StatusAccepted)
}Testing with slog
Discard Logger for Tests
// Simple: discard all logs
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
// With specific level (only errors logged)
logger := slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{
Level: slog.LevelError,
}))Capturing Logs in Tests
For tests that need to assert on log output, implement a custom slog.Handler:
type TestHandler struct {
mu sync.Mutex
records []slog.Record
}
func (h *TestHandler) Enabled(_ context.Context, _ slog.Level) bool {
return true
}
func (h *TestHandler) Handle(_ context.Context, r slog.Record) error {
h.mu.Lock()
defer h.mu.Unlock()
h.records = append(h.records, r.Clone())
return nil
}
func (h *TestHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
return &TestHandler{records: h.records}
}
func (h *TestHandler) WithGroup(_ string) slog.Handler {
return &TestHandler{records: h.records}
}
// Query helpers
func (h *TestHandler) HasMessage(msg string) bool {
h.mu.Lock()
defer h.mu.Unlock()
for _, r := range h.records {
if strings.Contains(r.Message, msg) {
return true
}
}
return false
}
func (h *TestHandler) HasAttr(key, value string) bool {
h.mu.Lock()
defer h.mu.Unlock()
for _, r := range h.records {
found := false
r.Attrs(func(a slog.Attr) bool {
if a.Key == key && strings.Contains(a.Value.String(), value) {
found = true
return false // found, stop iteration for this record
}
return true
})
if found {
return true
}
}
return false
}Important: The test handler captures r.Message separately from attributes. If your test assertions check for values that were moved from fmt.Sprintf to structured attributes during a migration, you need to update assertions to check attributes, not message strings.
// Usage in tests
func TestRetryLogging(t *testing.T) {
handler := &TestHandler{}
logger := slog.New(handler)
retrier := NewRetrier(logger)
retrier.Execute(failingFunc)
// Check message text
assert.True(t, handler.HasMessage("Job failed, retrying"))
// Check structured attributes
assert.True(t, handler.HasAttr("attempt", "1"))
assert.True(t, handler.HasAttr("maxRetries", "3"))
}Migration from logrus
Level Mapping
| logrus | slog | Notes |
|---|---|---|
Trace | Debug | slog has no Trace; use Debug |
Debug | Debug | Direct mapping |
Info | Info | Direct mapping |
Warn / Warning | Warn | Direct mapping |
Error | Error | Direct mapping |
Fatal | Error + os.Exit(1) | slog has no Fatal; log then exit |
Panic | Error + panic() | slog has no Panic; log then panic |
Callsite Conversion
// logrus printf-style
logger.Debugf("loaded config from %s", path)
logger.WithField("job", name).WithError(err).Error("execution failed")
logger.WithFields(logrus.Fields{"job": name, "attempt": n}).Warn("retrying")
// slog structured style
logger.Debug("loaded config", "file", path)
logger.Error("execution failed", "job", name, "error", err)
logger.Warn("retrying", "job", name, "attempt", n)Migration Checklist
1. Replace Logger interface/field types with *slog.Logger throughout 2. Replace logrus.New() with slog.New(slog.NewTextHandler(...)) 3. Convert logger.Debugf("msg %s", x) to logger.Debug("msg", "key", x) 4. Replace logrus.Fields{...} with inline key-value pairs 5. Replace WithError(err) with "error", err attribute 6. Replace WithField("k", v) with logger.With("k", v) for persistent context 7. Delete custom Logger interfaces — *slog.Logger IS the interface 8. Delete logrus adapter/wrapper code 9. Update test loggers (see Testing section above) 10. Run go mod tidy to remove logrus from go.mod 11. Add logrus to depguard deny list in .golangci.yml 12. Update CI workflows — remove references to deleted logging packages
CI Gotcha
When deleting a logging package, check CI workflow files for references:
# BAD: References deleted package — CI will fail with [setup failed]
go test -race ./core/... ./config/... ./logging/...
# GOOD: Removed deleted package
go test -race ./core/... ./config/...
# BAD: Matrix includes deleted package
package: [cli, core, config, logging, middlewares, web]
# GOOD: Removed from matrix
package: [cli, core, config, middlewares, web]Always grep CI workflows after deleting any package: grep -r "package-name" .github/workflows/
#!/bin/bash
# Go Project Verification Script
# Validates Go project structure and quality
set -e
PROJECT_DIR="${1:-.}"
ERRORS=0
WARNINGS=0
echo "=== Go Project Verification ==="
echo "Directory: $PROJECT_DIR"
echo ""
# Check go.mod exists
if [[ -f "$PROJECT_DIR/go.mod" ]]; then
echo "✅ go.mod found"
MODULE=$(grep "^module" "$PROJECT_DIR/go.mod" | awk '{print $2}')
echo " Module: $MODULE"
else
echo "❌ go.mod not found"
((ERRORS++))
fi
# Check go.sum exists
if [[ -f "$PROJECT_DIR/go.sum" ]]; then
echo "✅ go.sum found"
else
echo "⚠️ go.sum not found (run 'go mod tidy')"
((WARNINGS++))
fi
# Check standard directories
echo ""
echo "=== Directory Structure ==="
for dir in cmd core internal pkg; do
if [[ -d "$PROJECT_DIR/$dir" ]]; then
echo "✅ $dir/ exists"
fi
done
# Check for main.go
MAIN_FILES=$(find "$PROJECT_DIR" -name "main.go" 2>/dev/null | head -5)
if [[ -n "$MAIN_FILES" ]]; then
echo "✅ Entry points found:"
echo "$MAIN_FILES" | while read f; do echo " - $f"; done
else
echo "⚠️ No main.go found"
((WARNINGS++))
fi
# Run go vet
echo ""
echo "=== Static Analysis ==="
if command -v go &> /dev/null; then
cd "$PROJECT_DIR"
if go vet ./... 2>&1; then
echo "✅ go vet passed"
else
echo "❌ go vet found issues"
((ERRORS++))
fi
else
echo "⚠️ Go not installed, skipping vet"
((WARNINGS++))
fi
# Check for tests
echo ""
echo "=== Test Coverage ==="
TEST_FILES=$(find "$PROJECT_DIR" -name "*_test.go" 2>/dev/null | wc -l)
if [[ "$TEST_FILES" -gt 0 ]]; then
echo "✅ Found $TEST_FILES test files"
else
echo "⚠️ No test files found"
((WARNINGS++))
fi
# Check for Dockerfile
echo ""
echo "=== Deployment ==="
if [[ -f "$PROJECT_DIR/Dockerfile" ]]; then
echo "✅ Dockerfile found"
else
echo "⚠️ No Dockerfile found"
((WARNINGS++))
fi
# Check for Makefile
if [[ -f "$PROJECT_DIR/Makefile" ]]; then
echo "✅ Makefile found"
else
echo "⚠️ No Makefile found"
((WARNINGS++))
fi
# Summary
echo ""
echo "=== Summary ==="
echo "Errors: $ERRORS"
echo "Warnings: $WARNINGS"
if [[ $ERRORS -gt 0 ]]; then
echo "❌ Verification FAILED"
exit 1
else
echo "✅ Verification PASSED"
exit 0
fi