DEV Community

Code Atlas
Code Atlas

Posted on

Designing Functions That Compound: Small, Pure, and Composable

The Power of Compounding Functions

When I first started writing code, I treated functions like isolated tasks: parse this input, format that output, save this record. Each function worked, but the codebase became a tangled mess of duplicated logic and hidden side effects. The turning point was learning to design functions that compound: small, pure, and composable units that build on each other like Lego bricks.

Compounding functions aren't just about reuse. They make reasoning easier, testing trivial, and refactoring safe. Here's how I approach it.

Start With Pure Functions

A pure function is one that always returns the same output for the same input and has no side effects (no I/O, no mutation of external state). Purity is the foundation of composability because you can trust it. If a function mutates something, you can't safely combine it with others without worrying about order and hidden dependencies.

Here's a simple example in JavaScript:

// impure: mutates the array
function addToCart(cart, item) {
  cart.push(item);
  return cart;
}

// pure: returns a new array
function addToCart(cart, item) {
  return [...cart, item];
}
Enter fullscreen mode Exit fullscreen mode

The pure version can be used in any context without surprising the caller. It also makes testing trivial: no setup, no cleanup, just pass inputs and check outputs.

Make Functions Small and Single-Purpose

A function should do one thing and do it well. If you find yourself writing "and" in the function name (e.g., parseAndValidate), split it. Small functions are easier to reason about, test, and combine. They also tend to have clearer names, which is documentation in itself.

Consider this overblown function:

function processOrder(order) {
  // 30 lines of logic: validate, apply discount, calculate tax, format output
}
Enter fullscreen mode Exit fullscreen mode

Break it down:

function validateOrder(order) { /* ... */ }
function applyDiscount(order, discount) { /* ... */ }
function calculateTax(subtotal) { /* ... */ }
function formatOrder(order) { /* ... */ }
Enter fullscreen mode Exit fullscreen mode


n
Now each piece is testable in isolation and can be reused elsewhere. The composition happens at a higher level, like a pipeline.

Compose With Pipelines

Once you have small, pure functions, composition becomes natural. You can chain them, either manually or with a utility like pipe (available in lodash, Ramda, or you can write a tiny one yourself).

function pipe(...fns) {
  return (arg) => fns.reduce((acc, fn) => fn(acc), arg);
}

const processOrder = pipe(
  validateOrder,
  (order) => applyDiscount(order, order.coupon),
  calculateTax,
  formatOrder
);
Enter fullscreen mode Exit fullscreen mode

This reads like a recipe. Each step is a function that takes the output of the previous one. If you need to add a step, you just insert another function. If a step fails, you know exactly where to look.

Design Functions With Data Flow in Mind

A common mistake is writing functions that require too much context. For example, a function that takes an entire object but only uses one property. Instead, design functions to accept exactly what they need and return what makes sense for the next step.

// less composable
function getFullName(user) {
  return `${user.firstName} ${user.lastName}`;
}

// more composable
function getFullName(firstName, lastName) {
  return `${firstName} ${lastName}`;
}
Enter fullscreen mode Exit fullscreen mode

The second version can be used in map or reduce more easily, and it doesn't couple you to a specific data shape.

Embrace Currying for Partial Application

Currying lets you fix some arguments and get a new function for the rest. It's a powerful way to create specialized versions of generic functions, which then compose beautifully.

function multiply(a, b) {
  return a * b;
}

function curry2(fn) {
  return (a) => (b) => fn(a, b);
}

const multiplyCurried = curry2(multiply);
const double = multiplyCurried(2);
const triple = multiplyCurried(3);

[1, 2, 3].map(double); // [2, 4, 6]
Enter fullscreen mode Exit fullscreen mode

Now double and triple are reusable, composable pieces. You can combine them with other functions without rewriting logic.

Testability as a Compounding Benefit

When functions are pure and small, testing becomes a joy. You write a test per function with a few inputs and expected outputs. No mocking, no setup. And because functions compose, you can test the composition as a whole by simply testing the pipeline with realistic inputs.

Here's a quick test for a pure function:

// test.js
test('calculateTax applies 10% rate', () => {
  expect(calculateTax(100)).toBe(10);
});
Enter fullscreen mode Exit fullscreen mode

That's it. No database, no network, no global state. The confidence you get from these tests compounds: you can refactor the internals of a function without worrying about breaking the system, as long as the contract holds.

Avoid Over-Abstraction

Compounding doesn't mean abstracting everything into tiny pieces. Sometimes a function with a few lines is fine. The goal is to keep each unit at a level where it's obvious what it does. If you find yourself creating functions with names like doThing or process that just call other functions, you might be over-engineering.

A good heuristic: if a function is so short that it's just a wrapper, consider inlining it unless it provides a clear conceptual abstraction.

Refactoring Toward Compounding

If you have legacy code, start by identifying impure functions and converting them to pure ones. Then extract logical blocks into separate functions. Finally, compose them with a pipeline. Do it incrementally, one function at a time, and let tests guide you.

For example, I once had a massive handleRequest function that did validation, database access, and response formatting. I extracted the validation into a pure function, then the formatting, and left the side-effectful parts at the edges. The core logic became testable without a server.

The Compound Effect

Designing functions that compound isn't just a style choice. It's a discipline that pays off in every aspect of development: readability, maintainability, testing, and debugging. When each function is a small, pure, composable unit, your codebase becomes a toolkit of reliable building blocks. You start to see patterns, reuse becomes effortless, and new features feel like assembling existing pieces rather than writing fresh code from scratch.

Start small. Pick one function in your codebase that's doing too much, break it down, and compose it back together. The effect will surprise you, and it grows with every function you clean up. That's the compounding power of good design.

Top comments (0)