Essay

Simplify, Simplify, Simplify

Make code easier to understand, not merely smaller

By ·17 min read

Back when the most advanced programming language a geeky teen could find was BASICA (it had ADVANCED right in the name!), we were not very empathetic. We created sayings like “users are losers,” “PEBCAK: problem exists between chair and keyboard,” and “RTFM.” We could be quite mean.

Our code was even worse than our behavior. Our tools were horrible. Our expectations were dreadful. When code got long, we expected to keep it all in our heads. When we couldn’t, we’d question whether we were cut out for programming. Failing to reason about code, no matter how bad the code, was a personal failing.

Fortunately, we’ve learned that we don’t have to work so hard. Here are three ways to simplify.

Part one: Simplify

Sandi Metz carved out some programming advice on the Ruby Rogues podcast in 2013.1 Advice might not be a strong enough word. “Commandments” may be better. Her advice is aimed primarily at object-oriented Ruby programmers, but three numerical constraints and a rule for breaking them are worth trying elsewhere too.

Classes may not have more than 100 lines of code

Long ago, I worked with two women who always paired. They spent a month refactoring a single class: 10,000 lines of Java 7, including one method that was 5,000 lines long. Their work was a master class in refactoring.

Proposing a hundred-line limit would have gotten me kicked out of their space. But the number isn’t a measurement of good design. Splitting that class into a hundred files wouldn’t help if we still needed to understand all of them to change any one of them.

David Parnas explored this problem in his 1972 paper, On the Criteria To Be Used in Decomposing Systems into Modules.2 He took a small text-indexing system and decomposed it in two different ways. One followed the steps of processing the data. The other organized modules around design decisions they could conceal from the rest of the system.

Both worked. But changes to the data representation affected multiple parts of the first design, while the second was organized to confine such changes behind a module interface.

John Ousterhout takes this further in A Philosophy of Software Design.3 He describes deep modules: substantial functionality hidden behind simple interfaces. Sometimes a larger class is better precisely because it keeps more complexity to itself.

So the useful question isn’t how many lines a class contains. It’s what belongs together. Which design decisions does it hide? Can we change those decisions without changing unrelated parts of the application? Can we understand and use the class without understanding its implementation?

A line limit doesn’t give us answers; it gives us a reason to stop and ask.

Start with a constraint. See what it reveals.

Methods may not have more than five lines of code

Metz’s five-line rule encourages us to look for pieces of work that can be given meaningful names. A good name lets us understand what a function does without needing to read how it does it.

But moving five lines into another function doesn’t necessarily make anything simpler. We need to find a useful boundary, one that allows us to understand one part of the program without understanding another.

Every implementation detail we need to keep in our head adds a little to our cognitive load. It’s like piling snowflakes onto a tree branch, or straw on a camel’s back. Each weighs almost nothing, but eventually something is going to break. A well-named function lets us set some of that weight aside.

For an example, let’s borrow a small decision from Apollo 11’s lunar-module software, developed at MIT’s Instrumentation Laboratory, where Margaret Hamilton led the software engineering effort.4

The computer’s Executive program managed jobs and their working storage. To start an interpretive job, it needed two kinds of resources: one of five working-storage areas, called VAC areas, and one of seven core sets. It raised alarm 1201 when no VAC area was available, or 1202 when no core set was available.5

Let’s borrow those rules for a deliberately simplified Python example—not a translation of the flight software. We’ll represent the two resource pools with Boolean values, where True means a slot is free:

python
vac = next((i for i, free in enumerate(vac_areas) if free), None)
if vac is None:
    report_alarm(0o1201)
else:
    core = next((i for i, free in enumerate(core_sets) if free), None)
    if core is None:
        report_alarm(0o1202)
    else:
        reserve_resources(vac, core)

The alarm codes are written in octal, as they were in the original. We have a small piece of resource-allocation policy mixed with its consequences. To understand the caller, we have to follow the search, the order in which resources are checked, and both failure paths.

Let’s give that decision a name:

python
def plan_interpretive_job(vac_areas, core_sets):
    vac = next((i for i, free in enumerate(vac_areas) if free), None)
    if vac is None:
        return None, 0o1201

    core = next((i for i, free in enumerate(core_sets) if free), None)
    if core is None:
        return None, 0o1202

    return (vac, core), None

Now the caller has less to consider:

python
resources, alarm = plan_interpretive_job(vac_areas, core_sets)
if alarm is not None:
    report_alarm(alarm)
else:
    vac, core = resources
    reserve_resources(vac, core)

This isn’t just a standard-library search given another name. The helper expresses a policy: an interpretive job needs both kinds of storage; failure to find a VAC area produces 1201; failure to find a core set produces 1202. A caller can rely on that contract without reopening the implementation.

It’s also more than five lines long. Splitting it further just to meet the rule could scatter a small, coherent decision across several functions. The rule has given us a reason to examine the code; it hasn’t settled the design for us.

That’s what I want from a small function: a meaningful piece of work with a name I can rely on. Not merely five lines moved somewhere else.

We’ve also exposed another question: who owns vac_areas and core_sets, and what guarantees that their availability won’t change between planning and reservation? The real Executive wasn’t written as a separate plan-then-commit operation; the separation here is illustrative. A real allocator must coordinate those steps.

We’ll come back to that.

Pass no more than four parameters to a method

A function’s parameters tell us what it needs to know to do its work, and looking at its list of parameters can tell us quite a lot about the function itself. Do the parameters describe one coherent operation? Do some seem unrelated to the others? Are several parameters always used together? Does the function need different parameters depending on which path it takes?

A long parameter list can reveal a function that is doing too much. But it can also reveal several values that belong to one concept. Martin Fowler calls the latter data clumps.6 If we repeatedly pass a start date and an end date together, perhaps what we really have is an interval. Giving that concept a name lets us put its meaning, constraints, and perhaps some of its behavior in one place.

Parameters can also reveal unnecessary decisions. Consider a function that accepts a Boolean telling it which of two operations to perform. Fowler calls this a flag argument.7 Sometimes two well-named functions would express the available operations more clearly.

But shortening the list is not the goal. Putting eight unrelated arguments in a context object doesn’t make them related. Moving them into global state makes the signature shorter while making the function’s needs harder to see. Remember plan_interpretive_job(vac_areas, core_sets) from our Apollo example? Its two inputs describe two distinct resource pools. Changing it to plan_interpretive_job() would hide where that information comes from: an object field, a global variable, or something retrieved elsewhere. We’d have removed something useful from the signature.

I’d rather have an honest parameter list that tells me whether the work should be divided, whether related values deserve a name, or whether the function depends on things it shouldn’t need.

Four parameters isn’t a measure of good design. It’s another reason to stop and ask questions.

Break these rules only if your pair agrees

Sometimes breaking a rule produces better code. Maybe a longer method keeps a coherent calculation together. Maybe another parameter makes a dependency explicit. Maybe extracting one more helper would scatter an idea that’s already easy to follow.

When breaking a rule makes the code better, try explaining the rule and your reason for breaking it to another developer or in a pull request. Someone else may see something we’ve missed. Perhaps the longer method really does contain two responsibilities. Perhaps the extra parameter belongs to a concept we haven’t recognized. Or perhaps the exception is exactly the right choice.

We’re not looking for permission. We’re not looking for consensus. We’re testing our reasoning against another person’s understanding. If we can’t explain why breaking the rule improves the code, we may not understand the trade-off well enough.

The rule has served its purpose even when we decide to break it.

Net effect

Sandi describes the purpose of object-oriented design as reducing the cost of change.8 I find that a little too transactional. I prefer to think of these constraints as ways to protect my cognitive load.

I enjoy my work best—and find it most rewarding—when I can focus on the problem in front of me without keeping tens, hundreds, or thousands of lines of semi-related code in my head.

Smaller classes and methods can help, but only when they reduce what I need to understand. If I have to follow a dozen functions across half a dozen files to understand one operation, we haven’t necessarily simplified anything. We’ve just moved the complexity around.

The goal isn’t to write less code or divide it into smaller pieces. It’s to reduce how much we need to know at once.

Part two: Simplify

One of my favorite programming adages is “Don’t comment your code.” This can be counterintuitive, counterproductive, or just plain countered by others on your team. But it does have merit.

Comments lie. More precisely, they can describe what we intended long after the implementation has changed. When this happens, we have two conflicting accounts, and we need to work out which one matches the requirement.

Before writing a comment, I like to consider a few alternatives.

Can I write a well-named function instead?

We already have one: plan_interpretive_job. At the call site, that name lets us think about the resource-allocation decision without following two searches and two different alarm conditions.

But the implementation has to earn the name. If plan_interpretive_job also reserves the resources, the caller’s separate reserve_resources operation becomes suspicious. Are we reserving the same slots twice? Could the second operation fail? What other behavior might the name be hiding? Perhaps we need a different name, or perhaps we’ve combined two responsibilities that should be separate. Names aren’t decoration. They’re part of how we explain what another programmer can rely on, without requiring them to inspect the implementation each time.

Can I write a test instead?

Suppose we add this comment to our Apollo-inspired planner:

python
# Choose available VAC and core slots; report 1201 or 1202 if exhausted.

That tells a reader what we intend. But we can express the same expectations in a test:

python
def test_plan_interpretive_job():
    vacs = (False, True, True, False, False)
    cores = (False, False, True, False, False, False, False)

    assert plan_interpretive_job(vacs, cores) == ((1, 2), None)
    assert plan_interpretive_job((False,) * 5, cores) == (None, 0o1201)
    assert plan_interpretive_job(vacs, (False,) * 7) == (None, 0o1202)
    assert plan_interpretive_job((False,) * 5, (False,) * 7) == (None, 0o1201)

The first assertion checks that we select the first available slot in each pool. The next two distinguish exhausted VAC areas from exhausted core sets. The last establishes which alarm takes precedence if both are exhausted. Unlike the comment, these expectations can be checked against the implementation. If someone changes the allocation policy, the tests can catch it.

Tests can be wrong, too. They can express a mistaken requirement or leave out an important case. A passing test demonstrates agreement with the expectations we’ve written, not that we’ve expressed every necessary expectation.

Still, an executable example gives a reader something useful: a concrete statement of behavior that can be checked.

Does the comment preserve something the code cannot say?

The Apollo 11 Executive source includes this comment beside the instructions that reserve a working-storage area:

text
RESERVE THIS VAC AREA BY STORING A ZERO
IN ITS VAC USE REGISTER

The full comment also explains how the address of the reserved area is stored in the job’s priority word.9

Our Python example uses True to indicate an available slot. The Apollo code uses a different representation: storing zero in a VAC use register marks the area as reserved. That comment helps explain what the assembly instructions accomplish, including a detail that isn’t obvious from the instructions themselves.

That’s useful information. Removing it because “good code doesn’t need comments” would not make the original easier to understand.

Of course, this does not justify comments to excuse confusing code. If a comment is necessary to understand an ordinary calculation, perhaps the calculation needs a better name, a clearer representation, or a simpler implementation. Chances are excellent that the code can communicate this better.

Sometimes information genuinely belongs outside the code. A hardware constraint, an external specification, a compatibility requirement, or the reason an apparently simpler approach is unsafe may not be expressible in the implementation itself. First ask whether the code could make the idea clear. If it cannot, explain what a capable reader couldn’t reasonably infer.

I don’t want fewer comments at the expense of understanding. I want code, tests, and comments all to contribute to understanding.

Part three: Simplify

Pure functions give us another way to make code easier to understand. They let us separate calculations from changes to application state, so we can reason about one without needing to understand the other.

What makes a function pure?

A pure function returns the same result for the same input values and has no observable side effects. It doesn’t read the clock, fetch a database record, or modify an object supplied by its caller.

This gives us referential transparency: we can replace a function call with its result without changing the program’s meaning.

Let’s return to the Apollo 11 Executive, this time to a different operation.

When a job finished, the Executive’s ENDJOB1 routine released its working storage and searched for the highest-priority active job.10 Imagine a simplified scheduler that keeps job priorities in a collection, with None representing an unused slot. We might write:

python
def complete_job(index):
    scheduler.priorities[index] = None
    scheduler.dispatch_next()

This function has an implicit dependency on scheduler. It changes the scheduler’s state and starts another operation. To understand its behavior, we need to know what scheduler is, what state it holds, and what dispatch_next does.

We can separate part of that work into a pure function:

python
def priorities_after_completion(priorities, index):
    if not 0 <= index < len(priorities):
        raise IndexError(index)

    return priorities[:index] + (None,) + priorities[index + 1:]

Here, priorities is an immutable tuple containing priority numbers or None. The function calculates a new state without modifying the original. It doesn’t know about the scheduler, release any actual resources, or dispatch another job. We’ve made its dependencies explicit and separated the decision about the new state from the operation that applies it.

Simple to understand, simple to test

We can demonstrate its behavior with a few assertions:

python
before = (20, 40, 10)
after = priorities_after_completion(before, 1)

assert after == (20, None, 10)
assert before == (20, 40, 10)
assert priorities_after_completion(before, 1) == after

The first assertion verifies the result. The second demonstrates that the original state hasn’t changed. The third checks that the same inputs produce the same output. We can understand and test this behavior without starting a scheduler, creating a mock, or reproducing the state of a guidance computer. Everything the function needs is in its arguments.

Of course, we still need code that applies the change and dispatches the next job. In a concurrent system, that work must be coordinated so another operation can’t change the authoritative state between our calculation and its application. The pure function doesn’t solve that problem. It gives us one small piece of the problem that we can understand independently.

And this Python example isn’t a translation of Apollo’s implementation. It leaves out the actual machine representation, resource management, interrupt handling, and scheduling behavior. We’re borrowing an operation to examine, not redesigning a spacecraft.

Useful, not magical

Pure functions aren’t automatically simple or correct. A pure function can implement the wrong rule, contain a complicated algorithm, or consume unreasonable amounts of resources. Nor does purity make an entire application immune to concurrency problems. We still need to coordinate state changes, manage resources, and handle failures.

What purity offers is more modest, but remarkably useful: we can understand a calculation from its inputs and test it without setting up the world around it.

Gary Bernhardt demonstrates a practical way to organize applications around this principle in his 2012 Destroy All Software screencast, Functional Core, Imperative Shell.11 The idea is to keep decisions and calculations in a functional core, while an imperative shell handles interactions with the outside world. The core contains behavior we can reason about independently. The shell obtains inputs, calls the core, and applies its results.

Our example is a small instance of that separation. One function calculates what the scheduler’s state should become. Other code is responsible for making the change happen.

We can use this approach in almost any language. We don’t need to turn the entire application into a functional-programming exercise to benefit.

Room to think

Shorter classes, smaller methods, useful tests, and pure functions are not ends in themselves. They help when they let us see what matters right now.

A useful boundary lets us understand a caller without tracing every implementation detail. A test lets us check behavior without relying on memory or assumptions alone. A pure function lets us reason about a calculation without reconstructing the state of the world around it.

But simplicity requires judgment. Sometimes extracting a function hides complexity; sometimes it scatters it. Sometimes a test communicates more than a comment; sometimes a comment preserves something neither the code nor the test can say. Sometimes adding a parameter makes a function easier to understand, not harder. These are small decisions, but their effects accumulate. They can make a working day quite different.

I don’t want to prove that I can keep an entire system in my head. I want to understand what I’m changing and have useful evidence that it works.

I want to think about less, so I have more room to think about the problem in front of me.


  1. Sandi Metz’s original rules were discussed on Ruby Rogues in January 2013. The transcript and rule list1 give three numerical limits, a Rails controller/view rule, and permission to break the rules when a pair agrees. The fourth heading here discusses that exception, not the original Rails-specific rule. ↩︎

  2. D. L. Parnas, On the Criteria To Be Used in Decomposing Systems into Modules2, Communications of the ACM, December 1972. His contrasting decompositions of a key-word-in-context index illustrate information hiding: modules should conceal decisions likely to change, rather than merely follow processing steps. ↩︎

  3. John Ousterhout, A Philosophy of Software Design3, particularly the discussion of deep modules. A simple interface can hide substantial complexity; splitting code into more, shallower modules may increase what callers must understand. ↩︎

  4. Margaret Hamilton led MIT’s Apollo onboard-flight-software effort. Reflecting on that work, she said, “Looking back, we were the luckiest people in the world; there was no choice but to be pioneers.” MIT’s October 7, 2026 remembrance4 confirms that she died on September 30, 2026, at age 90. The flight software was the work of a team. ↩︎

  5. Apollo 11 Lunar Module software, Luminary 099, EXECUTIVE.agc5, FINDVAC2, VACFOUND, and NOVAC2, printed pages 1106–1107. The source searches five VAC-use registers, raising octal alarm 1201 when none is available, then searches seven core sets, raising 1202 when none is available. Unlike this simplified plan-before-commit model, the original reserves the VAC area before searching for a core set. The source also includes entry paths for jobs that do not require VAC storage. The Python snippets illustrate a resource decision rather than reproduce the flight software. ↩︎

  6. Martin Fowler, “Data Clump”6, January 5, 2006. Repeatedly passing related values together may suggest a missing domain value such as an interval. ↩︎

  7. Martin Fowler, “Flag Argument”7, June 23, 2011. A parameter that selects between distinct operations can obscure caller intent; separate named operations may communicate it better. ↩︎

  8. Sandi Metz, “How Shall We Define Design?”8, July 5, 2012. Metz explicitly describes the purpose of object-oriented design as reducing the cost of change. ↩︎

  9. Luminary 099, EXECUTIVE.agc9, VACFOUND, printed page 1106. The source comment explains that writing zero marks a VAC area as reserved and that the area’s address is stored in the low nine bits of a priority word. ↩︎

  10. Luminary 099, EXECUTIVE.agc10, ENDJOB1 and EJSCAN, printed pages 1113–1115. The routines release a completed job’s core set and VAC area and scan for the highest active priority. The Python model omits much of the real scheduler’s behavior. ↩︎

  11. Gary Bernhardt, Functional Core, Imperative Shell11, Destroy All Software, July 12, 2012. The screencast demonstrates pure application logic surrounded by a shell responsible for input/output, persistence, and network effects. ↩︎