Clean Code Series: The Principle of Small Functions

Have you ever been lost in a function with hundreds of lines and unclear responsibilities? Long functions are a sign that the code is going to be a maintenance headache. In the last article, we talked about the power of meaningful names. Now, let's take a look at the solution to giant functions: the small functions principle.

Breaking your code into smaller, focused functions is a key strategy for writing robust, testable, and maintainable software.

The Hidden Cost of Giant Functions

At first glance, grouping lots of logic into a single function might seem efficient. After all, everything’s in one place, right? Not quite. This convenience hides a cost known as accidental complexity. As functions grow, they turn into mazes where every new line makes understanding and maintaining the code exponentially harder.

Imagine searching for a specific tool in a huge, messy box full of unrelated items. That’s exactly what it’s like navigating a long function.

The issues are evident:

  • Hard to Read: High cognitive load.

  • Difficult to Test: Isolating logic is almost impossible.

  • More Bugs: Changes can have unexpected side effects.

  • Poor Reusability: Specific logic gets trapped.

  • SRP Violation: The function does more than one thing.

It’s like technical debt: harmless at first, but costly in the long run-and it can bring entire projects down.

Big Functions = Big Problems. Small Functions = Clarity

The Power of Conciseness: Why Small Functions Shine

Keeping your functions short and focused brings a cascade of benefits that directly impact your code’s health and your team’s productivity:

  • Instant Readability: A short function with a meaningful name (remember our last article?) reveals its purpose almost instantly.

  • Simpler Testing: Testing a unit that does one thing is much easier (think unit tests).

  • Reusability: Well-defined, single-purpose functions are natural candidates for reuse.

  • Easier Maintenance: Changes are safer and quicker. When a bug appears or requirements change, it’s much faster and safer to locate and update the relevant logic in a small function.

In short, small functions turn intimidating monoliths into manageable building blocks-like Lego pieces you can easily understand, test, combine, and modify.

Finding the Right Size: How Small Is Small Enough?

We get it-smaller functions are better. But how small is really small? Is there a magic number of lines?

The short answer: there’s no magic number, but there is a guiding principle.

The golden rule, echoing the Single Responsibility Principle (SRP), is that a function should do one thing, and do it well. If you can describe what a function does with a single, clear verb-without using “and” or “or”-you’re probably on the right track.

  • Metrics like 5–15 lines are guidelines, not strict rules. Clarity and single responsibility matter more.

Another key concept is level of abstraction. A function should operate at a single level. This means the operations inside should be at a similar level of detail. For example, a high-level function might coordinate calls to other, more detailed functions. Mixing high-level ideas with low-level details in the same function makes it confusing and hard to read.

So, when evaluating a function’s size, ask yourself:

  • Does it do only one thing?

  • Does its name clearly describe that one responsibility?

  • Does it operate at a single level of abstraction?

  • Can I easily test it in isolation?

Clarity, cohesion, and single responsibility are far more important than sticking to an arbitrary line count.

From Monolith to Modules: Refactoring in Practice

Theory is great, but nothing beats a practical example. Let’s refactor a registerUser function that validates data, checks for duplicates, and saves to the database.

“Before” Code (Go):

// Monolithic function
func registerUser(name, email, password, confirmPassword string) error {
    // 1. Field validations
    if name == "" || email == "" /* ... more validations ... */ {
        return errors.New("validation error")
    }

    // 2. Email format validation
    _, err := mail.ParseAddress(email)
    if err != nil {
        fmt.Println("Error: Invalid email")
        return errors.New("invalid email format")
    }

    // 3. Password validation
    if password != confirmPassword {
        return errors.New("passwords do not match")
    }

    // 4. Check if email already exists in DB
    exists, err := checkEmailExists(email)
    if err != nil { return err }
    if exists { return errors.New("email already registered") }

    // 5. Normalize name
    processedName := normalizeUserName(name)

    // 6. Save to DB
    err = saveUserToDatabase(processedName, email)
    if err != nil { return err }

    fmt.Println("User registered!")
    return nil
}

Analysis:

This registerUser function does too many things:

  1. Validates basic fields.

  2. Validates email format.

  3. Checks password rules.

  4. Checks for duplicate emails.

  5. Processes/normalizes the name.

  6. Prepares and executes the database insert.

  7. Logs messages at various stages.

It mixes validation, business logic, data processing, and database interaction. Testing just the password validation, for example, is impossible without running everything else. Reading it is tough, since you have to follow multiple levels of indentation and responsibilities.

Refactoring: Applying the Small Functions Principle

Let’s extract each responsibility into its own function:

“After” Code (Go):

// --- Validation Functions ---
func validateRequiredFields(name, email, password string) error {
    if name == "" || email == "" || password == "" {
        return errors.New("missing required fields")
    }
    return nil
}

func validateEmailFormat(email string) error {
    _, err := mail.ParseAddress(email)
    if err != nil {
        return errors.New("invalid email format")
    }
    return nil
}

func validatePassword(password, confirmPassword string) error {
    if len(password) < 8 {
        return errors.New("password must be at least 8 characters")
    }
    if password != confirmPassword {
        return errors.New("passwords do not match")
    }
    return nil
}

// --- Processing Functions ---

func normalizeUserName(name string) string {
    return strings.TrimSpace(strings.Title(strings.ToLower(name)))
}
// ... checkEmailExists and saveUserToDatabase omitted for brevity ...

// --- Orchestrator Function ---

func registerUserRefactored(name, email, password, confirmPassword string) error {
    fmt.Println("Starting registration...")

    // 1. Validations
    if err := validateRequiredFields(name, email, password); err != nil {
        return fmt.Errorf("validation failed: %w", err)
    }
    if err := validateEmailFormat(email); err != nil {
        return fmt.Errorf("validation failed: %w", err)
    }
    if err := validatePassword(password, confirmPassword); err != nil {
        return fmt.Errorf("validation failed: %w", err)
    }

    // 2. Check uniqueness
    exists, err := checkEmailExists(email)
    if err != nil { return errors.New("error checking email") }
    if exists { return errors.New("email already registered") }

    // 3. Process
    processedName := normalizeUserName(name)

    // 4. Save
    if err := saveUserToDatabase(processedName, email); err != nil {
        return errors.New("error saving user")
    }

    fmt.Println("User registered successfully:", processedName)
    return nil
}

Visible Benefits:

The registerUserRefactored function just orchestrates. Each step (validation, checking, processing, saving) is now a separate, clear, testable, and potentially reusable function. The complexity is distributed.

Conclusion: Small Pieces, Strong Code

Breaking the habit of writing long functions in favor of small, focused units is one of the most impactful changes you can make to improve your code’s quality. As we’ve seen, the benefits go far beyond aesthetics:

  • More readable and understandable code

  • Easier, more effective unit testing

  • Greater potential for logic reuse

  • Simpler, safer maintenance

There’s no magic line count, but the principle of single responsibility should be your guide. Functions that do just one thing, and do it well, are the foundation of robust, sustainable software.

Key Takeaways:

  1. Aim for Single Responsibility: Does your function do just one thing?

  2. Prioritize Clarity Over Line Count: The goal is understanding, not hitting a number.

  3. Small Functions Are Easier to Test: If it’s hard to test, it might be too big.

  4. Meaningful Names Matter (Even More Here): Clear names are crucial for small functions.

  5. Find the Balance: Avoid excessive fragmentation that hurts overall clarity.

Start small. Next time you write or refactor a function, ask yourself: “Can I break this down into smaller, more focused parts?” Most of the time, the answer will be a resounding “yes”-and your future self (and your team) will thank you.


📣 Keep the Conversation Going!

What’s your experience refactoring large functions? Share your thoughts in the comments!

👉 Follow me on Hashnode and connect on LinkedIn for more content on Go, AWS, Clean Code, and Cloud.

🔖 If you found this article useful, share it with your team or other developers who value clean, effective code!

🔗 Previous article: Clean Code: The Power of Meaningful Names

0
Subscribe to my newsletter

Read articles from Phelipe Rodovalho directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Phelipe Rodovalho
Phelipe Rodovalho