Introduction: Why Go Functions Are Special 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. if err != nil Common function problems I've seen: Functions over 100 lines: ~40% of codebases Mixed responsibilities: ~60% of functions Poor error handling: ~30% of bugs Missing defer for cleanup: ~45% of resource leaks Functions over 100 lines: ~40% of codebases Mixed responsibilities: ~60% of functions Poor error handling: ~30% of bugs Missing defer for cleanup: ~45% of resource leaks 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. defer Single Responsibility Principle: One Function — One Job 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 } // 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: Validates input data Manages database connections Executes queries Enriches data Handles logging Validates input data Manages database connections Executes queries Enriches data Handles logging The Screen Rule Quality metric: A function should fit entirely on a developer's screen (roughly 30-50 lines). If you need to scroll — time to refactor. Quality metric 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) } // 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: Fits on screen (20 lines max) Has single responsibility Can be tested independently Fits on screen (20 lines max) Fits on screen Has single responsibility Has single responsibility Can be tested independently Can be tested independently Error Handling: The Go Way Problem: Nested Hell 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) } } // 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) } } Solution: Early Return (Guard Clauses) // 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 } // 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 } Error Wrapping: Context Matters Since Go 1.13, fmt.Errorf with the %w verb wraps errors. Always use it: fmt.Errorf %w // 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 } // 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: Guaranteed Resource Cleanup defer is one of Go's killer features. Use it for guaranteed cleanup: defer // 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 } // 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 } // 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 } Pattern: Cleanup Functions 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 }) 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 }) Practical Tips 1. Function Naming // 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 // 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 2. Function Parameters 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: 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) 3. Return Values // 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) // 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) Clean Function Checklist Fits on screen (30-50 lines max) Does one thing (Single Responsibility) Has clear name (verb + noun) Uses early return for errors Wraps errors with context (%w) Uses defer for cleanup Accepts context if can be cancelled No side effects (or clearly documented) Fits on screen (30-50 lines max) Fits on screen Does one thing (Single Responsibility) Does one thing Has clear name (verb + noun) Has clear name Uses early return for errors Uses early return Wraps errors with context (%w) Wraps errors %w Uses defer for cleanup Uses defer Accepts context if can be cancelled Accepts context No side effects (or clearly documented) No side effects Conclusion 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!