Back to portfolio
The Art of...
Best Practices
Clean Code

The Art of Small Functions

How small functions turn complex logic into simple, readable code.

Jun 2026

10 min read


There is a certain kind of function that starts innocently enough. It handles one thing, maybe two. Then someone adds a special case. Then a flag to toggle behaviour between callers. Then a loop that also does some unrelated bookkeeping on the side. A year later it is three hundred lines long and the only person who understood it left the company in spring.

Functions grow because it is always easier to add to something existing than to decide where new logic actually belongs. The cost accumulates quietly — in bugs that are hard to isolate, in tests that require elaborate setup, in code reviews where nobody quite manages to follow the whole thing.

These are the patterns that fight back.

One thing, one name

The most useful heuristic for function size isn't a line count — it's whether you can describe what the function does without using the word and. If your description is “it validates the form and saves the data and sends a confirmation email”, that's three functions pretending to be one.

The version below does all four jobs in one place. Nothing is obviously wrong with it — it runs, the tests pass. But try writing a test that checks the email template without also needing a live Stripe key. Or try finding the bug when charges succeed but the database write fails. The jobs are tangled together, and untangling them later costs far more than separating them now.

one function, four jobs
submit_order.py
python
def submit_order(order, user):
    if not order.items:
        raise ValueError("Empty order")
    if not user.payment_method:
        raise ValueError("No payment method")

    charge = stripe.charge(user.payment_method, order.total())
    order.charge_id = charge.id

    email.send(
        to=user.email,
        subject="Order confirmed",
        body=render("confirm", order),
    )

    db.save(order)
    return order
submit_order_refactored.py
python
def validate_order(order, user):
    if not order.items:
        raise ValueError("Empty order")
    if not user.payment_method:
        raise ValueError("No payment method")

def charge_order(order, user):
    charge = stripe.charge(user.payment_method, order.total())
    order.charge_id = charge.id

def notify_user(order, user):
    email.send(
        to=user.email,
        subject="Order confirmed",
        body=render("confirm", order),
    )

def submit_order(order, user):
    validate_order(order, user)
    charge_order(order, user)
    notify_user(order, user)
    db.save(order)
    return order
decompositionclick to highlight

The refactored version has moretotal lines. That's fine. What you gain is far more valuable: charge_order can be tested without sending an email, notify_user can be tested without hitting Stripe, and when the Stripe integration breaks you know exactly where to look. submit_order itself becomes a four-line summary of the whole operation — readable in a single pass.

Private helpers aren't a cost

One of the most common objections to splitting functions is that it adds more names to track. This gets the tradeoff backwards. A well-named private helper isn't another thing to learn — it's a labelfor something you'd otherwise have to decode from scratch every time you read it.

pricing.py
python
def calculate_price(cart, user):
    subtotal = sum(item.price * item.qty for item in cart.items)
    discount  = _loyalty_discount(user, subtotal)
    tax       = _regional_tax(user.region, subtotal - discount)
    return subtotal - discount + tax

def _loyalty_discount(user, subtotal):
    if user.orders_count >= 10:
        return subtotal * 0.10
    if user.orders_count >= 3:
        return subtotal * 0.05
    return 0

def _regional_tax(region, amount):
    rate = TAX_RATES.get(region, DEFAULT_TAX_RATE)
    return amount * rate

calculate_price now reads like a summary. Someone skimming the codebase understands it in five seconds. Someone debugging a tax calculation goes directly to _regional_tax. And if tax rules change — they always change — you know exactly which function to touch, and which ones to leave alone.

The leading underscore signals “implementation detail, not public API”. The names themselves tell you what each piece does. That's two layers of documentation that cost you nothing extra to write.

When to merge, not split

Splitting has a failure mode too. Sometimes what looks like two things is actually one thing that needs two steps — and splitting it forces callers to know about the steps when they should only know about the result.

date_before.py
python
def parse_date_string(s):
    return s.split("-")

def parts_to_date(parts):
    return date(int(parts[0]), int(parts[1]), int(parts[2]))

# every caller must know the middle step
parts = parse_date_string("2024-03-15")
d     = parts_to_date(parts)

The intermediate parts variable is an implementation detail — no caller should ever need it on its own. Exposing it as a separate function just creates a dependency on something that was never meant to be public. Merge it back:

date_after.py
python
def parse_date(s):
    year, month, day = s.split("-")
    return date(int(year), int(month), int(day))

# callers only see what they need
d = parse_date("2024-03-15")

The test for whether to split isn't “are there two steps” but “do callers ever need the intermediate result?” If the answer is no, the split just creates a leaky abstraction.

The real signal

You'll know a function needs splitting not by counting its lines, but by noticing the moment you have to re-read it. If a function you wrote two weeks ago requires careful reading to understand, it's doing too much. A function you understand in a single pass — even a long one — is doing exactly one thing.

Small is a side effect, not the goal. When a function does one thing, it tends to be small. When it has a name that matches what it does, it tends to be easy to find. When it has no hidden dependencies, it tends to be easy to test. None of those are things you achieve by counting lines — they're things you achieve by being honest about what a function is actually for.

I've written my fair share of three-hundred-line functions, and every single one started out as a perfectly reasonable ten-line one that I kept “just adding one more thing” to. It never feels like the wrong call in the moment — stopping to figure out where new logic actually belongs is more effort than pasting it in and moving on. This ties back to the naming post: if you can't describe a function without the word and, that's the same problem showing up from a different angle. Split when the description gets greedy, merge when the split turns out to be fake, and future you will spend a lot less time re-reading old code trying to remember what it does :)