JavaRush /Courses /Go SELF /Function design: guard clauses

Function design: guard clauses

Go SELF
Level 37, Lesson 1
Available

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.

1
Task
Go SELF, level 37, lesson 1
Locked
Login validation
Login validation
1
Task
Go SELF, level 37, lesson 1
Locked
Dividing the Loot
Dividing the Loot
1
Task
Go SELF, level 37, lesson 1
Locked
Cashier coupon
Cashier coupon
1
Task
Go SELF, level 37, lesson 1
Locked
Notes command
Notes command
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION