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.summanually — it is generated bygotooling - Run
go mod tidybefore every PR that adds or removes dependencies - Pin all dependencies to an explicit version — no
@latestingo.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
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
}
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 |