Karya Semi
HomeBlogSearchTagsCategoriesAboutContact
Karya Semi

Less noise. More notes.

HomeBlogAboutContactPrivacy PolicyDisclaimer

© 2026 Karya Semi. All rights reserved.

XGitHubLinkedIn
  1. Home
  2. /Categories
  3. /Programming

Small Functions, Readable Code: A Practical Refactoring Guide

A practical guide to small functions readable code, with advice on function boundaries, parameters, return values, tests, and safe refactoring.

Dian Rijal Asyrof/June 30, 2026/6 min read
Illustration for Small Functions, Readable Code: A Practical Refactoring Guide
Advertisement

I used to think readable code was mostly about names. Better variable names, clearer class names, fewer abbreviations. That matters, and I still care about it. But small functions readable code comes from another habit too: cutting code at the right boundaries.

A long function can have good variable names and still feel heavy. You scan a flag to see which branch you're in. You edit one tiny rule and worry that unrelated code will break.

Small functions fix that when they're written with care. They give each piece of logic a narrow job, a clear input, and a result you can test without setting up the whole application.

This guide is about function size, boundaries, parameters, return values, and tests.

Start with the reason, not a line count

A small function is not automatically a good function. Ten tiny functions named processData, handleStuff, and doThing are just clutter with extra jumps.

The better rule is simple: a function should let you understand one decision at a time.

Martin Fowler's Extract Function refactoring shows the shape of this idea. When a fragment of code can be grouped and named, move it into a separate function and replace the old code with a call. Refactoring Guru describes the same move as taking a grouped code fragment, moving it into a new method, and replacing the old fragment with that method call.

The value is the name. The extracted function says what the block means.

Look at this checkout code:

function totalForOrder(order) {
  let total = 0;
 
  for (const item of order.items) {
    total += item.price * item.quantity;
  }
 
  if (order.customer.isMember) {
    total = total * 0.9;
  }
 
  if (order.shipping.country !== "ID") {
    total += 150000;
  }
 
  return total;
}

This isn't terrible. But it already mixes three ideas: item subtotal, member discount, and international shipping.

A clearer version names those decisions:

function totalForOrder(order) {
  const subtotal = itemSubtotal(order.items);
  const discountedTotal = applyMemberDiscount(subtotal, order.customer);
 
  return addShippingFee(discountedTotal, order.shipping);
}
 
function itemSubtotal(items) {
  return items.reduce((total, item) => {
    return total + item.price * item.quantity;
  }, 0);
}
 
function applyMemberDiscount(total, customer) {
  if (!customer.isMember) return total;
  return total * 0.9;
}
 
function addShippingFee(total, shipping) {
  if (shipping.country === "ID") return total;
  return total + 150000;
}

Now the top-level function reads like a receipt. You don't need to inspect every arithmetic detail to understand the order flow.

Extract around a real boundary

The easiest mistake is extracting code by eyesight. You see eight lines, you move them, and you feel productive. Sometimes that creates a function with no real purpose.

A stronger boundary usually appears when one of these things changes:

  • the reason for the code
  • the data it needs
  • the level of detail
  • the rule being expressed
  • the thing you want to test

The order example has three real boundaries because each piece answers a different business question. What do the items cost? Does the customer get a discount? What shipping fee applies?

This is also why variable names and function boundaries work together. A good variable name can clarify a value inside a function. A good function name can remove the need to read the inside at all. If naming is the pain point for you right now, I wrote a separate guide on variable naming best practices for readable code.

Don't extract only because a function feels long. Extract because the reader deserves a label for a chunk of intent.

Keep one level of detail per function

A readable function usually stays at one level of detail. It either describes the workflow or handles the mechanics. Trouble starts when it does both.

Compare this version:

async function registerUser(input) {
  if (input.email.length < 5) {
    throw new Error("Email is too short");
  }
 
  const passwordHash = await bcrypt.hash(input.password, 12);
 
  const user = await db.user.create({
    data: {
      email: input.email.toLowerCase(),
      passwordHash,
    },
  });
 
  await emailClient.send({
    to: user.email,
    subject: "Welcome",
    template: "welcome",
  });
 
  return user;
}

It validates, hashes, writes to the database, and sends email. That may be fine in a small app, but the levels are mixed. Some lines explain product flow. Other lines handle plumbing.

Here's a cleaner split:

async function registerUser(input) {
  const validInput = validateRegistrationInput(input);
  const user = await createUserAccount(validInput);
 
  await sendWelcomeEmail(user);
 
  return user;
}

The helper functions can be boring. That's good.

function validateRegistrationInput(input) {
  if (input.email.length < 5) {
    throw new Error("Email is too short");
  }
 
  return {
    email: input.email.toLowerCase(),
    password: input.password,
  };
}

The top-level function now tells the story. The helpers handle the screws and wires.

Give small functions fewer parameters

Small functions get hard to read when every one of them drags five arguments around.

This is a smell:

function canApplyCoupon(user, cart, coupon, today, region, featureFlags) {
  // too many reasons to change
}

The function may be small in lines, but the call site is noisy.

Try to reduce parameters by grouping data around the decision:

function canApplyCoupon(context) {
  return isCouponActive(context.coupon, context.today)
    && isCouponAllowedForRegion(context.coupon, context.region)
    && meetsCartMinimum(context.cart, context.coupon)
    && userCanUseCoupon(context.user, context.coupon);
}

That version still has several rules, so you may split further. The call site now passes one concept: coupon eligibility context.

Be careful though. A giant context object can hide dependencies. If a function only needs coupon and today, pass those two values.

My rough rule: one or two parameters feel easy, three is fine, four asks for a second look. More than that usually means the function is doing too much or the data shape is wrong.

Prefer return values over hidden changes

Small functions are easiest to trust when they return a value instead of quietly changing something outside themselves.

This version looks short, but it hides the result:

function applyDiscount(order) {
  if (order.customer.isMember) {
    order.total = order.total * 0.9;
  }
}

The caller has to know that order changes. That can be fine in some domains, but it makes refactoring harder because the function's output isn't visible at the call site.

This version is easier to test:

function discountedTotal(total, customer) {
  if (!customer.isMember) return total;
  return total * 0.9;
}
 
order.total = discountedTotal(order.total, order.customer);

Now the function has a simple contract. Give it a total and a customer. It returns the total to use.

Database writes, file changes, logging, and network calls are part of real software. But isolate those side effects. Keep calculation functions plain when you can.

Make tests follow the boundaries

Small functions pay off when tests can target the same boundaries a reader sees.

If the order logic lives in one long function, you may need to build a full order object for every test case. If discount logic has its own function, the test is tiny:

it("keeps the same total for non-members", () => {
  const total = discountedTotal(100000, { isMember: false });
 
  expect(total).toBe(100000);
});
 
it("applies a ten percent member discount", () => {
  const total = discountedTotal(100000, { isMember: true });
 
  expect(total).toBe(90000);
});

This is where small functions stop being a style preference. They lower the cost of checking behavior.

They also make error messages less scary. A failing test that points to discountedTotal gives you a smaller search area than a failing test that points to totalForOrder. This pairs well with reading programming error messages without panic.

Good tests are a pressure test for boundaries. If a function is awkward to test, check whether it mixes calculation with side effects or accepts too many inputs.

Refactor in safe, boring steps

Fowler describes refactoring as small behavior-preserving changes. That phrase matters. You're changing the shape of the code while keeping the same result.

A safe extraction flow looks like this:

  1. Find a block that has one reason to exist.
  2. Name what the block means.
  3. Move the block into a function.
  4. Pass only the data it needs.
  5. Return the value the caller needs.
  6. Run tests before doing the next extraction.

Do that slowly. Small functions are useful because they reduce risk, so don't create them with a risky edit.

Here's a tiny before-and-after refactor:

function invoiceTotal(invoice) {
  let total = invoice.items.reduce((sum, item) => sum + item.amount, 0);
 
  if (invoice.dueDate < new Date()) {
    total += 50000;
  }
 
  return total;
}

Extract the late fee rule:

function invoiceTotal(invoice) {
  const subtotal = invoiceSubtotal(invoice.items);
 
  return subtotal + lateFeeFor(invoice, new Date());
}
 
function invoiceSubtotal(items) {
  return items.reduce((sum, item) => sum + item.amount, 0);
}
 
function lateFeeFor(invoice, today) {
  if (invoice.dueDate >= today) return 0;
  return 50000;
}

Passing today into lateFeeFor is small, but it matters. The test can control time.

Know when small becomes too small

There is a bad version of this advice. Every line hides behind another function call.

This is annoying:

function isAllowed(user) {
  return isActive(user) && isVerified(user);
}
 
function isActive(user) {
  return user.active;
}
 
function isVerified(user) {
  return user.verified;
}

If those helpers don't carry domain meaning, they add noise.

A function earns its place when it does at least one of these jobs:

  • gives a meaningful name to a business rule
  • hides messy mechanical detail
  • separates side effects from calculation
  • makes testing smaller
  • removes duplication that was starting to drift

If it only turns one obvious line into one obvious call, delete it.

Practical checklist for small functions readable code

Use this when reviewing a function or refactoring one that feels heavy:

  • Can I explain the function's job in one plain sentence?
  • Does the function stay at one level of detail?
  • Is there a block of code that deserves a name?
  • Does each helper accept only the data it needs?
  • Can I reduce parameters without hiding dependencies in a mystery object?
  • Does the function return a value instead of changing state silently?
  • Are side effects isolated near the edge of the workflow?
  • Can I test the rule without building the whole application?
  • Would the call site still be readable if I didn't open the helper?
  • Did I run tests after each extraction?

The last question is dull on purpose. Clean code that breaks behavior is still broken code.

Final thought

Small functions are not a contest to write the fewest lines. They're a way to make code explain itself in pieces a human brain can hold.

Start with one long function you already dislike. Extract one real boundary. Name it honestly. Run the tests. Then stop and read the call site again.

If the code now tells the story faster, you made it better.

Sources

  • Martin Fowler, Extract Function
  • Martin Fowler, Refactoring: Improving the Design of Existing Code
  • Refactoring Guru, Extract Method
  • Kent Beck, Software Design: Tidy First?
Advertisement
DR

Dian Rijal Asyrof

Writes about useful AI tools, programming practice, and the craft of building reliable software.

Previous articleNext.js authentication checklist for production appsNext articleRAG Evaluation Checklist for AI Apps Before Users See Them
functionsrefactoringreadabilitytesting
Advertisement
Advertisement
On this page↓
  1. Start with the reason, not a line count
  2. Extract around a real boundary
  3. Keep one level of detail per function
  4. Give small functions fewer parameters
  5. Prefer return values over hidden changes
  6. Make tests follow the boundaries
  7. Refactor in safe, boring steps
  8. Know when small becomes too small
  9. Practical checklist for small functions readable code
  10. Final thought
  11. Sources

On this page

  1. Start with the reason, not a line count
  2. Extract around a real boundary
  3. Keep one level of detail per function
  4. Give small functions fewer parameters
  5. Prefer return values over hidden changes
  6. Make tests follow the boundaries
  7. Refactor in safe, boring steps
  8. Know when small becomes too small
  9. Practical checklist for small functions readable code
  10. Final thought
  11. Sources

See also

Illustration for GitHub Actions Parallel Steps: What CI Teams Should Check First
Software Engineering/Jun 29, 2026

GitHub Actions Parallel Steps: What CI Teams Should Check First

GitHub Actions parallel steps can cut CI waiting time, but only if teams clean up shared state, logs, caches, and test ownership first.

4 min read
github-actionsci
Illustration for Variable Naming Best Practices for Readable Code
Programming/Jun 28, 2026

Variable Naming Best Practices for Readable Code

Variable naming best practices for writing readable code, from clear nouns and boolean names to function verbs, scope, and safe refactoring habits.

4 min read
variable-namingclean-code
Illustration for The Definitive Guide to Test-Driven Development for Modern TypeScript Projects
Software Engineering/Jun 28, 2026

The Definitive Guide to Test-Driven Development for Modern TypeScript Projects

Master Test-Driven Development (TDD) in TypeScript. Learn the Red-Green-Refactor cycle, practical patterns, and build robust, maintainable code with confidence.

5 min read
tddtypescript