kumail.in
Back to blog
Software Engineering1 min read

Writing Maintainable Go Code at Scale

Package structure, error handling, and conventions that keep Go codebases healthy as they grow.

Go's simplicity is a feature, not a limitation. But without discipline, Go codebases become flat, tangled, and hard to navigate.

Package Structure

Organize by domain, not by layer:

internal/
  payment/
    handler.go
    service.go
    repository.go
    models.go
  property/
    handler.go
    service.go
    repository.go

Avoid models/, handlers/, services/ top-level packages. They create import cycles and obscure ownership.

Error Handling

Wrap errors with context at boundaries:

if err != nil {
    return fmt.Errorf("payment service: process transaction %s: %w", txID, err)
}

Define domain errors as sentinel values:

var (
    ErrPaymentNotFound = errors.New("payment not found")
    ErrInsufficientFunds = errors.New("insufficient funds")
)

Check with errors.Is() at API boundaries to map to HTTP status codes.

Interface Design

Define interfaces where they're used, not where they're implemented:

// In the service package
type PaymentRepository interface {
    GetByID(ctx context.Context, id string) (*Payment, error)
    Save(ctx context.Context, payment *Payment) error
}

Keep interfaces small — 1-3 methods. Large interfaces are hard to mock and indicate a design smell.

Key Takeaways

  • Package by domain, not technical layer
  • Wrap errors with context at every boundary
  • Define interfaces at the consumer, not provider
  • Consistency beats cleverness