Writing code is my passion. I’ve been doing it for a long time, but there is something basic that still makes me scratch my head, and that is function definition. Many young and beginner developers don’t know about function philosophy, how to use it, and how to define it properly.
I see so many young developers who can copy patterns, follow tutorials, and ship working code. But when I ask them why they defined a function a certain way, they go quiet. Sometimes they think they understand, but they don’t fully comprehend it.
That’s what this story is about. No steps. No textbook. Just my experience, my mistakes, and what I’ve learned about writing functions that actually make your code easier to live with.
Understand The Function
I don’t wanna go with the formal function definition, but I want you to truly understand what a function is. Understand it like you know the philosophy, until you can feel it. Like, “Oh, this is the function!”
Think of a function like a mini machine, a helpful mini-robot built to do one specific job. When you feed it an ingredient (the input), the machine follows a hidden set of instructions to do its work, and then hands you back a finished result (the output).
For example, if you build a makeJuice function, you can just drop an apple into it, and the machine will automatically wash, cut, and squeeze it to give you back apple juice.
Programmers use functions so they don’t have to write the exact same instructions over and over again, saving them time and keeping their code perfectly neat.
// Define
fn make_juice(fruit: &str) -> String {
// Compute
let washed = wash(fruit);
let cut = cut(washed);
let squeezed = squeeze(cut);
// Result
squeezed
}
// Usage
let result = make_juice("apple");
// result = "apple juice"
That’s the whole point of a function. You write the logic once, give it a good name, and reuse it everywhere without rewriting the same instructions over and over. It keeps your code clean, readable, and consistent.
The function does one job: make juice. It has multiple steps inside to finish the job. That is correct, that’s a solid function.
Function Rule
These are the rules you must understand before writing a function. Without these rules, believe me, you will ruin your entire codebase.
Keep Argument Low. Zero Is The Goal
The more arguments a function takes, the harder it is to use correctly. Every argument is something the caller has to remember. Every argument is one more thing that can be passed in the wrong order.
Zero arguments is the ideal. If you can write a function that takes nothing and still does useful work, that is a beautiful thing. And here is why that goal matters more than it sounds.
When a function takes zero arguments, it is completely self-contained. It does not depend on anything the caller provides. That means you can call it from anywhere, test it in isolation, and trust that it will always behave the same way. No setup required. No chance of passing something wrong.
One argument is still clean. The caller knows exactly what goes in and what comes out. There is a clear contract.
Two arguments start to create cognitive load. The caller now has to think about the order, the types, and whether both values are valid together.
Three or more? Now the caller has to hold a mental model of the function before they can even call it. The chance of passing arguments in the wrong order is real. The chance of forgetting one is real. Every new argument makes the function harder to use, harder to test, and harder to read at the call site.
When you find yourself adding a third or fourth argument, that is the function telling you something: these values belong together. They are a concept. Give that concept a name and wrap them in a struct.
Here is what bad looks like:
// BAD EXAMPLE
// Too many arguments. Hard to call. Easy to mess up the order.
fn create_user(
name: &str,
email: &str,
age: u32,
country: &str,
referral_code: &str,
) -> User {
// ...
}
And here is the fix. When arguments pile up, they are telling you something. They want to become a struct.
// GOOD EXAMPLE
// Group them. Give the group a name. Pass the idea.
struct NewUserParams<'a> {
name: &'a str,
email: &'a str,
age: u32,
country: &'a str,
referral_code: &'a str,
}
fn create_user(params: NewUserParams) -> User {
// ...
}
Now the call site is readable. The compiler catches mistakes. And when you need to add a new field later, you just update the struct. The function signature stays clean.
My personal rule: zero arguments is ideal, one is clear, two is acceptable. Three or more? Just trash your function.
Focus Responsible
I hear a lot of developers repeat “single responsibility principle” like it is a magic spell. And yes, I agree with it. But I want to be clear about what it actually means, because I see people get it wrong.
Single responsibility does not mean one line. It means one job. Look at this. This is technically a function, but it is useless:
// Single line, but what is the point?
fn get_name(person: &Person) -> &str {
&person.name
}
Compare that to this, which is a real single-responsibility function:
// One job: sanitize data. Multiple steps inside. That is fine.
fn clean_data(raw_input: String) -> CleanRecord {
let trimmed = raw_input.trim();
let sanitized = remove_special_characters(trimmed);
let formatted = parse_to_json(sanitized);
formatted
}
A function must have computation inside it. That is what makes it correct and useful. Steps that are grouped together so the code stays modular.
Here is why this rule matters. When a function is responsible for more than one job, changing one job risks breaking the other. Imagine a function that both validates user input and saves it to the database.
If the database schema changes, you open the function to fix the save logic, and now you are one wrong keystroke away from accidentally breaking the validation too. Two jobs, one function, one risky edit.
When a function has a single responsibility, a change has nowhere to spread. You open it, fix the one thing it does, close it. Done. Nothing else is at risk.
There is another reason. A single-responsibility function is easy to reuse. If your function validates input and also saves it, you can never use just the validation alone somewhere else.
But if validation is its own function, you can call it anywhere: on form submit, in a background job, in a test. The more focused a function is, the more places it can go.
And the last reason is the simplest: a focused function is easy to read. When you open it, you immediately know what it does. You do not have to scan the whole body to figure out which parts are doing what. The name tells you the job, and the body confirms it.
Keep Small
If your function is scrolling past your screen, it is doing too much. A good function should be readable in one glance.
Here is why this actually matters. When a function is small, you can understand it completely in seconds. You do not need to scroll. You do not need to trace through nested conditions or search for where a variable was set. The whole thing fits in your head at once.
Small functions are also easier to name. If you cannot name a function in a few words, that is usually a sign it is doing more than one job.
When you split it up and each piece gets its own name, those names become living documentation. You can read the top-level function and understand the whole flow just from the names alone, without even looking at the implementation.
And when something breaks, small functions make the bug easier to find. Instead of scanning 80 lines of mixed logic, you check which small function failed and the problem is contained.
The goal is not to make functions artificially tiny. The goal is that each function does exactly one job, and you can understand that job without scrolling.
Here is a function that has grown too big. It loads a post, validates it, marks it published, and notifies followers, all jammed into one place:
// This function is doing too much
async fn publish_post(post_id: &str) -> Result<(), Error> {
let post = db::find_post(post_id).await?;
if post.is_none() {
return Err(Error::PostNotFound);
}
let post = post.unwrap();
if post.title.is_empty() || post.content.is_empty() {
return Err(Error::PostNotReady);
}
db::update_post_status(post_id, "published").await?;
notifications::send_to_followers(&post.author_id, post_id).await?;
log::info!("Post {} is now live", post_id);
Ok(())
}
Split it. Give each step its own name:
// Step 1: Load the post from the database
async fn fetch_post(post_id: &str) -> Result<Post, Error> {
db::find_post(post_id).await?.ok_or(Error::PostNotFound)
}
// Step 2: Make sure the post has a title and content before going live
fn is_ready_to_publish(post: &Post) -> bool {
!post.title.is_empty() && !post.content.is_empty()
}
// Step 3: Mark the post as published
async fn mark_as_published(post_id: &str) -> Result<(), Error> {
db::update_post_status(post_id, "published").await
}
// Step 4: Let the author's followers know there is new content
async fn notify_followers(author_id: &str, post_id: &str) -> Result<(), Error> {
notifications::send_to_followers(author_id, post_id).await
}
// Main function: just tells the story, step by step
async fn publish_post(post_id: &str) -> Result<(), Error> {
let post = fetch_post(post_id).await?;
if !is_ready_to_publish(&post) {
return Err(Error::PostNotReady);
}
mark_as_published(post_id).await?;
notify_followers(&post.author_id, post_id).await
}
Now when you read publish_post, you do not need to trace through any logic. You just read the steps: load the post, check if it is ready, mark it published, notify followers. It reads like a sentence. Each helper does one thing, and you can test each one without touching the others.
When Define Function
This is the question beginners almost never ask, and it is the right one to start with. You cannot just define functions everywhere. You need a reason.
Here are the three situations where I always reach for a new function, based on my experience:
-
Repetitive code. If you find yourself writing the same block of logic in two or more places, stop. Extract it into a function with a clear name. Now you fix it once and the fix applies everywhere.
-
Complex logic. If you have a block of code that requires a comment above it just to explain what it does, that block deserves to be a function. The function name becomes the comment.
-
Documented logic. When a piece of logic represents a real business concept, like checking if a user qualifies for a discount, or validating a wallet address, it deserves its own function with its own name. Future readers will thank you.
Conclusion
After sharing my story and examples, I hope you understand what a good function looks like, what a bad one looks like, and most importantly, when you actually need to define one.
With this understanding, you can define your own functions properly. And with this story, I hope you have a better view of what function philosophy really means.
A function is not just a piece of code you define wherever you want. A function is what makes things in your code easier. Use it right.