1. Flat code instead of a nested “Christmas tree” of if
When people first hear “function design”, it often sounds like a conversation about aesthetic taste: some people like early return, others prefer one big if. In practice, it’s much more down-to-earth. A function’s structure directly affects the number of bugs, the speed of changes, and how quickly you can understand your own code after sleep, food, and life.
In Go it’s especially noticeable that a bad structure leads to a “forest of checks”. Even the Go authors have repeatedly noted that code where half the lines are error checks is hard to read and easy to misunderstand. That’s why we learn to write functions so that the main scenario reads top to bottom, and checks don’t turn the code into a Christmas tree of nested blocks.
The nesting problem and losing the happy path
Imagine you open a function and want to understand what it does in 15 seconds. If the code is built like a matryoshka doll with if inside if, your brain is forced to hold a bunch of conditions in working memory: “we’re currently in the branch where title is not empty, but id is valid, but there hasn’t been an error yet…”. It’s not because you’re a weak programmer — that’s just how attention works. It’s finite. And it gets annoyed.
Let’s look at a simplified example from our training “todo” app (for now it lives in memory and doesn’t aspire to Trello fame).
Example: a command handler in the “everything in one if” style
package main
import "fmt"
func addTaskBad(title string) {
if title != "" {
if len(title) <= 50 {
fmt.Println("added:", title) // added: ...
} else {
fmt.Println("title too long") // title too long
}
} else {
fmt.Println("empty title") // empty title
}
}
The problem is not that the code is “wrong”. The problem is that the happy path (the normal scenario: “we added a task”) is hidden somewhere inside, and the reader sees branching and exceptional cases first.
Guard clauses: checks at the top and early exit
A guard clause is like “a guard at the entrance”. It checks preconditions and immediately “turns back” bad cases. As a result, nesting almost disappears, and the main scenario ends up at the bottom, where it belongs.
The idea is very simple: if the condition is bad, handle it and exit immediately. No need to wrap the happy path in 3 levels of if when you can “throw out the trash” at the entrance.
The same example, but with guard clauses
package main
import "fmt"
func addTaskGood(title string) {
if title == "" {
fmt.Println("empty title") // empty title
return
}
if len(title) > 50 {
fmt.Println("title too long") // title too long
return
}
fmt.Println("added:", title) // added: ...
}
Notice the effect: now the function reads the way you would explain it to a person: “if it’s empty — error, if it’s too long — error, otherwise add it”.
You can picture it as a mini flowchart:
flowchart TD
A[Function start] --> B{title is empty?}
B -- yes --> C[report an error + return]
B -- no --> D{too long?}
D -- yes --> E[report an error + return]
D -- no --> F[run the main scenario]
Early returns with error
Early returns become even more useful when we work in the Go style (T, error). In that style, an “error” is not something rare and dramatic; it’s a normal outcome: the operation either succeeded or it didn’t.
The key reading rule: the code that does the work should be as linear as possible. And errors should be handled quickly, close to where they occur, and should immediately lead to an exit.
Bad option: a nested happy path
package todo
import "errors"
func NormalizeTitleBad(title string) (string, error) {
if title != "" {
return title, nil
}
return "", errors.New("title is empty")
}
This is still tolerable, but in real functions instead of one condition there will be five, and you can easily end up in a “forest”.
Option with a guard clause
package todo
import "errors"
func NormalizeTitle(title string) (string, error) {
if title == "" {
return "", errors.New("title is empty")
}
return title, nil
}
Yes, it looks “almost the same”. But when there are multiple conditions, the second style scales much better: you don’t have to keep in your head “are we still in the if branch or have we already returned?”.
3. Small functions and responsibility
One function — one responsibility
Let’s move on to a beginner’s favorite question: “How small should small functions be?” And the honest answer: “Small enough that the reader doesn’t cry.” A slightly more engineering answer: a small function is one you can describe with a single sentence without the word “and”.
If you say: “This function reads input and parses and validates and updates data and prints the result”, congratulations: you’ve found a refactoring candidate.
And yes, that’s normal: we all write that way at first. Your brain wants to “get the task done”, not “design the code”. But then the task is done, and you’re stuck living with the code. And code, like a cat, will mess up your slippers if you don’t train it.
Heuristic: when to extract, and when to keep
Sometimes beginners hear “make small functions” and start splitting everything: isNotEmptyString, isPositiveInt, printOk. As a result, readability gets worse: instead of linear code, you jump between files like stepping on stones.
A simple idea helps here. Extract when a step has a standalone name and meaning, or when it repeats, or when it hides details that prevent you from seeing the scenario. Don’t extract what appears once, is obvious, and doesn’t create noise.
A very handy self-check question: “If I name this as a separate function, will the name be more useful than the body?” If yes — extract. If no — keep it.
For clarity, here’s a small table (sometimes it’s harder to explain without one):
| Situation | What to do | Why |
|---|---|---|
| There are many steps inside the function and the scenario gets lost | Extract steps into helpers | Bring back a linear happy path |
| The same check is repeated in 3 places | Extract the check | Less copy-paste, easier to change the rule |
| The check is very short and appears only once | Often keep it | Otherwise it’s “a jump just to jump” |
| The function is described by a sentence with “and” | Split it up | Most likely responsibilities are mixed |
4. Example: making todo readable
Now let’s assemble the pieces into a mini app. Suppose we have an in-memory task service. We’re not building a complex architecture; we’re practicing exactly function structure: happy path, guard clauses, and small helpers.
Task model
First, a simple struct. Note: the example is small and not “ideal”, because today we’re practicing function readability, not domain modeling.
package todo
type Task struct {
ID int
Title string
Done bool
}
Service with in-memory storage
The service stores the list of tasks and the next ID. Nothing fancy.
package todo
type Service struct {
nextID int
tasks []Task
}
Constructor: set sensible defaults
We want Service to always start in a predictable state.
package todo
func NewService() Service {
return Service{nextID: 1}
}
Method Add: guard clauses and a linear happy path
When adding a task, there are usually two types of checks: “is the input valid at all?” and “can we perform the action?”. Today, title validation is enough for us.
package todo
import "errors"
func (s *Service) Add(title string) (Task, error) {
if title == "" {
return Task{}, errors.New("title is empty")
}
t := Task{ID: s.nextID, Title: title}
s.nextID++
s.tasks = append(s.tasks, t)
return t, nil
}
Here’s an important detail: the happy path (created the task, incremented the ID, appended it to the list) runs straight through, without else. And the error is a quick early exit.
If later you add normalization (trimming spaces), you will most likely want to extract it into a separate small function — and that will be the right time, because normalization will become a standalone step.
Small helper functions: lookup and MarkDone
Now let’s add task completion by ID. This is a typical spot where beginners write one big function that does everything: ID parsing, lookup, mutation, errors. We’ll do it neatly: a separate lookup, a separate operation.
Small helper: find the task index
package todo
func (s *Service) findIndexByID(id int) (int, bool) {
for i, t := range s.tasks {
if t.ID == id {
return i, true
}
}
return -1, false
}
The function is small, but very useful: now MarkDone reads like a scenario, not like “and lookup is also here”.
Main method: MarkDone with guard clauses
package todo
import "errors"
func (s *Service) MarkDone(id int) error {
if id <= 0 {
return errors.New("id must be positive")
}
i, ok := s.findIndexByID(id)
if !ok {
return errors.New("task not found")
}
s.tasks[i].Done = true
return nil
}
See the function “skeleton”? It’s very even: validate input → find → change → exit.
Parsing an ID from a string
We previously discussed the common task-solving pattern “read → parse → compute → print”. At the application level, the idea is the same: don’t mix everything into one mess. Even if you still write everything inside main, you can take small steps and extract them into functions.
Suppose we have a command string: "done 3". We want to parse it neatly. Let’s make a function parseDoneID, responsible only for parsing.
package todo
import (
"errors"
"strconv"
)
func parseDoneID(s string) (int, error) {
id, err := strconv.Atoi(s)
if err != nil {
return 0, errors.New("id must be integer")
}
return id, nil
}
Yes, the error is still just a simple string here — that’s fine at this level. The key is: the function does one thing and is easy to test, even if you aren’t writing tests yet (but your future self will thank you).
Mini refactoring of main: less nesting, more meaning
Let’s make a very primitive main that reads a command and an argument. Not a CLI parser, not flags — just a structure demonstration.
Bad style: everything in one place and lots of nesting
package main
import (
"fmt"
)
func main() {
var cmd, arg string
fmt.Scan(&cmd, &arg)
if cmd == "done" {
if arg != "" {
fmt.Println("ok") // ok
} else {
fmt.Println("missing id") // missing id
}
} else {
fmt.Println("unknown command") // unknown command
}
}
Here “ok” doesn’t mean “ok” at all — we didn’t do anything, but we already print it. A typical symptom: the function doesn’t have a clear scenario.
A bit better: guard clauses and steps
package main
import (
"fmt"
)
func main() {
var cmd, arg string
fmt.Scan(&cmd, &arg)
if cmd == "" {
fmt.Println("empty command") // empty command
return
}
if cmd != "done" {
fmt.Println("unknown command") // unknown command
return
}
if arg == "" {
fmt.Println("missing id") // missing id
return
}
fmt.Println("command looks valid") // command looks valid
}
Yes, this is still not full logic, but the structure is already more mature: “checked → exited → main scenario”. When you connect real work with todo.Service, you’ll just replace the last line with “parse id → mark done → print the result”, and the function will remain readable.
5. Common mistakes
Mistake #1: “hide the happy path inside three if blocks because it feels safer”.
Usually this comes from the desire to “keep everything under control”: first we check conditions, then more conditions, and only then we do the work. As a result, the main scenario is hidden so deep that it’s hard to see during a quick skim. Guard clauses fix this: handle bad cases immediately and exit, and keep the “work” at the bottom.
Mistake #2: writing if err != nil { ... } else { ... } out of habit.
In Go, the “error first — work second” style keeps code flat. If you add else, you almost always increase nesting without real benefit. An early return reads more easily and is simpler to extend with new checks.
Mistake #3: turning small functions into meaningless wrappers.
Sometimes you want to extract every other line into a separate function, and you end up with “architecture made of air”: lots of names, little meaning. A sign is a function that just calls another function without adding meaning, or a name that doesn’t explain why it exists. Small functions should reduce cognitive load, not increase it.
Mistake #4: mixing parsing, validation, and state mutation in one “kitchen sink” function.
In small programs this is easy to miss, but as soon as a second scenario appears, chaos begins: where the error is formed, where boundaries are checked, where data changes. Even if you keep everything in one file for now, try to separate the steps logically: parse the input first, then validate, then perform the action.
Mistake #5: turning early return into “early exits without a contract”.
Early return is useful when it’s predictable: either return a result, or an error, or print a clear message and stop. It’s bad when a function can “just exit” in the middle without explanation, leaving data in a strange state. That’s why early returns are especially good in functions that either don’t mutate state, or mutate it only after all checks.
GO TO FULL VERSION