I've reviewed over 1000 pull requests in Go over the past 6 years, and the same mistakes keep appearing. Remember your first Go code? It probably had dozens of if err != nil checks and 200-line functions that did everything at once. After analyzing over 50 Go projects, I've identified the main beginner problem: they write Go like Java or Python, ignoring the language's idioms.
Common function problems I've seen:
In this article — the first in a Clean Code in Go series — we'll explore how to write functions you won't be ashamed to show in code review. We'll discuss the single responsibility principle, error handling, and why defer is your best friend.
Here's a typical function from a real project (names changed):
// BAD: monster function does everything func ProcessUserData(userID int) (*User, error) { // Validation if userID <= 0 { log.Printf("Invalid user ID: %d", userID) return nil, errors.New("invalid user ID") } // Database connection db, err := sql.Open("postgres", connString) if err != nil { log.Printf("DB connection failed: %v", err) return nil, err } defer db.Close() var user User err = db.QueryRow("SELECT * FROM users WHERE id = $1", userID).Scan(&user.ID, &user.Name, &user.Email) if err != nil { log.Printf("Query failed: %v", err) return nil, err } // Data enrichment if user.Email != "" { domain := strings.Split(user.Email, "@")[1] user.EmailDomain = domain // Check corporate domain corporateDomains := []string{"google.com", "microsoft.com", "apple.com"} for _, corp := range corporateDomains { if domain == corp { user.IsCorporate = true break } } } // Logging log.Printf("User %d processed successfully", userID) return &user, nil }
This function violates SRP on multiple fronts:
Quality metric: A function should fit entirely on a developer's screen (roughly 30-50 lines). If you need to scroll — time to refactor.
Let's refactor following Go idioms:
// GOOD: each function has one responsibility func GetUser(ctx context.Context, userID int) (*User, error) { if err := validateUserID(userID); err != nil { return nil, fmt.Errorf("validation failed: %w", err) } user, err := fetchUserFromDB(ctx, userID) if err != nil { return nil, fmt.Errorf("fetch user %d: %w", userID, err) } enrichUserData(user) return user, nil } func validateUserID(id int) error { if id <= 0 { return fmt.Errorf("invalid user ID: %d", id) } return nil } func fetchUserFromDB(ctx context.Context, userID int) (*User, error) { row := db.QueryRowContext(ctx, ` SELECT id, name, email FROM users WHERE id = $1`, userID) var user User if err := row.Scan(&user.ID, &user.Name, &user.Email); err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, ErrUserNotFound } return nil, err } return &user, nil } func enrichUserData(user *User) { if user.Email == "" { return } parts := strings.Split(user.Email, "@") if len(parts) != 2 { return } user.EmailDomain = parts[1] user.IsCorporate = isCorporateDomain(user.EmailDomain) }
Now each function:
Beginners often create the "pyramid of doom":
// BAD: deep nesting func SendNotification(userID int, message string) error { user, err := GetUser(userID) if err == nil { if user.Email != "" { if user.IsActive { if user.NotificationsEnabled { err := smtp.Send(user.Email, message) if err == nil { log.Printf("Sent to %s", user.Email) return nil } else { log.Printf("Failed to send: %v", err) return err } } else { return errors.New("notifications disabled") } } else { return errors.New("user inactive") } } else { return errors.New("email empty") } } else { return fmt.Errorf("user not found: %v", err) } }
// GOOD: early return on errors func SendNotification(userID int, message string) error { user, err := GetUser(userID) if err != nil { return fmt.Errorf("get user %d: %w", userID, err) } if user.Email == "" { return ErrEmptyEmail } if !user.IsActive { return ErrUserInactive } if !user.NotificationsEnabled { return ErrNotificationsDisabled } if err := smtp.Send(user.Email, message); err != nil { return fmt.Errorf("send to %s: %w", user.Email, err) } log.Printf("Notification sent to %s", user.Email) return nil }
Since Go 1.13, fmt.Errorf with the %w verb wraps errors. Always use it:
// Define sentinel errors for business logic var ( ErrUserNotFound = errors.New("user not found") ErrInsufficientFunds = errors.New("insufficient funds") ErrOrderAlreadyProcessed = errors.New("order already processed") ) func ProcessPayment(orderID string) error { order, err := fetchOrder(orderID) if err != nil { // Add context to the error return fmt.Errorf("process payment for order %s: %w", orderID, err) } if order.Status == "processed" { return ErrOrderAlreadyProcessed } if err := chargeCard(order); err != nil { // Wrap technical errors return fmt.Errorf("charge card for order %s: %w", orderID, err) } return nil } // Calling code can check error type if err := ProcessPayment("ORD-123"); err != nil { if errors.Is(err, ErrOrderAlreadyProcessed) { // Business logic for already processed order return nil } if errors.Is(err, ErrInsufficientFunds) { // Notify user about insufficient funds notifyUser(err) } // Log unexpected errors log.Printf("Payment failed: %v", err) return err }
defer is one of Go's killer features. Use it for guaranteed cleanup:
// BAD: might forget to release resources func ReadConfig(path string) (*Config, error) { file, err := os.Open(path) if err != nil { return nil, err } data, err := io.ReadAll(file) if err != nil { file.Close() // Easy to forget during refactoring return nil, err } var config Config if err := json.Unmarshal(data, &config); err != nil { file.Close() // Duplication return nil, err } file.Close() // And again return &config, nil }
// GOOD: defer guarantees closure func ReadConfig(path string) (*Config, error) { file, err := os.Open(path) if err != nil { return nil, fmt.Errorf("open config %s: %w", path, err) } defer file.Close() // Will execute no matter what data, err := io.ReadAll(file) if err != nil { return nil, fmt.Errorf("read config %s: %w", path, err) } var config Config if err := json.Unmarshal(data, &config); err != nil { return nil, fmt.Errorf("parse config %s: %w", path, err) } return &config, nil }
func WithTransaction(ctx context.Context, fn func(*sql.Tx) error) error { tx, err := db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("begin transaction: %w", err) } // defer executes in LIFO order defer func() { if p := recover(); p != nil { tx.Rollback() panic(p) // re-throw panic after cleanup } if err != nil { tx.Rollback() } else { err = tx.Commit() } }() err = fn(tx) return err } // Usage err := WithTransaction(ctx, func(tx *sql.Tx) error { // All logic in transaction // Rollback/Commit happens automatically return nil })
// BAD: unclear purpose func Process(data []byte) error func Handle(r Request) Response func Do() error // GOOD: verb + noun func ParseJSON(data []byte) (*Config, error) func ValidateEmail(email string) error func SendNotification(user *User, msg string) error
If more than 3-4 parameters — use a struct:
// BAD: too many parameters func CreateUser(name, email, phone, address string, age int, isActive bool) (*User, error) // GOOD: group into struct type CreateUserRequest struct { Name string Email string Phone string Address string Age int IsActive bool } func CreateUser(req CreateUserRequest) (*User, error)
// BAD: boolean flags are unclear func CheckPermission(userID int) (bool, bool, error) // what does first bool mean? second? // GOOD: use named returns or struct func CheckPermission(userID int) (canRead, canWrite bool, err error) // BETTER: struct for complex results type Permissions struct { CanRead bool CanWrite bool CanDelete bool } func CheckPermission(userID int) (*Permissions, error)
%w)Clean functions in Go aren't just about following general Clean Code principles. It's about understanding and using language idioms: early return instead of nesting, error wrapping for context, defer for guaranteed cleanup.
In the next article, we'll discuss structs and methods: when to use value vs pointer receivers, how to organize composition properly, and why embedding isn't inheritance.
What's your approach to keeping functions clean? Do you have a maximum line limit for your team? Let me know in the comments!


