Go Idioms
Boring, explicit, race-safe Go. Accept interfaces, return structs.
Error Handling
Always wrap with context at package boundaries:
// Use %w to preserve error chain return fmt.Errorf("fetching user %s: %w", userID, err)
// Check wrapped errors if errors.Is(err, sql.ErrNoRows) { ... }
var paymentErr *PaymentError if errors.As(err, &paymentErr) { ... }
Never: %v (loses type), raw errors from exported functions, generic context.
Interface Design
Define interfaces in consuming package, not provider:
// notification/sender.go (consumer defines interface) type EmailSender interface { Send(ctx context.Context, to, subject, body string) error }
// email/client.go (provider implements) type Client struct { ... } func (c *Client) Send(ctx context.Context, to, subject, body string) error { ... }
Small interfaces (1-3 methods). Compose larger from smaller:
type Reader interface { Read(p []byte) (n int, err error) } type Writer interface { Write(p []byte) (n int, err error) } type ReadWriter interface { Reader; Writer }
Compile-time verification:
var _ EmailSender = (*Client)(nil)
Concurrency
Always propagate context:
func FetchData(ctx context.Context) ([]byte, error) { select { case <-ctx.Done(): return nil, ctx.Err() case result := <-dataChan: return result, nil } }
Bounded concurrency (semaphore):
sem := make(chan struct{}, 10) for _, item := range items { sem <- struct{}{} go func(item Item) { defer func() { <-sem }() process(item) }(item) }
Race safety: Always run go test -race ./...
Package Design
internal/ user/ # Domain: single purpose user.go service.go repository.go order/ # Another domain app/ # Dependency wiring cmd/ api/ # Entry points
Rules:
-
Single purpose per package
-
No generic names (utils, helpers, common)
-
No circular dependencies
-
Export only what's necessary
Dependency Injection
// Constructor accepts interfaces func NewUserService(repo UserRepository, mailer EmailSender) *UserService { return &UserService{repo: repo, mailer: mailer} }
// Wire in app/ package func NewApp() *App { repo := postgres.NewUserRepo(db) mailer := sendgrid.NewClient(apiKey) userSvc := user.NewUserService(repo, mailer) return &App{UserService: userSvc} }
Deps Struct Pattern (5+ Parameters)
When constructor takes 5+ parameters, use a deps struct:
// Group dependencies into named struct type ServiceDeps struct { Repo Repository Mailer EmailSender Logger Logger Config *Config Cache Cache }
// Panic on nil - catches programming errors at construction func NewService(deps ServiceDeps) *Service { if deps.Repo == nil { panic("NewService: Repo cannot be nil") } if deps.Mailer == nil { panic("NewService: Mailer cannot be nil") } // ... check all required deps return &Service{...} }
Benefits:
-
Named fields = self-documenting call sites
-
Adding deps doesn't change signature
-
Nil panics catch bugs at construction, not at runtime
When changing old constructor to deps struct:
-
Update ALL call sites to struct literal pattern
-
Check for tests expecting old nil-tolerance behavior
Test Cleanup
Use t.Cleanup() for teardown, not defer with error suppression:
// BAD - Error suppression hides failures defer func() { _ = os.Chdir(orig) }() // SA4017 warning
// GOOD - Proper cleanup with error handling t.Cleanup(func() { if err := os.Chdir(orig); err != nil { t.Errorf("cleanup failed: %v", err) } })
t.Cleanup runs after test completes (even on panic), integrates with test reporting.
Anti-Patterns
-
Goroutines without cancellation path (leaks)
-
Monolithic interfaces (10+ methods)
-
Framework-like inheritance patterns
-
Reflection when explicit types work
-
Global singletons for dependencies
-
Generic everything (overuse of generics)
-
interface{} / any without justification
-
defer with error suppression in tests
Embrace Boring
-
Explicit error handling at each step
-
Standard library first (map , []T , sort.Slice )
-
Table-driven tests
-
Struct composition, not inheritance
-
Clear, verbose code over clever code