Skip to content

Go – Best Practices

This page covers the patterns, practices, and documentation standards that produce production-grade, idiomatic Go at Cygnus Dynamics — grounded in Effective Go, the Go Code Review Comments wiki, and Google's Go Style Guide.

Documentation Comments

Go documentation is written as plain text comments directly above declarations. Every exported identifier must have a doc comment.

Rules

  • Doc comments are complete sentences beginning with the name of the thing being documented
  • Start with the identifier name — godoc uses this for indexing
  • Write in plain text — no Markdown, no HTML
  • Use blank lines to separate paragraphs within a comment

Package Comments

// Package orderservice provides lifecycle management for customer orders.
//
// It handles order creation, payment processing, inventory reservation,
// and fulfilment coordination for the Cygnus Dynamics platform.
//
// Usage:
//
//  svc := orderservice.NewService(repo, gateway, logger)
//  order, err := svc.CreateOrder(ctx, req)
package orderservice

Function and Method Comments

// CreateOrder creates a new order from the given request and returns the
// persisted order with a server-assigned ID.
//
// It validates the request, reserves inventory, processes payment, and
// sends a confirmation notification. If any step fails, the operation
// is rolled back and an error is returned.
//
// The caller is responsible for retrying on ErrTransient errors.
func (s *Service) CreateOrder(ctx context.Context, req *CreateOrderRequest) (*Order, error) {
// FindByID retrieves the order with the given ID.
// It returns ErrNotFound if no order with that ID exists.
func (r *Repository) FindByID(ctx context.Context, id int64) (*Order, error) {

Type Comments

// Order represents a customer purchase in the Cygnus Dynamics platform.
// An Order is immutable once it reaches the Confirmed state.
type Order struct {
    ID        int64
    UserID    int64
    Items     []*OrderItem
    Total     Money
    Status    OrderStatus
    CreatedAt time.Time
}

// OrderStatus represents the lifecycle state of an order.
type OrderStatus string

const (
    // StatusPending indicates the order has been created but not yet confirmed.
    StatusPending OrderStatus = "pending"

    // StatusConfirmed indicates payment has been processed successfully.
    StatusConfirmed OrderStatus = "confirmed"

    // StatusCancelled indicates the order was cancelled before fulfilment.
    StatusCancelled OrderStatus = "cancelled"
)

godoc preview

Run godoc -http=:6060 locally and open http://localhost:6060/pkg/ to preview documentation before raising a PR.

Package Design

Keep Packages Focused

// ❌ Vague package names — avoid
package util
package common
package helpers

// ✅ Focused, descriptive package names
package httputil
package orderrepo
package stripegateway

Separate Domain from Infrastructure

cygnus/
├── internal/
│   ├── order/              # domain: types, business logic
│   │   ├── order.go
│   │   ├── service.go
│   │   └── repository.go   # interface definition (consumer owns it)
│   └── payment/
│       ├── payment.go
│       └── gateway.go      # interface definition
├── postgres/               # infrastructure: DB implementation
│   └── order_repo.go
├── stripe/                 # infrastructure: Stripe gateway
│   └── gateway.go
└── api/                    # infrastructure: HTTP handlers
    └── order_handler.go

internal Package

Use internal to prevent external packages from importing implementation details:

// Can ONLY be imported by packages within github.com/cygnusdynamics/platform/...
import "github.com/cygnusdynamics/platform/internal/order"

Module Management

go.mod and go.sum

Both files must be committed to version control. They are the authoritative record of your module's dependencies:

# Initialise a new module
go mod init github.com/cygnusdynamics/platform

# Add a dependency
go get github.com/google/uuid@v1.6.0

# Add a specific version
go get golang.org/x/sync@v0.7.0

# Remove unused dependencies and tidy go.sum
go mod tidy

# Verify dependencies haven't been tampered with
go mod verify

# Vendor dependencies (for air-gapped or reproducible builds)
go mod vendor

Standard go.mod structure:

module github.com/cygnusdynamics/platform

go 1.22

require (
    github.com/google/uuid v1.6.0
    golang.org/x/sync v0.7.0
    go.uber.org/zap v1.27.0
)

require (
    // indirect dependencies managed by go mod tidy
    go.uber.org/multierr v1.11.0 // indirect
)

Rules:

  • Never edit go.sum manually — it is generated by go tooling
  • Run go mod tidy before every PR that adds or removes dependencies
  • Pin all dependencies to an explicit version — no @latest in go.mod
  • Review the changelog before upgrading a major version (v2+)

Graceful Shutdown

Every HTTP server and long-running service must handle OS signals and shut down gracefully — completing in-flight requests before exiting:

// ✅ Full graceful shutdown pattern
func main() {
    logger := slog.Default()

    // Build handler and server
    handler := buildHandler(logger)
    srv := &http.Server{
        Addr:         ":8080",
        Handler:      handler,
        ReadTimeout:  5 * time.Second,
        WriteTimeout: 10 * time.Second,
        IdleTimeout:  120 * time.Second,
    }

    // Start server in a goroutine
    serverErr := make(chan error, 1)
    go func() {
        logger.Info("server starting", "addr", srv.Addr)
        if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
            serverErr <- err
        }
    }()

    // Wait for interrupt or server error
    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)

    select {
    case err := <-serverErr:
        logger.Error("server error", "error", err)
    case sig := <-quit:
        logger.Info("shutting down", "signal", sig)
    }

    // Graceful shutdown — allow up to 30s for in-flight requests to complete
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()

    if err := srv.Shutdown(ctx); err != nil {
        logger.Error("forced shutdown", "error", err)
        os.Exit(1)
    }

    logger.Info("server stopped")
}

Testing

Go has a built-in test framework in testing. No third-party test framework is required for most cases, though testify is approved for assertions.

Test File Naming

order_service.go         →  order_service_test.go
payment_processor.go     →  payment_processor_test.go

Table-Driven Tests

The idiomatic Go testing pattern:

func TestCalculateDiscount(t *testing.T) {
    tests := []struct {
        name         string
        orderTotal   float64
        discountRate float64
        want         float64
        wantErr      bool
    }{
        {
            name:         "10% discount on 100",
            orderTotal:   100.0,
            discountRate: 0.10,
            want:         90.0,
        },
        {
            name:         "zero discount",
            orderTotal:   50.0,
            discountRate: 0.0,
            want:         50.0,
        },
        {
            name:         "invalid discount rate above 1",
            orderTotal:   100.0,
            discountRate: 1.5,
            wantErr:      true,
        },
    }

    for _, tc := range tests {
        t.Run(tc.name, func(t *testing.T) {
            got, err := calculateDiscount(tc.orderTotal, tc.discountRate)
            if tc.wantErr {
                if err == nil {
                    t.Errorf("calculateDiscount(%v, %v) = nil error, want error",
                        tc.orderTotal, tc.discountRate)
                }
                return
            }
            if err != nil {
                t.Fatalf("unexpected error: %v", err)
            }
            if got != tc.want {
                t.Errorf("calculateDiscount(%v, %v) = %v, want %v",
                    tc.orderTotal, tc.discountRate, got, tc.want)
            }
        })
    }
}

Subtests and Parallel Tests

Use t.Parallel() for independent tests that can run concurrently:

func TestOrderService(t *testing.T) {
    t.Run("GetOrder", func(t *testing.T) {
        t.Parallel()  // this subtest runs in parallel with others

        t.Run("returns order when found", func(t *testing.T) {
            t.Parallel()
            // ...
        })

        t.Run("returns ErrNotFound when not found", func(t *testing.T) {
            t.Parallel()
            // ...
        })
    })

    t.Run("CreateOrder", func(t *testing.T) {
        t.Parallel()
        // ...
    })
}

testify — Approved Assertion Library

While the standard testing package is preferred, testify is approved for more readable assertions:

import (
    "testing"
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"
)

func TestGetOrder(t *testing.T) {
    // require — fails immediately on assertion failure (use for setup)
    order, err := service.GetOrder(ctx, 1)
    require.NoError(t, err)
    require.NotNil(t, order)

    // assert — continues execution after failure (use for field checks)
    assert.Equal(t, int64(1), order.ID)
    assert.Equal(t, StatusPending, order.Status)
    assert.Greater(t, order.Total, 0.0)
}

// ✅ testify/mock for mock objects
type MockOrderRepository struct {
    mock.Mock
}

func (m *MockOrderRepository) FindByID(ctx context.Context, id int64) (*Order, error) {
    args := m.Called(ctx, id)
    return args.Get(0).(*Order), args.Error(1)
}

func TestOrderService_GetOrder(t *testing.T) {
    mockRepo := new(MockOrderRepository)
    expected := &Order{ID: 1, Status: StatusPending}

    mockRepo.On("FindByID", mock.Anything, int64(1)).Return(expected, nil)

    svc := NewOrderService(mockRepo, logger)
    result, err := svc.GetOrder(context.Background(), 1)

    require.NoError(t, err)
    assert.Equal(t, expected, result)
    mockRepo.AssertExpectations(t)
}

Test Helper Functions

// ✅ t.Helper() — failure attributed to caller, not the helper
func assertNoError(t *testing.T, err error) {
    t.Helper()
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
}

func assertOrderStatus(t *testing.T, order *Order, want OrderStatus) {
    t.Helper()
    if order.Status != want {
        t.Errorf("order status = %q, want %q", order.Status, want)
    }
}

Fuzz Testing

Use fuzz tests for functions that parse untrusted input:

// ✅ Fuzz test — Go finds edge cases automatically
func FuzzParseOrderReference(f *testing.F) {
    // Seed corpus — known valid and interesting inputs
    f.Add("ORD-000001")
    f.Add("ORD-999999")
    f.Add("")
    f.Add("not-an-order")

    f.Fuzz(func(t *testing.T, input string) {
        // Function must not panic — errors are acceptable, panics are not
        id, err := ParseOrderReference(input)
        if err != nil {
            return  // error is fine
        }
        // If it parsed successfully, validate the result
        if id <= 0 {
            t.Errorf("ParseOrderReference(%q) = %d, want > 0", input, id)
        }
    })
}

Error Messages in Tests

// ✅ Clear failure message — includes actual vs expected
if got != want {
    t.Errorf("CreateOrder() total = %v, want %v", got, want)
}

// ❌ Useless
if got != want {
    t.Errorf("wrong result")
}

Integration Tests with Build Tags

// order_repo_integration_test.go

//go:build integration

package postgres_test

func TestOrderRepository_FindByID_Integration(t *testing.T) {
    // Requires a real database connection
}
go test ./...                      # unit tests only
go test -tags=integration ./...    # includes integration tests

Coverage

go test -cover ./...
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out -o coverage.html
Layer Target
Domain logic (business rules) 90%+
Service layer 85%+
Repository layer Integration tests
HTTP handlers Integration tests

Comments

// ✅ Explains why — not what
// We add a 5-second buffer to the payment timeout to account for Stripe's
// own internal retry logic. Without this, our timeout fires before Stripe
// gives up, leaving the payment in an indeterminate state.
paymentCtx, cancel := context.WithTimeout(ctx, s.paymentTimeout+5*time.Second)
defer cancel()

// TODO(PROJ-1234): Switch to async notification when event bus is ready.
if err := s.emailService.SendSync(ctx, user.Email, tmpl); err != nil {
    s.logger.Error("confirmation email failed", "orderID", order.ID, "error", err)
}

// ❌ Narrates the code
// Increment the counter
count++

Patterns

Constructor Pattern

// ✅ Constructor validates and returns a ready-to-use value
func NewOrderService(
    repo OrderRepository,
    gateway PaymentGateway,
    logger *slog.Logger,
) (*OrderService, error) {
    if repo == nil {
        return nil, errors.New("repo must not be nil")
    }
    if gateway == nil {
        return nil, errors.New("gateway must not be nil")
    }
    if logger == nil {
        logger = slog.Default()
    }
    return &OrderService{repo: repo, gateway: gateway, logger: logger}, nil
}

Functional Options Pattern

For constructors with many optional parameters:

// ✅ Functional options — extensible, backward-compatible
type ServerOption func(*Server)

func WithTimeout(d time.Duration) ServerOption {
    return func(s *Server) { s.timeout = d }
}

func WithMaxRetries(n int) ServerOption {
    return func(s *Server) { s.maxRetries = n }
}

func NewServer(addr string, opts ...ServerOption) *Server {
    s := &Server{
        addr:       addr,
        timeout:    30 * time.Second,
        maxRetries: 3,
        logger:     slog.Default(),
    }
    for _, opt := range opts {
        opt(s)
    }
    return s
}

srv := NewServer(":8080",
    WithTimeout(60*time.Second),
    WithMaxRetries(5),
)

Guard Clauses — Reduce Nesting

// ✅ Guard clauses — happy path at top level
func (s *Service) ProcessOrder(ctx context.Context, orderID int64) error {
    if orderID <= 0 {
        return fmt.Errorf("invalid order id %d: %w", orderID, ErrInvalidInput)
    }

    order, err := s.repo.FindByID(ctx, orderID)
    if err != nil {
        return fmt.Errorf("finding order %d: %w", orderID, err)
    }

    if order.Status != StatusPending {
        return fmt.Errorf("order %d: %w", orderID, ErrOrderNotPending)
    }

    return s.fulfil(ctx, order)
}

Named Return Values

Use sparingly — only when they clarify multiple same-typed returns or when using defer to modify the return:

// ✅ Named returns clarify multiple same-typed returns
func divide(a, b float64) (result float64, err error) {
    if b == 0 {
        err = ErrDivisionByZero
        return
    }
    result = a / b
    return
}

// ✅ Named return modified by defer
func (r *repo) FindByID(ctx context.Context, id int64) (order *Order, err error) {
    defer func() {
        if err != nil {
            err = fmt.Errorf("repo.FindByID(%d): %w", id, err)
        }
    }()
    order, err = r.db.QueryRow(ctx, id)
    return
}

Tooling Quick Reference

Tool Command Purpose
gofmt gofmt -w . Format all files
goimports goimports -w . Format + manage imports
go vet go vet ./... Catch common bugs
go test go test ./... Run all tests
go test -race go test -race ./... Detect race conditions
go test -cover go test -cover ./... Coverage report
go test -fuzz go test -fuzz=FuzzFn ./pkg Fuzz a specific function
golangci-lint golangci-lint run ./... Full lint suite
staticcheck staticcheck ./... Advanced static analysis
godoc godoc -http=:6060 Preview documentation
go mod tidy go mod tidy Clean up go.mod and go.sum
go mod verify go mod verify Verify dependency integrity

Quick Reference

Practice Rule
Errors Return as last value; wrap with %w; lowercase strings; no trailing punctuation
Panic Only for programmer errors; never for runtime conditions; Must* for init-time only
Interfaces Accept interfaces, return structs; define at point of use; keep small
Context First parameter always; always named ctx; never stored in a struct; always defer cancel
Concurrency Use errgroup for concurrent work with errors; RWMutex for read-heavy state; atomic for counters
defer Use for cleanup, unlock, close; be aware of loop cost
gofmt Non-negotiable — all code must be formatted; use goimports
Naming MixedCaps / mixedCaps; no underscores; no UPPER_SNAKE
Packages Lowercase, single word, short; no stutter; no util / common
Testing Table-driven tests; t.Parallel() where safe; testify for assertions; t.Helper() in helpers
Fuzz tests All functions that parse untrusted input should have a fuzz test
Graceful shutdown Handle SIGTERM/SIGINT; use http.Server.Shutdown with a timeout
Modules go mod tidy before every PR; pin versions; commit go.sum
Docs Every exported identifier; starts with name; plain text; godoc preview before PR
Generics Only when genuinely reusable; prefer interfaces/slices/maps first