Learn how to write a function with one job.

•

•

One Function, One Reason to Change

I remember sitting in a dark apartment during my time at that logistics startup, staring at a single 400-line function that was supposed to “just process an order.” It wasn’t just processing an order; it was validating tax codes, pinging a third-party shipping API, updating a database, and sending a Slack notification. When the shipping API timed out, the whole thing collapsed like a house of cards, leaving me to trace a ghost in the machine for six hours. Most textbooks will give you some high-minded lecture about the Single Responsibility Principle, but they rarely tell you that learning how to write a function with one job is actually about preventing your own sleep deprivation.

I’m not here to sell you on some architectural purity or a new design pattern you can put on your resume. I want to talk about the practical, slightly messy reality of keeping your code predictable so that when things inevitably break, you can actually find the source of the fire. I’ll share what I’ve learned from years of production incidents about splitting logic effectively without over-engineering everything into a thousand tiny, unreadable files. No hype, just the trade-offs.

Table of Contents

Lessons Learned From Refactoring Complex Functions

Lessons Learned From Refactoring Complex Functions.

During my time at the insurance firm, I spent a lot of energy untangling what I call “God Functions”—those massive blocks of code that seem to handle everything from database connections to email notifications. I used to think I was being efficient by keeping everything in one place, but I was actually just building a house of cards. When I finally sat down to start refactoring complex functions, I realized that the real cost wasn’t the time spent writing them, but the time lost later trying to understand them. Every time I touched a line of code to fix a minor bug, something unrelated would break in a completely different module.

The breakthrough came when I stopped viewing separation of concerns in software design as an academic concept and started seeing it as a survival tactic. I began breaking these monsters down into smaller, predictable pieces. I learned that if a function requires five different arguments just to get started, it’s already trying to do too much. By reducing function complexity, I wasn’t just making the code look prettier; I was making it testable. It’s much easier to write a unit test for a function that calculates a tax rate than for one that calculates the tax, saves it to a ledger, and then pings a Slack webhook.

How Separation of Concerns in Software Design Saves Sleep

How Separation of Concerns in Software Design Saves Sleep

When I was at the insurance firm, we had this one massive settlement calculation engine. It was a thousand-line monster that handled database lookups, tax logic, currency conversion, and email notifications all in one go. On paper, it worked. In reality, whenever the tax laws changed, we spent three days terrified that a tiny tweak to a math formula would somehow break the SMTP connection or corrupt a database row. That’s the cost of ignoring separation of concerns in software design. You aren’t just writing code; you are building a web of invisible dependencies that wait for the worst possible moment to tangle.

By focusing on reducing function complexity, you’re essentially building firewalls between different parts of your logic. If the part of your code that talks to the database fails, it shouldn’t take down the part that calculates the user’s discount. I’ve found that when I break these things apart, testing becomes a trivial task rather than a weekend-long ordeal. You can test the math in isolation without needing to mock an entire network stack. It turns out that modular programming best practices aren’t just academic ideals—they are practical tools for ensuring that when something inevitably breaks, you can find the culprit in five minutes instead of five hours.

Five ways to keep your functions from becoming monsters

  • Name the function for what it actually does, not what it might do later. If I find myself using the word “and” in a function name—like `validateAndSaveUser`—it’s a red flag that I’ve actually written two functions and I’m just being lazy.
  • If you can’t describe the function’s purpose in a single, short sentence without using the word “process,” it’s probably doing too much. “ProcessOrder” is a trap; it’s a black hole that eventually swallows validation, inventory checks, and email triggers.
  • Watch out for the “Side Effect Creep.” A function that is supposed to just calculate a discount shouldn’t also be updating a database row or incrementing a counter in a global variable. I learned this when a simple price calculation bug ended up corrupting our entire audit log.
  • Use the “Extract Method” reflex. If you’re staring at a block of code that’s more than ten lines long and it feels heavy, don’t try to optimize the logic—just pull that chunk out into its own named function. It makes the main flow readable and the logic testable.
  • Test for one outcome, not five. If writing a unit test for a function requires a massive setup of mocks and complex state just to check one boolean, your function is trying to live too many lives at once. A single-purpose function should be easy to break and even easier to fix.

The Cost of Doing Too Much

If a function requires more than two or three different types of arguments—especially if it’s mixing domain logic with database calls—it’s probably doing too much. I’ve learned that “convenience” functions are usually just debt in disguise.

Testability is the ultimate litmus test; if you can’t write a unit test for a function without mocking half your infrastructure, the function isn’t focused enough. It’s much easier to fix a small, broken piece than a large, broken system.

Documentation isn’t a substitute for simplicity. If you find yourself writing a paragraph to explain what a function does, stop writing documentation and start splitting the function into two smaller ones that explain themselves.

The Long View

The Long View of clean code architecture.

At the end of the day, writing single-purpose functions isn’t about following some academic rulebook or pleasing a linter. It’s about reducing the cognitive load required to understand what your code is actually doing when things go sideways. I’ve spent enough hours staring at a 200-line function in a production log to know that complexity is a debt that always collects interest, usually at the worst possible time. By breaking things down into discrete, predictable steps, you aren’t just making the code cleaner; you are building a safety net for your future self. You’re making sure that when the next incident occurs, you can isolate the failure in minutes rather than hours.

I used to think that “clever” code was the hallmark of a good engineer, but fifteen years of production incidents have taught me otherwise. The best code I’ve ever written is the kind that looks almost boringly simple to a junior developer. We don’t write these functions to show off our mastery of complex logic; we write them so that the system remains predictable even when we aren’t there to watch it. Aim for clarity over cleverness, and you’ll find that you spend much less time fighting your own abstractions and much more time actually building things that last.

Frequently Asked Questions

How do I decide where one function ends and the next one should actually begin without over-engineering everything into a thousand tiny pieces?

I used to fall into the trap of splitting everything into micro-functions until my codebase looked like a jigsaw puzzle. Now, I use the “context switch” rule. If I have to stop thinking about what the logic is doing to focus on how it’s being transported—like suddenly worrying about a database connection or a specific JSON schema—that’s where the boundary lives. If the function’s purpose shifts from “calculating a discount” to “logging the result,” split it. Otherwise, leave it alone.

At what point does breaking things down too much start hurting performance or making the code harder to actually follow?

There’s a point where you’re no longer engineering, you’re just playing with Legos. If I have to jump through five different files just to trace a single variable’s lifecycle, I’ve gone too far. It’s a trade-off: you gain theoretical purity but lose the mental model of how the data actually flows. If the abstraction layer becomes more complex than the logic it’s supposed to hide, you’ve just traded one kind of technical debt for another.

How do you handle functions that naturally need to touch multiple layers, like a service that needs to both calculate something and log it to a database?

I used to think this was a violation of the rule, but the reality is more nuanced. You don’t want a single function doing the math and the database IO, but you do need a way to coordinate them. I usually handle this by keeping the “brain” (the calculation) pure and stateless, then using a thin orchestrator—a service layer—to call the logic and then pass the result to the logger. It keeps the math testable and the side effects predictable.

When I'm staring at a massive legacy function that's already broken, what's the safest way to start splitting it up without breaking the whole production flow?

Don’t try to be a hero and rewrite the whole thing in one PR. That’s how you end up on a bridge call at 3 AM. Start by extracting a single, pure calculation—something that takes an input and returns an output without touching a database or a global variable. Wrap it in a unit test. Once you’ve proven that tiny piece works in isolation, you can slowly pull the rest of the logic out, one thread at a time.

Tomasz Wierzbicki

About Tomasz Wierzbicki

I am not interested in arguing about frameworks. I am interested in why the thing broke at two in the morning and what would have prevented it. Most of what I know came from being wrong in production and writing it down afterwards.