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!


