DEV Community

Code Atlas
Code Atlas

Posted on

Designing Functions That Compound

The Power of Small, Composable Functions

I've spent years refactoring messy codebases. The most common problem I see isn't lack of comments or poor naming-it's functions that try to do too much. A function that does one thing well is easy to test, debug, and reuse. But the real magic happens when you design functions that can be composed together, each one feeding into the next. This is what I call "compounding" function design.

Start with the Data Flow

Before writing a function, I ask: what data goes in, and what comes out? If the answer involves multiple unrelated transformations, I split it. For example, consider a function that fetches user data, filters active users, and formats their names:

// Bad: one function does everything
async function getActiveUserNames() {
  const response = await fetch('/api/users');
  const users = await response.json();
  return users
    .filter(u => u.active)
    .map(u => `${u.firstName} ${u.lastName}`);
}
Enter fullscreen mode Exit fullscreen mode

This works, but it's hard to reuse any part. What if I need raw active users for another feature? I'd have to duplicate the fetch and filter logic.

Instead, I break it into three small functions:

async function fetchUsers() {
  const response = await fetch('/api/users');
  return response.json();
}

function filterActive(users) {
  return users.filter(u => u.active);
}

function formatNames(users) {
  return users.map(u => `${u.firstName} ${u.lastName}`);
}
Enter fullscreen mode Exit fullscreen mode

Now I can combine them in different ways:

const activeUsers = filterActive(await fetchUsers());
const activeNames = formatNames(activeUsers);
Enter fullscreen mode Exit fullscreen mode

Each function is pure (except the fetch), so I can test them in isolation. And I can reuse filterActive anywhere, not just after a fetch.

Make Functions Predictable

The best composable functions are pure: same input, same output, no side effects. This makes them predictable and easy to reason about. For instance, formatNames always returns the same array for the same input. It doesn't mutate the original array, doesn't log to console, doesn't touch global state.

If a function must have side effects (like fetchUsers), keep it at the edges of your system. Then pass the data through pure functions. This separation is the foundation of functional programming, but you can apply it even in imperative codebases.

Use Higher-Order Functions to Avoid Repetition

When you notice the same pattern of combining functions, abstract it. Higher-order functions (functions that take functions as arguments) are perfect for this. For example, I often need to apply a series of transformations to a list. Instead of writing a pipeline each time, I create a pipe helper:

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

const processUsers = pipe(filterActive, formatNames);

// Usage
const activeNames = processUsers(users);
Enter fullscreen mode Exit fullscreen mode

Now processUsers is a new function that compounds the two smaller ones. If I later need to add a transformation, I just add it to the pipe call:

const processUsers = pipe(filterActive, sortByName, formatNames);
Enter fullscreen mode Exit fullscreen mode

This is much cleaner than nesting function calls manually.

Design for Composition from the Start

When writing a new function, I ask: could someone else use this function as a building block? If so, I make sure:

  • The input and output are simple types (arrays, objects, primitives) rather than requiring specific context.
  • The function doesn't depend on external state unless absolutely necessary.
  • The name describes what it does, not why it's used (e.g., filterActive not getActiveUsersForDashboard).

This mindset shifts how you write code. Instead of solving a specific problem, you're building a toolbox of small, reliable pieces.

A Real-World Example

Let's say I'm building a report generator. I need to fetch sales data, group by region, compute totals, and format for CSV. Without composition, I'd write one giant function. With composition, I write:

const fetchSales = () => fetch('/api/sales').then(r => r.json());
const groupByRegion = sales => sales.reduce((acc, s) => {
  acc[s.region] = acc[s.region] || [];
  acc[s.region].push(s);
  return acc;
}, {});
const computeTotals = groups => Object.entries(groups).map(([region, items]) => ({
  region,
  total: items.reduce((sum, i) => sum + i.amount, 0)
}));
const toCSV = rows => rows.map(r => `${r.region},${r.total}`).join('\n');

const generateReport = pipe(fetchSales, groupByRegion, computeTotals, toCSV);
Enter fullscreen mode Exit fullscreen mode

Each step is testable in isolation. I can test computeTotals with mock data without ever touching the network. And if the requirements change-say, I need to filter out low-value sales-I just insert a new function into the pipe.

The Payoff

Designing functions that compound isn't about being clever. It's about making your codebase more maintainable. When every function is a small, composable unit, debugging becomes easier (you isolate the broken step), testing becomes trivial (you test each piece), and adding features becomes safer (you add a new function without touching existing ones).

Start small: pick one function that does too much, break it into pieces, and see how they fit together. Once you feel the difference, you won't go back.

Top comments (0)