Go – Language Features
Go's language design is intentionally constrained. Features that exist in other languages — inheritance, exceptions, generics overuse, operator overloading — are absent or limited by design. Understanding how Go expects you to solve these problems idiomatically is essential to writing good Go code.
Error Handling
Error handling is the most distinctive aspect of Go. Errors are values, not exceptions. Functions return errors as their last return value; callers must explicitly check them.
The Basic Pattern
// ✅ Idiomatic Go error handling — check immediately after the call
order, err := repo.FindByID(ctx, orderID)
if err != nil {
return nil, fmt.Errorf("finding order %d: %w", orderID, err)
}
// order is guaranteed non-nil here
Wrapping Errors — fmt.Errorf with %w
Always wrap errors with context using %w. This preserves the original error for errors.Is and errors.As:
// ✅ Wrap with %w — context added, original preserved
func (s *OrderService) CreateOrder(ctx context.Context, req *CreateOrderRequest) (*Order, error) {
if err := s.validateRequest(req); err != nil {
return nil, fmt.Errorf("validating create order request: %w", err)
}
order, err := s.repo.Save(ctx, req.ToOrder())
if err != nil {
return nil, fmt.Errorf("saving order for user %d: %w", req.UserID, err)
}
return order, nil
}
// Caller checks the original error type through the chain
if errors.Is(err, ErrDuplicateOrder) { ... }
if errors.As(err, &valErr) { ... }
Error String Convention
Error strings must be lowercase and must not end with punctuation. They are designed to compose into longer messages:
// ✅ Lowercase, no trailing punctuation
fmt.Errorf("order not found")
fmt.Errorf("invalid payment amount: %v", amount)
fmt.Errorf("user %d: insufficient credit", userID)
// ❌ Capitalised or with punctuation — breaks log composition
fmt.Errorf("Order not found.")
fmt.Errorf("Invalid amount.")
Sentinel Errors
// ✅ Sentinel errors — exported, Err prefix
var (
ErrNotFound = errors.New("not found")
ErrUnauthorised = errors.New("unauthorised")
ErrInvalidInput = errors.New("invalid input")
ErrAlreadyExists = errors.New("already exists")
)
if errors.Is(err, ErrNotFound) {
return http.StatusNotFound, nil
}
Custom Error Types
// ✅ Custom error type for rich error details
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation failed on %q: %s", e.Field, e.Message)
}
var valErr *ValidationError
if errors.As(err, &valErr) {
log.Printf("field %s failed: %s", valErr.Field, valErr.Message)
}
Never Use panic for Normal Errors
// ✅ Return an error for recoverable conditions
func (r *OrderRepository) FindByID(ctx context.Context, id int64) (*Order, error) {
if id <= 0 {
return nil, fmt.Errorf("invalid id %d: %w", id, ErrInvalidInput)
}
// ...
}
// ✅ Must* functions are acceptable for init-time setup only
func MustParseURL(raw string) *url.URL {
u, err := url.Parse(raw)
if err != nil {
panic(fmt.Sprintf("MustParseURL: invalid URL %q: %v", raw, err))
}
return u
}
// ❌ Never panic for regular runtime errors
func (s *Service) GetOrder(ctx context.Context, id int64) *Order {
order, err := s.repo.FindByID(ctx, id)
if err != nil {
panic(err) // crashes the entire server
}
return order
}
Interfaces
Accept Interfaces, Return Structs
// ✅ Accept interface, return concrete type
func NewOrderService(repo OrderRepository, gateway PaymentGateway) *OrderService {
return &OrderService{repo: repo, gateway: gateway}
}
// ❌ Accept concrete type — restricts callers, makes testing harder
func NewOrderService(repo *PostgresOrderRepository, gateway *StripeGateway) *OrderService {}
Define Interfaces at the Point of Use
Interfaces belong in the consumer package, not the producer:
// File: orderservice/service.go
package orderservice
// OrderRepository is what this service needs — defined here, not in postgres/
type OrderRepository interface {
FindByID(ctx context.Context, id int64) (*domain.Order, error)
Save(ctx context.Context, order *domain.Order) error
}
Verify Interface Satisfaction at Compile Time
// ✅ Compile-time check — fails with a helpful error if not satisfied
var _ OrderRepository = (*PostgresOrderRepository)(nil)
var _ PaymentGateway = (*StripeGateway)(nil)
Concurrency
Goroutines
Every goroutine must have a defined lifecycle — when will it stop and how?
// ✅ Goroutine with controlled lifecycle using context
func (s *WorkerService) Start(ctx context.Context) {
go func() {
for {
select {
case <-ctx.Done():
s.logger.Info("worker stopping", "reason", ctx.Err())
return
case job := <-s.queue:
if err := s.processJob(ctx, job); err != nil {
s.logger.Error("job failed", "jobID", job.ID, "error", err)
}
}
}
}()
}
// ❌ Fire-and-forget — no lifecycle management
go func() {
processOrder(order) // when does this end? what if it panics?
}()
context.Context
context.Context must be the first parameter of every function that does I/O, makes network calls, or spawns goroutines:
// ✅ context.Context as first parameter — always named ctx
func (s *OrderService) ProcessOrder(ctx context.Context, id int64) (*Order, error) {
order, err := s.repo.FindByID(ctx, id)
if err != nil {
return nil, fmt.Errorf("finding order: %w", err)
}
if err := s.payment.Charge(ctx, order.Total, order.PaymentMethod); err != nil {
return nil, fmt.Errorf("charging payment: %w", err)
}
return order, nil
}
// ❌ Storing context in a struct — breaks propagation
type OrderService struct {
ctx context.Context // never store context in a struct
}
Always defer cancel:
// ✅ Always defer cancel — prevents context leak
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
result, err := externalService.Call(ctx, request)
errgroup for Concurrent Work with Error Collection
Use golang.org/x/sync/errgroup when you need to run multiple goroutines concurrently and collect their errors. It is cleaner and safer than WaitGroup + error channels for this pattern:
import "golang.org/x/sync/errgroup"
// ✅ errgroup — concurrent work with collected errors
func (s *DashboardService) GetDashboard(ctx context.Context, userID int64) (*Dashboard, error) {
g, ctx := errgroup.WithContext(ctx)
var orders []*Order
var profile *UserProfile
var alerts []*Alert
g.Go(func() error {
var err error
orders, err = s.orderRepo.FindByUser(ctx, userID)
return fmt.Errorf("fetching orders: %w", err)
})
g.Go(func() error {
var err error
profile, err = s.userRepo.GetProfile(ctx, userID)
return fmt.Errorf("fetching profile: %w", err)
})
g.Go(func() error {
var err error
alerts, err = s.alertRepo.GetUnread(ctx, userID)
return fmt.Errorf("fetching alerts: %w", err)
})
// Wait waits for all goroutines and returns the first non-nil error.
// The context passed to each goroutine is cancelled when any returns an error.
if err := g.Wait(); err != nil {
return nil, err
}
return &Dashboard{Orders: orders, Profile: profile, Alerts: alerts}, nil
}
// ✅ errgroup with concurrency limit
func (s *BatchProcessor) ProcessAll(ctx context.Context, items []*Item) error {
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(10) // max 10 goroutines at once
for _, item := range items {
item := item // capture loop variable (required before Go 1.22)
g.Go(func() error {
return s.processItem(ctx, item)
})
}
return g.Wait()
}
sync.Mutex and sync.RWMutex
Use sync.Mutex to protect shared state. Use sync.RWMutex when reads are much more frequent than writes — it allows concurrent reads while writes are exclusive:
// ✅ sync.Mutex for shared mutable state
type OrderCache struct {
mu sync.Mutex
items map[int64]*Order
}
func (c *OrderCache) Set(id int64, order *Order) {
c.mu.Lock()
defer c.mu.Unlock()
if c.items == nil {
c.items = make(map[int64]*Order)
}
c.items[id] = order
}
func (c *OrderCache) Get(id int64) (*Order, bool) {
c.mu.Lock()
defer c.mu.Unlock()
if c.items == nil {
return nil, false
}
order, ok := c.items[id]
return order, ok
}
// ✅ sync.RWMutex — concurrent reads, exclusive writes
type RateConfig struct {
mu sync.RWMutex
limits map[string]int
}
func (r *RateConfig) GetLimit(endpoint string) int {
r.mu.RLock() // shared read lock — multiple readers allowed simultaneously
defer r.mu.RUnlock()
return r.limits[endpoint]
}
func (r *RateConfig) SetLimit(endpoint string, limit int) {
r.mu.Lock() // exclusive write lock
defer r.mu.Unlock()
r.limits[endpoint] = limit
}
// ❌ Using Mutex where RWMutex would allow safe concurrency
// ❌ Holding a lock across a network call or I/O operation
func (c *Cache) GetFromDB(ctx context.Context, id int64) (*Order, error) {
c.mu.Lock()
defer c.mu.Unlock()
return c.db.QueryRow(ctx, id) // ← holding lock during DB call: contention
}
sync/atomic for Simple Shared Counters
For simple integer counters shared between goroutines, sync/atomic is faster than a mutex:
import "sync/atomic"
// ✅ Atomic operations for simple counters
type Metrics struct {
requestCount atomic.Int64
errorCount atomic.Int64
activeWorkers atomic.Int32
}
func (m *Metrics) RecordRequest() {
m.requestCount.Add(1)
}
func (m *Metrics) RecordError() {
m.errorCount.Add(1)
}
func (m *Metrics) ActiveWorkers() int32 {
return m.activeWorkers.Load()
}
// ✅ atomic.Value for any value that is replaced atomically
type ConfigStore struct {
val atomic.Value // stores *Config
}
func (s *ConfigStore) Load() *Config {
return s.val.Load().(*Config)
}
func (s *ConfigStore) Store(cfg *Config) {
s.val.Store(cfg)
}
Channels
// ✅ Buffered channel for producer/consumer with backpressure
jobQueue := make(chan *Job, 100)
go func() {
defer close(jobQueue)
for _, job := range jobs {
select {
case jobQueue <- job:
case <-ctx.Done():
return
}
}
}()
for job := range jobQueue {
if err := process(ctx, job); err != nil {
log.Printf("job %d failed: %v", job.ID, err)
}
}
// ✅ select for non-blocking, timeout, or cancellation
select {
case result := <-resultChan:
handleResult(result)
case <-ctx.Done():
return ctx.Err()
case <-time.After(timeout):
return ErrTimeout
}
sync.WaitGroup
// ✅ WaitGroup — always Add before launching the goroutine
var wg sync.WaitGroup
for _, order := range orders {
order := order // capture loop variable (pre-Go 1.22)
wg.Add(1)
go func() {
defer wg.Done()
if err := processOrder(ctx, order); err != nil {
log.Printf("order %d failed: %v", order.ID, err)
}
}()
}
wg.Wait()
defer
// ✅ defer for guaranteed cleanup
func readFile(path string) ([]byte, error) {
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("opening %s: %w", path, err)
}
defer f.Close()
return io.ReadAll(f)
}
// ✅ defer for mutex unlock
func (s *Service) updateCache(key string, value any) {
s.mu.Lock()
defer s.mu.Unlock()
s.cache[key] = value
}
// ✅ defer for logging duration
func (s *Service) SlowOperation(ctx context.Context) error {
start := time.Now()
defer func() {
s.logger.Info("completed", "duration", time.Since(start))
}()
// ...
return nil
}
defer has a small cost in tight loops
defer has a small overhead per call. In tight inner loops, handle cleanup manually. For most code, the safety benefit far outweighs the cost.
io.Reader and io.Writer Composition
Go's io.Reader and io.Writer interfaces are the foundation of composable I/O. Use them instead of concrete types for maximum flexibility:
// ✅ Accept io.Reader / io.Writer — works with files, HTTP bodies, buffers, etc.
func ParseOrders(r io.Reader) ([]*Order, error) {
dec := json.NewDecoder(r)
var orders []*Order
return orders, dec.Decode(&orders)
}
func WriteReport(w io.Writer, orders []*Order) error {
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(orders)
}
// ✅ Works with any source — file, HTTP body, buffer
orders, err := ParseOrders(file)
orders, err = ParseOrders(resp.Body)
orders, err = ParseOrders(bytes.NewReader(data))
// ✅ io.MultiWriter — write to multiple destinations simultaneously
logFile, _ := os.Create("audit.log")
w := io.MultiWriter(os.Stdout, logFile)
WriteReport(w, orders)
// ✅ io.TeeReader — read and copy simultaneously
var buf bytes.Buffer
r := io.TeeReader(resp.Body, &buf)
orders, err := ParseOrders(r)
rawBody := buf.Bytes() // also have the raw bytes for logging
http.Handler Pattern
Go's net/http package uses the handler pattern for building HTTP servers. Use http.Handler and middleware composition rather than frameworks for most services:
// ✅ http.HandlerFunc for simple handlers
func (s *OrderHandler) GetOrder(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
http.Error(w, "invalid order id", http.StatusBadRequest)
return
}
order, err := s.service.GetOrder(r.Context(), id)
if err != nil {
if errors.Is(err, ErrNotFound) {
http.Error(w, "order not found", http.StatusNotFound)
return
}
s.logger.Error("getting order", "id", id, "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(order)
}
// ✅ Middleware using http.Handler composition
func LoggingMiddleware(logger *slog.Logger, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
logger.Info("request",
"method", r.Method,
"path", r.URL.Path,
"duration", time.Since(start),
)
})
}
func AuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Authorization")
if !isValidToken(token) {
http.Error(w, "unauthorised", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
// ✅ Composing middleware — outermost runs first
mux := http.NewServeMux()
mux.HandleFunc("GET /api/v1/orders/{id}", handler.GetOrder)
mux.HandleFunc("POST /api/v1/orders", handler.CreateOrder)
srv := &http.Server{
Addr: ":8080",
Handler: LoggingMiddleware(logger, AuthMiddleware(mux)),
}
Structs and Zero Values
Design structs so the zero value is useful:
// ✅ Zero value is useful
var mu sync.Mutex // ready immediately
var buf bytes.Buffer // ready immediately
// ✅ Lazy initialisation for zero-value usability
type OrderCache struct {
mu sync.Mutex
items map[int64]*Order
}
func (c *OrderCache) Get(id int64) (*Order, bool) {
c.mu.Lock()
defer c.mu.Unlock()
if c.items == nil {
return nil, false
}
return c.items[id], c.items[id] != nil
}
Generics (Go 1.18+)
Use generics when they provide genuine reuse without adding complexity:
// ✅ Generic utility functions
func Map[T, U any](slice []T, fn func(T) U) []U {
result := make([]U, len(slice))
for i, v := range slice {
result[i] = fn(v)
}
return result
}
func Filter[T any](slice []T, pred func(T) bool) []T {
var result []T
for _, v := range slice {
if pred(v) {
result = append(result, v)
}
}
return result
}
// ✅ Generic repository interface
type Repository[T any, ID comparable] interface {
FindByID(ctx context.Context, id ID) (T, error)
Save(ctx context.Context, entity T) error
Delete(ctx context.Context, id ID) error
}
// ❌ Generic where a simple interface would do
func Process[T OrderProcessor](p T) error {
return p.Process() // just use the interface directly
}
Least mechanism
If the problem can be solved with a slice, map, or interface, use those first. Reach for generics when you find yourself writing the same function multiple times for different types.
Slices and Maps
// ✅ Make with capacity when size is known
orders := make([]*Order, 0, len(rawOrders))
orderMap := make(map[int64]*Order, len(rawOrders))
// ✅ Check map key existence
order, ok := orderMap[id]
if !ok {
return nil, ErrNotFound
}
// ✅ Nil slice is valid for append
var orders []*Order
orders = append(orders, newOrder) // works fine
String Formatting
// ✅ Standard verbs
log.Printf("processing order: id=%d status=%s", order.ID, order.Status)
log.Printf("order details: %+v", order) // field names included
// ✅ Use slog (Go 1.21+) for structured logging
slog.Info("order created",
"orderID", order.ID,
"userID", order.UserID,
"total", order.Total,
)
slog.Error("payment failed",
"orderID", order.ID,
"error", err,
)
// ❌ String concatenation for messages
log.Printf("error: " + err.Error())