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.
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.
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 orderdef 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 orderThe 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.
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.
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 * ratecalculate_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.
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.
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:
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.
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 :)