This document catalogs coding conventions for Go CLI tools. It is intended for use as an operationalized code review prompt for AI-assisted review, but is also useful as a human reference.
When reviewing code, flag deviations from these patterns. Be pragmatic: the goal is consistency within a codebase, not pedantic enforcement. If a deviation improves readability or correctness, note it as an intentional departure rather than a defect.
Prefer clarity, composability, and maintainability over cleverness. Go's strength is boringly readable code — lean into that. Use the standard library aggressively. Resist the urge to abstract prematurely or import a dependency for something you can write in 20 lines. Use judgement, not dogma.
Every tool gets its own go.mod. For a monorepo with shared libraries, use a top-level module with internal packages:
tools/
├── go.mod
├── go.sum
├── cmd/
│ ├── ingest/
│ │ └── main.go
│ ├── reconcile/
│ │ └── main.go
│ └── sync/
│ └── main.go
├── internal/
│ ├── config/
│ ├── logging/
│ └── aws/
└── pkg/ # only if genuinely intended for external consumption
internal/ is the default for shared code. pkg/ is only for packages explicitly designed as public API for other modules. When in doubt, use internal/.
Pin the Go version in go.mod and use a .tool-versions or go.env for the team:
go 1.24
Use go.sum for reproducible builds. Run go mod tidy before every commit — CI should fail if go.mod and go.sum are dirty.
All projects use golangci-lint with a shared .golangci.yml. At minimum, enable:
linters:
enable:
- errcheck
- govet
- staticcheck
- unused
- ineffassign
- misspell
- revive
- gosec
- errorlint # enforce error wrapping best practices
- exhaustive # enforce exhaustive switch/select on enumsgo vet and staticcheck findings are non-negotiable. Treat them as errors in CI.
Order imports in three groups separated by blank lines: standard library, external dependencies, internal packages:
import (
"context"
"fmt"
"os"
"github.com/spf13/cobra"
"go.uber.org/zap"
"github.com/yourorg/tools/internal/config"
)goimports enforces this automatically. Run it on save.
Every repo has a Makefile at the root. This is the answer to "I just cloned this repo, now what." CI runs the same targets developers run locally.
.PHONY: build lint test tidy check
# Build all binaries into bin/
build:
go build -o bin/ ./cmd/...
# Lint with golangci-lint (config in .golangci.yml)
lint:
golangci-lint run ./...
# Run tests with race detector
test:
go test -race ./...
# Tidy and verify modules are clean
tidy:
go mod tidy
git diff --exit-code go.mod go.sum
# CI gate: everything that must pass before merge
check: tidy lint test buildRules:
make checkis the CI gate. It must pass before merge. Run it locally before pushing.make buildoutputs all binaries tobin/. Addbin/to.gitignore.make tidyfails ifgo.modorgo.sumare dirty — this catches forgottengo mod tidyruns.- Add tool-specific targets as needed (
make migrate,make generate,make integration-test), but the four core targets (build,lint,test,tidy) are non-negotiable. - Keep targets simple. If a target exceeds ~5 lines of shell, it belongs in a script in
scripts/that the Makefile calls.
Go doesn't have records, but the same instinct applies: separate data-carrying types from service types. Data structs should be plain, exported fields. Service types hold dependencies and attach methods:
// Data: plain struct, no methods beyond serialization
type SyncResult struct {
CompanyID string
Success bool
FailedKeys []string
Duration time.Duration
}
// Service: holds dependencies, has methods
type Reconciler struct {
db *sql.DB
logger *slog.Logger
clock func() time.Time // injectable for testing
}Small data types (< ~128 bytes, no mutability needs) should be passed and returned by value, not pointer. This is Go's equivalent of preferring value types:
// Good: small, immutable-ish, pass by value
type Tenant struct {
ID string
Name string
}
type DateRange struct {
Start time.Time
End time.Time
}
// Pointer receiver appropriate: large struct or needs mutation
type IngestionState struct {
// ... many fields, mutated over lifetime
}
func (s *IngestionState) MarkComplete(key string) { ... }Wrap primitive identifiers in named types to prevent parameter confusion:
type TenantID string
type CompanyID string
type BusinessID string
func GetConnection(tenant TenantID, company CompanyID) (*Connection, error) { ... }This makes GetConnection(companyID, tenantID) a compile error instead of a subtle bug. Use sparingly — only where misorderings are a real risk (multiple string parameters of the same shape).
Use NewX functions when a type requires initialization, validation, or has unexported fields. Return the concrete type, not an interface:
func NewReconciler(db *sql.DB, logger *slog.Logger, opts ...Option) *Reconciler {
r := &Reconciler{
db: db,
logger: logger,
clock: time.Now,
}
for _, opt := range opts {
opt(r)
}
return r
}For simple structs with all-exported fields, struct literals are fine — no constructor needed.
Go lacks sum types. Use typed constants with iota, and always handle the zero value explicitly:
type PlatformType int
const (
PlatformUnknown PlatformType = iota // zero value = unknown
PlatformAccounting
PlatformBanking
PlatformCommerce
)
func (p PlatformType) String() string {
switch p {
case PlatformAccounting:
return "Accounting"
case PlatformBanking:
return "Banking"
case PlatformCommerce:
return "Commerce"
default:
return fmt.Sprintf("PlatformType(%d)", p)
}
}For cases where you need exhaustiveness checking, exhaustive lint catches missing switch arms.
This is the single most important Go design principle. Define interfaces at the call site (consumer), not at the implementation:
// Good: interface defined where it's consumed, not where it's implemented
// In reconciler.go:
type AccountFetcher interface {
GetAccounts(ctx context.Context, tenant TenantID) ([]Account, error)
}
type Reconciler struct {
accounts AccountFetcher
// ...
}
// In accounts.go — no interface declared here, just a concrete type
type AccountStore struct {
db *sql.DB
}
func (s *AccountStore) GetAccounts(ctx context.Context, tenant TenantID) ([]Account, error) { ... }One to three methods is ideal. If an interface has more than five methods, it's probably doing too much. The standard library's io.Reader (one method) is the gold standard.
// Good: focused interface
type TokenRefresher interface {
RefreshToken(ctx context.Context, tenant TenantID) (Token, error)
}
// Suspicious: interface is a service dump
type BusinessManager interface {
GetBusiness(ctx context.Context, id string) (*Business, error)
CreateBusiness(ctx context.Context, b Business) error
UpdateBusiness(ctx context.Context, b Business) error
DeleteBusiness(ctx context.Context, id string) error
ListBusinesses(ctx context.Context, tenant TenantID) ([]Business, error)
GetBusinessConnections(ctx context.Context, id string) ([]Connection, error)
// ... this is just a struct with extra steps
}any (interface{}) in function signatures is almost always a design smell. It means "I gave up on types." Acceptable uses: logging arguments, JSON marshaling boundaries, generic containers. Unacceptable: core business logic parameters.
Every function that can fail returns an error. Check it immediately. Never discard errors silently:
// Good: check immediately, handle or propagate
result, err := store.GetAccounts(ctx, tenant)
if err != nil {
return nil, fmt.Errorf("fetching accounts for %s: %w", tenant, err)
}Always wrap errors with fmt.Errorf("context: %w", err) to build a trace. The message should describe what this function was trying to do, not repeat what the callee said:
// Good: each layer adds its own context
func (r *Reconciler) Run(ctx context.Context, tenant TenantID) error {
accounts, err := r.accounts.GetAccounts(ctx, tenant)
if err != nil {
return fmt.Errorf("reconciling tenant %s: %w", tenant, err)
}
// ...
}
// Bad: restating the callee's error
if err != nil {
return fmt.Errorf("failed to get accounts: %w", err) // "failed to" is noise
}Define sentinel errors for conditions callers need to match on. Use custom error types when callers need structured data:
var (
ErrNotFound = errors.New("not found")
ErrAlreadyExists = errors.New("already exists")
ErrRateLimited = errors.New("rate limited")
)
// Callers check with errors.Is:
if errors.Is(err, ErrNotFound) {
// handle missing resource
}
// Custom error type when callers need details:
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation: %s: %s", e.Field, e.Message)
}
// Callers check with errors.As:
var ve *ValidationError
if errors.As(err, &ve) {
fmt.Printf("bad field: %s\n", ve.Field)
}panic is for programmer bugs (impossible states, violated invariants in init), never for runtime errors. A CLI tool that panics on bad input is broken. Recover from panics only at the outermost boundary (e.g., a top-level middleware in a server, or the root command's RunE).
Go's error handling naturally produces guard clauses. Embrace them — never nest the happy path inside else:
// Good: guard clause, happy path is un-indented
token, err := auth.GetToken(ctx, tenant)
if err != nil {
return fmt.Errorf("getting token: %w", err)
}
// continue with token...
// Bad: unnecessary nesting
token, err := auth.GetToken(ctx, tenant)
if err == nil {
// happy path buried in a branch
} else {
return err
}Every function that does I/O, calls other services, or could be cancelled takes context.Context as its first parameter. Named ctx:
func (s *SyncService) Sync(ctx context.Context, tenant TenantID, companyID CompanyID) errorContext is request-scoped. Storing it in a struct means you're holding onto a cancelled context or sharing one across requests:
// Bad: context outlives the request
type Worker struct {
ctx context.Context // don't do this
}
// Good: pass per-call
func (w *Worker) Process(ctx context.Context, job Job) error { ... }Check ctx.Err() or use select on ctx.Done() in loops and before expensive operations:
for _, batch := range batches {
if err := ctx.Err(); err != nil {
return fmt.Errorf("cancelled during batch processing: %w", err)
}
if err := processBatch(ctx, batch); err != nil {
return err
}
}Use Cobra for every CLI tool, even single-command ones. The cognitive cost of "which framework did this tool use" is worse than the tiny overhead of Cobra on a simple tool. Cobra gives you consistent --help, flag parsing, subcommand structure, and shell completion for free. Standardize on it and stop thinking about it.
func main() {
root := &cobra.Command{
Use: "mytool",
Short: "Does the thing",
// No Run on root — forces subcommand usage
}
root.AddCommand(
newSyncCmd(),
newReconcileCmd(),
newReportCmd(),
)
if err := root.Execute(); err != nil {
os.Exit(1)
}
}Each subcommand lives in its own file and returns a *cobra.Command. Wire dependencies in the RunE closure:
func newSyncCmd() *cobra.Command {
var (
tenant string
dryRun bool
workers int
)
cmd := &cobra.Command{
Use: "sync [company-id]",
Short: "Sync data for a company",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
companyID := args[0]
cfg, err := config.Load()
if err != nil {
return fmt.Errorf("loading config: %w", err)
}
logger := logging.New(cfg.LogLevel)
db, err := openDB(ctx, cfg.DatabaseURL)
if err != nil {
return fmt.Errorf("connecting to database: %w", err)
}
defer db.Close()
svc := NewSyncService(db, logger)
return svc.Run(ctx, TenantID(tenant), CompanyID(companyID), dryRun)
},
}
cmd.Flags().StringVar(&tenant, "tenant", "", "tenant identifier (required)")
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "preview changes without writing")
cmd.Flags().IntVar(&workers, "workers", 4, "number of parallel workers")
_ = cmd.MarkFlagRequired("tenant")
return cmd
}Use distinct exit codes for different failure modes. Define them as constants:
const (
ExitOK = 0
ExitUsageError = 1
ExitRuntimeError = 2
ExitConfigError = 3
ExitPartialFailure = 4
)Cobra handles exit code 1 for usage errors by default. For other cases, handle in main:
func main() {
if err := root.Execute(); err != nil {
var cfgErr *config.Error
if errors.As(err, &cfgErr) {
os.Exit(ExitConfigError)
}
os.Exit(ExitRuntimeError)
}
}Standard output is for data (pipeable results). Standard error is for diagnostics (logs, progress, errors). Never mix them:
// Data goes to stdout — can be piped to jq, another tool, etc.
enc := json.NewEncoder(os.Stdout)
enc.Encode(result)
// Diagnostics go to stderr
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))If the tool's primary output is human-readable (not piped), stdout is fine for both, but design for the pipeable case first.
CLI tools should handle SIGINT/SIGTERM gracefully. Use signal.NotifyContext for cancellation:
func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := run(ctx); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
}Configuration sources, in precedence order: environment variables override flags, flags override file values, file values override defaults. Use a single config struct:
type Config struct {
DatabaseURL string `env:"DATABASE_URL" json:"database_url"`
LogLevel string `env:"LOG_LEVEL" json:"log_level"`
Workers int `env:"WORKERS" json:"workers"`
Timeout time.Duration `env:"TIMEOUT" json:"timeout"`
DryRun bool // flag-only, no file/env
}
func Load() (*Config, error) {
cfg := Config{
LogLevel: "info",
Workers: 4,
Timeout: 30 * time.Second,
}
// Load from file if present, then overlay env vars
// ...
if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("invalid config: %w", err)
}
return &cfg, nil
}Validate all configuration at startup before doing any work. A config error 30 minutes into a batch job is a waste:
func (c *Config) Validate() error {
if c.DatabaseURL == "" {
return errors.New("DATABASE_URL is required")
}
if c.Workers < 1 || c.Workers > 64 {
return fmt.Errorf("workers must be 1-64, got %d", c.Workers)
}
return nil
}Inject a clock function instead of calling time.Now() directly. This is the same principle as C#'s TimeProvider:
// In production
svc := &Service{clock: time.Now}
// In tests
svc := &Service{clock: func() time.Time { return fixedTime }}For more complex time needs, define a small interface:
type Clock interface {
Now() time.Time
}Every goroutine must have a clear shutdown path. Use context.Context for cancellation and sync.WaitGroup or errgroup.Group for completion:
g, ctx := errgroup.WithContext(ctx)
for _, job := range jobs {
g.Go(func() error {
return processJob(ctx, job)
})
}
if err := g.Wait(); err != nil {
return fmt.Errorf("processing jobs: %w", err)
}errgroup is the default for parallel work in CLI tools. It handles cancellation on first error and waitgroup semantics in one package:
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(workers) // bounded parallelism
for _, item := range items {
g.Go(func() error {
return process(ctx, item)
})
}
return g.Wait()Use channels when data flows between stages. Use sync.WaitGroup, errgroup, or sync.Mutex for synchronization. Don't use a chan struct{} when a WaitGroup is clearer:
// Good: channel as a pipeline stage
func produce(ctx context.Context, items []Item) <-chan Item {
ch := make(chan Item)
go func() {
defer close(ch)
for _, item := range items {
select {
case ch <- item:
case <-ctx.Done():
return
}
}
}()
return ch
}Always limit concurrency for I/O-bound work. A CLI tool that launches 10,000 goroutines to hit an API will get rate-limited or OOM. Use errgroup.SetLimit, a semaphore channel, or a worker pool:
// Semaphore pattern
sem := make(chan struct{}, maxConcurrency)
for _, item := range items {
sem <- struct{}{}
go func() {
defer func() { <-sem }()
process(ctx, item)
}()
}Use database/sql as the interface layer. pgx is preferred as the driver for PostgreSQL (better performance, native types). Dapper-style explicit SQL applies equally here — write your own queries, don't hide behind an ORM:
const accountsQuery = `
WITH target AS (
SELECT b.id AS business_id
FROM business b
JOIN financial_institution fi ON b.tenant_id = fi.tenant_id
WHERE fi.fi_identifier = $1 AND b.company_id = $2
)
SELECT a.id, a.name, a.type
FROM account a
JOIN target t ON a.business_id = t.business_id
ORDER BY a.name
`
func (s *AccountStore) GetAccounts(ctx context.Context, tenant TenantID, company CompanyID) ([]Account, error) {
rows, err := s.db.QueryContext(ctx, accountsQuery, string(tenant), string(company))
if err != nil {
return nil, fmt.Errorf("querying accounts: %w", err)
}
defer rows.Close()
var accounts []Account
for rows.Next() {
var a Account
if err := rows.Scan(&a.ID, &a.Name, &a.Type); err != nil {
return nil, fmt.Errorf("scanning account row: %w", err)
}
accounts = append(accounts, a)
}
return accounts, rows.Err()
}These carry over directly from the C# guide:
- Always specify columns — avoid
SELECT * - Always use parameterized queries (
$1,$2), neverfmt.Sprintfinto SQL - Use CTEs over subqueries for readability
- Paginate large result sets; prefer cursor-based pagination over
OFFSET/LIMIT - Batch large
INclauses (100+ items) withANY($1::text[])or temp tables
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("beginning transaction: %w", err)
}
defer tx.Rollback() // no-op if committed
// ... operations using tx ...
if err := tx.Commit(); err != nil {
return fmt.Errorf("committing transaction: %w", err)
}The defer tx.Rollback() pattern is idiomatic — it's a no-op after a successful commit and ensures cleanup on any error path.
Use encoding/json by default. For performance-sensitive paths, json/v2 (when stable) or github.com/goccy/go-json are acceptable drop-in replacements.
type SyncRequest struct {
TenantID string `json:"tenant_id"`
CompanyID string `json:"company_id"`
Platform PlatformType `json:"platform"`
Priority int `json:"priority,omitempty"`
}Use omitempty deliberately — it means "omit when zero value," which may or may not be what you want. An int field with omitempty drops 0, which may be meaningful.
func (p PlatformType) MarshalJSON() ([]byte, error) {
return json.Marshal(p.String())
}
func (p *PlatformType) UnmarshalJSON(data []byte) error {
var s string
if err := json.Unmarshal(data, &s); err != nil {
return err
}
switch s {
case "Accounting":
*p = PlatformAccounting
case "Banking":
*p = PlatformBanking
default:
return fmt.Errorf("unknown platform type: %q", s)
}
return nil
}Use log/slog from the standard library for CLI tools. It's zero-dependency, has the right level of abstraction for console programs, and writes to stderr by default (which is what you want for CLIs — see Section 6 on stdout/stderr discipline).
For long-running web services where logging is on the hot path, go.uber.org/zap is acceptable — it's measurably faster due to pre-allocation and zero-reflection design. But for CLI tools, logging throughput is never the bottleneck, and slog's simplicity wins.
// CLI: slog with text handler for human-readable output
handler := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
Level: slog.LevelInfo,
})
logger := slog.New(handler)
// CLI: JSON handler when output will be ingested by log aggregation
handler := slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{
Level: slog.LevelInfo,
})
logger := slog.New(handler)
// Good: structured key-value pairs
logger.Info("calculating insights",
"tenant", tenant,
"company_id", companyID,
"platform", platform,
)
logger.Error("sync failed",
"tenant", tenant,
"company_id", companyID,
"error", err,
"elapsed_ms", elapsed.Milliseconds(),
)Same principle as the C# guide — each value must be a discrete, queryable field:
// Good: each field independently queryable
logger.Info("processing transaction",
"tenant", tenant,
"company_id", companyID,
"txn_id", txnID,
)
// Bad: opaque string defeats structured logging
logger.Info(fmt.Sprintf("%s::%s - processing transaction %s", tenant, companyID, txnID))Pass loggers as dependencies, not globals. Use slog.With to add context that applies to all messages in a scope:
func (s *SyncService) Run(ctx context.Context, tenant TenantID, company CompanyID) error {
log := s.logger.With("tenant", tenant, "company_id", company)
log.Info("starting sync")
// all subsequent log calls in this scope include tenant and company_id
}| Level | Usage |
|---|---|
| Info | Start/completion of major operations, business events |
| Warn | Retry attempts, degraded scenarios, non-critical issues |
| Error | Failures (always include the error value) |
| Debug | Detailed operational info, only enabled in dev/troubleshooting |
start := time.Now()
// ... work
logger.Info("operation complete",
"tenant", tenant,
"elapsed_ms", time.Since(start).Milliseconds(),
)Never log sensitive information: passwords, tokens, PII, full credit card numbers, SSNs. Be cautious with user attributes — only log what's necessary for debugging.
Same as C#: reject invalid states early, keep the happy path at the lowest indentation level:
func (s *Service) Process(ctx context.Context, req Request) (*Result, error) {
if req.TenantID == "" {
return nil, &ValidationError{Field: "tenant_id", Message: "required"}
}
conn, err := s.getConnection(ctx, req.TenantID)
if err != nil {
return nil, fmt.Errorf("getting connection: %w", err)
}
// happy path continues un-indented...
}Go's multiple return values serve the same role as C#'s tuple returns:
// Found vs not-found vs error are three different outcomes
func (s *Store) GetAccount(ctx context.Context, id string) (account Account, found bool, err error) {
row := s.db.QueryRowContext(ctx, query, id)
if err := row.Scan(&account.ID, &account.Name); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return Account{}, false, nil
}
return Account{}, false, fmt.Errorf("scanning account: %w", err)
}
return account, true, nil
}For lookups that may miss, use the comma-ok pattern familiar from map access:
val, ok := cache[key]
if !ok {
// handle miss
}For operations that process multiple items where you want partial results, collect errors rather than failing on the first one:
var errs []error
for _, item := range items {
if err := process(ctx, item); err != nil {
errs = append(errs, fmt.Errorf("item %s: %w", item.ID, err))
continue
}
}
if len(errs) > 0 {
return fmt.Errorf("partial failure (%d/%d): %w", len(errs), len(items), errors.Join(errs...))
}In Go, a nil slice and an empty slice behave identically for len, cap, range, and append. Prefer nil (the zero value) — don't allocate when there's nothing to hold:
// Good: zero value is fine
var accounts []Account
// len(accounts) == 0, range works, append works
// Unnecessary: allocating for no reason
accounts := make([]Account, 0)
accounts := []Account{}Exception: JSON serialization. json.Marshal(nil) produces null, while json.Marshal([]Account{}) produces []. If the distinction matters to consumers, initialize explicitly.
results := make([]Result, 0, len(items))
for _, item := range items {
results = append(results, transform(item))
}Use maps.Keys, maps.Values, maps.Clone from the standard library instead of hand-rolling:
import "maps"
keys := slices.Sorted(maps.Keys(accountsByID))Use slices.SortFunc, slices.Contains, slices.Compact, etc.:
import "slices"
slices.SortFunc(accounts, func(a, b Account) int {
return strings.Compare(a.Name, b.Name)
})
hasAdmin := slices.ContainsFunc(roles, func(r Role) bool {
return r.Name == "admin"
})Same concept as C#'s .Chunk() — batch items for APIs with size limits:
func Chunk[T any](items []T, size int) [][]T {
var chunks [][]T
for size < len(items) {
items, chunks = items[size:], append(chunks, items[:size])
}
return append(chunks, items)
}
// Usage: DynamoDB BatchWriteItem supports max 25 items
for _, batch := range Chunk(writeRequests, 25) {
if err := writeBatch(ctx, batch); err != nil {
return err
}
}Or use slices.Chunk if available on your Go version.
Use testing from the standard library. No testify, no gomega, no ginkgo. Table-driven tests and t.Helper() cover nearly everything. If you need mocks, write them by hand or use a small code generator — a mock framework dependency is almost never worth it.
The default test structure. Each case is a named struct in a slice:
func TestGetPrimaryKeyName(t *testing.T) {
tests := []struct {
name string
platform PlatformType
want string
wantErr bool
}{
{name: "accounting", platform: PlatformAccounting, want: "companyAndInsight"},
{name: "banking", platform: PlatformBanking, want: "extCompanyId"},
{name: "unknown panics", platform: PlatformUnknown, wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := GetPrimaryKeyName(tt.platform)
if tt.wantErr {
if err == nil {
t.Fatal("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tt.want {
t.Errorf("GetPrimaryKeyName(%v) = %q, want %q", tt.platform, got, tt.want)
}
})
}
}Use t.Helper() for functions that report failures on behalf of the caller:
func assertNoError(t *testing.T, err error) {
t.Helper()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func assertEqual[T comparable](t *testing.T, got, want T) {
t.Helper()
if got != want {
t.Errorf("got %v, want %v", got, want)
}
}For complex test data, use testdata/ directories. For output comparison, use golden files:
func TestRenderReport(t *testing.T) {
got := renderReport(testInput)
golden := filepath.Join("testdata", t.Name()+".golden")
if *update {
os.WriteFile(golden, []byte(got), 0644)
}
want, _ := os.ReadFile(golden)
if got != string(want) {
t.Errorf("output mismatch; run with -update to refresh golden files")
}
}Write simple fake structs that satisfy interfaces. They're more readable and more maintainable than mock framework magic:
type fakeAccountStore struct {
accounts []Account
err error
}
func (f *fakeAccountStore) GetAccounts(ctx context.Context, tenant TenantID) ([]Account, error) {
return f.accounts, f.err
}
// In test:
store := &fakeAccountStore{
accounts: []Account{{ID: "1", Name: "Test"}},
}
svc := NewReconciler(store, slog.Default())Pattern: TestFunctionName_Scenario using sub-tests for cases:
func TestReconciler_Run(t *testing.T) {
t.Run("empty accounts returns early", func(t *testing.T) { ... })
t.Run("mismatched totals returns error", func(t *testing.T) { ... })
t.Run("successful reconciliation", func(t *testing.T) { ... })
}Mark tests as parallel when they don't share mutable state:
func TestExpensiveComputation(t *testing.T) {
t.Parallel()
// ...
}For table-driven tests, capture the loop variable and run subtests in parallel:
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
// ...
})
}All code is formatted with gofmt. No exceptions, no arguments, no custom settings. Use goimports as a superset (handles import ordering too). Configure your editor to run it on save.
Go has no official line limit, but target ~100-120 characters for readability. Wrap function signatures and long expressions:
func (s *SyncService) ProcessBatch(
ctx context.Context,
tenant TenantID,
companyID CompanyID,
items []SyncItem,
opts ProcessOptions,
) (*BatchResult, error) {Within a file, order declarations:
- Package-level constants and variables
- Types (structs, interfaces)
- Constructor functions (
NewX) - Methods grouped by receiver type
- Package-level functions (helpers, utilities)
Major types get their own file. Small related types (a struct and its constructor, an interface and a helper) can share a file. If a file exceeds ~400 lines, consider splitting.
Same as C#: comments explain why, not what. If the what/how isn't clear, improve the name:
// Bad: restating the code
// Check if rate is greater than zero
if rate > 0 { ... }
// Good: explaining domain knowledge
// Fed data uses VEB (bolivar fuerte) instead of the current ISO code VES (bolivar soberano).
if strings.EqualFold(code, "VES") {
return "VEB"
}Every package should have a doc comment in a doc.go or at the top of the primary file:
// Package reconcile provides tools for reconciling account data
// between external platforms and the internal ledger.
package reconcileAll exported functions, types, and methods have doc comments. Start with the name of the thing:
// GetAccounts returns all accounts for the given tenant and company.
// It returns an empty slice (not nil) if no accounts are found.
func (s *Store) GetAccounts(ctx context.Context, tenant TenantID, company CompanyID) ([]Account, error) {These are non-negotiable — they're enforced by the compiler and community norms:
- Exported identifiers are
PascalCase:GetAccounts,SyncResult,ErrNotFound - Unexported identifiers are
camelCase:processItem,accountStore,defaultTimeout - Acronyms are all-caps:
ID,URL,HTTP,API— notId,Url,Http - Package names are lowercase, single word when possible:
config,sync,ledger— notledgerUtils,sync_helpers - Interface names: single-method interfaces use the
-ersuffix:Reader,Writer,Closer,Fetcher. Multi-method interfaces describe the role:AccountStore,TokenProvider
Use one or two letter abbreviations, consistent across all methods on a type. Never self or this:
func (r *Reconciler) Run(ctx context.Context) error { ... }
func (r *Reconciler) validate() error { ... }
func (s *Store) GetAccounts(ctx context.Context) ([]Account, error) { ... }Short names for short scopes, descriptive names for long scopes:
// Good: short scope, short name
for i, a := range accounts { ... }
// Good: longer scope, descriptive name
var activeAccounts []Account
for _, account := range allAccounts {
if account.IsActive {
activeAccounts = append(activeAccounts, account)
}
}Package names qualify their exports. Don't repeat the package name in the type name:
// Bad: config.ConfigOptions stutters
package config
type ConfigOptions struct { ... }
// Good: config.Options reads naturally
package config
type Options struct { ... }Before reaching for a third-party package, check if the standard library covers it. Go's stdlib is unusually comprehensive. Common cases where teams reach for dependencies unnecessarily:
- HTTP clients:
net/httpis excellent. You rarely need a wrapper. - JSON:
encoding/jsoncovers most cases. Only reach for alternatives on hot paths with benchmarks. - Logging:
log/slogfor CLI tools (see Section 11).zapis acceptable for web services. - Testing:
testing+ table-driven tests covers 95% of needs.
These are fine to pull in without justification:
github.com/spf13/cobra— CLI framework (mandatory for all tools, see Section 6)github.com/aws/aws-sdk-go-v2— AWS API accessgithub.com/jackc/pgx/v5— PostgreSQL drivergolang.org/x/sync/errgroup— parallel goroutine managementgo.uber.org/zap— structured logging for web services (not CLIs)
Everything else needs a reason. "It's popular" is not a reason.
Run go get -u ./... and go mod tidy regularly. Pin major versions in go.mod. Review changelogs for security patches.
Go's zero values ("", 0, false, nil for pointers/slices/maps) are part of the type system. Design types so that the zero value is useful:
// Good: zero value is a valid, empty state
type BatchResult struct {
Processed int
Failed int
Errors []error // nil = no errors
}
// r := BatchResult{} is already valid, means "nothing processed, no errors"Use pointer fields when the zero value is meaningful and you need to distinguish "unset" from "zero":
type UpdateRequest struct {
Name *string // nil = don't update, "" = set to empty
Workers *int // nil = don't update, 0 = valid value
Active *bool // nil = don't update, false = valid value
}Same philosophy as the C# guide: eliminate nil/zero concerns at the edges. Inside the domain, types should carry only valid state:
// Boundary: validate and reject
func (h *Handler) HandleSync(req *http.Request) error {
var input SyncRequest
if err := json.NewDecoder(req.Body).Decode(&input); err != nil {
return fmt.Errorf("invalid request body: %w", err)
}
if input.TenantID == "" {
return &ValidationError{Field: "tenant_id", Message: "required"}
}
// domain functions receive validated, non-zero data
return h.svc.Sync(req.Context(), TenantID(input.TenantID), CompanyID(input.CompanyID))
}When serializing for external consumers, initialize slices if null vs [] matters:
type Response struct {
Items []Item `json:"items"`
}
// If Items might be nil, initialize before marshaling:
if resp.Items == nil {
resp.Items = []Item{}
}All new code uses aws-sdk-go-v2. Do not use v1 (aws-sdk-go).
out, err := client.ReceiveMessage(ctx, &sqs.ReceiveMessageInput{
QueueUrl: &queueURL,
WaitTimeSeconds: 20,
MaxNumberOfMessages: 10,
})Use the paginator helpers from SDK v2:
paginator := s3.NewListObjectsV2Paginator(client, &s3.ListObjectsV2Input{
Bucket: &bucket,
Prefix: &prefix,
})
for paginator.HasMorePages() {
page, err := paginator.NextPage(ctx)
if err != nil {
return fmt.Errorf("listing objects: %w", err)
}
for _, obj := range page.Contents {
// process
}
}// BatchWriteItem supports max 25 items
for _, batch := range Chunk(writeRequests, 25) {
_, err := client.BatchWriteItem(ctx, &dynamodb.BatchWriteItemInput{
RequestItems: map[string][]types.WriteRequest{
tableName: batch,
},
})
if err != nil {
return fmt.Errorf("batch write: %w", err)
}
}Same principle as the C# guide: check whether Go's stdlib already provides the functionality before writing a utility. In particular:
slicesandmapspackages replace many hand-rolled loops (Go 1.21+)slogreplaces custom logging for CLI tools (Go 1.21+);zapremains appropriate for serviceserrors.Joinreplaces custom multi-error types (Go 1.20+)sync.OnceValuereplaces lazy initialization patterns (Go 1.21+)http.NewServeMuxpattern matching replaces many router libraries (Go 1.22+)
Go's float64 has the same problems as C#'s double. Use shopspring/decimal or a similar arbitrary-precision library for monetary calculations. Never float64 for money:
import "github.com/shopspring/decimal"
rate := decimal.NewFromString("0.0425")
monthly := rate.Div(decimal.NewFromInt(12))Represent timestamps as time.Time, not Unix epoch integers. Convert at boundaries (JSON serialization, database storage), not in domain logic.
Same as C#: a function named GetExchangeRate can use unsafe.Pointer arithmetic internally if profiling demands it. The caller sees a clean API. Optimize hot paths, not cold paths. Profile before optimizing.
Go doesn't have inheritance. Use embedding for shared structure, interfaces for shared behavior:
// Embedding: shared fields
type BaseJob struct {
TenantID TenantID
CompanyID CompanyID
CreatedAt time.Time
}
type SyncJob struct {
BaseJob
Platform PlatformType
Priority int
}
// Interface: shared behavior
type Processor interface {
Process(ctx context.Context, job Job) error
}