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